Skip to content

Commit e8b8abe

Browse files
authored
[3.0] Add frankenphp single executable build for Windows (#1099)
2 parents 2e2548d + 5d76e0b commit e8b8abe

12 files changed

Lines changed: 402 additions & 17 deletions

File tree

config/pkg/lib/pthreads4w.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ pthreads4w:
1010
license: Apache-2.0
1111
static-libs@windows:
1212
- libpthreadVC3.lib
13+
- pthreadVC3.lib

config/pkg/target/frankenphp.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@ frankenphp:
1111
depends:
1212
- php-embed
1313
- go-xcaddy
14+
depends@windows:
15+
- php-embed
16+
- go-win
17+
- pthreads4w
1418
suggests@unix:
1519
- brotli
1620
- watcher
21+
suggests@windows:
22+
- brotli
1723
static-bins@unix:
1824
- frankenphp
25+
static-bins@windows:
26+
- frankenphp.exe

config/pkg/target/go-win.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
go-win:
2+
type: target
3+
artifact:
4+
binary: custom
5+
env:
6+
GOROOT: '{pkg_root_path}/go-win'
7+
GOBIN: '{pkg_root_path}/go-win/bin'
8+
GOPATH: '{pkg_root_path}/go-win/go'
9+
path@windows:
10+
- '{pkg_root_path}/go-win/bin'

src/Package/Artifact/go_win.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Package\Artifact;
6+
7+
use StaticPHP\Artifact\ArtifactDownloader;
8+
use StaticPHP\Artifact\Downloader\DownloadResult;
9+
use StaticPHP\Artifact\Downloader\Type\CheckUpdateResult;
10+
use StaticPHP\Attribute\Artifact\AfterBinaryExtract;
11+
use StaticPHP\Attribute\Artifact\CustomBinary;
12+
use StaticPHP\Attribute\Artifact\CustomBinaryCheckUpdate;
13+
use StaticPHP\Exception\DownloaderException;
14+
use StaticPHP\Util\GlobalEnvManager;
15+
16+
class go_win
17+
{
18+
#[CustomBinary('go-win', [
19+
'windows-x86_64',
20+
])]
21+
public function downBinary(ArtifactDownloader $downloader): DownloadResult
22+
{
23+
$pkgroot = PKG_ROOT_PATH;
24+
25+
// get version
26+
[$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text') ?: '');
27+
if ($version === '') {
28+
throw new DownloaderException('Failed to get latest Go version from https://go.dev/VERSION?m=text');
29+
}
30+
31+
// find SHA256 hash from download page
32+
$page = default_shell()->executeCurl('https://go.dev/dl/');
33+
if ($page === '' || $page === false) {
34+
throw new DownloaderException('Failed to get Go download page from https://go.dev/dl/');
35+
}
36+
37+
$version_regex = str_replace('.', '\.', $version);
38+
$pattern = "/class=\"download\" href=\"\\/dl\\/{$version_regex}\\.windows-amd64\\.zip\">.*?<tt>([a-f0-9]{64})<\\/tt>/s";
39+
if (preg_match($pattern, $page, $matches)) {
40+
$hash = $matches[1];
41+
} else {
42+
throw new DownloaderException("Failed to find download hash for Go {$version} windows-amd64");
43+
}
44+
45+
$url = "https://go.dev/dl/{$version}.windows-amd64.zip";
46+
$path = DOWNLOAD_PATH . DIRECTORY_SEPARATOR . "{$version}.windows-amd64.zip";
47+
default_shell()->executeCurlDownload($url, $path, retries: $downloader->getRetry());
48+
49+
// verify hash
50+
$file_hash = hash_file('sha256', $path);
51+
if ($file_hash !== $hash) {
52+
throw new DownloaderException("Hash mismatch for downloaded go-win binary. Expected {$hash}, got {$file_hash}");
53+
}
54+
55+
return DownloadResult::archive(basename($path), ['url' => $url, 'version' => $version], extract: "{$pkgroot}/go-win", verified: true, version: $version);
56+
}
57+
58+
#[CustomBinaryCheckUpdate('go-win', ['windows-x86_64'])]
59+
public function checkUpdateBinary(?string $old_version): CheckUpdateResult
60+
{
61+
[$version] = explode("\n", default_shell()->executeCurl('https://go.dev/VERSION?m=text') ?: '');
62+
if ($version === '') {
63+
throw new DownloaderException('Failed to get latest Go version from https://go.dev/VERSION?m=text');
64+
}
65+
return new CheckUpdateResult(
66+
old: $old_version,
67+
new: $version,
68+
needUpdate: $old_version === null || $version !== $old_version,
69+
);
70+
}
71+
72+
#[AfterBinaryExtract('go-win', ['windows-x86_64'])]
73+
public function afterExtract(string $target_path): void
74+
{
75+
if (!file_exists("{$target_path}\\bin\\go.exe")) {
76+
throw new DownloaderException("Go installation appears incomplete: go.exe not found at {$target_path}\\bin\\go.exe");
77+
}
78+
79+
GlobalEnvManager::putenv("GOROOT={$target_path}");
80+
GlobalEnvManager::putenv("GOPATH={$target_path}\\gopath");
81+
GlobalEnvManager::putenv("GOCACHE={$target_path}\\gocache");
82+
GlobalEnvManager::putenv("GOMODCACHE={$target_path}\\gopath\\pkg\\mod");
83+
GlobalEnvManager::addPathIfNotExists("{$target_path}\\bin");
84+
}
85+
}

