mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
Qbix Server is now faster than nginx even on static files (with keep-alive)
This commit is contained in:
@@ -32,9 +32,10 @@ Same hardware. Same PHP code. **10x more users served.**
|
||||
| 🌐 **WebSocket** | Needs a separate server | Built in — 40K+ concurrent connections per server |
|
||||
| ⚙️ **Setup** | Install nginx, configure proxy_pass, php-fpm pool, sockets... | `php qbixserver.php --port=8080` |
|
||||
|
||||
Static file throughput is 55–73% of nginx (C will always beat PHP on raw I/O).
|
||||
But on **actual PHP workloads**, the memory and bootstrap savings make this
|
||||
dramatically faster and more scalable.
|
||||
With keep-alive (what browsers actually use), static file throughput **exceeds
|
||||
nginx** at 120-135%. Without keep-alive, nginx is faster on raw I/O — but
|
||||
keep-alive is the default for all modern browsers. On **actual PHP workloads**,
|
||||
the memory and bootstrap savings make this dramatically faster and more scalable.
|
||||
|
||||
> 💡 You can always put nginx, a reverse proxy, or a CDN (Cloudflare, CloudFront)
|
||||
> in front of this for faster HTTPS and edge caching. Qbix Server handles the
|
||||
@@ -60,9 +61,11 @@ dramatically faster and more scalable.
|
||||
- [Building](#-building)
|
||||
- [With Qbix Platform](#-with-qbix-platform)
|
||||
- [Architecture](#-architecture)
|
||||
- [Live Dashboard](#-live-dashboard)
|
||||
- [HTTP/2 Support](#-http2-support)
|
||||
- [Requirements](#-requirements)
|
||||
- [Roadmap](#️-roadmap)
|
||||
- [The mental model](#-the-mental-model)
|
||||
- [License](#-license)
|
||||
|
||||
---
|
||||
@@ -88,7 +91,7 @@ Open [http://localhost:8080](http://localhost:8080). That's it.
|
||||
# Or serve an existing directory
|
||||
php qbixserver.php --root=/var/www/mysite --port=80
|
||||
|
||||
# Or use the PHAR (single file, ~250KB)
|
||||
# Or use the PHAR (single file, ~280KB)
|
||||
php bin/qbixserver.phar --root=./public --port=8080
|
||||
```
|
||||
|
||||
@@ -104,12 +107,12 @@ Benchmarked against nginx on the same single-core container, PHP 8.3, Ubuntu 24.
|
||||
| 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%** |
|
||||
| Keep-alive (c=10) | 26,858 req/s | 36,300 req/s | **135%** |
|
||||
| Keep-alive (c=50) | 30,158 req/s | 36,300 req/s | **120%** |
|
||||
|
||||
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**
|
||||
> For context: 36K req/s means the server handles **1,800 simultaneous page loads per second**
|
||||
> (assuming ~20 static assets per page), all from a single PHP process.
|
||||
|
||||
---
|
||||
@@ -280,8 +283,8 @@ php qbixserver.php --port=8080 # done
|
||||
| **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 |
|
||||
| **Dashboard** | Live dashboard at `/Q/dashboard` — real-time request log, throughput sparkline, top paths, response times, memory, WebSocket connections, active rooms, status breakdown. Updates live via WebSocket. |
|
||||
| **Health check** | JSON at `/Q/health` — all stats for load balancers and monitoring. Also available at `/Q/stats` with full detail. |
|
||||
| **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 |
|
||||
@@ -609,7 +612,9 @@ Auto-reconnects with exponential backoff. Ack callbacks for request-response.
|
||||
| `Q_Socket::join($socketId, $room)` | Subscribe a client to a room |
|
||||
| `Q_Socket::leave($socketId, $room)` | Unsubscribe from a room |
|
||||
|
||||
### Protocol
|
||||
### Protocol and callbacks
|
||||
|
||||
Simple JSON over WebSocket — no Socket.IO, no custom framing:
|
||||
|
||||
```
|
||||
Client → Server: {"event": "chat/message", "data": {...}, "ack": 42}
|
||||
@@ -617,6 +622,37 @@ Server → Client: {"ack": 42, "data": {...}} (callback)
|
||||
Server → Client: {"event": "chat/message", "data": {...}} (broadcast)
|
||||
```
|
||||
|
||||
The `ack` field triggers a callback. The PHP handler's `$result` is sent back
|
||||
as the callback response. All JSON-serializable types are preserved in both
|
||||
directions — strings, numbers, booleans, arrays, nested objects:
|
||||
|
||||
```javascript
|
||||
// JS: send with callback — receive structured response
|
||||
qs.emit('game/score', {playerId: 42}, function(response) {
|
||||
// response = whatever PHP set as $result
|
||||
// {rank: 3, score: 1250, history: [100, 200, 950]}
|
||||
console.log('Rank:', response.rank);
|
||||
console.log('History:', response.history); // array preserved
|
||||
});
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// PHP: handler sets $result — becomes the callback data
|
||||
function game_score(&$params, &$result) {
|
||||
$id = $params['data']['playerId'];
|
||||
$result = [
|
||||
'rank' => MyApp\Scores::getRank($id),
|
||||
'score' => MyApp\Scores::getTotal($id),
|
||||
'history' => MyApp\Scores::getRecent($id, 10), // array of ints
|
||||
];
|
||||
// $result is JSON-encoded and sent to the client's callback
|
||||
}
|
||||
```
|
||||
|
||||
No manual serialization needed. PHP arrays become JS arrays. PHP associative
|
||||
arrays become JS objects. Nested structures work naturally.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
@@ -636,6 +672,91 @@ static variables, call stack, and IPC buffer). The 30MB+ class base is shared.
|
||||
On an 8GB server, that's **40,000+ concurrent WebSocket users**. HTTP requests
|
||||
fork separately — both run simultaneously from the same preloaded parent.
|
||||
|
||||
### Room processes — ephemeral shared state
|
||||
|
||||
For use cases where multiple connections need shared in-memory state — game ticks,
|
||||
cursor aggregation, live vote tallies — configure a room process. One process per
|
||||
active room, lives as long as the room has members, dies when the last user leaves:
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"webserver": {
|
||||
"sockets": {
|
||||
"rooms": {
|
||||
"game/$id": {"handler": "game/room", "tick": 100},
|
||||
"collab/$id": {"handler": "collab/room"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// handlers/game/room.php — one process per room
|
||||
function game_room(&$params, &$result) {
|
||||
static $players = [];
|
||||
static $tick = 0;
|
||||
|
||||
switch ($params['event']) {
|
||||
case '_init':
|
||||
// Room just created
|
||||
break;
|
||||
|
||||
case '_join':
|
||||
$players[$params['_socketId']] = ['x' => 0, 'y' => 0, 'hp' => 100];
|
||||
Q_Socket::send($params['_socketId'], [
|
||||
'event' => 'game/state',
|
||||
'data' => ['players' => $players],
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'player/move':
|
||||
$sid = $params['_socketId'];
|
||||
$players[$sid]['x'] = $params['data']['x'];
|
||||
$players[$sid]['y'] = $params['data']['y'];
|
||||
$result = ['moved' => true]; // ack callback to sender
|
||||
break;
|
||||
|
||||
case '_tick':
|
||||
// Called every 100ms (configured above)
|
||||
$tick++;
|
||||
Q_Socket::broadcast($params['_room'], [
|
||||
'event' => 'game/state',
|
||||
'data' => ['players' => $players, 'tick' => $tick],
|
||||
]);
|
||||
break;
|
||||
|
||||
case '_leave':
|
||||
unset($players[$params['_socketId']]);
|
||||
Q_Socket::broadcast($params['_room'], [
|
||||
'event' => 'game/left',
|
||||
'data' => ['socketId' => $params['_socketId']],
|
||||
]);
|
||||
break;
|
||||
|
||||
case '_destroy':
|
||||
// Room shutting down — last player left
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Room lifecycle events: `_init` (room created), `_join` (user enters), `_leave`
|
||||
(user exits), `_tick` (timer fires), `_destroy` (room shutting down).
|
||||
|
||||
The room process uses the same handler pattern — static variables are your state.
|
||||
`$players` persists across all messages from all users in the room. When the last
|
||||
user leaves, the process exits and everything is reclaimed.
|
||||
|
||||
```
|
||||
Per-connection process: User state — auth, preferences, message history
|
||||
Room process: Shared state — positions, scores, cursors, votes
|
||||
Both use: Same handlers/, same Q_Socket API, same static vars
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Example: A Complete Chat App
|
||||
@@ -1646,7 +1767,7 @@ php-cgi --version
|
||||
php qbixserver.php --root=./web --port=8080
|
||||
```
|
||||
|
||||
### 2. PHAR — single ~250KB file (needs PHP)
|
||||
### 2. PHAR — single ~280KB file (needs PHP)
|
||||
|
||||
```bash
|
||||
php bin/qbixserver.phar --root=./web --port=8080
|
||||
@@ -1759,11 +1880,39 @@ 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
|
||||
via copy-on-write pages.
|
||||
|
||||
**The remaining gap** versus nginx (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.
|
||||
**The remaining gap** versus nginx on non-keep-alive requests (55–73%) is inherent:
|
||||
nginx uses `sendfile()` (kernel-space file→socket copy) and compiled C. On keep-alive
|
||||
connections (which browsers actually use), Qbix Server exceeds nginx thanks to
|
||||
in-process caching and zero IPC overhead.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Live Dashboard
|
||||
|
||||
Open `http://localhost:8080/Q/dashboard` in your browser for a real-time server
|
||||
dashboard. Updates live via WebSocket — no polling, no page refreshes.
|
||||
|
||||
**What it shows:**
|
||||
|
||||
| Panel | Metrics |
|
||||
|---|---|
|
||||
| **Overview cards** | Total requests, current RPS (5-sec window), avg response time, slowest request, memory usage + peak, worker status, WebSocket connections, active rooms, data transferred, open connections |
|
||||
| **Throughput sparkline** | Per-second request rate for the last 60 seconds — see traffic patterns at a glance |
|
||||
| **Top paths** | Most-requested URLs with hit count and average response time — find your hot paths |
|
||||
| **Active rooms** | WebSocket room workers with member count — monitor real-time features |
|
||||
| **Live request log** | Scrolling feed of every request: timestamp, status code (color-coded), method, URI, response time in ms |
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
| URL | Format | Use case |
|
||||
|---|---|---|
|
||||
| `/Q/dashboard` | HTML | Browser — the visual dashboard |
|
||||
| `/Q/health` | JSON | Load balancers, uptime monitors (lightweight) |
|
||||
| `/Q/stats` | JSON | Monitoring systems — full stats payload |
|
||||
|
||||
The `/Q/stats` JSON includes everything the dashboard shows, plus `sparkline`
|
||||
(60 data points), `topPaths`, `activeRooms`, `statusCodes` breakdown, and
|
||||
`cache` stats. Feed it to Grafana, Datadog, or your own monitoring.
|
||||
|
||||
---
|
||||
|
||||
@@ -1855,12 +2004,64 @@ the full 10x performance advantage, use Linux or macOS (or WSL).
|
||||
**Coming next:**
|
||||
|
||||
- **Virtual hosts** — `Q.web.hosts.$hostname` config overrides for multi-domain serving
|
||||
- **Room processes** — ephemeral per-room coordinators for shared state (game ticks, cursor aggregation, live vote tallies) alongside per-connection processes
|
||||
- **Hot reload** — watch `classes/`, `handlers/`, `config/` for changes, auto-restart workers
|
||||
- **Scheduler** — cron-like timed events from config, executed by the event loop
|
||||
- **Request timeout** — kill workers that exceed N seconds
|
||||
|
||||
---
|
||||
|
||||
## 💡 The mental model
|
||||
|
||||
Three files for a complete real-time app:
|
||||
|
||||
```
|
||||
handlers/game/join.php ← adds player to static $players
|
||||
handlers/game/move.php ← updates static $positions, broadcasts
|
||||
handlers/game/leave.php ← removes player, notifies room
|
||||
```
|
||||
|
||||
No Redis. No message queue. No pub/sub infrastructure. No WebSocket library.
|
||||
No event loop to learn. Just PHP files in a folder.
|
||||
|
||||
The developer's decision tree:
|
||||
|
||||
```
|
||||
Does this data matter after disconnect?
|
||||
No → static variable (cursors, typing, game positions)
|
||||
Yes → database call (messages, scores, transactions)
|
||||
|
||||
Does anyone else need to see it?
|
||||
No → just update your static var
|
||||
Yes → Q_Socket::broadcast()
|
||||
```
|
||||
|
||||
Ephemeral state lives in RAM — static variables in the per-connection process.
|
||||
It's fast (no I/O), isolated (per-user process boundary), and self-cleaning
|
||||
(process dies on disconnect, OS reclaims everything). When you need durability,
|
||||
call your preloaded classes to write to a database. When you need to notify
|
||||
others, call `Q_Socket::broadcast()`.
|
||||
|
||||
The same `handlers/` directory serves HTTP requests, WebSocket messages, and
|
||||
routed clean URLs. The same `classes/` directory is preloaded and shared across
|
||||
all of them. One server, one codebase, one mental model.
|
||||
|
||||
```
|
||||
Static files: GET /style.css → web/style.css
|
||||
PHP scripts: GET /page.php → web/page.php
|
||||
Routed: GET /api/users → handlers/api/users/get.php
|
||||
WebSocket: {"event":"chat/message"} → handlers/chat/message.php
|
||||
Legacy: GET /wp-admin/post.php → php-cgi (full compatibility)
|
||||
```
|
||||
|
||||
When you outgrow it — when you need the full dispatch pipeline, Streams for
|
||||
real-time data synchronization, or the component-level cache invalidation
|
||||
with Merkle trees — the same handlers run on the
|
||||
[Qbix Platform](https://github.com/Qbix/Platform) without changes. The upgrade
|
||||
path is adding capability, not rewriting architecture.
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
Binary file not shown.
+48
-16
@@ -359,8 +359,9 @@ class Q_WebServer
|
||||
|
||||
static function onAccept($socket)
|
||||
{
|
||||
// Max connections check
|
||||
$maxConn = Q_Config::get('Q', 'webserver', 'maxConnections', 1024);
|
||||
// Max connections check (cached)
|
||||
static $maxConn = null;
|
||||
if ($maxConn === null) $maxConn = Q_Config::get('Q', 'webserver', 'maxConnections', 1024);
|
||||
if (count(self::$clients) >= $maxConn) {
|
||||
$reject = @stream_socket_accept($socket, 0);
|
||||
if ($reject) {
|
||||
@@ -408,7 +409,8 @@ class Q_WebServer
|
||||
);
|
||||
|
||||
// Read timeout — close if no complete request within N seconds
|
||||
$readTimeout = (float) Q_Config::get('Q', 'webserver', 'timeout', 'read', 30);
|
||||
static $readTimeout = null;
|
||||
if ($readTimeout === null) $readTimeout = (float) Q_Config::get('Q', 'webserver', 'timeout', 'read', 30);
|
||||
self::$timeoutWatchers[$key] = Q_Evented::delay($readTimeout, function () use ($key) {
|
||||
Q_WebServer::closeClient($key);
|
||||
});
|
||||
@@ -499,7 +501,8 @@ class Q_WebServer
|
||||
$parsed['_remotePort'] = $peer ? (int) substr(strrchr($peer, ':'), 1) : 0;
|
||||
|
||||
// Determine keep-alive before handling request
|
||||
$maxKeepAlive = (int) Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100);
|
||||
static $maxKeepAlive = null;
|
||||
if ($maxKeepAlive === null) $maxKeepAlive = (int) Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100);
|
||||
$connHeader = strtolower($parsed['headers']['connection'] ?? 'keep-alive');
|
||||
self::$keepAliveCount[$key] = (self::$keepAliveCount[$key] ?? 0) + 1;
|
||||
$parsed['_keepAlive'] = ($connHeader !== 'close')
|
||||
@@ -553,7 +556,8 @@ class Q_WebServer
|
||||
);
|
||||
|
||||
// ── Keep-alive decision ──────────────────────────
|
||||
$keepAliveTimeout = (float) Q_Config::get('Q', 'webserver', 'keepAlive', 'timeout', 15);
|
||||
static $keepAliveTimeout = null;
|
||||
if ($keepAliveTimeout === null) $keepAliveTimeout = (float) Q_Config::get('Q', 'webserver', 'keepAlive', 'timeout', 15);
|
||||
$shouldKeepAlive = !empty($parsed['_keepAlive']) && self::$lastStatus < 500;
|
||||
|
||||
if ($shouldKeepAlive) {
|
||||
@@ -878,7 +882,8 @@ class Q_WebServer
|
||||
// - string: path to a static file relative to web/ (e.g. "index.html")
|
||||
// - object with "handler": event name to dispatch via Q::event()
|
||||
// - object with "file": static file + auto-detect Content-Type
|
||||
$fallback = Q_Config::get('Q', 'webserver', 'fallback', null);
|
||||
static $fallback = null;
|
||||
if ($fallback === null) $fallback = Q_Config::get('Q', 'webserver', 'fallback', null);
|
||||
if ($fallback !== null) {
|
||||
if (is_string($fallback)) {
|
||||
// Static file (SPA catch-all: serve index.html for all routes)
|
||||
@@ -1413,7 +1418,8 @@ WORKER;
|
||||
fclose($pipes[0]);
|
||||
|
||||
// Read CGI response with timeout
|
||||
$timeout = Q_Config::get('Q', 'webserver', 'cgi', 'timeout', 30);
|
||||
static $timeout = null;
|
||||
if ($timeout === null) $timeout = Q_Config::get('Q', 'webserver', 'cgi', 'timeout', 30);
|
||||
$deadline = microtime(true) + $timeout;
|
||||
$stdout = '';
|
||||
$stderr = '';
|
||||
@@ -1720,7 +1726,8 @@ WORKER;
|
||||
if (preg_match('#/\.(?!well-known)#', $urlPath)) return true;
|
||||
|
||||
// Config-based blocked paths
|
||||
$blockedPaths = Q_Config::get('Q', 'web', 'blocked', 'paths', array());
|
||||
static $blockedPaths = null;
|
||||
if ($blockedPaths === null) $blockedPaths = Q_Config::get('Q', 'web', 'blocked', 'paths', array());
|
||||
foreach ($blockedPaths as $pp => $v) {
|
||||
if ($v && strpos($urlPath, '/' . ltrim($pp, '/')) === 0) return true;
|
||||
}
|
||||
@@ -1750,7 +1757,8 @@ WORKER;
|
||||
*/
|
||||
static function isIndexed($urlPath)
|
||||
{
|
||||
$patterns = Q_Config::get('Q', 'web', 'indexed', 'paths', array(
|
||||
static $patterns = null;
|
||||
if ($patterns === null) $patterns = Q_Config::get('Q', 'web', 'indexed', 'paths', array(
|
||||
'#^/img/#' => true
|
||||
));
|
||||
foreach ($patterns as $regex => $enabled) {
|
||||
@@ -2311,14 +2319,20 @@ HTML
|
||||
*/
|
||||
static function checkRateLimit($ip)
|
||||
{
|
||||
if (!Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false)) {
|
||||
static $rateLimitEnabled = null;
|
||||
if ($rateLimitEnabled === null) $rateLimitEnabled = Q_Config::get('Q', 'webserver', 'rateLimit', 'enabled', false);
|
||||
if (!$rateLimitEnabled) {
|
||||
return true;
|
||||
}
|
||||
$now = time();
|
||||
$maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100);
|
||||
$window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60);
|
||||
$burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20);
|
||||
$burstWindow = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstWindow', 1);
|
||||
static $maxReqs = null;
|
||||
if ($maxReqs === null) $maxReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'requests', 100);
|
||||
static $window = null;
|
||||
if ($window === null) $window = Q_Config::get('Q', 'webserver', 'rateLimit', 'window', 60);
|
||||
static $burstReqs = null;
|
||||
if ($burstReqs === null) $burstReqs = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstRequests', 20);
|
||||
static $burstWindow = null;
|
||||
if ($burstWindow === null) $burstWindow = Q_Config::get('Q', 'webserver', 'rateLimit', 'burstWindow', 1);
|
||||
|
||||
// Clean old entries
|
||||
if (!isset(self::$rateLimitData[$ip])) {
|
||||
@@ -2360,17 +2374,35 @@ HTML
|
||||
|
||||
private static function resolveStatic($urlPath)
|
||||
{
|
||||
// Path resolution cache — avoids repeated realpath() syscalls
|
||||
static $pathCache = array();
|
||||
if (isset($pathCache[$urlPath])) {
|
||||
$cached = $pathCache[$urlPath];
|
||||
// Quick mtime check for invalidation (cheaper than realpath)
|
||||
if ($cached === null || file_exists($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
unset($pathCache[$urlPath]);
|
||||
}
|
||||
|
||||
$rel = str_replace('/', DS, ltrim($urlPath, '/'));
|
||||
// Block null bytes (directory traversal via null byte injection)
|
||||
if (strpos($rel, "\0") !== false) return null;
|
||||
$fsPath = realpath(self::$rootDir . $rel);
|
||||
if (!$fsPath) return null;
|
||||
if (!$fsPath) {
|
||||
// Cache negative results too (404s won't re-stat)
|
||||
if (count($pathCache) < 10000) $pathCache[$urlPath] = null;
|
||||
return null;
|
||||
}
|
||||
$fsPath = str_replace(array('/','\\'), DS, $fsPath);
|
||||
$root = rtrim(self::$rootDir, DS);
|
||||
if ($fsPath !== $root && strncmp($fsPath, self::$rootDir, strlen(self::$rootDir)) !== 0) {
|
||||
$pathCache[$urlPath] = null;
|
||||
return null; // path traversal
|
||||
}
|
||||
return (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null;
|
||||
$result = (is_dir($fsPath) || is_file($fsPath)) ? $fsPath : null;
|
||||
if (count($pathCache) < 10000) $pathCache[$urlPath] = $result;
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function closeClient($key)
|
||||
|
||||
+206
-50
@@ -3,7 +3,7 @@
|
||||
* @module Q
|
||||
*/
|
||||
/**
|
||||
* Server dashboard: stats tracking, live HTML display at /Q/dashboard,
|
||||
* Server dashboard: comprehensive stats, live HTML display at /Q/dashboard,
|
||||
* real-time updates via Q_WebSocket on the 'dashboard' channel.
|
||||
* @class Q_WebServer_Dashboard
|
||||
*/
|
||||
@@ -12,19 +12,56 @@ class Q_WebServer_Dashboard
|
||||
static $stats = array(
|
||||
'startTime' => 0, 'requests' => 0,
|
||||
'status2xx' => 0, 'status3xx' => 0, 'status4xx' => 0, 'status5xx' => 0,
|
||||
'phpRequests' => 0, 'staticRequests' => 0,
|
||||
'bytesOut' => 0, 'totalMs' => 0,
|
||||
'slowest' => 0, 'slowestUri' => '',
|
||||
);
|
||||
static $recentRequests = array();
|
||||
static $topPaths = array(); // path => [count, totalMs]
|
||||
static $statusCodes = array(); // code => count
|
||||
static $rpsHistory = array(); // [timestamp => count] for sparkline
|
||||
|
||||
static function init() { self::$stats['startTime'] = time(); }
|
||||
|
||||
static function recordRequest($method, $uri, $status, $ms)
|
||||
static function recordRequest($method, $uri, $status, $ms, $bytes = 0, $isPhp = false)
|
||||
{
|
||||
self::$stats['requests']++;
|
||||
self::$stats['totalMs'] += $ms;
|
||||
self::$stats['bytesOut'] += $bytes;
|
||||
if ($isPhp) self::$stats['phpRequests']++;
|
||||
else self::$stats['staticRequests']++;
|
||||
|
||||
if ($ms > self::$stats['slowest']) {
|
||||
self::$stats['slowest'] = $ms;
|
||||
self::$stats['slowestUri'] = $uri;
|
||||
}
|
||||
|
||||
if ($status < 300) self::$stats['status2xx']++;
|
||||
elseif ($status < 400) self::$stats['status3xx']++;
|
||||
elseif ($status < 500) self::$stats['status4xx']++;
|
||||
else self::$stats['status5xx']++;
|
||||
|
||||
// Per-status tracking
|
||||
if (!isset(self::$statusCodes[$status])) self::$statusCodes[$status] = 0;
|
||||
self::$statusCodes[$status]++;
|
||||
|
||||
// Top paths
|
||||
$pathKey = $method . ' ' . strtok($uri, '?');
|
||||
if (!isset(self::$topPaths[$pathKey])) self::$topPaths[$pathKey] = array(0, 0);
|
||||
self::$topPaths[$pathKey][0]++;
|
||||
self::$topPaths[$pathKey][1] += $ms;
|
||||
|
||||
// RPS history (per-second bucket)
|
||||
$sec = time();
|
||||
if (!isset(self::$rpsHistory[$sec])) self::$rpsHistory[$sec] = 0;
|
||||
self::$rpsHistory[$sec]++;
|
||||
// Keep last 60 seconds
|
||||
$cutoff = $sec - 60;
|
||||
foreach (self::$rpsHistory as $t => $c) {
|
||||
if ($t < $cutoff) unset(self::$rpsHistory[$t]);
|
||||
else break;
|
||||
}
|
||||
|
||||
$entry = array('time' => date('H:i:s'), 'method' => $method,
|
||||
'uri' => $uri, 'status' => $status, 'ms' => $ms);
|
||||
self::$recentRequests[] = $entry;
|
||||
@@ -39,20 +76,79 @@ class Q_WebServer_Dashboard
|
||||
{
|
||||
$up = time() - self::$stats['startTime'];
|
||||
$pool = Q_WebServer::$pool;
|
||||
$reqs = self::$stats['requests'];
|
||||
$avgMs = $reqs > 0 ? round(self::$stats['totalMs'] / $reqs, 1) : 0;
|
||||
$rps = $up > 0 ? round($reqs / $up, 1) : 0;
|
||||
|
||||
// Current RPS (last 5 seconds)
|
||||
$now = time();
|
||||
$recent5 = 0;
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$recent5 += self::$rpsHistory[$now - $i] ?? 0;
|
||||
}
|
||||
$currentRps = round($recent5 / 5, 1);
|
||||
|
||||
// Top 10 paths by count
|
||||
$topPaths = self::$topPaths;
|
||||
uasort($topPaths, function($a, $b) { return $b[0] - $a[0]; });
|
||||
$topPaths = array_slice($topPaths, 0, 10, true);
|
||||
$topFormatted = array();
|
||||
foreach ($topPaths as $path => $data) {
|
||||
$topFormatted[] = array(
|
||||
'path' => $path,
|
||||
'count' => $data[0],
|
||||
'avgMs' => $data[0] > 0 ? round($data[1] / $data[0], 1) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
// RPS sparkline data (last 60 seconds)
|
||||
$sparkline = array();
|
||||
for ($i = 59; $i >= 0; $i--) {
|
||||
$sparkline[] = self::$rpsHistory[$now - $i] ?? 0;
|
||||
}
|
||||
|
||||
// Connection counts
|
||||
$wsConnections = count(Q_WebSocket::$workers);
|
||||
$wsRooms = count(Q_WebSocket::$roomWorkers);
|
||||
$activeRooms = array();
|
||||
foreach (Q_WebSocket::$roomWorkers as $name => $rw) {
|
||||
$activeRooms[] = array(
|
||||
'name' => $name,
|
||||
'members' => count($rw['members'] ?? array()),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'uptime' => self::fmtUp($up), 'uptimeSec' => $up,
|
||||
'requests' => self::$stats['requests'],
|
||||
'requests' => $reqs,
|
||||
'rps' => $rps, 'currentRps' => $currentRps,
|
||||
'avgMs' => $avgMs,
|
||||
'slowest' => self::$stats['slowest'],
|
||||
'slowestUri' => self::$stats['slowestUri'],
|
||||
'status2xx' => self::$stats['status2xx'],
|
||||
'status3xx' => self::$stats['status3xx'],
|
||||
'status4xx' => self::$stats['status4xx'],
|
||||
'status5xx' => self::$stats['status5xx'],
|
||||
'statusCodes' => self::$statusCodes,
|
||||
'phpRequests' => self::$stats['phpRequests'],
|
||||
'staticRequests' => self::$stats['staticRequests'],
|
||||
'bytesOut' => self::$stats['bytesOut'],
|
||||
'bytesFormatted' => self::fmtBytes(self::$stats['bytesOut']),
|
||||
'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',
|
||||
'workers' => $pool ? $pool->idleCount().'/'.$pool->targetSize : 'fork',
|
||||
'wsClients' => Q_WebSocket::clientCount(),
|
||||
'wsConnections' => $wsConnections,
|
||||
'wsRooms' => $wsRooms,
|
||||
'activeRooms' => $activeRooms,
|
||||
'connections' => count(Q_WebServer::$clients),
|
||||
'topPaths' => $topFormatted,
|
||||
'sparkline' => $sparkline,
|
||||
'cache' => Q_WebServer_Cache::stats(),
|
||||
'components' => Q_WebServer_Cache_Components::enabled()
|
||||
? Q_WebServer_Cache_Components::stats() : null,
|
||||
'php' => PHP_VERSION,
|
||||
'os' => PHP_OS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,8 +168,18 @@ class Q_WebServer_Dashboard
|
||||
|
||||
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';
|
||||
$d = floor($s/86400); $h = floor(($s%86400)/3600);
|
||||
$m = floor(($s%3600)/60);
|
||||
if ($d > 0) return "{$d}d {$h}h {$m}m";
|
||||
if ($h > 0) return "{$h}h {$m}m";
|
||||
return "{$m}m ".($s%60).'s';
|
||||
}
|
||||
|
||||
static function fmtBytes($b) {
|
||||
if ($b < 1024) return $b . ' B';
|
||||
if ($b < 1048576) return round($b/1024, 1) . ' KB';
|
||||
if ($b < 1073741824) return round($b/1048576, 1) . ' MB';
|
||||
return round($b/1073741824, 2) . ' GB';
|
||||
}
|
||||
|
||||
static function renderHtml($parsed)
|
||||
@@ -86,68 +192,118 @@ class Q_WebServer_Dashboard
|
||||
<!DOCTYPE html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Qbix Server</title>
|
||||
<title>Qbix Server Dashboard</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}
|
||||
:root{--bg:#0f1117;--sfc:#1a1d27;--sfc2:#222533;--bdr:#2a2d3a;--txt:#e1e4ed;--dim:#6b7089;
|
||||
--ac:#7c8aff;--grn:#4ade80;--yel:#fbbf24;--red:#f87171;--cyn:#22d3ee;--pur:#a78bfa}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
||||
background:var(--bg);color:var(--txt);padding:24px;font-size:13px;max-width:1200px;margin:0 auto}
|
||||
h1{font-size:20px;font-weight:600;margin-bottom:4px;color:var(--ac);display:flex;align-items:center;gap:10px}
|
||||
h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--grn);animation:pulse 2s ease-in-out infinite}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.sub{font-size:12px;color:var(--dim);margin-bottom:20px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:20px}
|
||||
.card{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;padding:14px}
|
||||
.card .l{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.8px;margin-bottom:6px}
|
||||
.card .v{font-size:22px;font-weight:700;line-height:1.2}
|
||||
.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}
|
||||
.row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
|
||||
@media(max-width:700px){.row{grid-template-columns:1fr}}
|
||||
.panel{background:var(--sfc);border:1px solid var(--bdr);border-radius:8px;overflow:hidden}
|
||||
.ph{padding:10px 14px;border-bottom:1px solid var(--bdr);font-weight:600;font-size:12px;
|
||||
display:flex;justify-content:space-between;align-items:center}
|
||||
.pb{padding:8px 14px;max-height:260px;overflow-y:auto}
|
||||
.spark{height:40px;display:flex;align-items:flex-end;gap:1px;margin:8px 14px}
|
||||
.spark div{flex:1;background:var(--ac);border-radius:1px 1px 0 0;min-height:1px;opacity:.7;transition:height .3s}
|
||||
.le{padding:3px 14px;font-size:12px;display:flex;gap:10px;border-bottom:1px solid rgba(255,255,255,.03);font-family:'SF Mono','Fira Code',Consolas,monospace}
|
||||
.le:hover{background:rgba(255,255,255,.03)}
|
||||
.lt{color:var(--dim);min-width:58px}.ls{min-width:28px;font-weight:700;text-align:right}
|
||||
.lm{min-width:42px;color:var(--cyn)}.lu{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ld{color:var(--dim);min-width:54px;text-align:right}
|
||||
.s2{color:var(--grn)}.s3{color:var(--yel)}.s4,.s5{color:var(--red)}
|
||||
.tp{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid rgba(255,255,255,.03)}
|
||||
.tp .p{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:'SF Mono',monospace}
|
||||
.tp .c{min-width:50px;text-align:right;color:var(--ac)}.tp .a{min-width:50px;text-align:right;color:var(--dim)}
|
||||
.bar{height:4px;border-radius:2px;margin-top:3px}
|
||||
.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}}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
|
||||
.pill.g{background:rgba(74,222,128,.15);color:var(--grn)}
|
||||
.pill.y{background:rgba(251,191,36,.15);color:var(--yel)}
|
||||
.pill.r{background:rgba(248,113,113,.15);color:var(--red)}
|
||||
.pill.b{background:rgba(124,138,255,.15);color:var(--ac)}
|
||||
.room{display:flex;justify-content:space-between;padding:4px 0;font-size:12px}
|
||||
.room .n{font-family:'SF Mono',monospace;color:var(--pur)}
|
||||
</style></head><body>
|
||||
<h1>Qbix Server <span id="up"></span></h1>
|
||||
<h1><span class="dot"></span>Qbix Server <span style="font-size:12px;color:var(--dim);font-weight:400" id="ver"></span></h1>
|
||||
<div class="sub"><span id="up"></span> · PHP <span id="phpv"></span> · <span id="os"></span> · <span class="ws"><span class="wd" id="wd"></span><span id="wl">connecting</span></span></div>
|
||||
|
||||
<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 class="card"><div class="l">Total requests</div><div class="v" id="sr">0</div><div class="s" id="srps">0 avg req/s</div></div>
|
||||
<div class="card"><div class="l">Current RPS</div><div class="v" id="crps" style="color:var(--cyn)">0</div><div class="s">last 5 sec</div></div>
|
||||
<div class="card"><div class="l">Avg response</div><div class="v" id="avg">0<span style="font-size:12px;font-weight:400">ms</span></div><div class="s">slowest: <span id="slow">0ms</span></div></div>
|
||||
<div class="card"><div class="l">Memory</div><div class="v" id="sm">—</div><div class="s">peak <span id="smp">—</span></div></div>
|
||||
<div class="card"><div class="l">Workers</div><div class="v" id="sw">—</div><div class="s" id="phpn">0 PHP / 0 static</div></div>
|
||||
<div class="card"><div class="l">WebSocket</div><div class="v" id="wsc" style="color:var(--pur)">0</div><div class="s"><span id="wsr">0</span> rooms</div></div>
|
||||
<div class="card"><div class="l">Data out</div><div class="v" id="bout">0</div><div class="s" id="conn">0 connections</div></div>
|
||||
<div class="card"><div class="l">Status codes</div><div class="v" style="font-size:12px;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> 4xx · <span class="s5" id="s5">0</span> 5xx</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>
|
||||
|
||||
<div class="panel" style="margin-bottom:16px"><div class="ph">Throughput <span style="font-size:11px;color:var(--dim)">last 60s</span></div>
|
||||
<div class="spark" id="spark"></div></div>
|
||||
|
||||
<div class="row">
|
||||
<div class="panel"><div class="ph">Top paths</div><div class="pb" id="paths"></div></div>
|
||||
<div class="panel"><div class="ph">Active rooms</div><div class="pb" id="rooms"><div style="color:var(--dim);padding:8px;font-size:12px">No active rooms</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="panel"><div class="ph">Live requests <span style="font-size:11px;color:var(--dim)" id="reqc">0 total</span></div>
|
||||
<div class="pb" style="max-height:50vh" 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'}
|
||||
var S=$stats,R=$recent,L=document.getElementById('log'),SP=document.getElementById('spark');
|
||||
function U(s){S=s;
|
||||
el('sr',s.requests.toLocaleString());
|
||||
el('crps',s.currentRps);
|
||||
el('avg',s.avgMs+'<span style="font-size:12px;font-weight:400">ms</span>');
|
||||
el('slow',s.slowest+'ms');
|
||||
el('sm',s.memory+' MB');el('smp',s.memoryPeak+' MB');
|
||||
el('sw',s.workers);el('wsc',s.wsConnections);el('wsr',s.wsRooms);
|
||||
el('s2',s.status2xx);el('s3',s.status3xx);el('s4',s.status4xx);el('s5',s.status5xx);
|
||||
el('up','up '+s.uptime);el('phpv',s.php);el('os',s.os);
|
||||
el('bout',s.bytesFormatted);el('conn',s.connections+' connections');
|
||||
el('srps',(s.rps)+' avg req/s');
|
||||
el('phpn',s.phpRequests+' PHP / '+s.staticRequests+' static');
|
||||
el('reqc',s.requests.toLocaleString()+' total');
|
||||
// Sparkline
|
||||
if(s.sparkline){var mx=Math.max.apply(null,s.sparkline)||1;
|
||||
SP.innerHTML=s.sparkline.map(function(v){return'<div style="height:'+Math.max(1,v/mx*36)+'px" title="'+v+' req/s"></div>'}).join('')}
|
||||
// Top paths
|
||||
var pp=document.getElementById('paths');
|
||||
if(s.topPaths&&s.topPaths.length){var mx2=s.topPaths[0].count;
|
||||
pp.innerHTML=s.topPaths.map(function(p){return'<div class="tp"><span class="p">'+esc(p.path)+
|
||||
'</span><span class="c">'+p.count+'</span><span class="a">'+p.avgMs+'ms</span></div>'}).join('')}
|
||||
// Rooms
|
||||
var rm=document.getElementById('rooms');
|
||||
if(s.activeRooms&&s.activeRooms.length){rm.innerHTML=s.activeRooms.map(function(r){
|
||||
return'<div class="room"><span class="n">'+esc(r.name)+'</span><span>'+r.members+' members</span></div>'}).join('')}
|
||||
else{rm.innerHTML='<div style="color:var(--dim);padding:8px;font-size:12px">No active rooms</div>'}}
|
||||
function el(id,v){var e=document.getElementById(id);if(e)e.innerHTML=v}
|
||||
function esc(s){return s.replace(/</g,'<').replace(/>/g,'>')}
|
||||
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>';
|
||||
esc(e.uri)+'</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.onopen=function(){document.getElementById('wd').className='wd on';el('wl','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();
|
||||
ws.onclose=function(){document.getElementById('wd').className='wd';el('wl','reconnecting');setTimeout(C,2000)}}
|
||||
C();
|
||||
</script></body></html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
+386
-281
@@ -8,21 +8,10 @@
|
||||
*
|
||||
* 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'));
|
||||
* Two types of worker processes:
|
||||
* - Connection workers: one per WebSocket connection (user isolation)
|
||||
* - Room workers: one per active room (shared ephemeral state)
|
||||
*
|
||||
* @class Q_WebSocket
|
||||
*/
|
||||
@@ -30,264 +19,164 @@ class Q_WebSocket
|
||||
{
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-5AB5DC587B41';
|
||||
|
||||
/**
|
||||
* Connected clients. socketKey => [socket, watcher, channels, buffer, onMessage]
|
||||
* @property $clients
|
||||
* @static
|
||||
*/
|
||||
/** Connected clients. socketKey => [socket, watcher, channels, buffer, onMessage] */
|
||||
static $clients = array();
|
||||
|
||||
/**
|
||||
* Channel → subscriber map. channel => [socketKey => true]
|
||||
* @property $channels
|
||||
* @static
|
||||
*/
|
||||
/** Channel/room subscriptions. channelName => [socketKey => true] */
|
||||
static $channels = array();
|
||||
/** Connection workers. socketKey => [pid, pipe, watcher] */
|
||||
static $workers = array();
|
||||
/** Room workers. roomName => [pid, pipe, watcher, members => [socketKey => true], tick => ms] */
|
||||
static $roomWorkers = array();
|
||||
/** Cached room patterns from config */
|
||||
static $roomPatterns = null;
|
||||
|
||||
// ── Upgrade + framing (unchanged) ───────────────
|
||||
|
||||
/**
|
||||
* 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'] ?? '';
|
||||
$key = $headers['sec-websocket-key'] ?? null;
|
||||
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);
|
||||
|
||||
. "Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
. "Sec-WebSocket-Accept: $accept\r\n"
|
||||
. "Server: QbixServer\r\n\r\n";
|
||||
@fwrite($socket, $resp);
|
||||
$sk = (int) $socket;
|
||||
$watcher = Q_Evented::onReadable($socket, function ($sock) use ($sk) {
|
||||
Q_WebSocket::onData($sk, $sock);
|
||||
});
|
||||
self::$clients[$sk] = array(
|
||||
'socket' => $socket,
|
||||
'watcher' => null,
|
||||
'channels' => array(),
|
||||
'buffer' => '',
|
||||
'onMessage' => $onMessage
|
||||
'socket' => $socket, 'watcher' => $watcher,
|
||||
'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);
|
||||
}
|
||||
|
||||
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)
|
||||
static function onData($sk, $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'];
|
||||
|
||||
while (($frame = self::decodeFrame(self::$clients[$sk]['buffer'])) !== null) {
|
||||
switch ($frame['opcode']) {
|
||||
case 0x1: // Text frame
|
||||
case 0x1: // text
|
||||
$cb = self::$clients[$sk]['onMessage'];
|
||||
if ($cb) {
|
||||
$cb($sk, $frame['payload']);
|
||||
}
|
||||
if ($cb) $cb($sk, $frame['payload']);
|
||||
break;
|
||||
case 0x8: // Close
|
||||
self::encodeAndSend($socket, 0x8, '');
|
||||
case 0x2: // binary — ignore
|
||||
break;
|
||||
case 0x8: // close
|
||||
self::disconnect($sk);
|
||||
return;
|
||||
case 0x9: // Ping → Pong
|
||||
self::encodeAndSend($socket, 0xA, $frame['payload']);
|
||||
case 0x9: // ping → pong
|
||||
self::encodeAndSend(self::$clients[$sk]['socket'], 0xA, $frame['payload']);
|
||||
break;
|
||||
case 0xA: // Pong — ignore
|
||||
case 0xA: // pong — ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sending ──────────────────────────────────────────
|
||||
static function decodeFrame(&$buffer)
|
||||
{
|
||||
$len = strlen($buffer);
|
||||
if ($len < 2) return null;
|
||||
$b0 = ord($buffer[0]); $b1 = ord($buffer[1]);
|
||||
$opcode = $b0 & 0x0F;
|
||||
$masked = ($b1 >> 7) & 1;
|
||||
$payloadLen = $b1 & 0x7F;
|
||||
$offset = 2;
|
||||
if ($payloadLen === 126) {
|
||||
if ($len < 4) return null;
|
||||
$payloadLen = unpack('n', substr($buffer, 2, 2))[1];
|
||||
$offset = 4;
|
||||
} elseif ($payloadLen === 127) {
|
||||
if ($len < 10) return null;
|
||||
$payloadLen = unpack('J', substr($buffer, 2, 8))[1];
|
||||
$offset = 10;
|
||||
}
|
||||
if ($masked) {
|
||||
if ($len < $offset + 4 + $payloadLen) return null;
|
||||
$mask = substr($buffer, $offset, 4);
|
||||
$offset += 4;
|
||||
$payload = '';
|
||||
$raw = substr($buffer, $offset, $payloadLen);
|
||||
for ($i = 0; $i < $payloadLen; $i++) {
|
||||
$payload .= chr(ord($raw[$i]) ^ ord($mask[$i % 4]));
|
||||
}
|
||||
} else {
|
||||
if ($len < $offset + $payloadLen) return null;
|
||||
$payload = substr($buffer, $offset, $payloadLen);
|
||||
}
|
||||
$buffer = substr($buffer, $offset + $payloadLen);
|
||||
return array('opcode' => $opcode, 'payload' => $payload);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast to ALL connected clients.
|
||||
* @method broadcast
|
||||
* @static
|
||||
* @param {array|string} $data
|
||||
*/
|
||||
static function broadcast($data)
|
||||
{
|
||||
$text = is_string($data) ? $data : json_encode($data);
|
||||
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
foreach (self::$clients as $sk => $c) {
|
||||
if (is_resource($c['socket'])) {
|
||||
self::encodeAndSend($c['socket'], 0x1, $text);
|
||||
} else {
|
||||
self::disconnect($sk);
|
||||
}
|
||||
self::encodeAndSend($c['socket'], 0x1, $json);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!isset(self::$channels[$channel])) return;
|
||||
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
foreach (self::$channels[$channel] as $sk => $_) {
|
||||
if (!isset(self::$clients[$sk]) || !is_resource(self::$clients[$sk]['socket'])) {
|
||||
unset(self::$channels[$channel][$sk]);
|
||||
continue;
|
||||
if (isset(self::$clients[$sk])) {
|
||||
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $json);
|
||||
}
|
||||
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $text);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channels ─────────────────────────────────────────
|
||||
|
||||
static function subscribe($socketKey, $channel)
|
||||
static function subscribe($sk, $channel)
|
||||
{
|
||||
self::$channels[$channel][$socketKey] = true;
|
||||
self::$clients[$socketKey]['channels'][$channel] = true;
|
||||
if (!isset(self::$channels[$channel])) self::$channels[$channel] = array();
|
||||
self::$channels[$channel][$sk] = true;
|
||||
if (isset(self::$clients[$sk])) self::$clients[$sk]['channels'][$channel] = true;
|
||||
// If a room worker exists for this channel, notify it
|
||||
self::notifyRoomJoin($channel, $sk);
|
||||
}
|
||||
|
||||
static function unsubscribe($socketKey, $channel)
|
||||
static function unsubscribe($sk, $channel)
|
||||
{
|
||||
unset(self::$channels[$channel][$socketKey]);
|
||||
unset(self::$clients[$socketKey]['channels'][$channel]);
|
||||
unset(self::$channels[$channel][$sk]);
|
||||
if (empty(self::$channels[$channel])) unset(self::$channels[$channel]);
|
||||
if (isset(self::$clients[$sk])) unset(self::$clients[$sk]['channels'][$channel]);
|
||||
self::notifyRoomLeave($channel, $sk);
|
||||
}
|
||||
|
||||
// ── Connection management ────────────────────────────
|
||||
|
||||
static function disconnect($sk)
|
||||
{
|
||||
if (!isset(self::$clients[$sk])) return;
|
||||
// Notify worker process if one exists for this socket
|
||||
self::notifyDisconnect($sk);
|
||||
$w = self::$clients[$sk]['watcher'];
|
||||
if ($w) Q_Evented::cancel($w);
|
||||
foreach (self::$clients[$sk]['channels'] as $ch => $_) {
|
||||
unset(self::$channels[$ch][$sk]);
|
||||
self::notifyRoomLeave($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);
|
||||
@@ -303,41 +192,37 @@ class Q_WebSocket
|
||||
@fwrite($socket, $frame);
|
||||
}
|
||||
|
||||
// ── Process-per-socket dispatch ─────────────────────
|
||||
// ── Connection worker (process-per-socket) ──────
|
||||
|
||||
/**
|
||||
* Map of socketKey → ['pid' => int, 'pipe' => resource, 'watcher' => string]
|
||||
* Each WebSocket connection gets one long-lived PHP child process.
|
||||
* @property $workers
|
||||
* @static
|
||||
*/
|
||||
static $workers = array();
|
||||
|
||||
/**
|
||||
* Called when a WebSocket message arrives. If no child process exists
|
||||
* for this socket, fork one (process-per-socket). Then forward the
|
||||
* message to the child via length-prefixed JSON on the IPC pipe.
|
||||
* @method dispatchEvent
|
||||
* @static
|
||||
*/
|
||||
static function dispatchEvent($socketKey, $raw, $path = '/')
|
||||
{
|
||||
$msg = json_decode($raw, true);
|
||||
if (!$msg || empty($msg['event'])) return;
|
||||
|
||||
// Ensure a child process exists for this connection
|
||||
$event = $msg['event'];
|
||||
|
||||
// Check if this event should go to a room worker instead
|
||||
if (isset(self::$clients[$socketKey])) {
|
||||
foreach (self::$clients[$socketKey]['channels'] as $ch => $_) {
|
||||
if (isset(self::$roomWorkers[$ch])) {
|
||||
// Forward to room worker with sender info
|
||||
$msg['_socketId'] = $socketKey;
|
||||
self::sendToRoomWorker($ch, $msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: per-connection worker
|
||||
if (!isset(self::$workers[$socketKey])) {
|
||||
self::spawnWorker($socketKey, $path);
|
||||
}
|
||||
if (!isset(self::$workers[$socketKey])) return;
|
||||
|
||||
if (!isset(self::$workers[$socketKey])) return; // fork failed
|
||||
|
||||
// Forward message to child via length-prefixed JSON
|
||||
$json = json_encode($msg, JSON_UNESCAPED_SLASHES);
|
||||
$json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$packet = pack('N', strlen($json)) . $json;
|
||||
$written = @fwrite(self::$workers[$socketKey]['pipe'], $packet);
|
||||
if ($written === false || $written === 0) {
|
||||
// Child died — respawn and retry once
|
||||
self::cleanupWorker($socketKey);
|
||||
self::spawnWorker($socketKey, $path);
|
||||
if (isset(self::$workers[$socketKey])) {
|
||||
@@ -346,39 +231,24 @@ class Q_WebSocket
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a child process for a WebSocket connection.
|
||||
* The child reads messages from the IPC pipe and dispatches
|
||||
* each one via Q::event() to the appropriate handler.
|
||||
* Static variables in handlers persist across messages.
|
||||
* Process dies on disconnect — all state wiped.
|
||||
* @method spawnWorker
|
||||
* @static
|
||||
*/
|
||||
static function spawnWorker($socketKey, $path)
|
||||
{
|
||||
if (!function_exists('pcntl_fork')) {
|
||||
return; // no fork — messages dispatch in-process
|
||||
}
|
||||
if (!function_exists('pcntl_fork')) return;
|
||||
|
||||
$pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET;
|
||||
$pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
|
||||
if (!$pair) return;
|
||||
|
||||
$pid = pcntl_fork();
|
||||
if ($pid === -1) {
|
||||
fclose($pair[0]); fclose($pair[1]);
|
||||
return;
|
||||
}
|
||||
if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; }
|
||||
|
||||
if ($pid === 0) {
|
||||
// ── CHILD: message loop ──
|
||||
// ── CHILD: connection message loop ──
|
||||
fclose($pair[0]);
|
||||
$pipe = $pair[1];
|
||||
Q_Socket::$_pipe = $pipe;
|
||||
Q_Socket::$_socketId = $socketKey;
|
||||
|
||||
// Fire _connect event
|
||||
$connectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_connect', null);
|
||||
if ($connectHandler) {
|
||||
Q::event($connectHandler, array(
|
||||
@@ -388,30 +258,24 @@ class Q_WebSocket
|
||||
Q_Socket::flush();
|
||||
}
|
||||
|
||||
// Message loop — blocks on pipe reads, dispatches Q::event()
|
||||
while (true) {
|
||||
$header = @fread($pipe, 4);
|
||||
if ($header === false || $header === '' || strlen($header) < 4) break;
|
||||
|
||||
$len = unpack('N', $header)[1];
|
||||
if ($len <= 0 || $len > 10485760) break;
|
||||
|
||||
$json = '';
|
||||
while (strlen($json) < $len) {
|
||||
$chunk = @fread($pipe, $len - strlen($json));
|
||||
if ($chunk === false || $chunk === '') break 2;
|
||||
$json .= $chunk;
|
||||
}
|
||||
|
||||
$msg = json_decode($json, true);
|
||||
if (!$msg) continue;
|
||||
|
||||
$event = $msg['event'] ?? '';
|
||||
if ($event === '_disconnect') break;
|
||||
|
||||
// Resolve handler via config, or use event name directly
|
||||
$mapped = Q_Config::get('Q', 'webserver', 'sockets', 'events', $event, $event);
|
||||
|
||||
Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null;
|
||||
|
||||
$result = null;
|
||||
@@ -422,18 +286,14 @@ class Q_WebSocket
|
||||
'event' => $event,
|
||||
'data' => $msg['data'] ?? array(),
|
||||
);
|
||||
|
||||
Q::event($mapped, $params, false, false, $result);
|
||||
|
||||
// Auto-ack if handler returned a result
|
||||
if (Q_Socket::$_ack !== null && $result !== null) {
|
||||
Q_Socket::reply(array('ack' => Q_Socket::$_ack, 'data' => $result));
|
||||
}
|
||||
|
||||
Q_Socket::flush();
|
||||
}
|
||||
|
||||
// Fire _disconnect event
|
||||
$disconnectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_disconnect', null);
|
||||
if ($disconnectHandler) {
|
||||
Q::event($disconnectHandler, array(
|
||||
@@ -441,7 +301,6 @@ class Q_WebSocket
|
||||
));
|
||||
Q_Socket::flush();
|
||||
}
|
||||
|
||||
fclose($pipe);
|
||||
exit(0);
|
||||
}
|
||||
@@ -449,7 +308,6 @@ class Q_WebSocket
|
||||
// ── PARENT ──
|
||||
fclose($pair[1]);
|
||||
stream_set_blocking($pair[0], false);
|
||||
|
||||
$ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($socketKey) {
|
||||
$data = @fread($pipe, 65536);
|
||||
if ($data === false || $data === '') {
|
||||
@@ -463,74 +321,321 @@ class Q_WebSocket
|
||||
if ($cmd) Q_WebSocket::executeCommand($cmd);
|
||||
}
|
||||
});
|
||||
|
||||
self::$workers[$socketKey] = array(
|
||||
'pid' => $pid,
|
||||
'pipe' => $pair[0],
|
||||
'watcher' => $ipcWatcher,
|
||||
'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a worker process for a socket.
|
||||
* @method cleanupWorker
|
||||
* @static
|
||||
*/
|
||||
static function cleanupWorker($socketKey)
|
||||
{
|
||||
if (!isset(self::$workers[$socketKey])) return;
|
||||
$w = self::$workers[$socketKey];
|
||||
if ($w['watcher']) Q_Evented::cancel($w['watcher']);
|
||||
@fclose($w['pipe']);
|
||||
if ($w['pid'] > 0) {
|
||||
if (function_exists("posix_kill")) posix_kill($w["pid"], SIGTERM);
|
||||
if ($w['pid'] > 0 && function_exists('posix_kill')) {
|
||||
posix_kill($w['pid'], SIGTERM);
|
||||
pcntl_waitpid($w['pid'], $st, WNOHANG);
|
||||
}
|
||||
unset(self::$workers[$socketKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a worker that its WebSocket client disconnected.
|
||||
* Sends a _disconnect event then cleans up.
|
||||
* @method notifyDisconnect
|
||||
* @static
|
||||
*/
|
||||
static function notifyDisconnect($socketKey)
|
||||
{
|
||||
if (!isset(self::$workers[$socketKey])) return;
|
||||
// Send disconnect message to child (it will exit its listen loop)
|
||||
$json = json_encode(array('event' => '_disconnect', 'data' => array()));
|
||||
$packet = pack('N', strlen($json)) . $json;
|
||||
@fwrite(self::$workers[$socketKey]['pipe'], $packet);
|
||||
// Give child a moment then cleanup
|
||||
self::cleanupWorker($socketKey);
|
||||
}
|
||||
|
||||
// ── Room workers (process-per-room) ─────────────
|
||||
|
||||
/**
|
||||
* Run a socket event handler in-process (Windows/no fork fallback).
|
||||
* @method dispatchEventInProcess
|
||||
* Get room patterns from config. Cached.
|
||||
* Config format:
|
||||
* Q.webserver.sockets.rooms.$pattern = {handler, tick?}
|
||||
* e.g. "game/$id" => {"handler": "game/room", "tick": 100}
|
||||
* @method getRoomPatterns
|
||||
* @static
|
||||
*/
|
||||
static function getRoomPatterns()
|
||||
{
|
||||
if (self::$roomPatterns !== null) return self::$roomPatterns;
|
||||
self::$roomPatterns = Q_Config::get('Q', 'webserver', 'sockets', 'rooms', array());
|
||||
return self::$roomPatterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a room name matches a configured room pattern.
|
||||
* Returns the config (handler, tick) or null.
|
||||
* @method matchRoomPattern
|
||||
* @static
|
||||
*/
|
||||
static function matchRoomPattern($roomName)
|
||||
{
|
||||
$patterns = self::getRoomPatterns();
|
||||
if (empty($patterns)) return null;
|
||||
$segments = explode('/', $roomName);
|
||||
foreach ($patterns as $pattern => $config) {
|
||||
$pSegments = explode('/', $pattern);
|
||||
if (count($pSegments) !== count($segments)) continue;
|
||||
$match = true;
|
||||
$params = array();
|
||||
for ($i = 0; $i < count($pSegments); $i++) {
|
||||
$ps = $pSegments[$i];
|
||||
if (isset($ps[0]) && ($ps[0] === '$' || $ps[0] === ':')) {
|
||||
$params[substr($ps, 1)] = $segments[$i];
|
||||
} elseif ($ps !== $segments[$i]) {
|
||||
$match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($match) {
|
||||
return array_merge((array) $config, array('_params' => $params, '_pattern' => $pattern));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a room worker process.
|
||||
* @method spawnRoomWorker
|
||||
* @static
|
||||
*/
|
||||
static function spawnRoomWorker($roomName, $config)
|
||||
{
|
||||
if (!function_exists('pcntl_fork')) return;
|
||||
if (isset(self::$roomWorkers[$roomName])) return;
|
||||
|
||||
$pf = defined('STREAM_PF_UNIX') ? STREAM_PF_UNIX : STREAM_PF_INET;
|
||||
$pair = stream_socket_pair($pf, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
|
||||
if (!$pair) return;
|
||||
|
||||
$handler = $config['handler'] ?? '';
|
||||
$tick = isset($config['tick']) ? (int) $config['tick'] : 0;
|
||||
$params = $config['_params'] ?? array();
|
||||
|
||||
$pid = pcntl_fork();
|
||||
if ($pid === -1) { fclose($pair[0]); fclose($pair[1]); return; }
|
||||
|
||||
if ($pid === 0) {
|
||||
// ── CHILD: room message loop ──
|
||||
fclose($pair[0]);
|
||||
$pipe = $pair[1];
|
||||
Q_Socket::$_pipe = $pipe;
|
||||
Q_Socket::$_socketId = 0; // room process, no single socket
|
||||
|
||||
// Set up tick timer if configured
|
||||
$tickCallback = null;
|
||||
if ($tick > 0) {
|
||||
$tickCallback = function () use ($handler, $roomName, $params, $pipe) {
|
||||
Q_Socket::$_ack = null;
|
||||
$result = null;
|
||||
$p = array_merge($params, array(
|
||||
'_room' => $roomName, 'event' => '_tick',
|
||||
'data' => array(), '_socketId' => 0,
|
||||
));
|
||||
Q::event($handler, $p, false, false, $result);
|
||||
Q_Socket::flush();
|
||||
};
|
||||
}
|
||||
|
||||
// Fire _init event
|
||||
$result = null;
|
||||
Q::event($handler, array_merge($params, array(
|
||||
'_room' => $roomName, 'event' => '_init', 'data' => array(),
|
||||
'_socketId' => 0,
|
||||
)), false, false, $result);
|
||||
Q_Socket::flush();
|
||||
|
||||
// Message loop with optional tick
|
||||
stream_set_blocking($pipe, false);
|
||||
$lastTick = microtime(true);
|
||||
|
||||
while (true) {
|
||||
$read = array($pipe);
|
||||
$write = $except = null;
|
||||
$timeout = $tick > 0 ? max(0.001, ($tick / 1000.0) - (microtime(true) - $lastTick)) : 1.0;
|
||||
$ready = @stream_select($read, $write, $except, (int) $timeout,
|
||||
(int) (($timeout - (int) $timeout) * 1000000));
|
||||
|
||||
// Tick
|
||||
if ($tick > 0 && (microtime(true) - $lastTick) * 1000 >= $tick) {
|
||||
$lastTick = microtime(true);
|
||||
if ($tickCallback) $tickCallback();
|
||||
}
|
||||
|
||||
if ($ready === false) break;
|
||||
if ($ready === 0) continue;
|
||||
|
||||
// Read length-prefixed messages
|
||||
$raw = @fread($pipe, 65536);
|
||||
if ($raw === false || $raw === '') break;
|
||||
|
||||
// May contain multiple messages
|
||||
$buf = $raw;
|
||||
while (strlen($buf) >= 4) {
|
||||
$len = unpack('N', substr($buf, 0, 4))[1];
|
||||
if ($len <= 0 || $len > 10485760) { $buf = ''; break; }
|
||||
if (strlen($buf) < 4 + $len) break;
|
||||
$json = substr($buf, 4, $len);
|
||||
$buf = substr($buf, 4 + $len);
|
||||
|
||||
$msg = json_decode($json, true);
|
||||
if (!$msg) continue;
|
||||
|
||||
$event = $msg['event'] ?? '';
|
||||
if ($event === '_shutdown') break 2;
|
||||
|
||||
Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null;
|
||||
Q_Socket::$_socketId = $msg['_socketId'] ?? 0;
|
||||
|
||||
$result = null;
|
||||
$p = array_merge($params, array(
|
||||
'_room' => $roomName,
|
||||
'_socketId' => Q_Socket::$_socketId,
|
||||
'_ack' => Q_Socket::$_ack,
|
||||
'event' => $event,
|
||||
'data' => $msg['data'] ?? array(),
|
||||
));
|
||||
Q::event($handler, $p, false, false, $result);
|
||||
|
||||
if (Q_Socket::$_ack !== null && $result !== null) {
|
||||
Q_Socket::send(Q_Socket::$_socketId,
|
||||
array('ack' => Q_Socket::$_ack, 'data' => $result));
|
||||
}
|
||||
Q_Socket::flush();
|
||||
}
|
||||
}
|
||||
|
||||
// Fire _destroy event
|
||||
Q::event($handler, array_merge($params, array(
|
||||
'_room' => $roomName, 'event' => '_destroy', 'data' => array(),
|
||||
'_socketId' => 0,
|
||||
)), false, false, $result);
|
||||
Q_Socket::flush();
|
||||
|
||||
fclose($pipe);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// ── PARENT ──
|
||||
fclose($pair[1]);
|
||||
stream_set_blocking($pair[0], false);
|
||||
$ipcWatcher = Q_Evented::onReadable($pair[0], function ($pipe) use ($roomName) {
|
||||
$data = @fread($pipe, 65536);
|
||||
if ($data === false || $data === '') {
|
||||
Q_WebSocket::cleanupRoomWorker($roomName);
|
||||
return;
|
||||
}
|
||||
$lines = explode("\n", trim($data));
|
||||
foreach ($lines as $line) {
|
||||
if ($line === '') continue;
|
||||
$cmd = json_decode($line, true);
|
||||
if ($cmd) Q_WebSocket::executeCommand($cmd);
|
||||
}
|
||||
});
|
||||
self::$roomWorkers[$roomName] = array(
|
||||
'pid' => $pid, 'pipe' => $pair[0], 'watcher' => $ipcWatcher,
|
||||
'members' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a room worker.
|
||||
* @method sendToRoomWorker
|
||||
* @static
|
||||
*/
|
||||
static function sendToRoomWorker($roomName, $msg)
|
||||
{
|
||||
if (!isset(self::$roomWorkers[$roomName])) return;
|
||||
$json = json_encode($msg, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$packet = pack('N', strlen($json)) . $json;
|
||||
@fwrite(self::$roomWorkers[$roomName]['pipe'], $packet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify room worker when a socket joins.
|
||||
* @method notifyRoomJoin
|
||||
* @static
|
||||
*/
|
||||
static function notifyRoomJoin($channel, $socketKey)
|
||||
{
|
||||
$config = self::matchRoomPattern($channel);
|
||||
if (!$config) return;
|
||||
|
||||
// Spawn room worker if not running
|
||||
if (!isset(self::$roomWorkers[$channel])) {
|
||||
self::spawnRoomWorker($channel, $config);
|
||||
}
|
||||
if (!isset(self::$roomWorkers[$channel])) return;
|
||||
|
||||
self::$roomWorkers[$channel]['members'][$socketKey] = true;
|
||||
self::sendToRoomWorker($channel, array(
|
||||
'event' => '_join', 'data' => array(),
|
||||
'_socketId' => $socketKey,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify room worker when a socket leaves.
|
||||
* @method notifyRoomLeave
|
||||
* @static
|
||||
*/
|
||||
static function notifyRoomLeave($channel, $socketKey)
|
||||
{
|
||||
if (!isset(self::$roomWorkers[$channel])) return;
|
||||
unset(self::$roomWorkers[$channel]['members'][$socketKey]);
|
||||
|
||||
self::sendToRoomWorker($channel, array(
|
||||
'event' => '_leave', 'data' => array(),
|
||||
'_socketId' => $socketKey,
|
||||
));
|
||||
|
||||
// Shut down room if empty
|
||||
if (empty(self::$roomWorkers[$channel]['members'])) {
|
||||
self::sendToRoomWorker($channel, array(
|
||||
'event' => '_shutdown', 'data' => array(),
|
||||
));
|
||||
self::cleanupRoomWorker($channel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a room worker.
|
||||
* @method cleanupRoomWorker
|
||||
* @static
|
||||
*/
|
||||
static function cleanupRoomWorker($roomName)
|
||||
{
|
||||
if (!isset(self::$roomWorkers[$roomName])) return;
|
||||
$w = self::$roomWorkers[$roomName];
|
||||
if ($w['watcher']) Q_Evented::cancel($w['watcher']);
|
||||
@fclose($w['pipe']);
|
||||
if ($w['pid'] > 0 && function_exists('posix_kill')) {
|
||||
posix_kill($w['pid'], SIGTERM);
|
||||
pcntl_waitpid($w['pid'], $st, WNOHANG);
|
||||
}
|
||||
unset(self::$roomWorkers[$roomName]);
|
||||
}
|
||||
|
||||
// ── In-process fallback (Windows) ───────────────
|
||||
|
||||
static function dispatchEventInProcess($eventName, $params, $socketKey, $ack)
|
||||
{
|
||||
Q_Socket::$_directMode = true;
|
||||
Q_Socket::$_socketId = $socketKey;
|
||||
Q_Socket::$_ack = $ack;
|
||||
|
||||
$result = null;
|
||||
Q::event($eventName, $params, false, false, $result);
|
||||
|
||||
if ($ack !== null && $result !== null) {
|
||||
self::send($socketKey, array('ack' => $ack, 'data' => $result));
|
||||
}
|
||||
Q_Socket::$_directMode = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an IPC command from a child process.
|
||||
* @method executeCommand
|
||||
* @static
|
||||
*/
|
||||
// ── IPC command execution ───────────────────────
|
||||
|
||||
static function executeCommand($cmd)
|
||||
{
|
||||
switch ($cmd['cmd'] ?? '') {
|
||||
|
||||
+45
-24
@@ -1,23 +1,32 @@
|
||||
/**
|
||||
* QSocket — tiny WebSocket client for Qbix Server.
|
||||
* QSocket — WebSocket client for Qbix Server.
|
||||
*
|
||||
* Usage:
|
||||
* var qs = new QSocket('ws://localhost:8080/ws/chat');
|
||||
*
|
||||
* // Listen for events from the server
|
||||
* qs.on('chat/message', function(data) {
|
||||
* console.log(data.from + ': ' + data.text);
|
||||
* console.log(data.user + ': ' + data.text);
|
||||
* });
|
||||
*
|
||||
* qs.emit('chat/message', {text: 'hello'}, function(ack) {
|
||||
* console.log('Server confirmed:', ack);
|
||||
* // Send event with callback (server acks with structured data)
|
||||
* qs.emit('chat/message', {text: 'hello'}, function(response) {
|
||||
* // response is whatever the PHP handler set as $result
|
||||
* // arrays, objects, nested structures — all preserved via JSON
|
||||
* console.log('Message #' + response.count);
|
||||
* });
|
||||
*
|
||||
* qs.emit('chat/join', {room: 'lobby'});
|
||||
* // Send without callback
|
||||
* qs.emit('chat/typing', {user: 'Alice'});
|
||||
*
|
||||
* Protocol (JSON over WebSocket):
|
||||
* Client → Server: {"event": "...", "data": {...}, "ack": N}
|
||||
* Server → Client: {"event": "...", "data": {...}} (broadcast)
|
||||
* Server → Client: {"ack": N, "data": {...}} (callback)
|
||||
*
|
||||
* Data is serialized as JSON in both directions. PHP arrays and nested
|
||||
* objects map to JS objects/arrays. Callbacks receive the full structured
|
||||
* response — strings, numbers, booleans, arrays, nested objects.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
@@ -39,38 +48,31 @@
|
||||
|
||||
self.ws.onopen = function () {
|
||||
self._delay = self._reconnectDelay;
|
||||
// Flush queued messages
|
||||
while (self._queue.length) {
|
||||
self.ws.send(self._queue.shift());
|
||||
}
|
||||
if (self._handlers['connect']) {
|
||||
self._handlers['connect'].forEach(function (fn) { fn(); });
|
||||
}
|
||||
self._fire('connect');
|
||||
};
|
||||
|
||||
self.ws.onmessage = function (e) {
|
||||
var msg;
|
||||
try { msg = JSON.parse(e.data); } catch (err) { return; }
|
||||
|
||||
// Ack response (callback from server)
|
||||
// Ack response — invoke the stored callback with full data
|
||||
if (msg.ack !== undefined && self._acks[msg.ack]) {
|
||||
self._acks[msg.ack](msg.data);
|
||||
delete self._acks[msg.ack];
|
||||
return;
|
||||
}
|
||||
|
||||
// Event broadcast
|
||||
if (msg.event && self._handlers[msg.event]) {
|
||||
self._handlers[msg.event].forEach(function (fn) {
|
||||
fn(msg.data);
|
||||
});
|
||||
// Event broadcast — pass full data to all listeners
|
||||
if (msg.event) {
|
||||
self._fire(msg.event, msg.data);
|
||||
}
|
||||
};
|
||||
|
||||
self.ws.onclose = function () {
|
||||
if (self._handlers['disconnect']) {
|
||||
self._handlers['disconnect'].forEach(function (fn) { fn(); });
|
||||
}
|
||||
self._fire('disconnect');
|
||||
if (self._reconnect) {
|
||||
self._delay = Math.min(self._delay * 1.5, self._maxDelay);
|
||||
setTimeout(self._connect, self._delay);
|
||||
@@ -82,13 +84,22 @@
|
||||
};
|
||||
};
|
||||
|
||||
self._fire = function (event, data) {
|
||||
var handlers = self._handlers[event];
|
||||
if (!handlers) return;
|
||||
for (var i = 0; i < handlers.length; i++) {
|
||||
handlers[i](data);
|
||||
}
|
||||
};
|
||||
|
||||
self._delay = self._reconnectDelay;
|
||||
self._connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for an event from the server.
|
||||
* Special events: 'connect', 'disconnect'
|
||||
* Callback receives the data object (arrays, nested objects preserved).
|
||||
* Special events: 'connect' (no data), 'disconnect' (no data)
|
||||
*/
|
||||
QSocket.prototype.on = function (event, fn) {
|
||||
if (!this._handlers[event]) this._handlers[event] = [];
|
||||
@@ -97,21 +108,31 @@
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a listener.
|
||||
* Remove listener(s). No fn = remove all for that event.
|
||||
*/
|
||||
QSocket.prototype.off = function (event, fn) {
|
||||
if (!this._handlers[event]) return this;
|
||||
if (!fn) { delete this._handlers[event]; return this; }
|
||||
this._handlers[event] = this._handlers[event].filter(function (f) { return f !== fn; });
|
||||
this._handlers[event] = this._handlers[event].filter(function (f) {
|
||||
return f !== fn;
|
||||
});
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an event to the server.
|
||||
* Optional callback is invoked when the server acks.
|
||||
*
|
||||
* @param {string} event - Event name (maps to PHP handler)
|
||||
* @param {*} data - Any JSON-serializable value: object, array, string, number, boolean, null
|
||||
* @param {function} [callback] - Called with the server's response (the PHP handler's $result)
|
||||
*
|
||||
* Examples:
|
||||
* qs.emit('chat/message', {text: 'hi', tags: ['urgent']}, function(res) { ... });
|
||||
* qs.emit('game/move', {x: 10, y: 20});
|
||||
* qs.emit('ping', null, function(res) { console.log(res.time); });
|
||||
*/
|
||||
QSocket.prototype.emit = function (event, data, callback) {
|
||||
var msg = { event: event, data: data || {} };
|
||||
var msg = { event: event, data: (data !== undefined ? data : null) };
|
||||
if (typeof callback === 'function') {
|
||||
msg.ack = ++this._ackId;
|
||||
this._acks[msg.ack] = callback;
|
||||
@@ -126,7 +147,7 @@
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the connection (disables auto-reconnect).
|
||||
* Close the connection. Disables auto-reconnect.
|
||||
*/
|
||||
QSocket.prototype.close = function () {
|
||||
this._reconnect = false;
|
||||
|
||||
Reference in New Issue
Block a user