$includeDirs * @param list $sources * @param array $components */ private function __construct( public string $name, public string $installPath, public string $kind, public int $abi, public int $cStandard, public int $cxxStandard, public array $includeDirs, public array $sources, public array $components, public ?string $extensionName, public ?string $extensionModuleEntry, ) { } /** @return list */ public static function discover(?string $compilerRoot = null): array { $packages = []; foreach (InstalledVersions::getInstalledPackages() as $package) { $installPath = InstalledVersions::getInstallPath($package); if (!is_string($installPath)) { continue; } $manifestPath = $installPath . DIRECTORY_SEPARATOR . 'composer.json'; $manifest = json_decode((string) @file_get_contents($manifestPath), true); if (!is_array($manifest) || !is_array($manifest['extra']['typephp-native'] ?? null)) { continue; } $packages[] = self::load($package, $compilerRoot); } usort( $packages, static function (self $left, self $right): int { $priority = ['runtime' => 0, 'bridge' => 1, 'library' => 2, 'extension' => 3]; return [$priority[$left->kind], $left->name] <=> [$priority[$right->kind], $right->name]; }, ); return $packages; } public static function load(string $package, ?string $compilerRoot = null): self { if ($package === 'swoole/php-ext-standard') { throw new RuntimeException( 'The standard extension is built into swoole/php-nano; remove obsolete package `swoole/php-ext-standard`' ); } $root = self::resolveLocalPackage($package, $compilerRoot); if ($root === null) { $installPath = class_exists(InstalledVersions::class) && InstalledVersions::isInstalled($package) ? InstalledVersions::getInstallPath($package) : null; $resolvedInstallPath = is_string($installPath) ? realpath($installPath) : false; $root = $resolvedInstallPath !== false ? $resolvedInstallPath : null; } if ($root === null) { throw new RuntimeException( "Native dependency `{$package}` is not installed; run `composer require {$package}`" ); } if (!is_dir($root)) { throw new RuntimeException("Unable to resolve Composer install path for `{$package}`"); } $manifestPath = $root . DIRECTORY_SEPARATOR . 'composer.json'; $manifest = json_decode((string) file_get_contents($manifestPath), true); $native = is_array($manifest) ? ($manifest['extra']['typephp-native'] ?? null) : null; if (!is_array($native)) { throw new RuntimeException( "Composer package `{$package}` does not publish extra.typephp-native" ); } $kind = $native['kind'] ?? 'library'; if (!is_string($kind) || !in_array($kind, ['runtime', 'bridge', 'extension', 'library'], true)) { throw new RuntimeException("Invalid native package kind in `{$package}`"); } $extensionName = null; $extensionModuleEntry = null; if ($kind === 'extension') { if (!str_starts_with($package, 'swoole/php-ext-')) { throw new RuntimeException( "Native extension package `{$package}` must use the `swoole/php-ext-*` naming convention" ); } $extension = $native['extension'] ?? null; $extensionName = is_array($extension) ? ($extension['name'] ?? null) : null; $extensionModuleEntry = is_array($extension) ? ($extension['module-entry'] ?? null) : null; if (!is_string($extensionName) || preg_match('/^[a-z][a-z0-9_]*$/', $extensionName) !== 1 || !is_string($extensionModuleEntry) || preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $extensionModuleEntry) !== 1) { throw new RuntimeException("Invalid native extension metadata in `{$package}`"); } $expectedPackage = 'swoole/php-ext-' . str_replace('_', '-', $extensionName); if ($package !== $expectedPackage) { throw new RuntimeException( "Native extension `{$extensionName}` must use Composer package `{$expectedPackage}`" ); } $runtimeConstraint = $manifest['require']['swoole/php-nano'] ?? null; if (!is_string($runtimeConstraint) || $runtimeConstraint === '') { throw new RuntimeException( "Native extension package `{$package}` must require `swoole/php-nano`" ); } } $abi = $native['abi'] ?? null; $cStandard = $native['c-standard'] ?? null; $cxxStandard = $native['cxx-standard'] ?? null; if (!is_int($abi) || !is_int($cStandard) || !is_int($cxxStandard)) { throw new RuntimeException("Invalid native ABI metadata in `{$package}`"); } if ($cStandard !== 11) { throw new RuntimeException( "Native dependency `{$package}` requires unsupported C{$cStandard}; expected C11" ); } if ($cxxStandard !== 17) { throw new RuntimeException( "Native dependency `{$package}` requires unsupported C++{$cxxStandard}; expected C++17" ); } $sources = self::resolveEntries($root, $native['sources'] ?? null, false, $package); foreach ($sources as $source) { $extension = strtolower(pathinfo($source, PATHINFO_EXTENSION)); if (!in_array($extension, ['c', 'cc', 'cpp', 'cxx'], true)) { throw new RuntimeException( "Native package `{$package}` contains unsupported source type: {$source}" ); } } $components = []; foreach (($native['components'] ?? []) as $componentName => $component) { if (!is_string($componentName) || preg_match('/^[a-z][a-z0-9_.-]*$/', $componentName) !== 1 || !is_array($component)) { throw new RuntimeException("Invalid native component metadata in `{$package}`"); } $componentExtension = $component['extension'] ?? strstr($componentName, '.', true); if ($componentExtension === false) { $componentExtension = $componentName; } if (!is_string($componentExtension) || preg_match('/^[a-z][a-z0-9_]*$/', $componentExtension) !== 1) { throw new RuntimeException("Invalid extension name for component `{$componentName}`"); } $componentSourceEntries = $component['sources'] ?? []; if (!is_array($componentSourceEntries)) { throw new RuntimeException( "Invalid source list in component `{$package}:{$componentName}`" ); } $componentSources = $componentSourceEntries === [] ? [] : self::resolveEntries( $root, $componentSourceEntries, false, "{$package}:{$componentName}", ); foreach ($componentSources as $source) { if (!in_array($source, $sources, true)) { throw new RuntimeException( "Component `{$package}:{$componentName}` owns a source not published by the package" ); } } $defines = self::stringList($component['defines'] ?? [], 'define', $package, $componentName); foreach ($defines as $define) { if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*(?:=.*)?$/', $define) !== 1) { throw new RuntimeException("Invalid define in component `{$package}:{$componentName}`"); } } $requires = self::stringList($component['requires'] ?? [], 'requirement', $package, $componentName); $moduleEntry = $component['module-entry'] ?? null; if ($moduleEntry !== null && (!is_string($moduleEntry) || preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $moduleEntry) !== 1)) { throw new RuntimeException("Invalid module entry in component `{$package}:{$componentName}`"); } $components[$componentName] = new ComposerNativeComponent( $componentName, $componentExtension, $componentSources, $defines, $requires, $moduleEntry, ); } return new self( $package, $root, $kind, $abi, $cStandard, $cxxStandard, self::resolveEntries($root, $native['include-dirs'] ?? null, true, $package), $sources, $components, $extensionName, $extensionModuleEntry, ); } /** @return list */ private static function stringList( mixed $values, string $label, string $package, string $component, ): array { if (!is_array($values)) { throw new RuntimeException("Invalid {$label} list in component `{$package}:{$component}`"); } foreach ($values as $value) { if (!is_string($value) || $value === '') { throw new RuntimeException("Invalid {$label} in component `{$package}:{$component}`"); } } return array_values(array_unique($values)); } /** * Composer installs all three core packages as siblings below vendor/swoole. * The same layout is used by the monorepo checkout, where a package may not * yet be present in the checkout's generated InstalledVersions metadata. */ private static function resolveLocalPackage(string $package, ?string $compilerRoot): ?string { if ($compilerRoot === null || !str_starts_with($package, 'swoole/')) { return null; } $packageDirectory = substr($package, strlen('swoole/')); $candidates = [ $compilerRoot . '/' . $packageDirectory, $compilerRoot . '/vendor/swoole/' . $packageDirectory, dirname($compilerRoot) . '/' . $packageDirectory, ]; foreach ($candidates as $path) { $candidate = realpath($path); if ($candidate === false || !is_dir($candidate)) { continue; } $manifest = json_decode((string) @file_get_contents($candidate . '/composer.json'), true); if (is_array($manifest) && ($manifest['name'] ?? null) === $package) { return $candidate; } } return null; } /** @return list */ private static function resolveEntries( string $root, mixed $entries, bool $directories, string $package, ): array { if (!is_array($entries) || $entries === []) { throw new RuntimeException("Native source metadata is empty in `{$package}`"); } $resolved = []; foreach ($entries as $entry) { if (!is_string($entry) || $entry === '' || self::isAbsolutePath($entry)) { throw new RuntimeException("Invalid native source path in `{$package}`"); } $path = realpath($root . DIRECTORY_SEPARATOR . $entry); $validType = $path !== false && ($directories ? is_dir($path) : is_file($path)); if (!$validType || !self::isInside($root, (string) $path)) { throw new RuntimeException( "Native source entry `{$entry}` is missing or escapes package `{$package}`" ); } $resolved[] = $path; } return $resolved; } private static function isInside(string $root, string $path): bool { return $path === $root || str_starts_with($path, rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); } private static function isAbsolutePath(string $path): bool { return $path[0] === '/' || $path[0] === '\\' || preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1; } }