src/Package/Library/pthreads4w.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public function buildWin(LibraryPackage $lib): void
2626
FileSystem::createDir($lib->getLibDir());
2727
FileSystem::createDir($lib->getIncludeDir());
2828
FileSystem::copy("{$lib->getSourceDir()}\\libpthreadVC3.lib", "{$lib->getLibDir()}\\libpthreadVC3.lib");
29+
// FrankenPHP's cgo.go uses -lpthreadVC3, which lld-link maps to pthreadVC3.lib (no lib prefix)
30+
FileSystem::copy("{$lib->getSourceDir()}\\libpthreadVC3.lib", "{$lib->getLibDir()}\\pthreadVC3.lib");
2931
FileSystem::copy("{$lib->getSourceDir()}\\_ptw32.h", "{$lib->getIncludeDir()}\\_ptw32.h");
3032
FileSystem::copy("{$lib->getSourceDir()}\\pthread.h", "{$lib->getIncludeDir()}\\pthread.h");
3133
FileSystem::copy("{$lib->getSourceDir()}\\sched.h", "{$lib->getIncludeDir()}\\sched.h");

src/Package/Target/php.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ public function init(TargetPackage $package): void
161161
// embed build options
162162
if ($package->getName() === 'php' || $package->getName() === 'php-embed') {
163163
$package->addBuildOption('build-shared', 'D', InputOption::VALUE_REQUIRED, 'Shared extensions to build, comma separated', '');
164+
$package->addBuildOption('maintainer-skip-build', null, null, '(maintainer only) skip embed build if exists');
164165
}
165166

