Add Swoole alternative backend

This commit is contained in:
Jonathan Sykes
2026-10-14 00:43:38 +08:00
parent f465cc86d0
commit 1130fabc94
11 changed files with 756 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
/** Runtime asset manifest and static-byte preparation, kept independent of the HTTP server. */
final class AssetManifest
{
public array $manifest;
public array $delivery;
public string $index;
public string $serviceWorker;
public array $compressed = [];
public function __construct(private string $root, bool $hashing = true, bool $assetSync = true, ?string $override = null)
{
$this->root = rtrim($root, '/');
$assetSync = $hashing && $assetSync;
$raw = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS));
foreach ($it as $entry) if ($entry->isFile()) {
$path = '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($this->root) + 1));
$raw[$path] = file_get_contents($entry->getPathname());
}
ksort($raw, SORT_STRING);
$definition = isset($raw['/assets.json']) ? json_decode($raw['/assets.json'], true, 512, JSON_THROW_ON_ERROR) : ['groups' => ['core' => ['contract' => 1, 'eager' => true, 'files' => array_keys($raw)]]];
$include = $definition['include'] ?? [];
$runtime = [];
foreach ($raw as $path => $body) if (in_array($path, $include, true) || self::runtimePath($path)) $runtime[$path] = $body;
$groups = $definition['groups'] ?? [];
ksort($groups, SORT_STRING);
$contracts = [];
$membership = [];
foreach ($groups as $name => $group) {
$contracts[$name] = (int)($group['contract'] ?? 1);
foreach (($group['files'] ?? []) as $path) {
if (!isset($runtime[$path])) throw new RuntimeException("Asset group $name references missing or excluded $path");
if (isset($membership[$path])) throw new RuntimeException("Duplicate asset group membership for $path");
$membership[$path] = $name;
}
}
$files = [];
$delivery = [];
foreach ($raw as $path => $body) {
$entry = ['h' => self::hash10($body), 's' => strlen($body), 'g' => $membership[$path] ?? 'core'];
$delivery[$path] = $entry;
if (isset($runtime[$path])) $files[$path] = $entry;
}
$sourceIndex = $raw['/index.html'] ?? '';
$sourceIndex = str_replace('__APP_SCRIPT_HASH__', base64_encode(hash('sha256', $raw['/app.js'] ?? '', true)), $sourceIndex);
$canonicalIndex = $this->stampIndex($sourceIndex, $files, '__BUILD_TAG__', true);
$canonicalIndex = $this->embedAssets($canonicalIndex, $files, $groups, '__BUILD_TAG__', true, $assetSync);
$canonicalFiles = $files;
if ($sourceIndex !== '') $canonicalFiles['/index.html'] = [
'h' => self::hash10($canonicalIndex), 's' => strlen($canonicalIndex), 'g' => $membership['/index.html'] ?? 'core', 'source' => $files['/index.html']['h'] ?? '',
];
$canonical = ['assetSync' => $assetSync, 'files' => $canonicalFiles, 'groups' => $groups, 'contracts' => $contracts];
$canonicalJson = json_encode($canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$legacy = '';
foreach ($runtime as $path => $body) $legacy .= './public' . $path . $body;
$buildTag = $hashing ? substr(hash('sha256', $canonicalJson), 0, 12) : ($override ?: substr(hash('sha256', $legacy), 0, 12));
$this->index = $this->embedAssets($this->stampIndex($sourceIndex, $files, $buildTag, $hashing), $files, $groups, $buildTag, $hashing, $assetSync);
$swSource = $raw['/sw.js'] ?? '';
$this->serviceWorker = preg_replace("/typeof __BUILD_TAG__ !== 'undefined' \\? __BUILD_TAG__ : '[^']*'/", '"' . $buildTag . '"', $swSource) ?? $swSource;
$this->serviceWorker = str_replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", $assetSync ? 'true' : 'false', $this->serviceWorker);
$this->serviceWorker = str_replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" . ($files['/asset-sync-core.js']['h'] ?? '') . "')", $this->serviceWorker);
$files['/index.html'] = ['h' => self::hash10($this->index), 's' => strlen($this->index), 'g' => 'core'];
$files['/sw.js'] = ['h' => self::hash10($this->serviceWorker), 's' => strlen($this->serviceWorker), 'g' => 'core'];
$delivery['/index.html'] = $files['/index.html'];
$delivery['/sw.js'] = $files['/sw.js'];
$this->delivery = $delivery;
$this->manifest = ['buildTag' => $buildTag, 'files' => $files, 'groups' => $groups, 'contracts' => $contracts];
}
public static function hash10(string $body): string { return substr(hash('sha256', $body), 0, 10); }
public static function runtimePath(string $path): bool
{
return $path !== '/admin.html' && !preg_match('~(?:^|/)perf(?:/|$)~i', $path)
&& !preg_match('~\.(?:test\.(?:js|mjs)|md|txt|c|entry\.js)$~i', $path);
}
private function embedAssets(string $source, array $files, array $groups, string $tag, bool $hashing, bool $assetSync): string
{
$pageFiles = [];
foreach ($groups as $group) foreach (($group['files'] ?? []) as $path) if ($path !== '/index.html' && $path !== '/sw.js' && isset($files[$path])) {
$pageFiles[$path] = $files[$path];
if (!$hashing) $pageFiles[$path]['h'] = $tag;
}
$json = json_encode(['buildTag' => $tag, 'groups' => (object)$groups, 'files' => (object)$pageFiles, 'appCache' => $assetSync], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$json = str_replace('<', '\\u003c', $json);
return preg_replace('~(<script type="application/json" id="ytp-assets">).*?(</script>)~s', '$1' . $json . '$2', $source, 1) ?? $source;
}
private function stampIndex(string $source, array $files, string $tag, bool $hashing): string
{
if (!$hashing) {
$source = str_replace('__BUILD_TAG__', $tag, $source);
return preg_replace_callback('~((?:href|src)=")((?![a-z]+:|/)[^"?]+\.(?:css|js))(\")~i', fn($m) => $m[1] . $m[2] . '?v=' . $tag . $m[3], $source) ?? $source;
}
$source = preg_replace_callback('~<(?:script|link)\b[^>]*>~i', function($match) use ($files) {
return preg_replace_callback('~\b(href|src)=(["\'])([^"\']+)\2~i', function($attr) use ($files) {
$url = $attr[3];
if (preg_match('~^(?:[a-z][a-z\d+.-]*:|//|#)~i', $url)) return $attr[0];
$parts = parse_url($url);
$path = $parts['path'] ?? $url;
$path = '/' . ltrim(rawurldecode($path), '/');
if (!isset($files[$path]) || !preg_match('~\.(?:css|js)$~i', $path) || $path === '/sw.js') return $attr[0];
parse_str($parts['query'] ?? '', $query); $query['v'] = $files[$path]['h'];
$newUrl = ($parts['path'] ?? $url) . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
return $attr[1] . '=' . $attr[2] . $newUrl . $attr[2];
}, $match[0]) ?? $match[0];
}, $source) ?? $source;
return str_replace('__BUILD_TAG__', $tag, $source);
}
public function prepareCompression(): void
{
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS));
foreach ($it as $entry) if ($entry->isFile()) {
$path = '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($this->root) + 1));
$raw = file_get_contents($entry->getPathname());
$this->compressed[$path] = preg_match('~\.(?:js|css|html|json|webmanifest|svg|txt)$~i', $path)
? self::compress($raw) : ['raw'=>$raw,'gz'=>null,'br'=>null,'etag'=>'"'.substr(hash('sha256',$raw),0,32).'"'];
}
$this->compressed['/index.html'] = self::compress($this->index);
$this->compressed['/sw.js'] = self::compress($this->serviceWorker);
}
public static function compress(string $raw): array
{
$br = null;
if (function_exists('brotli_compress')) $br = brotli_compress($raw, 5, BROTLI_TEXT);
elseif (is_executable('/usr/bin/brotli')) {
$output = sys_get_temp_dir() . '/ytp-brotli-' . bin2hex(random_bytes(8));
$p = proc_open(['/usr/bin/brotli', '-q', '5', '-c'], [['pipe','r'],['file',$output,'w'],['file','/dev/null','w']], $pipes, null, null, ['bypass_shell'=>true]);
if (is_resource($p)) {
fwrite($pipes[0], $raw); fclose($pipes[0]);
if (proc_close($p) === 0 && is_file($output)) $br = file_get_contents($output);
@unlink($output);
}
}
return ['raw' => $raw, 'gz' => gzencode($raw, 9), 'br' => $br, 'etag' => '"' . substr(hash('sha256', $raw), 0, 32) . '"'];
}
public function cacheControl(string $path, ?string $version, bool $hashing): string
{
$short = preg_match('~(^|/)(fonts|icons)/~', $path) ? 'public, max-age=2592000' : 'no-cache';
if (!$hashing && $path === '/index.html') return 'no-cache';
$binaryLegacy = !$hashing && !preg_match('~\.(?:js|css|html|json|webmanifest|svg|txt)$~i', $path);
if ($version !== null) {
$current = $hashing ? ($this->delivery[$path]['h'] ?? null) : $this->manifest['buildTag'];
if ($current !== null && hash_equals((string)$current, $version)) return 'public, max-age=31536000, immutable';
return $hashing ? 'no-store' : 'no-cache';
}
return preg_match('~(^|/)(fonts|icons)/~', $path) && ($hashing || $binaryLegacy) ? 'public, max-age=2592000' : 'no-cache';
}
}