mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**55–73% 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
@@ -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
@@ -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";
|
||||
Binary file not shown.
+225
@@ -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();
|
||||
Vendored
BIN
Binary file not shown.
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,'<')+'</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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
]);
|
||||
Reference in New Issue
Block a user