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
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Qbix
Copyright (c) 2024-2026 Qbix, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+316
View File
@@ -0,0 +1,316 @@
# ⚡ Qbix Server
A pure PHP web server. No nginx, no Apache, no php-fpm.
One process serves static files, PHP scripts, WebSocket connections, and a live dashboard.
**5573% of nginx throughput** on static files. Zero dependencies beyond PHP itself.
---
## 📑 Table of Contents
- [Quick Start](#-quick-start)
- [Performance](#-performance)
- [Features](#-features)
- [Configuration](#-configuration)
- [PHP Scripts](#-php-scripts)
- [Three Ways to Run](#-three-ways-to-run)
- [Building](#-building)
- [With Qbix Platform](#-with-qbix-platform)
- [Architecture](#-architecture)
- [Requirements](#-requirements)
- [License](#-license)
---
## 🚀 Quick Start
```bash
# Clone
git clone https://github.com/Qbix/Server.git
cd Server
# Create a web directory
mkdir web
echo '<h1>Hello World</h1>' > web/index.html
# Run
php server.php --port=8080
```
Open [http://localhost:8080](http://localhost:8080). That's it.
```bash
# Or serve an existing directory
php server.php --root=/var/www/mysite --port=80
# Or use the PHAR (single file, 196KB)
php bin/qbix-server.phar --root=./public --port=8080
```
---
## 📊 Performance
Benchmarked against nginx on the same single-core container, PHP 8.3, Ubuntu 24.
13KB static file, best-of-3 runs, warm caches.
| Scenario | nginx | Qbix Server | Ratio |
|---|---|---|---|
| Sequential (c=1) | 10,154 req/s | 6,376 req/s | **63%** |
| Concurrent (c=10) | 12,300 req/s | 6,876 req/s | **56%** |
| High concurrency (c=50) | 12,919 req/s | 7,253 req/s | **56%** |
| Keep-alive (c=10) | 26,858 req/s | 19,700 req/s | **73%** |
| Keep-alive (c=50) | 30,158 req/s | 20,369 req/s | **67%** |
Zero failed requests across 50,000+ requests at concurrency 50. Server never crashed.
> For context: 20K req/s means the server handles **1,000 simultaneous page loads per second**
> (assuming ~20 static assets per page), all from a single PHP process.
---
## ✨ Features
| Category | What you get |
|---|---|
| **Static files** | ETag, 304 Not Modified, Last-Modified, MIME type detection, in-memory response cache |
| **Keep-alive** | HTTP/1.0 and 1.1, TCP_NODELAY, configurable limits |
| **PHP execution** | `.php` files in document root run in-process or via pre-fork worker pool |
| **Compression** | On-the-fly gzip/brotli + pre-compressed `.gz`/`.br` siblings |
| **WebSocket** | RFC 6455 upgrade on any path |
| **Dashboard** | Live stats at `/Q/dashboard` — request rates, memory, status codes |
| **Health check** | JSON at `/Q/health` — for load balancers and monitoring |
| **Control panel** | Password-protected at `/Q/panel` — manage apps and scripts |
| **Rate limiting** | Per-IP with configurable windows and burst limits |
| **Security** | Path traversal blocked, dotfiles blocked, 431 for oversized headers, 400 for malformed requests |
| **Graceful shutdown** | SIGTERM/SIGINT drain in-flight requests before closing |
| **TLS** | Optional HTTPS with auto-certbot or manual certs |
| **Logging** | Colored terminal output + file-based access logs |
---
## ⚙️ Configuration
Create `config/server.json` next to your `web/` directory, or pass `--config=path/to/config.json`:
```json
{
"Q": {
"webserver": {
"keepAlive": {
"max": 100,
"timeout": 15
},
"maxConnections": 1024,
"fileCache": {
"maxSize": 67108864,
"maxFile": 1048576,
"checkInterval": 1
},
"rateLimit": {
"enabled": true,
"requests": 100,
"window": 60
}
}
}
}
```
| Key | Default | What it does |
|---|---|---|
| `keepAlive.max` | 100 | Max requests per keep-alive connection |
| `keepAlive.timeout` | 15 | Seconds before closing idle connection |
| `maxConnections` | 1024 | Max simultaneous connections |
| `fileCache.maxSize` | 64MB | Total memory for cached file responses |
| `fileCache.maxFile` | 1MB | Largest file to cache in memory |
| `fileCache.checkInterval` | 1 | Seconds between file modification checks |
| `rateLimit.enabled` | false | Enable per-IP rate limiting |
| `rateLimit.requests` | 100 | Requests per window |
| `rateLimit.window` | 60 | Window in seconds |
---
## 🐘 PHP Scripts
Any `.php` file in your document root is executed when requested:
```
web/
index.html ← served as static file
style.css ← served as static file
api.php ← executed as PHP
webhook.php ← executed as PHP
```
PHP scripts have full access to `$_SERVER`, `$_GET`, `$_POST`, `$_REQUEST`:
```php
<?php
// web/api.php
header('Content-Type: application/json');
echo json_encode([
'time' => date('c'),
'method' => $_SERVER['REQUEST_METHOD'],
'query' => $_GET,
]);
```
For concurrent PHP execution, use `--workers=N` to pre-fork a worker pool.
---
## 📦 Three Ways to Run
### 1. From source (needs PHP 8.1+)
```bash
php server.php --root=./web --port=8080
```
### 2. PHAR — single 196KB file (needs PHP)
```bash
php bin/qbix-server.phar --root=./web --port=8080
# Or make it executable
chmod +x bin/qbix-server.phar
./bin/qbix-server.phar --port=8080
```
### 3. Static binary — no PHP needed
```bash
# Download from GitHub Releases
chmod +x qbix-server-linux-x86_64
./qbix-server-linux-x86_64 --root=./web --port=8080
```
The binary bundles PHP 8.3 + extensions into a single ~15MB executable.
Copy it to any Linux machine and run. No dependencies.
---
## 🔨 Building
### Build the PHAR
```bash
php -d phar.readonly=0 build-phar.php
# Output: bin/qbix-server.phar
```
### Build the static binary
```bash
# With Docker (easiest):
./build-binary.sh --docker
# With static-php-cli installed locally:
./build-binary.sh
# Output: bin/qbix-server (~15MB)
```
The binary is built using [static-php-cli](https://github.com/crazywhalecc/static-php-cli),
which compiles PHP + extensions into a statically linked binary.
GitHub Actions automatically builds binaries for **x86_64** and **aarch64** on every tagged release.
---
## 🔌 With Qbix Platform
Qbix Server is extracted from the [Qbix Platform](https://github.com/Qbix/Platform) — a full-stack
framework for building social apps with real-time streams, user management, and plugin architecture.
When you have a Qbix app, the server uses the full framework:
```bash
php server.php --app=/path/to/myapp --port=8080
```
In this mode:
- Requests route through `Q_Dispatcher` — the full Qbix event pipeline
- Plugins load automatically (Users, Streams, Assets, etc.)
- Clean URLs work (`/community/123` → module routing)
- Static files still use the fast path (no framework overhead)
- The dashboard shows Qbix-specific stats
The standalone mode (without `--app`) runs as a plain web server — no framework, no plugins.
PHP files execute directly, static files serve from memory. Use this for simple sites,
APIs, or any project that doesn't need the full Qbix stack.
### Qbix Platform scripts
The full Platform includes additional server scripts like `static.php` for
CDN-style static file serving with versioned URLs. See the
[Platform repository](https://github.com/Qbix/Platform) for details.
---
## 🏗️ Architecture
```
┌──────────────────┐
HTTP request ────→ │ Event Loop │ stream_select (zero deps)
│ (single thread)│ or amphp/revolt (optional)
└────────┬─────────┘
┌───────────────┼───────────────┐
│ │ │
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ Static │ │ PHP │ │ WebSocket │
│ Files │ │ Dispatch│ │ Upgrade │
│ │ │ │ │ │
│ In-memory │ │ In-proc │ │ RFC 6455 │
│ response │ │ or fork │ │ frames │
│ cache │ │ pool │ │ │
└──────────┘ └──────────┘ └──────────┘
```
**Static files** are served from an in-memory response cache. The full HTTP response
(headers + body) is pre-built and sent in a single `fwrite()` call. The cache is
mtime-validated with configurable check intervals. Combined with `TCP_NODELAY`,
this delivers sub-millisecond response times.
**PHP scripts** run in-process (single-threaded, suitable for lightweight APIs)
or in a pre-fork worker pool (`--workers=N`) for concurrent PHP execution.
Workers are forked after class preloading, so they share the base memory footprint.
**The remaining gap** versus nginx (Qbix at 55-73%) is inherent: nginx uses
`sendfile()` (kernel-space file→socket copy), `epoll` (O(1) event notification),
and compiled C. PHP's `stream_select` is `select(2)`, file serving goes through
userspace, and every operation has interpreter overhead. Getting to 55-73% of C
performance from pure interpreted PHP is about as good as it gets.
---
## 📋 Requirements
**For server.php and PHAR:**
- PHP 8.1 or later
- Extensions: `sockets`, `pcntl` (for signals + workers), `openssl` (for HTTPS)
```bash
# Check
php -m | grep -E 'sockets|pcntl|openssl'
# Install on Ubuntu/Debian
sudo apt install php-cli php-sockets
```
**For the static binary:**
- Nothing. The PHP runtime is included.
---
## 📄 License
MIT — see [LICENSE](LICENSE).
Part of the [Qbix Platform](https://github.com/Qbix/Platform).
+176
View File
@@ -0,0 +1,176 @@
#!/bin/bash
#
# Build a self-contained Qbix Server binary.
#
# The binary includes the PHP interpreter + all server code.
# No PHP installation needed on the target machine.
#
# Usage: ./build-binary.sh [--arch=x86_64|aarch64] [--os=linux|macos]
#
# Prerequisites:
# - Docker (for cross-compilation) or local build tools
# - ~2GB disk space for the build
#
# Output: bin/qbix-server (or bin/qbix-server-$OS-$ARCH)
#
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$SCRIPT_DIR/bin"
SRC_DIR="$SCRIPT_DIR/src"
ARCH="${ARCH:-$(uname -m)}"
OS="${OS:-linux}"
PHP_VERSION="8.3"
# Parse args
for arg in "$@"; do
case $arg in
--arch=*) ARCH="${arg#*=}" ;;
--os=*) OS="${arg#*=}" ;;
--help)
echo "Usage: $0 [--arch=x86_64|aarch64] [--os=linux|macos]"
echo ""
echo "Builds a self-contained Qbix Server binary."
echo "Requires Docker for cross-compilation."
exit 0
;;
esac
done
mkdir -p "$BIN_DIR"
echo "═══════════════════════════════════════════"
echo " Building Qbix Server binary"
echo " PHP: $PHP_VERSION"
echo " OS: $OS"
echo " Arch: $ARCH"
echo "═══════════════════════════════════════════"
echo ""
# ── Method 1: static-php-cli (preferred) ─────────
build_with_static_php_cli() {
echo "Using static-php-cli..."
# Check if static-php-cli is available
if ! command -v spc &>/dev/null; then
echo "Installing static-php-cli..."
# Download the latest release
SPC_URL="https://github.com/crazywhalecc/static-php-cli/releases/latest/download/spc-linux-x86_64.tar.gz"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
SPC_URL="https://github.com/crazywhalecc/static-php-cli/releases/latest/download/spc-linux-aarch64.tar.gz"
fi
curl -sL "$SPC_URL" | tar xz -C /tmp/
chmod +x /tmp/spc
SPC="/tmp/spc"
else
SPC="spc"
fi
# Build PHP micro SAPI with required extensions
$SPC doctor --auto-fix 2>/dev/null || true
$SPC download --with-php=$PHP_VERSION \
--for-extensions=pcntl,sockets,openssl,mbstring,filter,ctype,tokenizer
$SPC build \
pcntl,sockets,openssl,mbstring,filter,ctype,tokenizer \
--build-micro \
--debug
MICRO_SFXN="buildroot/bin/micro.sfx"
# First build the PHAR, then cat micro.sfx + phar = binary
echo "Building PHAR for embedding..."
php -d phar.readonly=0 "$SCRIPT_DIR/build-phar.php"
echo "Combining micro.sfx + PHAR..."
cat "$MICRO_SFXN" "$BIN_DIR/qbix-server.phar" > "$BIN_DIR/qbix-server"
chmod +x "$BIN_DIR/qbix-server"
echo ""
echo "Binary built: $BIN_DIR/qbix-server"
ls -lh "$BIN_DIR/qbix-server"
}
# ── Method 2: Docker-based build ─────────────────
build_with_docker() {
echo "Using Docker for isolated build..."
# Create a temporary Dockerfile
TMPDIR=$(mktemp -d)
cp -r "$SRC_DIR" "$TMPDIR/src"
cp "$SCRIPT_DIR/build-phar.php" "$TMPDIR/"
cat > "$TMPDIR/Dockerfile" << 'DOCKERFILE'
FROM php:8.3-cli-alpine AS builder
RUN apk add --no-cache curl bash tar
# Install static-php-cli
RUN curl -sL https://github.com/crazywhalecc/static-php-cli/releases/latest/download/spc-linux-x86_64.tar.gz \
| tar xz -C /usr/local/bin/ && chmod +x /usr/local/bin/spc
WORKDIR /build
COPY src/ src/
COPY build-phar.php .
# Build PHAR first
RUN mkdir -p bin && php -d phar.readonly=0 build-phar.php
# Download PHP sources and build micro SAPI
RUN spc doctor --auto-fix 2>/dev/null || true
RUN spc download --with-php=8.3 --for-extensions=pcntl,sockets,openssl,mbstring,filter
RUN spc build pcntl,sockets,openssl,mbstring,filter --build-micro
# Combine
RUN cat buildroot/bin/micro.sfx bin/qbix-server.phar > bin/qbix-server && \
chmod +x bin/qbix-server
FROM scratch
COPY --from=builder /build/bin/qbix-server /qbix-server
DOCKERFILE
docker build -t qbix-server-builder "$TMPDIR"
docker create --name qbix-extract qbix-server-builder
docker cp qbix-extract:/qbix-server "$BIN_DIR/qbix-server"
docker rm qbix-extract
docker rmi qbix-server-builder 2>/dev/null || true
rm -rf "$TMPDIR"
echo ""
echo "Binary built: $BIN_DIR/qbix-server"
ls -lh "$BIN_DIR/qbix-server"
}
# ── Method 3: Manual build (fallback) ────────────
build_manual() {
echo "Building PHAR (binary build requires static-php-cli or Docker)..."
php -d phar.readonly=0 "$SCRIPT_DIR/build-phar.php"
echo ""
echo "PHAR built. For a static binary, install static-php-cli or use Docker:"
echo " Method A: curl -sL https://github.com/crazywhalecc/static-php-cli/... | tar xz"
echo " Method B: $0 --docker"
echo ""
echo "The PHAR works identically: php bin/qbix-server.phar --port=8080"
}
# ── Choose build method ──────────────────────────
if [ "$1" = "--docker" ] && command -v docker &>/dev/null; then
build_with_docker
elif command -v spc &>/dev/null || [ -f /tmp/spc ]; then
build_with_static_php_cli
elif command -v docker &>/dev/null; then
echo "static-php-cli not found, falling back to Docker build..."
build_with_docker
else
build_manual
fi
echo ""
echo "Done."
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env php
<?php
/**
* Build qbix-server.phar — single-file distributable.
*
* Usage: php build-phar.php
* Output: bin/qbix-server.phar
*
* The PHAR includes all server classes and the minimal Q shim.
* Run with: php bin/qbix-server.phar --root=./web --port=8080
*
* Requires: phar.readonly=0 in php.ini (or pass -d phar.readonly=0)
*/
if (ini_get('phar.readonly')) {
echo "Error: phar.readonly is enabled.\n";
echo "Run with: php -d phar.readonly=0 build-phar.php\n";
exit(1);
}
$pharFile = __DIR__ . '/bin/qbix-server.phar';
if (file_exists($pharFile)) {
unlink($pharFile);
}
@mkdir(__DIR__ . '/bin', 0755, true);
echo "Building qbix-server.phar...\n";
$phar = new Phar($pharFile, 0, 'qbix-server.phar');
$phar->startBuffering();
// Add source files
$srcDir = __DIR__ . '/src';
$phar->buildFromIterator(
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($srcDir, RecursiveDirectoryIterator::SKIP_DOTS)
),
$srcDir
);
// Add the entry point (modified for PHAR)
$stub = <<<'STUB'
#!/usr/bin/env php
<?php
Phar::mapPhar('qbix-server.phar');
// Bootstrap
require 'phar://qbix-server.phar/Q.php';
// Parse args the same way as server.php
$opts = array(
'root' => null,
'app' => null,
'host' => '0.0.0.0',
'port' => 8080,
'workers' => 0,
'config' => null,
'pid' => null,
'debug' => false,
);
define('QBIX_SERVER_VERSION', '1.0.0');
foreach ($argv as $i => $arg) {
if ($i === 0) continue;
if ($arg === '--help' || $arg === '-h') {
echo "Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n\n";
echo "Usage: php qbix-server.phar [options]\n\n";
echo " --root=DIR Document root (default: ./web)\n";
echo " --app=DIR Qbix app directory\n";
echo " --host=IP Bind address (default: 0.0.0.0)\n";
echo " --port=PORT Listen port (default: 8080)\n";
echo " --workers=N Pre-fork workers (default: 0)\n";
echo " --config=FILE JSON config file\n";
echo " --version Print version\n";
exit(0);
}
if ($arg === '--version' || $arg === '-v') {
echo "Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n";
exit(0);
}
if ($arg === '--debug') { $opts['debug'] = true; continue; }
if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) $opts[$m[1]] = $m[2];
}
// Qbix app mode
if ($opts['app']) {
$appDir = realpath($opts['app']);
$qInc = $appDir . '/scripts/Q.inc.php';
if (!$qInc) $qInc = $appDir . '/../Platform/scripts/Q.inc.php';
if (file_exists($qInc)) {
define('APP_DIR', $appDir);
require_once $qInc;
}
$webDir = $appDir . '/web';
} else {
$webDir = $opts['root'] ?: (getcwd() . '/web');
}
$webDir = realpath($webDir);
if (!$webDir || !is_dir($webDir)) {
fwrite(STDERR, "Error: document root not found\n");
exit(1);
}
if ($opts['config']) Q_Config::load($opts['config']);
Q_WebServer::$onRequest = function ($method, $uri, $status, $ms) {
$c = array(2=>"\033[32m",3=>"\033[33m",4=>"\033[31m",5=>"\033[31m");
echo date('H:i:s').' '.($c[(int)($status/100)]??'').$status."\033[0m $method $uri ({$ms}ms)\n";
};
echo "\n Qbix Server v" . QBIX_SERVER_VERSION . " (PHAR)\n";
echo " http://{$opts['host']}:{$opts['port']} Root: " . basename($webDir) . "\n\n";
Q_WebServer::start($webDir, $opts['host'], (int)$opts['port'], (int)$opts['workers']);
Q_WebServer::run();
__HALT_COMPILER();
STUB;
$phar->setStub($stub);
$phar->stopBuffering();
// Make executable
chmod($pharFile, 0755);
$size = filesize($pharFile);
echo "Built: bin/qbix-server.phar (" . round($size / 1024) . " KB)\n";
echo "Run: php bin/qbix-server.phar --root=./web --port=8080\n";
echo "Or: chmod +x bin/qbix-server.phar && ./bin/qbix-server.phar --port=8080\n";
BIN
View File
Binary file not shown.
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env php
<?php
/**
* Qbix Server — pure PHP web server.
*
* Standalone: php server.php --root=./web --port=8080
* With Qbix: php server.php --app=/path/to/myapp --port=8080
*
* Options:
* --root=DIR Document root (default: ./web)
* --app=DIR Qbix app directory (loads full Q framework)
* --host=IP Bind address (default: 0.0.0.0)
* --port=PORT Listen port (default: 8080)
* --workers=N Pre-fork PHP workers (default: 0 = in-process)
* --config=FILE JSON config file to load
* --pid=PATH Write PID file
* --debug Enable verbose logging
* --version Print version and exit
* --help Print usage and exit
*/
define('QBIX_SERVER_VERSION', '1.0.0');
// ── Parse CLI args ──────────────────────────────────
$opts = array(
'root' => null,
'app' => null,
'host' => '0.0.0.0',
'port' => 8080,
'workers' => 0,
'config' => null,
'pid' => null,
'debug' => false,
);
foreach ($argv as $i => $arg) {
if ($i === 0) continue;
if ($arg === '--help' || $arg === '-h') {
echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n\n";
echo "Usage: php server.php [options]\n\n";
echo "Options:\n";
echo " --root=DIR Document root (default: ./web)\n";
echo " --app=DIR Qbix app directory (uses full Q framework)\n";
echo " --host=IP Bind address (default: 0.0.0.0)\n";
echo " --port=PORT Listen port (default: 8080)\n";
echo " --workers=N Pre-fork workers (default: 0 = in-process)\n";
echo " --config=FILE JSON config file\n";
echo " --pid=PATH PID file path\n";
echo " --debug Verbose logging\n";
echo " --version Print version\n";
exit(0);
}
if ($arg === '--version' || $arg === '-v') {
echo "Qbix Server v" . QBIX_SERVER_VERSION . "\n";
exit(0);
}
if ($arg === '--debug') {
$opts['debug'] = true;
continue;
}
if (preg_match('/^--(\w+)=(.+)$/', $arg, $m)) {
$opts[$m[1]] = $m[2];
}
}
// ── Determine mode: standalone vs Qbix app ──────────
$qbixMode = false;
if ($opts['app']) {
// Qbix app mode — load the full framework
$appDir = realpath($opts['app']);
if (!$appDir || !is_dir($appDir)) {
fwrite(STDERR, "Error: app directory not found: {$opts['app']}\n");
exit(1);
}
// Look for Q.inc.php
$qInc = $appDir . '/scripts/Q.inc.php';
if (!file_exists($qInc)) {
// Try Platform path
$qInc = $appDir . '/../Platform/scripts/Q.inc.php';
}
if (file_exists($qInc)) {
define('APP_DIR', $appDir);
require_once $qInc;
$qbixMode = true;
} else {
fwrite(STDERR, "Warning: Q.inc.php not found, running in standalone mode\n");
}
$webDir = $appDir . '/web';
} else {
$webDir = $opts['root'] ?: (getcwd() . '/web');
}
if (!$qbixMode) {
// Standalone mode — load minimal Q shim
require_once __DIR__ . '/src/Q.php';
}
$webDir = realpath($webDir);
if (!$webDir || !is_dir($webDir)) {
fwrite(STDERR, "Error: document root not found: " . ($opts['root'] ?: './web') . "\n");
fwrite(STDERR, "Create a web/ directory or use --root=DIR\n");
exit(1);
}
// ── Load config ─────────────────────────────────────
// Default server config
$defaultConfig = array(
'Q' => array(
'webserver' => array(
'keepAlive' => array('max' => 100, 'timeout' => 15),
'timeout' => array('read' => 30),
'maxConnections' => 1024,
'fileCache' => array(
'maxSize' => 67108864,
'maxFile' => 1048576,
'checkInterval' => 1,
),
'rateLimit' => array(
'enabled' => false,
'requests' => 100,
'window' => 60,
'burstRequests' => 20,
'burstWindow' => 1,
),
),
'web' => array(
'cache' => array(
'enabled' => true,
'defaultTtl' => 0,
'components' => array('enabled' => false),
),
),
),
);
// Apply defaults
foreach ($defaultConfig as $k1 => $v1) {
if (is_array($v1)) {
foreach ($v1 as $k2 => $v2) {
if (is_array($v2)) {
foreach ($v2 as $k3 => $v3) {
if (is_array($v3)) {
foreach ($v3 as $k4 => $v4) {
if (Q_Config::get($k1, $k2, $k3, $k4, null) === null) {
Q_Config::set($k1, $k2, $k3, $k4, $v4);
}
}
} else {
if (Q_Config::get($k1, $k2, $k3, null) === null) {
Q_Config::set($k1, $k2, $k3, $v3);
}
}
}
}
}
}
}
// User config file
if ($opts['config']) {
Q_Config::load($opts['config']);
}
// Config from app directory
$appConfig = dirname($webDir) . '/config/server.json';
if (file_exists($appConfig)) {
Q_Config::load($appConfig);
}
// ── PID file ────────────────────────────────────────
if ($opts['pid']) {
file_put_contents($opts['pid'], getmypid());
register_shutdown_function(function () use ($opts) {
@unlink($opts['pid']);
});
}
// ── Request logging ─────────────────────────────────
$colors = array(
2 => "\033[32m", // green for 2xx
3 => "\033[33m", // yellow for 3xx
4 => "\033[31m", // red for 4xx
5 => "\033[31m", // red for 5xx
);
$reset = "\033[0m";
Q_WebServer::$onRequest = function ($method, $uri, $status, $ms) use ($colors, $reset, $opts) {
$color = $colors[(int)($status / 100)] ?? '';
$time = date('H:i:s');
echo "$time {$color}{$status}{$reset} $method $uri ({$ms}ms)\n";
};
// ── Start server ────────────────────────────────────
echo "\n";
echo " ┌──────────────────────────────────────┐\n";
echo " │ Qbix Server v" . QBIX_SERVER_VERSION . str_repeat(' ', 22 - strlen(QBIX_SERVER_VERSION)) . "\n";
echo " ├──────────────────────────────────────┤\n";
echo " │ http://{$opts['host']}:{$opts['port']}" . str_repeat(' ', max(0, 24 - strlen("{$opts['host']}:{$opts['port']}"))) . "\n";
echo " │ Root: " . basename($webDir) . str_repeat(' ', max(0, 29 - strlen(basename($webDir)))) . "\n";
echo " │ Mode: " . ($qbixMode ? 'Qbix Platform' : 'Standalone') . str_repeat(' ', $qbixMode ? 16 : 19) . "\n";
echo " │ PHP: " . ($opts['workers'] ? $opts['workers'] . ' workers' : 'in-process') . str_repeat(' ', max(0, 30 - strlen($opts['workers'] ? $opts['workers'] . ' workers' : 'in-process'))) . "\n";
echo " ├──────────────────────────────────────┤\n";
echo " │ Dashboard: /Q/dashboard │\n";
echo " │ Health: /Q/health │\n";
echo " │ Ctrl+C to stop │\n";
echo " └──────────────────────────────────────┘\n";
echo "\n";
Q_WebServer::start(
$webDir,
$opts['host'],
(int) $opts['port'],
(int) $opts['workers']
);
Q_WebServer::run();
BIN
View File
Binary file not shown.
+161
View File
@@ -0,0 +1,161 @@
<?php
/**
* Minimal Q shim for standalone Qbix Server.
*
* Provides just enough of the Q framework for Q_WebServer and its
* dependencies to function without the full Qbix Platform.
* When running inside the full Platform, this file is never loaded —
* the real Q class takes over.
*/
if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
class Q
{
/**
* Safe nested array access. Returns $default if any key is missing.
* Signature: Q::ifset($arr, 'key1', 'key2', ..., $default)
*/
static function ifset(&$arr)
{
$args = func_get_args();
array_shift($args); // remove $arr
$default = array_pop($args); // last arg is default
$ref = &$arr;
foreach ($args as $key) {
if (!is_array($ref) || !array_key_exists($key, $ref)) {
return $default;
}
$ref = &$ref[$key];
}
return $ref;
}
/**
* JSON encode with error handling
*/
static function json_encode($value, $options = 0)
{
return json_encode($value, $options | JSON_UNESCAPED_SLASHES);
}
/**
* Fire an event. No-op in standalone mode.
*/
static function event($name, $params = array(), $type = '')
{
// No event system in standalone mode
return null;
}
/**
* Autoloader for Q_* classes
*/
static function autoload($className)
{
if (strpos($className, 'Q_') !== 0 && $className !== 'Q_Config') return;
$path = str_replace('_', DS, $className) . '.php';
$full = dirname(__FILE__) . DS . $path;
if (file_exists($full)) {
require_once $full;
}
}
}
spl_autoload_register(array('Q', 'autoload'));
/**
* Minimal Q_Config — reads JSON config files merged together.
*/
class Q_Config
{
private static $data = array();
private static $loaded = false;
/**
* Load config from JSON file(s)
*/
static function load($path)
{
if (!file_exists($path)) return;
$json = json_decode(file_get_contents($path), true);
if (is_array($json)) {
self::$data = self::merge(self::$data, $json);
}
self::$loaded = true;
}
/**
* Set a config value programmatically
*/
static function set(/* key1, key2, ..., value */)
{
$args = func_get_args();
$value = array_pop($args);
$ref = &self::$data;
foreach ($args as $key) {
if (!isset($ref[$key]) || !is_array($ref[$key])) {
$ref[$key] = array();
}
$ref = &$ref[$key];
}
$ref = $value;
}
/**
* Get a config value with default.
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
*/
static function get(/* key1, key2, ..., default */)
{
$args = func_get_args();
$default = array_pop($args);
$ref = self::$data;
foreach ($args as $key) {
if (!is_array($ref) || !array_key_exists($key, $ref)) {
return $default;
}
$ref = $ref[$key];
}
return $ref;
}
/**
* Get a config value or throw.
*/
static function expect(/* key1, key2, ... */)
{
$args = func_get_args();
$ref = self::$data;
foreach ($args as $key) {
if (!is_array($ref) || !array_key_exists($key, $ref)) {
throw new Exception("Missing config: " . implode('.', $args));
}
$ref = $ref[$key];
}
return $ref;
}
/**
* Get all config data
*/
static function getAll()
{
return self::$data;
}
/**
* Deep merge arrays (scalars overwrite, arrays merge recursively)
*/
private static function merge($base, $overlay)
{
foreach ($overlay as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
$base[$key] = self::merge($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
}
+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);
}
}
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>Qbix Server</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 60px auto; color: #333; }
h1 { color: #2563eb; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; }
.status { color: #16a34a; }
</style>
</head>
<body>
<h1>Qbix Server</h1>
<p class="status">Server is running.</p>
<p>This page is served from the <code>web/</code> directory.</p>
<p>Endpoints:</p>
<ul>
<li><a href="/Q/health">/Q/health</a> — JSON health check</li>
<li><a href="/Q/dashboard">/Q/dashboard</a> — Live dashboard</li>
</ul>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<?php
header('Content-Type: application/json');
echo json_encode([
'time' => date('c'),
'php' => PHP_VERSION,
'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'uri' => $_SERVER['REQUEST_URI'] ?? '/',
'server' => 'Qbix Server',
]);