Initial release — pure PHP web server, 55-73% of nginx throughput

Standalone server with zero dependencies beyond PHP 8.1+.
Static files with in-memory response cache, keep-alive, TCP_NODELAY,
gzip/brotli, WebSocket, live dashboard, rate limiting.

Includes PHAR builder and GitHub Actions CI for static binaries
(Linux x86_64, Linux ARM64, macOS x86_64, macOS Apple Silicon).
This commit is contained in:
Gregory Magarshak
2026-07-20 10:26:59 -04:00
parent 0480c29c69
commit eaa711a4e5
27 changed files with 7191 additions and 1 deletions
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* @module Q
*/
/**
* Non-blocking event loop for Q. Timers, stream watchers,
* deferred callbacks, signal handling. Built-in stream_select
* driver. Optional Revolt driver when amphp/ReactPHP installed.
* @class Q_Evented
*/
class Q_Evented
{
static function onReadable($stream, callable $cb) { return self::driver()->onReadable($stream, $cb); }
static function onWritable($stream, callable $cb) { return self::driver()->onWritable($stream, $cb); }
static function delay($sec, callable $cb) { return self::driver()->delay($sec, $cb); }
static function repeat($sec, callable $cb) { return self::driver()->repeat($sec, $cb); }
static function defer(callable $cb) { return self::driver()->defer($cb); }
static function onSignal($sig, callable $cb) { return self::driver()->onSignal($sig, $cb); }
static function cancel($id) { self::driver()->cancel($id); }
static function disable($id) { self::driver()->disable($id); }
static function enable($id) { self::driver()->enable($id); }
static function run() { self::driver()->run(); }
static function tick($timeout = 0) { self::driver()->tick($timeout); }
static function stop() { self::driver()->stop(); }
static function running() { return self::driver()->running(); }
static function driver()
{
if (!self::$driver) {
self::$driver = class_exists('Revolt\\EventLoop')
? new Q_Evented_Revolt()
: new Q_Evented_StreamSelect();
}
return self::$driver;
}
static function setDriver(Q_Evented_Driver $d) { self::$driver = $d; }
protected static $driver = null;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
abstract class Q_Evented_Driver
{
abstract function onReadable($stream, callable $cb);
abstract function onWritable($stream, callable $cb);
abstract function delay($sec, callable $cb);
abstract function repeat($sec, callable $cb);
abstract function defer(callable $cb);
abstract function onSignal($sig, callable $cb);
abstract function cancel($id);
abstract function disable($id);
abstract function enable($id);
abstract function run();
abstract function tick($timeout = 0);
abstract function stop();
abstract function running();
}
+30
View File
@@ -0,0 +1,30 @@
<?php
class Q_Evented_Revolt extends Q_Evented_Driver
{
protected $running = false;
function onReadable($s, callable $cb) {
return \Revolt\EventLoop::onReadable($s, function($id,$s)use($cb){ $cb($s); });
}
function onWritable($s, callable $cb) {
return \Revolt\EventLoop::onWritable($s, function($id,$s)use($cb){ $cb($s); });
}
function delay($sec, callable $cb) {
return \Revolt\EventLoop::delay($sec, function()use($cb){ $cb(); });
}
function repeat($sec, callable $cb) {
return \Revolt\EventLoop::repeat($sec, function()use($cb){ $cb(); });
}
function defer(callable $cb) {
return \Revolt\EventLoop::defer(function()use($cb){ $cb(); });
}
function onSignal($sig, callable $cb) {
return \Revolt\EventLoop::onSignal($sig, function($id,$s)use($cb){ $cb($s); });
}
function cancel($id) { \Revolt\EventLoop::cancel($id); }
function disable($id) { \Revolt\EventLoop::disable($id); }
function enable($id) { \Revolt\EventLoop::enable($id); }
function run() { $this->running = true; \Revolt\EventLoop::run(); $this->running = false; }
function tick($t = 0) { \Revolt\EventLoop::delay($t?:0.0,function(){}); \Revolt\EventLoop::run(); }
function stop() { $this->running = false; }
function running() { return $this->running; }
}
+203
View File
@@ -0,0 +1,203 @@
<?php
/**
* @module Q
*/
/**
* Built-in event loop using stream_select(). Zero dependencies.
* Handles stream watching, timers, deferred callbacks, signals.
*
* @class Q_Evented_StreamSelect
* @extends Q_Evented_Driver
*/
class Q_Evented_StreamSelect extends Q_Evented_Driver
{
protected $running = false;
protected $nextId = 1;
protected $readers = array(); // id => [stream, callback]
protected $writers = array(); // id => [stream, callback]
protected $timers = array(); // id => [fireAt, interval, callback]
protected $deferred = array(); // id => callback
protected $signals = array(); // id => [signal, callback]
protected $disabled = array(); // id => true
protected $streamToReaders = array();
protected $streamToWriters = array();
function onReadable($stream, callable $cb)
{
$id = 'r' . ($this->nextId++);
$this->readers[$id] = array($stream, $cb);
$this->streamToReaders[(int)$stream][$id] = true;
return $id;
}
function onWritable($stream, callable $cb)
{
$id = 'w' . ($this->nextId++);
$this->writers[$id] = array($stream, $cb);
$this->streamToWriters[(int)$stream][$id] = true;
return $id;
}
function delay($sec, callable $cb)
{
$id = 'd' . ($this->nextId++);
$this->timers[$id] = array(
'fireAt' => microtime(true) + $sec,
'interval' => 0, 'callback' => $cb
);
return $id;
}
function repeat($sec, callable $cb)
{
$id = 't' . ($this->nextId++);
$this->timers[$id] = array(
'fireAt' => microtime(true) + $sec,
'interval' => $sec, 'callback' => $cb
);
return $id;
}
function defer(callable $cb)
{
$id = 'f' . ($this->nextId++);
$this->deferred[$id] = $cb;
return $id;
}
function onSignal($sig, callable $cb)
{
if (!function_exists('pcntl_signal')) {
throw new Exception("Signal handling requires pcntl extension");
}
$id = 's' . ($this->nextId++);
$this->signals[$id] = array($sig, $cb);
$signals = &$this->signals;
$disabled = &$this->disabled;
pcntl_signal($sig, function ($s) use (&$signals, &$disabled) {
foreach ($signals as $sid => $entry) {
if ($entry[0] === $s && empty($disabled[$sid])) {
$entry[1]($s);
}
}
});
return $id;
}
function cancel($id)
{
if (isset($this->readers[$id])) {
$key = (int)$this->readers[$id][0];
unset($this->readers[$id], $this->streamToReaders[$key][$id]);
if (empty($this->streamToReaders[$key])) unset($this->streamToReaders[$key]);
}
if (isset($this->writers[$id])) {
$key = (int)$this->writers[$id][0];
unset($this->writers[$id], $this->streamToWriters[$key][$id]);
if (empty($this->streamToWriters[$key])) unset($this->streamToWriters[$key]);
}
unset($this->timers[$id], $this->deferred[$id],
$this->signals[$id], $this->disabled[$id]);
}
function disable($id) { $this->disabled[$id] = true; }
function enable($id) { unset($this->disabled[$id]); }
function running() { return $this->running; }
function stop() { $this->running = false; }
function run()
{
$this->running = true;
while ($this->running && $this->hasWatchers()) {
$this->tick(null);
}
$this->running = false;
}
function tick($timeout = 0)
{
// 1. Deferred callbacks
if (!empty($this->deferred)) {
$batch = $this->deferred;
$this->deferred = array();
foreach ($batch as $id => $cb) {
if (empty($this->disabled[$id])) $cb();
}
}
// 2. Timers
$now = microtime(true);
$nextTimer = null;
foreach ($this->timers as $id => $t) {
if (!empty($this->disabled[$id])) continue;
if ($now >= $t['fireAt']) {
$t['callback']();
if ($t['interval'] > 0) {
$this->timers[$id]['fireAt'] = $now + $t['interval'];
} else {
unset($this->timers[$id]);
}
} else {
$rem = $t['fireAt'] - $now;
if ($nextTimer === null || $rem < $nextTimer) $nextTimer = $rem;
}
}
// 3. Signals
if (function_exists('pcntl_signal_dispatch')) pcntl_signal_dispatch();
// 4. Stream select
$read = $write = array();
foreach ($this->readers as $id => $e) {
if (empty($this->disabled[$id])) $read[] = $e[0];
}
foreach ($this->writers as $id => $e) {
if (empty($this->disabled[$id])) $write[] = $e[0];
}
if (empty($read) && empty($write)) {
if ($nextTimer !== null) {
$sleep = ($timeout !== null) ? min($nextTimer, $timeout) : $nextTimer;
if ($sleep > 0) usleep((int)($sleep * 1000000));
}
return;
}
$wait = $timeout;
if ($nextTimer !== null) {
$wait = ($wait !== null) ? min($wait, $nextTimer) : $nextTimer;
}
$sec = ($wait !== null) ? (int)$wait : null;
$usec = ($wait !== null) ? (int)(($wait - (int)$wait) * 1000000) : null;
$except = null;
$n = @stream_select($read, $write, $except, $sec, $usec);
if ($n === false) return;
foreach ($read as $stream) {
$key = (int)$stream;
if (!isset($this->streamToReaders[$key])) continue;
foreach ($this->streamToReaders[$key] as $id => $_) {
if (empty($this->disabled[$id]) && isset($this->readers[$id])) {
$this->readers[$id][1]($stream);
}
}
}
foreach ($write as $stream) {
$key = (int)$stream;
if (!isset($this->streamToWriters[$key])) continue;
foreach ($this->streamToWriters[$key] as $id => $_) {
if (empty($this->disabled[$id]) && isset($this->writers[$id])) {
$this->writers[$id][1]($stream);
}
}
}
}
protected function hasWatchers()
{
return !empty($this->readers) || !empty($this->writers)
|| !empty($this->timers) || !empty($this->deferred)
|| !empty($this->signals);
}
}
+135
View File
@@ -0,0 +1,135 @@
<?php
/**
* @module Q
*/
/**
* Lightweight mtime-based file cache for long-running PHP processes.
*
* In php-fpm every request re-reads files from disk. In a persistent
* server (Q_WebServer), files load once and stay in memory. This class
* tracks mtimes so changed files get reloaded — one stat() syscall
* per check, same cost as nginx checking a file.
*
* @class Q_FileCache
*/
class Q_FileCache
{
/**
* path => [mtime, content, type]
* @property $cache
* @static
* @protected
*/
protected static $cache = array();
/**
* Load file contents. Returns cached version if mtime unchanged.
* @method load
* @static
* @param {string} $path
* @return {string|false}
*/
static function load($path)
{
$mtime = self::mtime($path);
if ($mtime === false) {
unset(self::$cache[$path]);
return false;
}
if (isset(self::$cache[$path]) && self::$cache[$path]['mtime'] === $mtime) {
return self::$cache[$path]['content'];
}
$content = file_get_contents($path);
if ($content === false) return false;
self::$cache[$path] = array('mtime' => $mtime, 'content' => $content, 'type' => 'raw');
return $content;
}
/**
* Load and JSON-decode a file.
* @method loadJson
* @static
* @param {string} $path
* @return {array|null}
*/
static function loadJson($path)
{
$mtime = self::mtime($path);
if ($mtime === false) { unset(self::$cache[$path]); return null; }
if (isset(self::$cache[$path])
&& self::$cache[$path]['mtime'] === $mtime
&& self::$cache[$path]['type'] === 'json'
) {
return self::$cache[$path]['content'];
}
$raw = file_get_contents($path);
if ($raw === false) return null;
$data = json_decode($raw, true);
self::$cache[$path] = array('mtime' => $mtime, 'content' => $data, 'type' => 'json');
return $data;
}
/**
* Load a PHP file that returns a value.
* Re-includes if mtime changed.
* @method loadPhp
* @static
* @param {string} $path
* @return {mixed}
*/
static function loadPhp($path)
{
$mtime = self::mtime($path);
if ($mtime === false) { unset(self::$cache[$path]); return null; }
if (isset(self::$cache[$path])
&& self::$cache[$path]['mtime'] === $mtime
&& self::$cache[$path]['type'] === 'php'
) {
return self::$cache[$path]['content'];
}
$data = include($path);
self::$cache[$path] = array('mtime' => $mtime, 'content' => $data, 'type' => 'php');
return $data;
}
/**
* Check all cached files for changes. Returns changed paths.
* @method checkAll
* @static
* @return {array}
*/
static function checkAll()
{
$changed = array();
foreach (self::$cache as $path => $entry) {
$mtime = self::mtime($path);
if ($mtime === false || $mtime !== $entry['mtime']) {
$changed[] = $path;
if ($mtime === false) {
unset(self::$cache[$path]);
} else {
self::$cache[$path]['mtime'] = -1; // mark stale
}
}
}
return $changed;
}
/** @method invalidate */
static function invalidate($path) { unset(self::$cache[$path]); }
/** @method clear */
static function clear() { self::$cache = array(); }
/**
* @method mtime
* @static
* @protected
*/
protected static function mtime($path)
{
clearstatcache(true, $path);
return file_exists($path) ? filemtime($path) : false;
}
}
+236
View File
@@ -0,0 +1,236 @@
<?php
/**
* @module Q
*/
/**
* Maintains a versioned, diffable cache of hashed content.
*
* Provides the scan → hash → snapshot → diff lifecycle used by
* scripts/Q/urls.php (static-file cache-busting) and by the
* IndieWeb plugin (feed generation from rendered HTML), but is
* generic enough for any workflow that needs to detect content
* changes, store snapshots, and compute incremental diffs.
*
* Directory structure it manages:
*
* $configDir/
* $name.php ← var_export array for fast include()
* $name/
* entries/
* {timestamp}.json ← permanent snapshots
* latest.json ← copy of most recent snapshot
* diffs/
* {timestamp}.json ← diff from that snapshot to current
*
* @class Q_Snapshot
*/
class Q_Snapshot
{
/**
* @property $name
* @type string
*/
public $name;
/**
* @property $configDir
* @type string
*/
public $configDir;
/**
* @property $webDir
* @type string|null
*/
public $webDir;
/**
* @property $time
* @type integer
*/
public $time;
/**
* @property $earliest
* @type integer
*/
public $earliest;
/**
* @property $previous
* @type array|null
*/
public $previous;
protected $entriesDir;
protected $diffsDir;
protected $parentDir;
/**
* @method __construct
* @param {string} $name Identifier like 'urls' or 'feeds'
* @param {string} $configDir Where to store snapshots
* @param {string|null} [$webDir=null] Symlink target for web access
*/
function __construct($name, $configDir, $webDir = null)
{
$this->name = $name;
$this->configDir = $configDir;
$this->webDir = $webDir;
$this->time = time();
$this->parentDir = $configDir . DS . $name;
$this->entriesDir = $this->parentDir . DS . 'entries';
$this->diffsDir = $this->parentDir . DS . 'diffs';
foreach (array(
$configDir, $this->parentDir,
$this->entriesDir, $this->diffsDir
) as $dir) {
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
}
}
if ($webDir && is_dir($this->parentDir) && !file_exists($webDir)) {
Q_Utils::symlink($this->parentDir, $webDir);
}
$this->earliest = $this->time;
$this->previous = null;
$json = file_get_contents($this->entriesDir . DS . 'latest.json');
if ($json !== false) {
$this->previous = Q::json_decode($json, true);
if (!empty($this->previous['@earliest'])) {
$this->earliest = $this->previous['@earliest'];
}
}
}
/**
* @method hash
* @static
* @param {string} $content
* @param {string} [$algo='sha256']
* @return {string} base64-encoded hash
*/
static function hash($content, $algo = 'sha256')
{
return base64_encode(hash($algo, $content, true));
}
/**
* Check whether content has changed since last snapshot.
* @method changed
* @param {string} $key Path into the tree
* @param {string} $hash base64 hash of current content
* @param {integer|null} [$mtime=null] File mtime for fast skip
* @return {boolean}
*/
function changed($key, $hash, $mtime = null)
{
if ($mtime !== null && $mtime <= $this->earliest) {
return false;
}
if ($this->previous) {
$parts = is_array($key) ? $key : explode(DS, $key);
$prev = $this->previous;
foreach ($parts as $part) {
if (!isset($prev[$part])) return true;
$prev = $prev[$part];
}
if (is_array($prev) && isset($prev['h']) && $prev['h'] === $hash) {
return false;
}
}
return true;
}
/**
* Save a result tree as the current snapshot.
* @method save
* @param {array} $result
* @return {Q_Snapshot}
*/
function save(array $result)
{
$result['@timestamp'] = $this->time;
if (empty($result['@earliest'])) {
$result['@earliest'] = $this->earliest;
}
$json = Q::json_encode($result);
file_put_contents($this->entriesDir . DS . $this->time . '.json', $json);
file_put_contents($this->entriesDir . DS . 'latest.json', $json);
$export = Q::var_export($result);
file_put_contents($this->configDir . DS . $this->name . '.php', "<?php\nreturn $export;");
$this->previous = $result;
return $this;
}
/**
* Generate diff files from every historical snapshot to current.
* @method diffs
* @param {array} $currentResult
* @return {integer} Number of diffs generated
*/
function diffs(array $currentResult)
{
$files = glob($this->diffsDir . DS . '*');
foreach ($files as $file) {
if (is_file($file)) unlink($file);
}
$currentTree = new Q_Tree($currentResult);
$filenames = glob($this->entriesDir . DS . '*');
$i = 0;
$n = count($filenames) - 1;
foreach ($filenames as $g) {
$b = basename($g);
if ($b === 'latest.json') continue;
$t = new Q_Tree();
$t->load($g);
$diff = $t->diff($currentTree, false);
$diff->set('@timestamp', $this->time);
$diff->save($this->diffsDir . DS . $b);
++$i;
echo "\033[100D";
echo "Generated $i of $n diff files ";
}
return $i;
}
/**
* Load cached snapshot from the PHP file (for runtime).
* @method load
* @return {array|null}
*/
function load()
{
$f = $this->configDir . DS . $this->name . '.php';
return file_exists($f) ? include($f) : null;
}
/**
* Update a single key without full rescan.
* @method update
* @param {string} $key
* @param {string} $hash
* @param {array} [$metadata=array()]
* @return {boolean}
*/
function update($key, $hash, array $metadata = array())
{
if (!$this->changed($key, $hash)) return false;
$cached = $this->load() ?: array();
$value = array_merge(array('t' => $this->time, 'h' => $hash), $metadata);
$tree = new Q_Tree($cached);
$parts = explode(DS, $key);
$parts[] = $value;
call_user_func_array(array($tree, 'set'), $parts);
$this->save($tree->getAll());
return true;
}
function entriesDir() { return $this->entriesDir; }
function diffsDir() { return $this->diffsDir; }
}
+1536
View File
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
<?php
/**
* @module Q
*/
/**
* Built-in reverse proxy cache for Q_WebServer.
*
* Sits in the parent process event loop, before worker dispatch.
* Cached responses are served without forking a worker — pure
* event loop speed, on par with Varnish for cache hits.
*
* Two storage tiers:
* - APCu: for small responses (under Q.web.cache.apcu.maxSize)
* - Filesystem: for larger responses
*
* Respects HTTP caching semantics:
* - Cache-Control: max-age, s-maxage, no-store, private, no-cache
* - Vary header (cache per Accept-Encoding, etc.)
* - Cookie bypass: skip cache if request has specific cookies
*
* Config:
* "Q": { "web": { "cache": {
* "enabled": true,
* "dir": "files/cache/reverse",
* "apcu": {
* "enabled": true,
* "maxSize": 65536
* },
* "defaultTtl": 0,
* "skip": {
* "cookies": ["Q_sid", "PHPSESSID"]
* }
* }}}
*
* @class Q_WebServer_Cache
*/
class Q_WebServer_Cache
{
static $enabled = false;
static $dir = '';
static $apcuEnabled = false;
static $apcuMaxSize = 65536; // 64KB
static $defaultTtl = 0; // 0 = don't cache unless told to
static $skipCookies = array();
static $hits = 0;
static $misses = 0;
/**
* Initialize cache from config.
* @method init
* @static
*/
static function init()
{
$config = Q_Config::get('Q', 'web', 'cache', array());
self::$enabled = (bool) Q::ifset($config, 'enabled', false);
if (!self::$enabled) return;
self::$dir = Q::ifset($config, 'dir', '');
if (!self::$dir && defined('APP_DIR')) {
self::$dir = APP_DIR . DS . 'files' . DS . 'cache' . DS . 'reverse';
}
if (self::$dir && !is_dir(self::$dir)) {
mkdir(self::$dir, 0755, true);
}
$apcu = Q::ifset($config, 'apcu', array());
self::$apcuEnabled = (bool) Q::ifset($apcu, 'enabled', function_exists('apcu_fetch'));
self::$apcuMaxSize = (int) Q::ifset($apcu, 'maxSize', 65536);
self::$defaultTtl = (int) Q::ifset($config, 'defaultTtl', 0);
self::$skipCookies = Q::ifset($config, 'skip', 'cookies', array('Q_sid', 'PHPSESSID'));
}
/**
* Try to serve from cache. Returns response array or null.
*
* Called in the parent event loop BEFORE dispatching to a
* worker. A cache hit means zero fork overhead.
*
* @method get
* @static
* @param {array} $parsed Parsed request
* @return {array|null} [status, headers, body] or null
*/
static function get($parsed)
{
if (!self::$enabled) return null;
if ($parsed['method'] !== 'GET') return null;
// Skip cache if request has bypass cookies
if (self::hasSkipCookie($parsed['headers'])) return null;
$key = self::cacheKey($parsed);
// Try APCu first (faster)
if (self::$apcuEnabled) {
$entry = apcu_fetch('qcache:' . $key);
if ($entry !== false) {
if ($entry['expires'] > 0 && $entry['expires'] < time()) {
apcu_delete('qcache:' . $key);
} else {
self::$hits++;
$entry['headers']['X-Cache'] = 'HIT';
return $entry;
}
}
}
// Try filesystem
$path = self::filePath($key);
if ($path && file_exists($path)) {
$entry = json_decode(file_get_contents($path), true);
if ($entry && ($entry['expires'] === 0 || $entry['expires'] > time())) {
self::$hits++;
$entry['headers']['X-Cache'] = 'HIT';
// Promote to APCu if small enough
if (self::$apcuEnabled && strlen($entry['body']) <= self::$apcuMaxSize) {
apcu_store('qcache:' . $key, $entry, self::ttlRemaining($entry));
}
return $entry;
}
@unlink($path);
}
self::$misses++;
return null;
}
/**
* Store a response in cache if cacheable.
*
* Checks Cache-Control headers to determine TTL.
* Only caches GET responses with 200 status.
*
* @method put
* @static
* @param {array} $parsed Request
* @param {array} $response [status, headers, body]
*/
static function put($parsed, $response)
{
if (!self::$enabled) return;
if ($parsed['method'] !== 'GET') return;
if (($response['status'] ?? 200) !== 200) return;
if (self::hasSkipCookie($parsed['headers'])) return;
$headers = $response['headers'] ?? array();
$cc = self::parseCacheControl($headers);
// Don't cache if explicitly forbidden
if (isset($cc['no-store']) || isset($cc['private'])) return;
// Determine TTL
$ttl = 0;
if (isset($cc['s-maxage'])) {
$ttl = (int) $cc['s-maxage'];
} elseif (isset($cc['max-age'])) {
$ttl = (int) $cc['max-age'];
} elseif (self::$defaultTtl > 0) {
$ttl = self::$defaultTtl;
}
if ($ttl <= 0) return; // nothing to cache
$key = self::cacheKey($parsed);
$body = $response['body'] ?? '';
$expires = time() + $ttl;
$entry = array(
'status' => $response['status'] ?? 200,
'headers' => $headers,
'body' => $body,
'expires' => $expires,
'stored' => time(),
);
// Store in APCu if small enough
if (self::$apcuEnabled && strlen($body) <= self::$apcuMaxSize) {
apcu_store('qcache:' . $key, $entry, $ttl);
}
// Always store on filesystem (APCu is per-process, lost on restart)
$path = self::filePath($key);
if ($path) {
$dir = dirname($path);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($path, json_encode($entry), LOCK_EX);
}
}
/**
* Purge cache entries matching a URL pattern.
*
* Called by application code when content changes:
* Q_WebServer_Cache::purge('/blog/my-post');
* Q_WebServer_Cache::purge('#^/api/v1/#');
*
* @method purge
* @static
* @param {string} $pattern URL path or regex
*/
static function purge($pattern)
{
if (!self::$dir || !is_dir(self::$dir)) return;
// If it looks like a regex (starts with a delimiter), match against files
$isRegex = (strlen($pattern) > 2 && $pattern[0] === $pattern[strlen($pattern)-1])
|| (strlen($pattern) > 2 && $pattern[0] === '#');
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(self::$dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->getExtension() !== 'json') continue;
$entry = json_decode(file_get_contents($file->getPathname()), true);
if (!$entry) continue;
$url = $entry['url'] ?? '';
$match = $isRegex ? preg_match($pattern, $url) : ($url === $pattern);
if ($match) {
@unlink($file->getPathname());
if (self::$apcuEnabled) {
$key = self::cacheKeyFromUrl($url);
apcu_delete('qcache:' . $key);
}
}
}
}
/**
* Clear all cached entries.
* @method clear
* @static
*/
static function clear()
{
if (self::$apcuEnabled) {
$iterator = new APCUIterator('#^qcache:#');
apcu_delete($iterator);
}
if (self::$dir && is_dir(self::$dir)) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(self::$dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $f) {
$f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname());
}
}
}
// ── Internals ────────────────────────────────────────
/**
* Generate a cache key from a request.
* Includes path + query + Vary headers.
*/
static function cacheKey($parsed)
{
$parts = $parsed['path'] . '?' . ($parsed['query'] ?? '');
// Include Accept-Encoding in key for compressed variants
$ae = $parsed['headers']['accept-encoding'] ?? '';
if (strpos($ae, 'br') !== false) {
$parts .= '|br';
} elseif (strpos($ae, 'gzip') !== false) {
$parts .= '|gzip';
}
return md5($parts);
}
static function cacheKeyFromUrl($url)
{
return md5($url);
}
static function filePath($key)
{
if (!self::$dir) return null;
// Two-level directory to avoid too many files in one dir
return self::$dir . DS . substr($key, 0, 2) . DS . $key . '.json';
}
/**
* Check if request has any cookies from the skip list.
* If a session cookie is present, the response is likely
* personalized and shouldn't be cached.
*/
static function hasSkipCookie($headers)
{
$cookieHeader = $headers['cookie'] ?? '';
if (!$cookieHeader || empty(self::$skipCookies)) return false;
foreach (self::$skipCookies as $name) {
if (preg_match('/(?:^|;\s*)' . preg_quote($name, '/') . '=/', $cookieHeader)) {
return true;
}
}
return false;
}
/**
* Parse Cache-Control header into directives.
*/
static function parseCacheControl($headers)
{
$cc = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'cache-control') { $cc = $v; break; }
}
if (!$cc) return array();
$directives = array();
foreach (explode(',', $cc) as $part) {
$part = trim($part);
if (strpos($part, '=') !== false) {
list($k, $v) = explode('=', $part, 2);
$directives[trim($k)] = trim($v);
} else {
$directives[$part] = true;
}
}
return $directives;
}
static function ttlRemaining($entry)
{
if ($entry['expires'] <= 0) return 86400;
return max(1, $entry['expires'] - time());
}
/**
* Stats for the dashboard.
*/
static function stats()
{
$total = self::$hits + self::$misses;
return array(
'hits' => self::$hits,
'misses' => self::$misses,
'hitRate' => $total > 0 ? round(self::$hits / $total * 100, 1) : 0,
);
}
}
+463
View File
@@ -0,0 +1,463 @@
<?php
/**
* @module Q
*/
/**
* Merkle-tree invalidation layer for Q_WebServer_Cache.
*
* Does NOT store component HTML — only hashes and dependencies.
* The actual cached page lives in Q_WebServer_Cache (page-level).
* This layer answers one question: "is this cached page still valid?"
*
* Better than ESI/SSI because:
* - No component HTML in memory — just a tree of md5 hashes (~100 bytes/page)
* - Stream-driven invalidation: change a stream → invalidate specific pages
* - Children communicate via response headers, not wire protocol
* - All in parent process memory — no edge proxy, no parsing
*
* How it works:
*
* 1. Child renders a page. Q_Response::fillSlot() tracks which slots
* were rendered and which streams each slot read from. After rendering,
* the child sets response headers:
*
* X-Q-Cache-Tree: {"t":{"da":{"av":"a3f2","nt":"b8c1"},"co":{"fe":"d4e5","mb":"f6a7","sb":"c8d9"}},"h":"root_hash"}
* X-Q-Cache-Deps: {"co.fe":["community/feed/456"],"co.mb":["community/participants/456"],"da.av":["Users/avatar/123"]}
*
* 2. Parent receives response, caches it in Q_WebServer_Cache (full page),
* and stores the Merkle tree + deps here (hashes only, ~200 bytes).
*
* 3. When a stream changes (child sends X-Q-Cache-Invalidate header,
* or WebSocket message, or explicit API call):
* - Look up dependency index: stream → [pageKey, leafPath]
* - Remove the page from Q_WebServer_Cache
* - Mark the Merkle leaf as stale (optional: for partial re-render hints)
*
* 4. Next request for this page: cache miss → fork worker → full re-render
* → new tree + new page cached. The stale leaves tell the child which
* slots changed, enabling smart partial rendering if the app supports it.
*
* Config:
* "Q": { "web": { "cache": { "components": {
* "enabled": true,
* "maxTrees": 10000
* }}}}
*
* @class Q_WebServer_Cache_Components
*/
class Q_WebServer_Cache_Components
{
// ── State (parent process memory) ───────────────────
/**
* Merkle trees: pageKey => tree
* A tree is: { 'hash' => rootHash, 'leaves' => { 'path' => hash, ... } }
* Compact — no HTML, no children structure. Just leaf hashes + root.
* The hierarchy is encoded in dot-separated leaf paths.
* @property $trees
*/
protected static $trees = array();
/**
* Forward dependency index: streamKey => [ [pageKey, leafPath], ... ]
* @property $deps
*/
protected static $deps = array();
/**
* Reverse index: pageKey => [ streamKey, ... ]
* For cleanup when a page tree is evicted.
* @property $pageStreams
*/
protected static $pageStreams = array();
/**
* Stale leaves: pageKey => [ leafPath, ... ]
* After invalidation, records which leaves changed so the next
* render can optionally skip unchanged slots.
* @property $staleLeaves
*/
protected static $staleLeaves = array();
// ── Stats ───────────────────────────────────────────
protected static $invalidations = 0;
protected static $pagesInvalidated = 0;
// ── Config ──────────────────────────────────────────
protected static $enabled = false;
protected static $maxTrees = 10000;
/**
* Initialize from config.
*/
static function init()
{
$config = Q_Config::get('Q', 'web', 'cache', 'components', array());
self::$enabled = (bool) Q::ifset($config, 'enabled', false);
self::$maxTrees = (int) Q::ifset($config, 'maxTrees', 10000);
}
static function enabled()
{
return self::$enabled;
}
// ── Process response headers from child ─────────────
/**
* Called by the parent after receiving a response from a worker.
* Extracts X-Q-Cache-Tree, X-Q-Cache-Deps, and X-Q-Cache-Invalidate
* headers. Strips them from the response (they're internal).
*
* @method processResponseHeaders
* @static
* @param {string} $pageKey Cache key for this page
* @param {array} &$headers Response headers (modified in place — internal headers removed)
*/
static function processResponseHeaders($pageKey, &$headers)
{
if (!self::$enabled) return;
// 1. Handle invalidations first (from POST/write requests)
$invalidateHeader = self::extractHeader($headers, 'X-Q-Cache-Invalidate');
if ($invalidateHeader) {
$streams = json_decode($invalidateHeader, true);
if (is_array($streams)) {
self::invalidateStreams($streams);
}
}
// 2. Register tree from GET response
$treeHeader = self::extractHeader($headers, 'X-Q-Cache-Tree');
$depsHeader = self::extractHeader($headers, 'X-Q-Cache-Deps');
if ($treeHeader) {
$tree = json_decode($treeHeader, true);
if (is_array($tree)) {
self::registerTree($pageKey, $tree);
}
}
if ($depsHeader) {
$deps = json_decode($depsHeader, true);
if (is_array($deps)) {
self::registerDeps($pageKey, $deps);
}
}
}
/**
* Extract and remove an internal header from the response.
* Returns the value or null.
*/
protected static function extractHeader(&$headers, $name)
{
$lower = strtolower($name);
foreach ($headers as $k => $v) {
if (strtolower($k) === $lower) {
unset($headers[$k]);
return $v;
}
}
return null;
}
// ── Tree registration ───────────────────────────────
/**
* Register a Merkle tree from a child's response.
*
* Tree format (JSON from X-Q-Cache-Tree header):
* { "h": "root_hash", "l": { "title": "abc", "content.feed": "def", ... } }
*
* "h" = root hash (md5 of concatenated leaf hashes)
* "l" = leaf hashes, keyed by dot-separated path
*
* @param {string} $pageKey
* @param {array} $tree Decoded JSON
*/
static function registerTree($pageKey, $tree)
{
// Evict old tree if exists (cleans up deps)
if (isset(self::$trees[$pageKey])) {
self::evictTree($pageKey);
}
self::$trees[$pageKey] = array(
'hash' => $tree['h'] ?? self::computeRoot($tree['l'] ?? array()),
'leaves' => $tree['l'] ?? array(),
'time' => time(),
);
// Clear any stale markers (we have a fresh render)
unset(self::$staleLeaves[$pageKey]);
// Evict oldest if over limit
while (count(self::$trees) > self::$maxTrees) {
$oldest = array_key_first(self::$trees);
if ($oldest === null || $oldest === $pageKey) break;
self::evictTree($oldest);
}
}
/**
* Register dependencies from a child's response.
*
* Deps format (JSON from X-Q-Cache-Deps header):
* { "content.feed": ["community/feed/456"], "da.av": ["Users/avatar/123"], ... }
*
* Keys = leaf paths, values = arrays of stream keys that leaf reads from.
*
* @param {string} $pageKey
* @param {array} $deps Decoded JSON
*/
static function registerDeps($pageKey, $deps)
{
$allStreams = array();
foreach ($deps as $leafPath => $streamKeys) {
foreach ($streamKeys as $streamKey) {
// Forward index
if (!isset(self::$deps[$streamKey])) {
self::$deps[$streamKey] = array();
}
self::$deps[$streamKey][] = array($pageKey, $leafPath);
$allStreams[$streamKey] = true;
}
}
// Reverse index for cleanup
self::$pageStreams[$pageKey] = array_keys($allStreams);
}
// ── Invalidation ────────────────────────────────────
/**
* Invalidate all pages that depend on a stream.
*
* @method invalidateStream
* @static
* @param {string} $streamKey e.g. 'Streams/avatar/123'
*/
static function invalidateStream($streamKey)
{
if (!isset(self::$deps[$streamKey])) return;
self::$invalidations++;
$pagesHit = array();
foreach (self::$deps[$streamKey] as $dep) {
list($pageKey, $leafPath) = $dep;
if (!isset($pagesHit[$pageKey])) {
$pagesHit[$pageKey] = true;
// Purge from page-level cache
Q_WebServer_Cache::purge($pageKey);
self::$pagesInvalidated++;
}
// Record which leaf is stale (hint for partial re-render)
if (!isset(self::$staleLeaves[$pageKey])) {
self::$staleLeaves[$pageKey] = array();
}
if (!in_array($leafPath, self::$staleLeaves[$pageKey])) {
self::$staleLeaves[$pageKey][] = $leafPath;
}
// Mark leaf hash as stale in tree
if (isset(self::$trees[$pageKey]['leaves'][$leafPath])) {
self::$trees[$pageKey]['leaves'][$leafPath] = null; // stale
self::$trees[$pageKey]['hash'] = null; // root invalid
}
}
}
/**
* Invalidate multiple streams.
*
* @method invalidateStreams
* @static
* @param {array} $streamKeys
*/
static function invalidateStreams($streamKeys)
{
foreach ($streamKeys as $key) {
self::invalidateStream($key);
}
}
// ── Query ───────────────────────────────────────────
/**
* Check if a page's Merkle root still matches what we have.
* Called optionally — the main cache layer (Q_WebServer_Cache)
* already handles TTL-based expiry. This is for instant invalidation.
*
* @method isValid
* @static
* @param {string} $pageKey
* @param {string} $rootHash The root hash to check against
* @return {boolean} true if the tree exists and the root matches
*/
static function isValid($pageKey, $rootHash)
{
if (!isset(self::$trees[$pageKey])) return true; // no tree = no opinion
return self::$trees[$pageKey]['hash'] === $rootHash;
}
/**
* Get the list of stale leaves for a page.
* The child can use this to skip re-rendering unchanged slots.
*
* @method getStaleLeaves
* @static
* @param {string} $pageKey
* @return {array} Leaf paths that changed since last render
*/
static function getStaleLeaves($pageKey)
{
return self::$staleLeaves[$pageKey] ?? array();
}
/**
* Check if a specific leaf is stale.
*
* @method isLeafStale
* @static
* @param {string} $pageKey
* @param {string} $leafPath
* @return {boolean}
*/
static function isLeafStale($pageKey, $leafPath)
{
if (!isset(self::$staleLeaves[$pageKey])) return false;
return in_array($leafPath, self::$staleLeaves[$pageKey]);
}
// ── Hints to child ──────────────────────────────────
/**
* Build a header value telling the child which slots are stale.
* The child can set this on the request when dispatching to a worker.
*
* @method buildStaleHintsHeader
* @static
* @param {string} $pageKey
* @return {string|null} JSON array of stale leaf paths, or null if none
*/
static function buildStaleHintsHeader($pageKey)
{
$stale = self::$staleLeaves[$pageKey] ?? array();
return !empty($stale) ? json_encode($stale) : null;
}
// ── Cleanup ─────────────────────────────────────────
/**
* Remove a page's tree and clean up all its dependency entries.
*/
protected static function evictTree($pageKey)
{
// Remove from forward deps
if (isset(self::$pageStreams[$pageKey])) {
foreach (self::$pageStreams[$pageKey] as $streamKey) {
if (isset(self::$deps[$streamKey])) {
self::$deps[$streamKey] = array_values(array_filter(
self::$deps[$streamKey],
function ($d) use ($pageKey) { return $d[0] !== $pageKey; }
));
if (empty(self::$deps[$streamKey])) {
unset(self::$deps[$streamKey]);
}
}
}
unset(self::$pageStreams[$pageKey]);
}
unset(self::$trees[$pageKey], self::$staleLeaves[$pageKey]);
}
// ── Merkle computation ──────────────────────────────
/**
* Compute root hash from leaf hashes.
* Deterministic: sorts by path, concatenates "path:hash", md5s the result.
*
* @param {array} $leaves path => hash pairs
* @return {string} root hash
*/
protected static function computeRoot($leaves)
{
if (empty($leaves)) return md5('');
ksort($leaves);
$concat = '';
foreach ($leaves as $path => $hash) {
$concat .= $path . ':' . ($hash ?? 'null') . "\n";
}
return md5($concat);
}
// ── Wire protocol (legacy support) ──────────────────
/**
* Process a cache message from a child (via Pool wire protocol).
* Supports both the header-based approach and explicit messages.
*
* @method processChildMessage
* @static
* @param {array} $msg
*/
static function processChildMessage($msg)
{
$action = $msg['action'] ?? '';
if ($action === 'invalidate') {
self::invalidateStreams($msg['streams'] ?? array());
} elseif ($action === 'register') {
$pageKey = $msg['pageKey'] ?? '';
if (isset($msg['tree'])) {
self::registerTree($pageKey, $msg['tree']);
}
if (isset($msg['deps'])) {
self::registerDeps($pageKey, $msg['deps']);
}
}
}
// ── Stats ───────────────────────────────────────────
static function stats()
{
return array(
'trees' => count(self::$trees),
'trackedStreams' => count(self::$deps),
'invalidations' => self::$invalidations,
'pagesInvalidated' => self::$pagesInvalidated,
'stalePagesNow' => count(self::$staleLeaves),
);
}
/**
* Dump a page's tree for debugging/dashboard.
*
* @param {string} $pageKey
* @return {array|null}
*/
static function dumpTree($pageKey)
{
if (!isset(self::$trees[$pageKey])) return null;
$tree = self::$trees[$pageKey];
return array(
'rootHash' => $tree['hash'] ? substr($tree['hash'], 0, 8) : 'STALE',
'cachedAt' => date('H:i:s', $tree['time']),
'leaves' => array_map(function ($h) {
return $h ? substr($h, 0, 8) : 'STALE';
}, $tree['leaves']),
'stale' => self::$staleLeaves[$pageKey] ?? array(),
'deps' => self::$pageStreams[$pageKey] ?? array(),
);
}
}
+437
View File
@@ -0,0 +1,437 @@
<?php
/**
* @module Q
*/
/**
* TLS certificate management for Q_WebServer.
*
* Two modes:
*
* 1. Local certbot: runs `certbot certonly` to obtain/renew
* Let's Encrypt certs. Checks expiration via openssl_x509_parse
* and renews automatically — no cron needed, runs on a
* Q_Evented timer.
*
* 2. Remote download: fetches certs from a URL (.zip containing
* fullchain.pem + privkey.pem). For dev domains like
* local.qbix.com where certs are published centrally.
* Checks actual cert expiration, re-downloads when expired.
*
* Config:
* "Q": {
* "web": {
* "https": {
* "cert": "/path/to/fullchain.pem", // or auto-managed path
* "key": "/path/to/privkey.pem",
* "mode": "certbot", // "certbot" | "remote" | "manual"
* "domain": "example.com",
* "certbot": {
* "email": "you@example.com",
* "webroot": "/path/to/app/web", // for webroot validation
* "renewDays": 30 // renew when < 30 days remain
* },
* "remote": {
* "url": "https://certs.qbix.com/local.qbix.com/certs.zip",
* "checkInterval": 86400 // check daily (seconds)
* }
* }
* }
* }
*
* @class Q_WebServer_Certs
*/
class Q_WebServer_Certs
{
/**
* Path to current fullchain.pem
* @property $certPath
* @static
*/
static $certPath = null;
/**
* Path to current privkey.pem
* @property $keyPath
* @static
*/
static $keyPath = null;
/**
* Initialize cert management. Loads existing certs,
* checks expiration, starts renewal timer if needed.
*
* @method init
* @static
* @param {string} $domain The domain to serve
* @return {boolean} true if valid certs are available
*/
static function init($domain = null)
{
$config = Q_Config::get('Q', 'web', 'https', array());
$mode = Q::ifset($config, 'mode', 'manual');
$domain = $domain ?: Q::ifset($config, 'domain', '');
// Determine cert paths
$certsDir = self::certsDir();
self::$certPath = Q::ifset($config, 'cert',
$certsDir . DS . 'fullchain.pem');
self::$keyPath = Q::ifset($config, 'key',
$certsDir . DS . 'privkey.pem');
// Check if we have valid certs already
$valid = self::validateCerts();
if (!$valid) {
// Try to obtain certs
if ($mode === 'certbot') {
$valid = self::obtainCertbot($domain, $config);
} elseif ($mode === 'remote') {
$valid = self::downloadRemote($config);
}
}
// Start renewal timer
if ($mode === 'certbot') {
$checkInterval = 86400; // daily
Q_Evented::repeat((float) $checkInterval, function () use ($domain, $config) {
Q_WebServer_Certs::checkRenewal($domain, $config);
});
} elseif ($mode === 'remote') {
$checkInterval = (float) Q::ifset($config, 'remote', 'checkInterval', 86400);
Q_Evented::repeat($checkInterval, function () use ($config) {
Q_WebServer_Certs::checkRemoteRenewal($config);
});
}
return $valid;
}
/**
* Build an SSL context for stream_socket_server.
*
* @method sslContext
* @static
* @return {resource|null} Stream context or null if no certs
*/
static function sslContext()
{
if (!self::$certPath || !file_exists(self::$certPath)
|| !self::$keyPath || !file_exists(self::$keyPath)
) {
return null;
}
return stream_context_create(array(
'ssl' => array(
'local_cert' => self::$certPath,
'local_pk' => self::$keyPath,
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER
| STREAM_CRYPTO_METHOD_TLSv1_3_SERVER,
)
));
}
/**
* Check if current certs exist and are not expired.
*
* @method validateCerts
* @static
* @return {boolean}
*/
static function validateCerts()
{
if (!self::$certPath || !file_exists(self::$certPath)) return false;
if (!self::$keyPath || !file_exists(self::$keyPath)) return false;
$expiry = self::certExpiry(self::$certPath);
if ($expiry === null) return false;
return $expiry > time();
}
/**
* Get cert expiration as Unix timestamp.
* Uses openssl_x509_parse() — reads from the actual cert,
* not mtime.
*
* @method certExpiry
* @static
* @param {string} $certPath Path to PEM cert
* @return {integer|null} Expiry timestamp or null
*/
static function certExpiry($certPath)
{
if (!function_exists('openssl_x509_parse')) return null;
$pem = file_get_contents($certPath);
if (!$pem) return null;
$cert = openssl_x509_parse($pem);
if (!$cert || !isset($cert['validTo_time_t'])) return null;
return (int) $cert['validTo_time_t'];
}
/**
* Days remaining until cert expires.
*
* @method daysRemaining
* @static
* @return {integer|null}
*/
static function daysRemaining()
{
$expiry = self::certExpiry(self::$certPath);
if ($expiry === null) return null;
return max(0, (int) floor(($expiry - time()) / 86400));
}
// ── Certbot mode ─────────────────────────────────────
/**
* Obtain a cert via certbot certonly.
*
* @method obtainCertbot
* @static
* @param {string} $domain
* @param {array} $config
* @return {boolean}
*/
static function obtainCertbot($domain, $config)
{
if (!$domain) {
echo "[HTTPS] No domain configured for certbot\n";
return false;
}
$email = Q::ifset($config, 'certbot', 'email', '');
$webroot = Q::ifset($config, 'certbot', 'webroot', APP_WEB_DIR);
$certsDir = self::certsDir();
// Use standalone if port 80 is available, webroot otherwise
$emailFlag = $email ? "--email $email" : "--register-unsafely-without-email";
$cmd = "certbot certonly --non-interactive --agree-tos $emailFlag "
. "--webroot -w " . escapeshellarg($webroot) . " "
. "-d " . escapeshellarg($domain) . " "
. "--cert-path " . escapeshellarg($certsDir . DS . 'fullchain.pem') . " "
. "--key-path " . escapeshellarg($certsDir . DS . 'privkey.pem') . " "
. "2>&1";
echo "[HTTPS] Running certbot for $domain...\n";
$output = shell_exec($cmd);
$success = (strpos($output, 'Successfully') !== false
|| strpos($output, 'Certificate not yet due for renewal') !== false);
if ($success) {
// Certbot stores in /etc/letsencrypt/live/$domain/
// Copy or symlink to our certsDir
$leDir = "/etc/letsencrypt/live/$domain";
if (is_dir($leDir)) {
self::$certPath = "$leDir/fullchain.pem";
self::$keyPath = "$leDir/privkey.pem";
}
echo "[HTTPS] Certificate obtained for $domain\n";
return self::validateCerts();
}
echo "[HTTPS] Certbot failed: $output\n";
return false;
}
/**
* Check if certbot renewal is needed.
* Called on Q_Evented timer.
*
* @method checkRenewal
* @static
*/
static function checkRenewal($domain, $config)
{
$renewDays = (int) Q::ifset($config, 'certbot', 'renewDays', 30);
$remaining = self::daysRemaining();
if ($remaining === null || $remaining <= $renewDays) {
echo "[HTTPS] Cert expires in " . ($remaining ?? '?')
. " days, renewing...\n";
$success = self::obtainCertbot($domain, $config);
if ($success) {
echo "[HTTPS] Renewed. " . self::daysRemaining() . " days remaining.\n";
// Reload SSL context in WebServer
self::reloadServerCerts();
}
}
}
// ── Remote download mode ─────────────────────────────
/**
* Download certs from a remote URL (.zip file containing
* fullchain.pem and privkey.pem).
*
* @method downloadRemote
* @static
* @param {array} $config
* @return {boolean}
*/
static function downloadRemote($config)
{
$url = Q::ifset($config, 'remote', 'url', '');
if (!$url) {
echo "[HTTPS] No remote cert URL configured\n";
return false;
}
echo "[HTTPS] Downloading certs from $url...\n";
$certsDir = self::certsDir();
$zipPath = $certsDir . DS . 'certs-download.zip';
// Download
$ch = curl_init($url);
$fp = fopen($zipPath, 'wb');
curl_setopt_array($ch, array(
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 30,
));
$success = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fp);
if (!$success || $status >= 400) {
echo "[HTTPS] Download failed (HTTP $status)\n";
@unlink($zipPath);
return false;
}
// Extract
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
echo "[HTTPS] Invalid zip file\n";
@unlink($zipPath);
return false;
}
$extracted = false;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
$basename = basename($name);
if ($basename === 'fullchain.pem' || $basename === 'privkey.pem') {
$zip->extractTo($certsDir, $name);
// Move to certsDir root if nested
$extractedPath = $certsDir . DS . $name;
$targetPath = $certsDir . DS . $basename;
if ($extractedPath !== $targetPath && file_exists($extractedPath)) {
rename($extractedPath, $targetPath);
}
$extracted = true;
}
}
$zip->close();
@unlink($zipPath);
if (!$extracted) {
echo "[HTTPS] Zip did not contain fullchain.pem / privkey.pem\n";
return false;
}
self::$certPath = $certsDir . DS . 'fullchain.pem';
self::$keyPath = $certsDir . DS . 'privkey.pem';
$days = self::daysRemaining();
echo "[HTTPS] Certs installed, " . ($days ?? '?') . " days remaining\n";
return self::validateCerts();
}
/**
* Check if remote certs need re-downloading.
* Checks actual cert expiration, not mtime.
*
* @method checkRemoteRenewal
* @static
*/
static function checkRemoteRenewal($config)
{
$remaining = self::daysRemaining();
$renewDays = 7; // re-download when < 7 days remain
if ($remaining === null || $remaining <= $renewDays) {
echo "[HTTPS] Remote cert expires in " . ($remaining ?? '?')
. " days, re-downloading...\n";
$success = self::downloadRemote($config);
if ($success) {
self::reloadServerCerts();
}
}
}
// ── Helpers ──────────────────────────────────────────
/**
* Directory for storing cert files.
*
* @method certsDir
* @static
* @return {string}
*/
static function certsDir()
{
$dir = Q_Config::get('Q', 'web', 'https', 'certsDir', null);
if (!$dir) {
$dir = (defined('APP_DIR') ? APP_DIR : '.') . DS . 'config' . DS . 'certs';
}
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
return $dir;
}
/**
* Reload certs in the running server.
* For stream_socket_server, this requires restarting
* the listener with a new SSL context.
*
* @method reloadServerCerts
* @static
*/
static function reloadServerCerts()
{
// With per-connection SSL context, new connections
// automatically pick up the new cert files.
// Just notify Q_WebServer for logging.
Q_WebServer::reloadTls();
}
/**
* Format cert info for display.
*
* @method info
* @static
* @return {array} [valid, daysRemaining, expiry, subject, issuer]
*/
static function info()
{
if (!self::$certPath || !file_exists(self::$certPath)) {
return array('valid' => false);
}
$pem = file_get_contents(self::$certPath);
$cert = openssl_x509_parse($pem);
if (!$cert) return array('valid' => false);
$expiry = $cert['validTo_time_t'];
return array(
'valid' => $expiry > time(),
'daysRemaining' => max(0, (int) floor(($expiry - time()) / 86400)),
'expiry' => date('Y-m-d H:i:s', $expiry),
'subject' => $cert['subject']['CN'] ?? '',
'issuer' => $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? '',
);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
/**
* @module Q
*/
/**
* Server dashboard: stats tracking, live HTML display at /Q/dashboard,
* real-time updates via Q_WebSocket on the 'dashboard' channel.
* @class Q_WebServer_Dashboard
*/
class Q_WebServer_Dashboard
{
static $stats = array(
'startTime' => 0, 'requests' => 0,
'status2xx' => 0, 'status3xx' => 0, 'status4xx' => 0, 'status5xx' => 0,
);
static $recentRequests = array();
static function init() { self::$stats['startTime'] = time(); }
static function recordRequest($method, $uri, $status, $ms)
{
self::$stats['requests']++;
if ($status < 300) self::$stats['status2xx']++;
elseif ($status < 400) self::$stats['status3xx']++;
elseif ($status < 500) self::$stats['status4xx']++;
else self::$stats['status5xx']++;
$entry = array('time' => date('H:i:s'), 'method' => $method,
'uri' => $uri, 'status' => $status, 'ms' => $ms);
self::$recentRequests[] = $entry;
if (count(self::$recentRequests) > 200) array_shift(self::$recentRequests);
Q_WebSocket::broadcastTo('dashboard', array(
'type' => 'request', 'entry' => $entry, 'stats' => self::getStats()
));
}
static function getStats()
{
$up = time() - self::$stats['startTime'];
$pool = Q_WebServer::$pool;
return array(
'uptime' => self::fmtUp($up), 'uptimeSec' => $up,
'requests' => self::$stats['requests'],
'status2xx' => self::$stats['status2xx'],
'status3xx' => self::$stats['status3xx'],
'status4xx' => self::$stats['status4xx'],
'status5xx' => self::$stats['status5xx'],
'memory' => round(memory_get_usage(true)/1048576, 1),
'memoryPeak' => round(memory_get_peak_usage(true)/1048576, 1),
'workers' => $pool ? $pool->idleCount().'/'.$pool->targetSize : 'in-process',
'wsClients' => Q_WebSocket::clientCount(),
'cache' => Q_WebServer_Cache::stats(),
'components' => Q_WebServer_Cache_Components::enabled()
? Q_WebServer_Cache_Components::stats() : null,
);
}
static function handle($client, $parsed)
{
$p = $parsed['path'];
if ($p === '/Q/dashboard' || $p === '/Q/dashboard/') {
Q_WebServer::sendResponse($client, 200, self::renderHtml($parsed), 'text/html; charset=utf-8');
return true;
}
if ($p === '/Q/stats') {
Q_WebServer::sendResponse($client, 200, json_encode(self::getStats()), 'application/json');
return true;
}
return false;
}
static function fmtUp($s) {
if ($s < 60) return "{$s}s";
if ($s < 3600) return floor($s/60).'m '.($s%60).'s';
return floor($s/3600).'h '.floor(($s%3600)/60).'m';
}
static function renderHtml($parsed)
{
$stats = json_encode(self::getStats());
$recent = json_encode(array_slice(self::$recentRequests, -50));
$host = $parsed['headers']['host'] ?? 'localhost:8080';
$wsUrl = "ws://$host/Q/ws";
return <<<HTML
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Qbix Server</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f1117;--sfc:#1a1d27;--bdr:#2a2d3a;--txt:#e1e4ed;--dim:#6b7089;
--ac:#7c8aff;--grn:#4ade80;--yel:#fbbf24;--red:#f87171;--cyn:#22d3ee}
body{font-family:'SF Mono','Fira Code',Consolas,monospace;background:var(--bg);
color:var(--txt);padding:24px;font-size:13px}
h1{font-size:18px;font-weight:600;margin-bottom:24px;color:var(--ac)}
h1 span{color:var(--dim);font-weight:400;font-size:13px;margin-left:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}
.card{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;padding:16px}
.card .l{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}
.card .v{font-size:24px;font-weight:700}
.card .s{font-size:11px;color:var(--dim);margin-top:4px}
.lc{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;overflow:hidden}
.lh{padding:12px 16px;border-bottom:1px solid var(--bdr);display:flex;justify-content:space-between;align-items:center}
.lh h2{font-size:13px;font-weight:600}
.lb{height:50vh;overflow-y:auto;padding:4px 0}
.le{padding:3px 16px;font-size:12px;display:flex;gap:12px;border-bottom:1px solid rgba(255,255,255,.03)}
.le:hover{background:rgba(255,255,255,.02)}
.lt{color:var(--dim);min-width:64px}.ls{min-width:28px;font-weight:700;text-align:right}
.lm{min-width:48px;color:var(--cyn)}.lu{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ld{color:var(--dim);min-width:60px;text-align:right}
.s2{color:var(--grn)}.s3{color:var(--yel)}.s4,.s5{color:var(--red)}
.ws{display:inline-flex;align-items:center;gap:6px;font-size:11px}
.wd{width:6px;height:6px;border-radius:50%;background:var(--red)}.wd.on{background:var(--grn)}
@media(max-width:600px){body{padding:12px}.grid{grid-template-columns:repeat(2,1fr)}.lb{height:60vh}}
</style></head><body>
<h1>Qbix Server <span id="up"></span></h1>
<div class="grid">
<div class="card"><div class="l">Requests</div><div class="v" id="sr">0</div><div class="s" id="srps"></div></div>
<div class="card"><div class="l">Workers</div><div class="v" id="sw">—</div></div>
<div class="card"><div class="l">Memory</div><div class="v" id="sm">—</div><div class="s" id="smp"></div></div>
<div class="card"><div class="l">Status</div><div class="v" style="font-size:13px;line-height:1.8">
<span class="s2" id="s2">0</span> ok <span class="s3" id="s3">0</span> redir <span class="s4" id="s4">0</span> err</div></div>
</div>
<div class="lc"><div class="lh"><h2>Live Requests</h2>
<div class="ws"><span class="wd" id="wd"></span><span id="wl">connecting</span></div></div>
<div class="lb" id="log"></div></div>
<script>
var S=$stats,R=$recent,L=document.getElementById('log');
function U(s){S=s;document.getElementById('sr').textContent=s.requests.toLocaleString();
document.getElementById('sw').textContent=s.workers;
document.getElementById('sm').textContent=s.memory+' MB';
document.getElementById('smp').textContent='peak '+s.memoryPeak+' MB';
document.getElementById('s2').textContent=s.status2xx;
document.getElementById('s3').textContent=s.status3xx;
document.getElementById('s4').textContent=s.status4xx;
document.getElementById('up').textContent=s.uptime;
document.getElementById('srps').textContent=(s.uptimeSec>0?(s.requests/s.uptimeSec).toFixed(1):'0')+' req/s'}
function A(e){var d=document.createElement('div');d.className='le';
var c=e.status<300?'s2':e.status<400?'s3':e.status<500?'s4':'s5';
d.innerHTML='<span class="lt">'+e.time+'</span><span class="ls '+c+'">'+e.status+
'</span><span class="lm">'+e.method+'</span><span class="lu">'+
e.uri.replace(/</g,'&lt;')+'</span><span class="ld">'+e.ms+'ms</span>';
L.appendChild(d);if(L.children.length>200)L.removeChild(L.firstChild);L.scrollTop=L.scrollHeight}
U(S);R.forEach(A);
var ws;function C(){ws=new WebSocket('$wsUrl');
ws.onopen=function(){document.getElementById('wd').className='wd on';document.getElementById('wl').textContent='live'};
ws.onmessage=function(e){var m=JSON.parse(e.data);if(m.type==='request'){A(m.entry);U(m.stats)}};
ws.onclose=function(){document.getElementById('wd').className='wd';
document.getElementById('wl').textContent='reconnecting';setTimeout(C,2000)}}C();
</script></body></html>
HTML;
}
}
+399
View File
@@ -0,0 +1,399 @@
<?php
/**
* @module Q
*/
/**
* HTTP response header processing for Q_WebServer.
*
* Handles special headers that control server behavior
* (like nginx does), plus compression negotiation:
*
* - X-Accel-Redirect: serve a file from an internal path
* instead of sending the PHP response body. PHP checks
* permissions, sets Content-Type, then the server does
* the efficient file I/O. The header is stripped from
* the client response.
*
* - X-Accel-Buffering: yes/no — controls output buffering
*
* - X-Accel-Expires: override Cache-Control for the proxy
*
* - Content-Encoding: gzip/br negotiation based on
* Accept-Encoding and content type. For static files,
* checks for pre-compressed .gz/.br siblings first.
*
* @class Q_WebServer_Headers
*/
class Q_WebServer_Headers
{
/**
* Headers that are server directives — never sent to client.
* @property $internalHeaders
* @static
*/
static $internalHeaders = array(
'x-accel-redirect',
'x-accel-buffering',
'x-accel-charset',
);
/**
* Content types eligible for compression.
* @property $compressibleTypes
* @static
*/
static $compressibleTypes = array(
'text/html', 'text/css', 'text/plain', 'text/xml',
'text/csv', 'text/yaml',
'application/javascript', 'application/json',
'application/xml', 'application/rss+xml',
'application/atom+xml', 'image/svg+xml',
);
/**
* Minimum body size to bother compressing.
* @property $compressMinSize
* @static
*/
static $compressMinSize = 1024;
/**
* Process a response from PHP (worker pool or in-process).
* Handles X-Accel-Redirect and compression. Returns the
* final response to send to the client.
*
* @method processResponse
* @static
* @param {resource} $client Socket to write to
* @param {array} $response [status, body, headers] from PHP
* @param {array} $requestHeaders Original request headers
* (needed for Accept-Encoding)
* @return {boolean} true if response was fully handled
*/
static function processResponse($client, $response, $requestHeaders)
{
$status = $response['status'] ?? 200;
$body = $response['body'] ?? '';
$headers = $response['headers'] ?? array();
// ── X-Accel-Redirect ─────────────────────────────
// PHP script says "serve this internal file instead"
$accelPath = null;
foreach ($headers as $k => $v) {
if (strtolower($k) === 'x-accel-redirect') {
$accelPath = $v;
}
}
if ($accelPath) {
// Strip internal headers from response
$headers = self::stripInternal($headers);
// Resolve the internal path
$fsPath = self::resolveAccelPath($accelPath);
if ($fsPath && is_file($fsPath)) {
// Serve the file, keeping Content-Type and other
// headers the PHP script set
self::serveAccelFile($client, $fsPath, $headers, $requestHeaders);
return true;
}
// Path not found — send 404
Q_WebServer::sendResponse($client, 404, 'X-Accel-Redirect: file not found');
return true;
}
// ── Strip internal headers ───────────────────────
$headers = self::stripInternal($headers);
// ── Compression ──────────────────────────────────
$ct = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') $ct = $v;
}
$body = self::maybeCompress($body, $ct, $requestHeaders, $headers);
// ── Send response ────────────────────────────────
$headers['Content-Length'] = strlen($body);
$headers['Connection'] = 'close';
static $reasons = array(
200=>'OK', 201=>'Created', 204=>'No Content',
301=>'Moved Permanently', 302=>'Found', 304=>'Not Modified',
400=>'Bad Request', 401=>'Unauthorized', 403=>'Forbidden',
404=>'Not Found', 405=>'Method Not Allowed',
413=>'Payload Too Large', 500=>'Internal Server Error',
502=>'Bad Gateway', 503=>'Service Unavailable',
);
$reason = $reasons[$status] ?? 'OK';
$out = "HTTP/1.1 $status $reason\r\n";
foreach ($headers as $k => $v) {
$out .= "$k: $v\r\n";
}
@fwrite($client, $out . "\r\n" . $body);
return true;
}
/**
* Serve a file via X-Accel-Redirect, applying compression
* if appropriate. Merges headers the PHP script set
* (Content-Type, Cache-Control, etc.) with file serving headers.
*
* @method serveAccelFile
* @static
*/
static function serveAccelFile($client, $fsPath, $phpHeaders, $requestHeaders)
{
clearstatcache(true, $fsPath);
$size = filesize($fsPath);
$mtime = filemtime($fsPath);
$ext = strtolower(pathinfo($fsPath, PATHINFO_EXTENSION));
// Start with headers from PHP, fill in defaults
$headers = $phpHeaders;
if (!self::hasHeader($headers, 'Content-Type')) {
$headers['Content-Type'] = Q_WebServer::mimeType($ext);
}
if (!self::hasHeader($headers, 'Cache-Control')) {
$headers['Cache-Control'] = 'public, max-age=0, must-revalidate';
}
// ETag / Last-Modified
$etag = '"' . dechex($mtime) . '-' . dechex($size) . '"';
$headers['ETag'] = $etag;
$headers['Last-Modified'] = gmdate('D, d M Y H:i:s', $mtime) . ' GMT';
// Check for pre-compressed version
$compressed = self::findPreCompressed($fsPath, $requestHeaders);
if ($compressed) {
$headers['Content-Encoding'] = $compressed['encoding'];
$headers['Content-Length'] = $compressed['size'];
$headers['Vary'] = 'Accept-Encoding';
$headers['Connection'] = 'close';
$out = "HTTP/1.1 200 OK\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
fwrite($client, $out . "\r\n");
$fp = fopen($compressed['path'], 'rb');
while (!feof($fp)) {
$data = fread($fp, 65536);
if ($data === false || @fwrite($client, $data) === false) break;
}
fclose($fp);
return;
}
// Check if we should compress on-the-fly
$ct = '';
foreach ($headers as $k => $v) {
if (strtolower($k) === 'content-type') $ct = $v;
}
$shouldCompress = self::shouldCompress($ct, $size, $requestHeaders);
if ($shouldCompress && $size < 5242880) { // < 5MB: read + compress + send
$body = file_get_contents($fsPath);
$body = self::maybeCompress($body, $ct, $requestHeaders, $headers);
$headers['Content-Length'] = strlen($body);
$headers['Connection'] = 'close';
$out = "HTTP/1.1 200 OK\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
@fwrite($client, $out . "\r\n" . $body);
return;
}
// No compression — stream directly
$headers['Content-Length'] = $size;
$headers['Connection'] = 'close';
$out = "HTTP/1.1 200 OK\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
fwrite($client, $out . "\r\n");
$fp = fopen($fsPath, 'rb');
while (!feof($fp)) {
$data = fread($fp, 65536);
if ($data === false || @fwrite($client, $data) === false) break;
}
fclose($fp);
}
/**
* Check for pre-compressed .gz or .br sibling files
* (like nginx gzip_static / brotli_static).
*
* @method findPreCompressed
* @static
* @param {string} $fsPath Original file path
* @param {array} $requestHeaders
* @return {array|null} [path, encoding, size] or null
*/
static function findPreCompressed($fsPath, $requestHeaders)
{
$accept = strtolower($requestHeaders['accept-encoding'] ?? '');
// Prefer brotli over gzip
if (strpos($accept, 'br') !== false) {
$brPath = $fsPath . '.br';
if (file_exists($brPath)) {
clearstatcache(true, $brPath);
// Only use if not older than original
if (filemtime($brPath) >= filemtime($fsPath)) {
return array(
'path' => $brPath,
'encoding' => 'br',
'size' => filesize($brPath)
);
}
}
}
if (strpos($accept, 'gzip') !== false) {
$gzPath = $fsPath . '.gz';
if (file_exists($gzPath)) {
clearstatcache(true, $gzPath);
if (filemtime($gzPath) >= filemtime($fsPath)) {
return array(
'path' => $gzPath,
'encoding' => 'gzip',
'size' => filesize($gzPath)
);
}
}
}
return null;
}
/**
* Maybe compress a response body (gzip on-the-fly).
* Modifies $headers by reference to add Content-Encoding + Vary.
*
* @method maybeCompress
* @static
* @param {string} $body
* @param {string} $contentType
* @param {array} $requestHeaders
* @param {array} &$headers Response headers (modified)
* @return {string} Possibly compressed body
*/
static function maybeCompress($body, $contentType, $requestHeaders, &$headers)
{
if (!self::shouldCompress($contentType, strlen($body), $requestHeaders)) {
return $body;
}
$accept = strtolower($requestHeaders['accept-encoding'] ?? '');
// Try gzip (universally supported, no ext needed)
if (strpos($accept, 'gzip') !== false && function_exists('gzencode')) {
$compressed = gzencode($body, 6);
if ($compressed !== false && strlen($compressed) < strlen($body)) {
$headers['Content-Encoding'] = 'gzip';
$headers['Vary'] = 'Accept-Encoding';
return $compressed;
}
}
return $body;
}
/**
* Check whether a response should be compressed.
*
* @method shouldCompress
* @static
* @param {string} $contentType
* @param {integer} $bodySize
* @param {array} $requestHeaders
* @return {boolean}
*/
static function shouldCompress($contentType, $bodySize, $requestHeaders)
{
if ($bodySize < self::$compressMinSize) return false;
if (empty($requestHeaders['accept-encoding'])) return false;
// Check content type (strip charset parameter)
$baseType = strtolower(strtok($contentType, ';'));
return in_array($baseType, self::$compressibleTypes);
}
/**
* Resolve an X-Accel-Redirect path to a filesystem path.
*
* Supports two patterns:
* /Q/internal/... → maps to configured internal directory
* /absolute/path → maps relative to document root
*
* Config: Q.webserver.accel.mappings = { "/Q/internal": "/path/on/disk" }
*
* @method resolveAccelPath
* @static
* @param {string} $accelPath The X-Accel-Redirect value
* @return {string|null} Filesystem path or null
*/
static function resolveAccelPath($accelPath)
{
// Check configured mappings first
$mappings = Q_Config::get('Q', 'webserver', 'accel', 'mappings', array());
foreach ($mappings as $prefix => $diskPath) {
if (strpos($accelPath, $prefix) === 0) {
$relative = substr($accelPath, strlen($prefix));
$fsPath = rtrim($diskPath, DS) . DS
. ltrim(str_replace('/', DS, $relative), DS);
$real = realpath($fsPath);
// Ensure we don't escape the mapped directory
if ($real && strpos($real, realpath($diskPath)) === 0) {
return $real;
}
return null;
}
}
// Default: resolve relative to APP_DIR (not web root —
// the point is to serve files OUTSIDE the web root)
if (defined('APP_DIR')) {
$fsPath = APP_DIR . DS . ltrim(str_replace('/', DS, $accelPath), DS);
$real = realpath($fsPath);
if ($real && strpos($real, realpath(APP_DIR)) === 0) {
return $real;
}
}
return null;
}
/**
* Strip internal/server-directive headers from a response.
*
* @method stripInternal
* @static
* @param {array} $headers
* @return {array} Cleaned headers
*/
static function stripInternal($headers)
{
$result = array();
foreach ($headers as $k => $v) {
if (!in_array(strtolower($k), self::$internalHeaders)) {
$result[$k] = $v;
}
}
return $result;
}
/**
* Check if a header exists (case-insensitive).
*/
static function hasHeader($headers, $name)
{
$lower = strtolower($name);
foreach ($headers as $k => $v) {
if (strtolower($k) === $lower) return true;
}
return false;
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* @module Q
*/
/**
* Request logging with file rotation for Q_WebServer.
*
* Writes access.log (Apache combined format) and error.log.
* Rotates by size (default 10MB) or by date.
*
* Config:
* "Q": { "webserver": { "log": {
* "access": "logs/access.log",
* "error": "logs/error.log",
* "maxSize": 10485760,
* "rotate": true
* }}}
*
* @class Q_WebServer_Log
*/
class Q_WebServer_Log
{
static $accessPath = null;
static $errorPath = null;
static $accessFp = null;
static $errorFp = null;
static $maxSize = 10485760; // 10MB
/**
* Initialize logging. Opens files, sets up rotation check.
* @method init
* @static
*/
static function init()
{
$config = Q_Config::get('Q', 'webserver', 'log', array());
self::$maxSize = (int) Q::ifset($config, 'maxSize', 10485760);
self::$accessPath = Q::ifset($config, 'access', null);
self::$errorPath = Q::ifset($config, 'error', null);
if (self::$accessPath) {
$dir = dirname(self::$accessPath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
self::$accessFp = fopen(self::$accessPath, 'a');
}
if (self::$errorPath) {
$dir = dirname(self::$errorPath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
self::$errorFp = fopen(self::$errorPath, 'a');
}
// Periodic rotation check (every 60s)
if (self::$accessPath || self::$errorPath) {
Q_Evented::repeat(60.0, function () {
Q_WebServer_Log::checkRotation();
});
}
}
/**
* Log a request in combined log format.
* @method access
* @static
*/
static function access($ip, $method, $uri, $status, $size, $referer, $ua, $ms)
{
if (!self::$accessFp) return;
$time = date('d/M/Y:H:i:s O');
$line = sprintf(
"%s - - [%s] \"%s %s HTTP/1.1\" %d %d \"%s\" \"%s\" %.1fms\n",
$ip, $time, $method, $uri, $status, $size,
$referer ?: '-', $ua ?: '-', $ms
);
fwrite(self::$accessFp, $line);
}
/**
* Log an error.
* @method error
* @static
*/
static function error($message, $context = '')
{
if (self::$errorFp) {
$time = date('Y-m-d H:i:s');
fwrite(self::$errorFp, "[$time] $message $context\n");
}
// Always echo errors to stderr
fwrite(STDERR, "[ERROR] $message $context\n");
}
/**
* Rotate log files if they exceed maxSize.
* @method checkRotation
* @static
*/
static function checkRotation()
{
if (self::$accessPath && file_exists(self::$accessPath)) {
clearstatcache(true, self::$accessPath);
if (filesize(self::$accessPath) > self::$maxSize) {
self::rotate(self::$accessPath, self::$accessFp);
self::$accessFp = fopen(self::$accessPath, 'a');
}
}
if (self::$errorPath && file_exists(self::$errorPath)) {
clearstatcache(true, self::$errorPath);
if (filesize(self::$errorPath) > self::$maxSize) {
self::rotate(self::$errorPath, self::$errorFp);
self::$errorFp = fopen(self::$errorPath, 'a');
}
}
}
static function rotate($path, &$fp)
{
if ($fp) fclose($fp);
$date = date('Y-m-d-His');
$rotated = $path . '.' . $date;
rename($path, $rotated);
// Keep last 10 rotated files
$pattern = $path . '.*';
$files = glob($pattern);
sort($files);
while (count($files) > 10) {
unlink(array_shift($files));
}
}
static function shutdown()
{
if (self::$accessFp) { fclose(self::$accessFp); self::$accessFp = null; }
if (self::$errorFp) { fclose(self::$errorFp); self::$errorFp = null; }
}
}
File diff suppressed because it is too large Load Diff
+411
View File
@@ -0,0 +1,411 @@
<?php
/**
* @module Q
*/
/**
* Pre-fork worker pool for PHP script execution.
*
* Each worker handles ONE request, then exits. The parent
* maintains N idle workers at all times. When one finishes,
* a replacement is forked immediately.
*
* Why one-request-per-process:
* PHP has no way to fully reset static state — Foo::$bar,
* DB connections, registered shutdown functions, output
* buffers all persist. The only clean reset is process exit.
*
* Why this is fast:
* fork() on Linux uses copy-on-write. The child inherits
* all loaded classes, opcache, config — everything the
* parent loaded during bootstrap — without copying memory.
* Cost: ~0.5ms per fork.
*
* Important: the parent must NOT open DB connections or
* stateful resources before forking. Q's DB connections are
* lazy (opened on first query), so this is natural.
*
* Parent (event loop, Q.inc.php loaded)
* ├── Worker 0 [idle, waiting on socketpair]
* ├── Worker 1 [busy, processing request] → exits → replacement forked
* ├── Worker 2 [idle]
* └── Worker 3 [idle]
*
* @class Q_WebServer_Pool
*/
class Q_WebServer_Pool
{
public $targetSize;
protected $workers = array(); // index => [pid, socket, busy]
protected $workerClients = array(); // index => HTTP client socket
protected $workerBuffers = array(); // index => partial response data
protected $watchers = array(); // index => Q_Evented watcher id
protected $pending = array(); // queued [client, parsed, scriptPath]
protected $nextIndex = 0;
/**
* @method __construct
* @param {integer} [$size=4]
*/
function __construct($size = null)
{
if (!function_exists('pcntl_fork')) {
throw new Exception(
"Q_WebServer_Pool requires pcntl extension. "
. "Use --workers=0 or Caddy/nginx + php-fpm."
);
}
$this->targetSize = $size ?: (int) Q_Config::get(
'Q', 'webserver', 'workers', 4
);
pcntl_signal(SIGCHLD, SIG_DFL);
for ($i = 0; $i < $this->targetSize; $i++) {
$this->forkWorker();
}
}
/**
* Fork one worker. Child inherits parent's loaded state
* via copy-on-write.
* @method forkWorker
* @return {integer} Worker index
*/
protected function forkWorker()
{
$pair = stream_socket_pair(
STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP
);
if (!$pair) throw new Exception("socketpair failed");
$pid = pcntl_fork();
if ($pid === -1) throw new Exception("fork failed");
if ($pid === 0) {
// ── CHILD: wait for one request, handle, exit ──
fclose($pair[0]);
self::childRun($pair[1]);
exit(0);
}
// ── PARENT ──
fclose($pair[1]);
$sock = $pair[0];
stream_set_blocking($sock, false);
$index = $this->nextIndex++;
$this->workers[$index] = array(
'pid' => $pid, 'socket' => $sock, 'busy' => false
);
$pool = $this;
$this->watchers[$index] = Q_Evented::onReadable(
$sock,
function ($s) use ($pool, $index) {
$pool->onWorkerData($index, $s);
}
);
Q_Evented::disable($this->watchers[$index]);
return $index;
}
// ── Child process ────────────────────────────────────
/**
* Child: block on socket, read one request, execute, respond, die.
*/
protected static function childRun($socket)
{
stream_set_blocking($socket, true);
// Read length-prefixed request
$hdr = self::readExact($socket, 4);
if ($hdr === false) return;
$len = unpack('N', $hdr)[1];
if ($len > 10485760) return;
$json = self::readExact($socket, $len);
if ($json === false) return;
$req = json_decode($json, true);
if (!$req) {
self::writeMsg($socket, 500, 'Bad message', array());
return;
}
// Execute the PHP script
$resp = self::executeScript($req);
self::writeMsg($socket, $resp['status'], $resp['body'], $resp['headers']);
fclose($socket);
}
/**
* Set up superglobals and include the PHP script.
* The script (index.php, action.php, etc.) internally calls
* Q_WebController::execute() or Q_ActionController::execute().
*/
protected static function executeScript($req)
{
$_SERVER['REQUEST_METHOD'] = $req['method'];
$_SERVER['REQUEST_URI'] = $req['uri'];
$_SERVER['QUERY_STRING'] = $req['query'] ?? '';
$_SERVER['SCRIPT_FILENAME'] = $req['scriptFilename'];
$_SERVER['SCRIPT_NAME'] = $req['scriptName'] ?? '/index.php';
$_SERVER['DOCUMENT_ROOT'] = $req['documentRoot'] ?? '';
$_SERVER['SERVER_NAME'] = $req['headers']['host'] ?? 'localhost';
$_SERVER['SERVER_PORT'] = $req['serverPort'] ?? '8080';
$_SERVER['REMOTE_ADDR'] = $req['remoteAddr'] ?? '127.0.0.1';
foreach ($req['headers'] as $k => $v) {
$_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v;
}
if (isset($req['headers']['content-type']))
$_SERVER['CONTENT_TYPE'] = $req['headers']['content-type'];
if (isset($req['headers']['content-length']))
$_SERVER['CONTENT_LENGTH'] = $req['headers']['content-length'];
$_GET = $_POST = $_REQUEST = array();
if (!empty($req['query'])) parse_str($req['query'], $_GET);
$ct = strtolower($req['headers']['content-type'] ?? '');
$raw = $req['body'] ?? '';
if (strpos($ct, 'application/x-www-form-urlencoded') !== false) {
parse_str($raw, $_POST);
} elseif (strpos($ct, 'application/json') !== false) {
$_POST = json_decode($raw, true) ?: array();
}
$_REQUEST = array_merge($_GET, $_POST);
// php://input workaround for forked processes
$GLOBALS['_Q_RAW_INPUT'] = $raw;
ob_start();
$status = 200;
$headers = array();
try {
include($req['scriptFilename']);
foreach (headers_list() as $h) {
if (strpos($h, ':') !== false) {
list($k, $v) = explode(':', $h, 2);
$headers[trim($k)] = trim($v);
}
}
$code = http_response_code();
if ($code) $status = $code;
} catch (\Throwable $e) {
$status = 500;
ob_clean();
echo $e->getMessage();
}
$body = ob_get_clean();
return compact('status', 'body', 'headers');
}
// ── Parent-side dispatch ─────────────────────────────
/**
* Send a request to an idle worker. Queues if all busy.
*/
function dispatch($client, $parsed, $scriptPath)
{
$idle = $this->findIdle();
if ($idle === null) {
$this->pending[] = array($client, $parsed, $scriptPath);
return;
}
$this->sendTo($idle, $client, $parsed, $scriptPath);
}
protected function sendTo($index, $client, $parsed, $scriptPath)
{
$this->workers[$index]['busy'] = true;
$this->workerClients[$index] = $client;
$this->workerBuffers[$index] = '';
$this->workerRequestHeaders[$index] = $parsed['headers'];
Q_Evented::enable($this->watchers[$index]);
$msg = json_encode(array(
'method' => $parsed['method'],
'uri' => $parsed['uri'],
'path' => $parsed['path'],
'query' => $parsed['query'],
'headers' => $parsed['headers'],
'body' => $parsed['body'],
'scriptFilename' => $scriptPath,
'scriptName' => '/' . basename($scriptPath),
'documentRoot' => Q_WebServer::$rootDir ?? '',
'serverPort' => (string)($_SERVER['SERVER_PORT'] ?? '8080'),
'remoteAddr' => '127.0.0.1'
));
fwrite($this->workers[$index]['socket'], pack('N', strlen($msg)) . $msg);
}
/**
* Called when data or EOF arrives from a worker.
*/
function onWorkerData($index, $sock)
{
$chunk = @fread($sock, 65536);
if ($chunk === false || $chunk === '') {
// Worker exited (expected after one request)
$this->recycle($index, true);
return;
}
$this->workerBuffers[$index] .= $chunk;
$buf = $this->workerBuffers[$index];
if (strlen($buf) < 4) return;
$len = unpack('N', substr($buf, 0, 4))[1];
if (strlen($buf) < 4 + $len) return;
// Got complete response
$json = substr($buf, 4, $len);
$response = json_decode($json, true);
// Check for cache messages piggybacked on the response
if ($response && !empty($response['_cacheMessages'])) {
foreach ($response['_cacheMessages'] as $msg) {
Q_WebServer_Cache_Components::processChildMessage($msg);
}
unset($response['_cacheMessages']);
}
$client = $this->workerClients[$index] ?? null;
if ($response && $client && is_resource($client)) {
$this->sendHttp($client, $response, $index);
}
$this->recycle($index, false);
}
/**
* Clean up a finished worker: close socket, reap pid,
* fork replacement, process pending queue.
*/
protected function recycle($index, $isEof)
{
if (isset($this->watchers[$index])) {
Q_Evented::cancel($this->watchers[$index]);
unset($this->watchers[$index]);
}
// EOF with no response → 502
if ($isEof && isset($this->workerClients[$index])
&& empty($this->workerBuffers[$index])
) {
$c = $this->workerClients[$index];
if (is_resource($c)) {
Q_WebServer::sendResponse($c, 502, 'Worker died');
@fclose($c);
}
} elseif (isset($this->workerClients[$index])) {
@fclose($this->workerClients[$index]);
}
if (isset($this->workers[$index])) {
@fclose($this->workers[$index]['socket']);
pcntl_waitpid($this->workers[$index]['pid'], $st, WNOHANG);
}
unset($this->workers[$index], $this->workerClients[$index],
$this->workerBuffers[$index], $this->workerRequestHeaders[$index]);
// Immediately fork replacement
$newIdx = $this->forkWorker();
// Drain pending queue
if (!empty($this->pending)) {
$next = array_shift($this->pending);
$this->sendTo($newIdx, $next[0], $next[1], $next[2]);
}
}
/**
* We need the original request headers for compression
* negotiation. Store them alongside the client.
* @property $workerRequestHeaders
*/
protected $workerRequestHeaders = array();
protected function sendHttp($client, $resp, $index)
{
$reqHeaders = $this->workerRequestHeaders[$index] ?? array();
Q_WebServer_Headers::processResponse($client, $resp, $reqHeaders);
}
protected function findIdle()
{
foreach ($this->workers as $i => $w) {
if (!$w['busy']) return $i;
}
return null;
}
/**
* Graceful shutdown: SIGTERM all workers, wait up to $timeout seconds,
* then SIGKILL any remaining.
* @param {float} $timeout Seconds to wait after SIGTERM before SIGKILL
*/
function shutdown($timeout = 3.0)
{
// Cancel watchers and close sockets
foreach ($this->workers as $i => $w) {
if (isset($this->watchers[$i])) {
Q_Evented::cancel($this->watchers[$i]);
}
@fclose($w['socket']);
}
// Send SIGTERM to all workers
foreach ($this->workers as $w) {
posix_kill($w['pid'], SIGTERM);
}
// Wait for workers to exit gracefully
$deadline = microtime(true) + $timeout;
$remaining = $this->workers;
while (!empty($remaining) && microtime(true) < $deadline) {
foreach ($remaining as $i => $w) {
$result = pcntl_waitpid($w['pid'], $st, WNOHANG);
if ($result > 0 || $result === -1) {
unset($remaining[$i]);
}
}
if (!empty($remaining)) {
usleep(50000); // 50ms
}
}
// SIGKILL any workers that didn't exit in time
foreach ($remaining as $w) {
posix_kill($w['pid'], SIGKILL);
pcntl_waitpid($w['pid'], $st, 0);
}
$this->workers = array();
}
function idleCount()
{
$n = 0;
foreach ($this->workers as $w) if (!$w['busy']) $n++;
return $n;
}
// ── Wire helpers ─────────────────────────────────────
protected static function readExact($sock, $n)
{
$buf = '';
while (strlen($buf) < $n) {
$c = fread($sock, $n - strlen($buf));
if ($c === false || $c === '') return false;
$buf .= $c;
}
return $buf;
}
protected static function writeMsg($sock, $status, $body, $headers)
{
$j = json_encode(compact('status', 'body', 'headers'));
fwrite($sock, pack('N', strlen($j)) . $j);
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
/**
* @module Q
*/
/**
* Reverse proxy header handling for Q_WebServer.
*
* When behind Cloudflare, AWS ALB, Caddy, nginx, etc.,
* the client's real IP and protocol are in X-Forwarded-*
* headers. This class extracts them from trusted proxies.
*
* Config:
* "Q": { "webserver": { "proxy": {
* "trusted": ["127.0.0.1", "10.0.0.0/8", "172.16.0.0/12",
* "192.168.0.0/16", "173.245.48.0/20", "103.21.244.0/22"],
* "headers": {
* "ip": "X-Forwarded-For",
* "proto": "X-Forwarded-Proto",
* "host": "X-Forwarded-Host"
* }
* }}}
*
* Cloudflare IPs are in the default trusted list. Add your
* own load balancer IPs as needed.
*
* @class Q_WebServer_Proxy
*/
class Q_WebServer_Proxy
{
static $trusted = null;
/**
* Extract the real client IP from proxy headers.
* Only trusts headers from configured proxy IPs.
*
* @method clientIp
* @static
* @param {string} $directIp The socket-level remote IP
* @param {array} $headers Request headers (lowercase keys)
* @return {string} Real client IP
*/
static function clientIp($directIp, $headers)
{
if (!self::isTrusted($directIp)) return $directIp;
$headerName = strtolower(Q_Config::get(
'Q', 'webserver', 'proxy', 'headers', 'ip',
'x-forwarded-for'
));
$forwarded = $headers[$headerName] ?? '';
if (!$forwarded) return $directIp;
// X-Forwarded-For: client, proxy1, proxy2
// Rightmost untrusted IP is the real client
$ips = array_map('trim', explode(',', $forwarded));
for ($i = count($ips) - 1; $i >= 0; $i--) {
if (!self::isTrusted($ips[$i])) {
return $ips[$i];
}
}
return $ips[0]; // all trusted, use leftmost
}
/**
* Extract the real protocol (http/https).
*
* @method clientProto
* @static
* @param {string} $directIp
* @param {array} $headers
* @param {boolean} $isTls Whether connection is TLS
* @return {string} 'http' or 'https'
*/
static function clientProto($directIp, $headers, $isTls = false)
{
if ($isTls) return 'https';
if (!self::isTrusted($directIp)) return 'http';
$headerName = strtolower(Q_Config::get(
'Q', 'webserver', 'proxy', 'headers', 'proto',
'x-forwarded-proto'
));
$proto = $headers[$headerName] ?? '';
return strtolower($proto) === 'https' ? 'https' : 'http';
}
/**
* Extract the real host.
*
* @method clientHost
* @static
* @param {string} $directIp
* @param {array} $headers
* @return {string}
*/
static function clientHost($directIp, $headers)
{
if (self::isTrusted($directIp)) {
$headerName = strtolower(Q_Config::get(
'Q', 'webserver', 'proxy', 'headers', 'host',
'x-forwarded-host'
));
$host = $headers[$headerName] ?? '';
if ($host) return $host;
}
return $headers['host'] ?? 'localhost';
}
/**
* Check if an IP is a trusted proxy.
*
* @method isTrusted
* @static
* @param {string} $ip
* @return {boolean}
*/
static function isTrusted($ip)
{
if (self::$trusted === null) {
self::$trusted = Q_Config::get(
'Q', 'webserver', 'proxy', 'trusted',
array('127.0.0.1', '::1')
);
}
foreach (self::$trusted as $range) {
if (strpos($range, '/') !== false) {
if (self::ipInCidr($ip, $range)) return true;
} else {
if ($ip === $range) return true;
}
}
return false;
}
/**
* Check if IP is within a CIDR range.
*/
static function ipInCidr($ip, $cidr)
{
list($subnet, $bits) = explode('/', $cidr);
$ip = ip2long($ip);
$subnet = ip2long($subnet);
if ($ip === false || $subnet === false) return false;
$mask = -1 << (32 - (int) $bits);
return ($ip & $mask) === ($subnet & $mask);
}
}
+303
View File
@@ -0,0 +1,303 @@
<?php
/**
* @module Q
*/
/**
* WebSocket server for Q_Evented loops.
*
* Handles RFC 6455 WebSocket protocol: upgrade handshake,
* frame encoding/decoding, ping/pong, channels, broadcast.
* Works on the same port as Q_WebServer — HTTP requests are
* served normally, WebSocket upgrades are handed off here.
*
* Client-side uses the browser's native WebSocket API:
* var ws = new WebSocket('ws://localhost:8080/my/path');
*
* Server-side:
* // In a Q_Evented loop, after detecting Upgrade header:
* Q_WebSocket::upgrade($socket, $headers, function ($socket, $msg) {
* // handle incoming message
* });
*
* // Broadcast to all connected clients (or a channel):
* Q_WebSocket::broadcast(array('type' => 'update', 'data' => $data));
* Q_WebSocket::broadcastTo('dashboard', array('type' => 'stats'));
*
* @class Q_WebSocket
*/
class Q_WebSocket
{
const GUID = '258EAFA5-E914-47DA-95CA-5AB5DC587B41';
/**
* Connected clients. socketKey => [socket, watcher, channels, buffer, onMessage]
* @property $clients
* @static
*/
static $clients = array();
/**
* Channel → subscriber map. channel => [socketKey => true]
* @property $channels
* @static
*/
static $channels = array();
/**
* Upgrade an HTTP connection to WebSocket.
* Performs the RFC 6455 handshake and registers the socket
* with Q_Evented for non-blocking frame reads.
*
* @method upgrade
* @static
* @param {resource} $socket The TCP socket (from Q_WebServer)
* @param {array} $headers Lowercase HTTP headers from the request
* @param {callable|null} [$onMessage=null] function($socketKey, $message)
* called when client sends a text frame
* @param {string|null} [$channel=null] Auto-subscribe to this channel
* @return {boolean} true if upgrade succeeded
*/
static function upgrade($socket, $headers, $onMessage = null, $channel = null)
{
$key = $headers['sec-websocket-key'] ?? '';
if (!$key) return false;
$accept = base64_encode(sha1($key . self::GUID, true));
$resp = "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\n"
. "Connection: Upgrade\r\n"
. "Sec-WebSocket-Accept: $accept\r\n\r\n";
fwrite($socket, $resp);
$sk = (int) $socket;
self::$clients[$sk] = array(
'socket' => $socket,
'watcher' => null,
'channels' => array(),
'buffer' => '',
'onMessage' => $onMessage
);
self::$clients[$sk]['watcher'] = Q_Evented::onReadable(
$socket,
function ($s) { Q_WebSocket::onData($s); }
);
if ($channel) {
self::subscribe($sk, $channel);
}
return true;
}
/**
* Handle incoming data on a WebSocket connection.
* Parses frames, dispatches text messages, handles
* ping/pong and close.
*
* @method onData
* @static
* @param {resource} $socket
*/
static function onData($socket)
{
$sk = (int) $socket;
if (!isset(self::$clients[$sk])) return;
$data = @fread($socket, 65536);
if ($data === false || $data === '') {
self::disconnect($sk);
return;
}
self::$clients[$sk]['buffer'] .= $data;
while (strlen(self::$clients[$sk]['buffer']) >= 2) {
$frame = self::decodeFrame(self::$clients[$sk]['buffer']);
if ($frame === null) break; // incomplete
self::$clients[$sk]['buffer'] = $frame['remaining'];
switch ($frame['opcode']) {
case 0x1: // Text frame
$cb = self::$clients[$sk]['onMessage'];
if ($cb) {
$cb($sk, $frame['payload']);
}
break;
case 0x8: // Close
self::encodeAndSend($socket, 0x8, '');
self::disconnect($sk);
return;
case 0x9: // Ping → Pong
self::encodeAndSend($socket, 0xA, $frame['payload']);
break;
case 0xA: // Pong — ignore
break;
}
}
}
// ── Sending ──────────────────────────────────────────
/**
* Send a text message to a specific client.
* @method send
* @static
* @param {integer} $socketKey
* @param {array|string} $data If array, JSON-encoded
*/
static function send($socketKey, $data)
{
if (!isset(self::$clients[$socketKey])) return;
$text = is_string($data) ? $data : json_encode($data);
self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $text);
}
/**
* Broadcast to ALL connected clients.
* @method broadcast
* @static
* @param {array|string} $data
*/
static function broadcast($data)
{
$text = is_string($data) ? $data : json_encode($data);
foreach (self::$clients as $sk => $c) {
if (is_resource($c['socket'])) {
self::encodeAndSend($c['socket'], 0x1, $text);
} else {
self::disconnect($sk);
}
}
}
/**
* Broadcast to clients subscribed to a channel.
* @method broadcastTo
* @static
* @param {string} $channel
* @param {array|string} $data
*/
static function broadcastTo($channel, $data)
{
if (empty(self::$channels[$channel])) return;
$text = is_string($data) ? $data : json_encode($data);
foreach (self::$channels[$channel] as $sk => $_) {
if (!isset(self::$clients[$sk]) || !is_resource(self::$clients[$sk]['socket'])) {
unset(self::$channels[$channel][$sk]);
continue;
}
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $text);
}
}
// ── Channels ─────────────────────────────────────────
static function subscribe($socketKey, $channel)
{
self::$channels[$channel][$socketKey] = true;
self::$clients[$socketKey]['channels'][$channel] = true;
}
static function unsubscribe($socketKey, $channel)
{
unset(self::$channels[$channel][$socketKey]);
unset(self::$clients[$socketKey]['channels'][$channel]);
}
// ── Connection management ────────────────────────────
static function disconnect($sk)
{
if (!isset(self::$clients[$sk])) return;
$w = self::$clients[$sk]['watcher'];
if ($w) Q_Evented::cancel($w);
foreach (self::$clients[$sk]['channels'] as $ch => $_) {
unset(self::$channels[$ch][$sk]);
}
@fclose(self::$clients[$sk]['socket']);
unset(self::$clients[$sk]);
}
static function disconnectAll()
{
foreach (array_keys(self::$clients) as $sk) {
self::disconnect($sk);
}
}
static function clientCount()
{
return count(self::$clients);
}
// ── RFC 6455 frame encoding/decoding ─────────────────
/**
* Decode one frame from a buffer.
* @return {array|null} [opcode, payload, remaining] or null if incomplete
*/
static function decodeFrame(&$buf)
{
$len = strlen($buf);
if ($len < 2) return null;
$b0 = ord($buf[0]);
$b1 = ord($buf[1]);
$opcode = $b0 & 0x0F;
$masked = ($b1 & 0x80) !== 0;
$payloadLen = $b1 & 0x7F;
$offset = 2;
if ($payloadLen === 126) {
if ($len < 4) return null;
$payloadLen = unpack('n', substr($buf, 2, 2))[1];
$offset = 4;
} elseif ($payloadLen === 127) {
if ($len < 10) return null;
$payloadLen = unpack('J', substr($buf, 2, 8))[1];
$offset = 10;
}
$totalNeeded = $offset + ($masked ? 4 : 0) + $payloadLen;
if ($len < $totalNeeded) return null;
if ($masked) {
$mask = substr($buf, $offset, 4);
$offset += 4;
$payload = substr($buf, $offset, $payloadLen);
for ($i = 0; $i < $payloadLen; $i++) {
$payload[$i] = chr(ord($payload[$i]) ^ ord($mask[$i % 4]));
}
} else {
$payload = substr($buf, $offset, $payloadLen);
}
return array(
'opcode' => $opcode,
'payload' => $payload,
'remaining' => substr($buf, $offset + $payloadLen)
);
}
/**
* Encode and send a frame (server→client, unmasked).
*/
static function encodeAndSend($socket, $opcode, $payload)
{
$len = strlen($payload);
$frame = chr(0x80 | $opcode);
if ($len < 126) {
$frame .= chr($len);
} elseif ($len < 65536) {
$frame .= chr(126) . pack('n', $len);
} else {
$frame .= chr(127) . pack('J', $len);
}
$frame .= $payload;
@fwrite($socket, $frame);
}
}