166167
// legacy php target build options
@@ -265,10 +266,6 @@ public function validate(Package $package): void
265266
if (!$package->getBuildOption('enable-zts')) {
266267
throw new WrongUsageException('FrankenPHP SAPI requires ZTS enabled PHP, build with `--enable-zts`!');
267268
}
268-
// frankenphp doesn't support windows, BSD is currently not supported by StaticPHP
269-
if (!in_array(PHP_OS_FAMILY, ['Linux', 'Darwin'])) {
270-
throw new WrongUsageException('FrankenPHP SAPI is only available on Linux and macOS!');
271-
}
272269
}
273270
// linux does not support loading shared libraries when target is pure static
274271
$embed_type = getenv('SPC_CMD_VAR_PHP_EMBED_TYPE') ?: 'static';
@@ -345,7 +342,11 @@ public function beforeBuild(PackageBuilder $builder, Package $package): void
345342
public function postInstall(TargetPackage $package, PackageInstaller $installer): void
346343
{
347344
if ($package->getName() === 'frankenphp') {
348-
$package->runStage([$this, 'smokeTestFrankenphpForUnix']);
345+
if (SystemTarget::getTargetOS() === 'Windows') {
346+
$package->runStage([$this, 'smokeTestFrankenphpForWindows']);
347+
} else {
348+
$package->runStage([$this, 'smokeTestFrankenphpForUnix']);
349+
}
349350
return;
350351
}
351352
if ($package->getName() !== 'php') {

src/Package/Target/php/frankenphp.php

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66

77
use Package\Target\php;
88
use StaticPHP\Attribute\Package\Stage;
9+
use StaticPHP\Config\PackageConfig;
10+
use StaticPHP\Exception\EnvironmentException;
911
use StaticPHP\Exception\SPCInternalException;
1012
use StaticPHP\Exception\ValidationException;
1113
use StaticPHP\Exception\WrongUsageException;
14+
use StaticPHP\Package\LibraryPackage;
1215
use StaticPHP\Package\PackageBuilder;
1316
use StaticPHP\Package\PackageInstaller;
1417
use StaticPHP\Package\TargetPackage;
@@ -18,6 +21,7 @@
1821
use StaticPHP\Util\InteractiveTerm;
1922
use StaticPHP\Util\SPCConfigUtil;
2023
use StaticPHP\Util\System\LinuxUtil;
24+
use StaticPHP\Util\System\WindowsUtil;
2125
use ZM\Logger\ConsoleColor;
2226

2327
trait frankenphp
@@ -171,6 +175,197 @@ public function processFrankenphpApp(TargetPackage $package): void
171175
}
172176
}
173177

178+
#[Stage]
179+
public function buildFrankenphpForWindows(TargetPackage $package, PackageInstaller $installer, PackageBuilder $builder): void
180+
{
181+
if (getenv('GOROOT') === false) {
182+
throw new EnvironmentException('go-win is not initialized properly. GOROOT is not set.');
183+
}
184+
185+
$clang_info = WindowsUtil::findClang();
186+
if ($clang_info === false) {
187+
throw new EnvironmentException(
188+
'Clang not found. FrankenPHP Windows build requires the LLVM toolchain component of Visual Studio. ' .
189+
'Install it in Visual Studio Installer under "C++ Clang tools for Windows", or set the CC environment variable.'
190+
);
191+
}
192+
193+
$frankenphp_version = $this->getFrankenPHPVersion($package);
194+
$libphp_version = php::getPHPVersion();
195+
$major = intdiv(PHP_VERSION_ID, 10000);
196+
$source_dir = $package->getSourceDir();
197+
198+
// collect PHP include paths in clang -I format (not MSVC /I).
199+
// Use forward slashes and NO quotes around paths: when Go passes CGO_CFLAGS tokens
200+
// directly to clang via exec(), any embedded quotes become literal characters in
201+
// the argument string and break include-path resolution.
202+
$include = str_replace('\\', '/', BUILD_INCLUDE_PATH);
203+
// The PHP source root is needed so that Windows-only headers installed only in
204+
// the source tree (e.g. win32/ioutil.h, win32/winutil.h) can be found via their
205+
// relative #include paths like `#include "win32/ioutil.h"`.
206+
$php_src = str_replace('\\', '/', SOURCE_PATH . '/php-src');
207+
$cgo_cflags = implode(' ', [
208+
"-I{$include}",
209+
"-I{$include}/php",
210+
"-I{$include}/php/main",
211+
"-I{$include}/php/Zend",
212+
"-I{$include}/php/TSRM",
213+
"-I{$include}/php/ext",
214+
"-I{$php_src}",
215+
"-I{$php_src}/main",
216+
"-I{$php_src}/ext",
217+
"-I{$php_src}/Zend",
218+
"-I{$php_src}/TSRM",
219+
"-DFRANKENPHP_VERSION={$frankenphp_version}",
220+
'-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1',
221+
]);
222+
223+
$dep_libs = [];
224+
foreach ($installer->getResolvedPackages(LibraryPackage::class) as $lib) {
225+
foreach (PackageConfig::get($lib->getName(), 'static-libs', []) as $lib_file) {
226+
if (file_exists("{$package->getLibDir()}\\{$lib_file}")) {
227+
$lib_name = preg_replace('/\.lib$/i', '', $lib_file);
228+
$dep_libs[] = "-l{$lib_name}";
229+
}
230+
}
231+
}
232+
233+
$dep_libs = array_unique($dep_libs);
234+
$lib_dir = str_replace('\\', '/', BUILD_LIB_PATH);
235+
$php_embed_lib = "-lphp{$major}embed";
236+
$win_sys_libs = '-lkernel32 -lole32 -luser32 -ladvapi32 -lshell32 -lws2_32 -ldnsapi -lpsapi -lbcrypt';
237+
$cgo_ldflags = clean_spaces(implode(' ', array_filter([
238+
"-L{$lib_dir}",
239+
$php_embed_lib,
240+
implode(' ', $dep_libs),
241+
$win_sys_libs,
242+
'-llibcmt',
243+
'-Wl,/NODEFAULTLIB:msvcrt',
244+
'-Wl,/NODEFAULTLIB:msvcrtd',
245+
'-Wl,/FORCE:MULTIPLE',
246+
])));
247+
248+
// build tags: skip watcher (no inotify/kqueue on Windows)
249+
$go_build_tags = 'nobadger,nomysql,nopgx,nowatcher';
250+
if (!$installer->isPackageResolved('brotli')) {
251+
$go_build_tags .= ',nobrotli';
252+
}
253+
254+
$go_ldflags =
255+
'-extldflags=-fuse-ld=lld ' .
256+
"-X 'github.com/caddyserver/caddy/v2/modules/caddyhttp.ServerHeader=FrankenPHP Caddy' " .
257+
"-X 'github.com/caddyserver/caddy/v2.CustomBinaryName=frankenphp' " .
258+
"-X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP v{$frankenphp_version} PHP {$libphp_version} Caddy'";
259+
260+
// CGO on Windows tokenizes CC/CXX like a shell command line, splitting on spaces.
261+
// Paths like "C:\Program Files\..." break because only "C:\Program" is used.
262+
// Fix: prepend clang's directory to PATH and use plain executable names instead,
263+
// which matches FrankenPHP's official CI approach (CC=clang, CXX=clang++).
264+
$clang_dir = dirname($clang_info['clang']);
265+
$env = [
266+
'CGO_ENABLED' => '1',
267+
'CC' => 'clang.exe',
268+
'CXX' => 'clang++.exe',
269+
'PATH' => $clang_dir . ';' . getenv('PATH'),
270+
'CGO_CFLAGS' => clean_spaces($cgo_cflags),
271+
'CGO_LDFLAGS' => $cgo_ldflags,
272+
];
273+
274+
InteractiveTerm::setMessage('Building frankenphp: ' . ConsoleColor::yellow('embedding Windows metadata'));
275+
$package->runStage([$this, 'embedFrankenphpWindowsMetadata']);
276+
277+
InteractiveTerm::setMessage('Building frankenphp: ' . ConsoleColor::yellow('building with go build'));
278+
279+
cmd()->cd("{$source_dir}\\caddy\\frankenphp")
280+
->setEnv($env)
281+
->exec("go build -v -tags \"{$go_build_tags}\" -ldflags \"{$go_ldflags}\" -o frankenphp.exe .");
282+
283+
$builder->deployBinary("{$source_dir}\\caddy\\frankenphp\\frankenphp.exe", BUILD_BIN_PATH . '\frankenphp.exe');
284+
$package->setOutput('Binary path for FrankenPHP SAPI', BUILD_BIN_PATH . '\frankenphp.exe');
285+
}
286+
287+
/**
288+
* Embed Windows PE metadata (version info + icon) into resource.syso so that
289+
* go build picks it up automatically. Mirrors the official FrankenPHP Windows CI.
290+
*/
291+
#[Stage]
292+
public function embedFrankenphpWindowsMetadata(TargetPackage $package): void
293+
{
294+
$frankenphp_version = $this->getFrankenPHPVersion($package);
295+
$source_dir = $package->getSourceDir();
296+
$build_dir = "{$source_dir}\\caddy\\frankenphp";
297+
298+
[$p1, $p2, $p3] = explode('.', $frankenphp_version);
299+
$major = (int) $p1;
300+
$minor = (int) $p2;
301+
$patch = (int) $p3;
302+
303+
$version_info = [
304+
'FixedFileInfo' => [
305+
'FileVersion' => ['Major' => $major, 'Minor' => $minor, 'Patch' => $patch, 'Build' => 0],
306+
'ProductVersion' => ['Major' => $major, 'Minor' => $minor, 'Patch' => $patch, 'Build' => 0],
307+
],
308+
'StringFileInfo' => [
309+
'CompanyName' => 'FrankenPHP',
310+
'FileDescription' => 'The modern PHP app server',
311+
'FileVersion' => $frankenphp_version,
312+
'InternalName' => 'frankenphp',
313+
'OriginalFilename' => 'frankenphp.exe',
314+
'LegalCopyright' => '(c) 2022 Kévin Dunglas, MIT License',
315+
'ProductName' => 'FrankenPHP',
316+
'ProductVersion' => $frankenphp_version,
317+
'Comments' => 'https://frankenphp.dev/',
318+
],
319+
'VarFileInfo' => [
320+
'Translation' => ['LangID' => 9, 'CharsetID' => 1200],
321+
],
322+
];
323+
324+
file_put_contents("{$build_dir}\\versioninfo.json", json_encode($version_info, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
325+
326+
// Install goversioninfo if not already installed.
327+
// GOPATH is set by the go-win artifact initializer via GlobalEnvManager::putenv().
328+
$goversioninfo = getenv('GOROOT') . '\bin\goversioninfo.exe';
329+
if (!file_exists($goversioninfo)) {
330+
cmd()->exec('go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest');
331+
}
332+
333+
// -64: embed as 64-bit resource; -icon: relative path from the build dir to the repo root icon.
334+
cmd()->cd($build_dir)
335+
->exec("\"{$goversioninfo}\" -64 -icon {$package->getSourceDir()}\\frankenphp.ico versioninfo.json -o resource.syso");
336+
}
337+
338+
#[Stage]
339+
public function smokeTestFrankenphpForWindows(PackageBuilder $builder): void
340+
{
341+
// analyse --no-smoke-test option
342+
$no_smoke_test = $builder->getOption('no-smoke-test', false);
343+
$option = match ($no_smoke_test) {
344+
false => false, // default value, run all smoke tests
345+
null => 'all', // --no-smoke-test without value, skip all smoke tests
346+
default => parse_comma_list($no_smoke_test), // --no-smoke-test=frankenphp,...
347+
};
348+
if ($option === 'all' || (is_array($option) && in_array('frankenphp', $option, true))) {
349+
return;
350+
}
351+
352+
InteractiveTerm::setMessage('Running FrankenPHP smoke test');
353+
$frankenphp = BUILD_BIN_PATH . '\frankenphp.exe';
354+
if (!file_exists($frankenphp)) {
355+
throw new ValidationException(
356+
"FrankenPHP binary not found: {$frankenphp}",
357+
validation_module: 'FrankenPHP smoke test'
358+
);
359+
}
360+
[$ret, $output] = cmd()->execWithResult("{$frankenphp} version");
361+
if ($ret !== 0 || !str_contains(implode('', $output), 'FrankenPHP')) {
362+
throw new ValidationException(
363+
'FrankenPHP failed smoke test: ret[' . $ret . ']. out[' . implode('', $output) . ']',
364+
validation_module: 'FrankenPHP smoke test'
365+
);
366+
}
367+
}
368+
174369
protected function getFrankenPHPVersion(TargetPackage $package): string
175370
{
176371
if ($version = getenv('FRANKENPHP_VERSION')) {

0 commit comments

Comments
 (0)