Added support for socket.io and simple JSON websocket payloads via /Q/socket.js

This commit is contained in:
Gregory Magarshak
2026-07-21 17:23:25 -04:00
parent b2cf9c788c
commit 42ed5a5d47
8 changed files with 1499 additions and 434 deletions
+811 -306
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+9
View File
@@ -205,6 +205,9 @@ if (file_exists($appConfig)) {
Q_Config::load($appConfig); Q_Config::load($appConfig);
} }
// Preload handlers if configured (Q.handlers.preload: true)
Q::preload();
// ── PID file ──────────────────────────────────────── // ── PID file ────────────────────────────────────────
if ($opts['pid']) { if ($opts['pid']) {
@@ -242,6 +245,12 @@ echo " │" . str_pad(" http://{$opts['host']}:{$opts['port']}", $W) . "│\n"
echo "" . str_pad(" Root: " . basename($webDir), $W) . "\n"; echo "" . str_pad(" Root: " . basename($webDir), $W) . "\n";
echo "" . str_pad(" Mode: " . ($qbixMode ? 'Qbix Platform' : 'Standalone'), $W) . "\n"; echo "" . str_pad(" Mode: " . ($qbixMode ? 'Qbix Platform' : 'Standalone'), $W) . "\n";
echo "" . str_pad(" PHP: " . ($opts['workers'] ? $opts['workers'] . ' workers' : 'in-process'), $W) . "\n"; echo "" . str_pad(" PHP: " . ($opts['workers'] ? $opts['workers'] . ' workers' : 'in-process'), $W) . "\n";
$nClasses = count(get_declared_classes());
$nHandlers = Q::$preloadedHandlers;
$preloadLabel = $nHandlers > 0
? " Preloaded: {$nClasses} classes, {$nHandlers} handlers"
: " Preloaded: {$nClasses} classes (handlers: lazy)";
echo "" . str_pad($preloadLabel, $W) . "\n";
echo "" . str_repeat('─', $W) . "\n"; echo "" . str_repeat('─', $W) . "\n";
echo "" . str_pad(" Dashboard: /Q/dashboard", $W) . "\n"; echo "" . str_pad(" Dashboard: /Q/dashboard", $W) . "\n";
echo "" . str_pad(" Health: /Q/health", $W) . "\n"; echo "" . str_pad(" Health: /Q/health", $W) . "\n";
+207 -64
View File
@@ -137,6 +137,18 @@ class Q
static $_responseHeaders = array(); static $_responseHeaders = array();
static $_responseCode = 200; static $_responseCode = 200;
/**
* Get the app name. Used to prefix handler function names.
* Set via config: {"Q": {"app": "MyApp"}}
* @method app
* @static
* @return {string} App name, or empty string if not set
*/
static function app()
{
return Q_Config::get('Q', 'app', '');
}
/** /**
* Set a response header. Wraps PHP's header() and captures it. * Set a response header. Wraps PHP's header() and captures it.
* Scripts can call either header() directly or Q::header() both work. * Scripts can call either header() directly or Q::header() both work.
@@ -287,7 +299,9 @@ class Q
static function canHandle($eventName) static function canHandle($eventName)
{ {
$parts = explode('/', $eventName); $parts = explode('/', $eventName);
$funcName = str_replace('-', '_', implode('_', $parts)); $baseName = str_replace('-', '_', implode('_', $parts));
$app = Q::app();
$funcName = ($app !== '' ? $app . '_' : '') . $baseName;
if (function_exists($funcName)) return true; if (function_exists($funcName)) return true;
// Try to load from handlers/ directory // Try to load from handlers/ directory
@@ -327,7 +341,9 @@ class Q
} }
$parts = explode('/', $eventName); $parts = explode('/', $eventName);
$funcName = str_replace('-', '_', implode('_', $parts)); $baseName = str_replace('-', '_', implode('_', $parts));
$app = Q::app();
$funcName = ($app !== '' ? $app . '_' : '') . $baseName;
if (!function_exists($funcName)) { if (!function_exists($funcName)) {
if ($skipIncludes) return null; if ($skipIncludes) return null;
@@ -481,6 +497,57 @@ class Q
self::$paths[] = $projectRoot; self::$paths[] = $projectRoot;
} }
} }
/**
* Preload all handler files if Q.handlers.preload is true.
* Call this after config is loaded and before the server starts accepting
* connections. Handlers are included once in the parent process and shared
* via COW across all forked children.
*
* Off by default handlers lazy-load via include_once on first call,
* which is fine with opcache (edit a file, refresh, see the change).
* Enable in production for full COW sharing of handler bytecode.
*
* @method preload
* @static
*/
static function preload()
{
if (!Q_Config::get('Q', 'handlers', 'preload', false)) {
return;
}
foreach (self::$paths as $base) {
$handlersDir = $base . DS . 'handlers';
if (is_dir($handlersDir)) {
self::preloadDir($handlersDir);
}
}
}
/**
* Recursively include all .php files in a directory.
* @method preloadDir
* @static
* @param {string} $dir Directory to scan
*/
static function preloadDir($dir)
{
$entries = @scandir($dir);
if (!$entries) return;
foreach ($entries as $entry) {
if ($entry[0] === '.') continue;
$path = $dir . DS . $entry;
if (is_dir($path)) {
self::preloadDir($path);
} elseif (substr($entry, -4) === '.php') {
include_once $path;
self::$preloadedHandlers++;
}
}
}
/** @var integer Number of preloaded handler files */
static $preloadedHandlers = 0;
} }
spl_autoload_register(array('Q', 'autoload')); spl_autoload_register(array('Q', 'autoload'));
@@ -488,80 +555,117 @@ spl_autoload_register(array('Q', 'autoload'));
// ── Q_Socket ──────────────────────────────────────── // ── Q_Socket ────────────────────────────────────────
/** /**
* PHP API for WebSocket handlers sending messages, managing rooms. * WebSocket connection context. Passed to per-connection handlers as
* $params['socket']. Use instance methods to communicate with clients.
* *
* Each WebSocket connection gets one PHP process. The server dispatches * function my_handler(&$params, &$result) {
* messages via Q::event() to handlers. Handlers use Q_Socket to send * extract($params); // $socket, $event, $data
* data back. Static variables in handlers persist across messages * $socket->reply(['hello' => 'world']);
* (same process) and are wiped on disconnect (process dies). * $socket->join('chat/general', ['name' => 'Alice']);
* $location = $socket->getLocation(); // RPC call to client
* }
* *
* @class Q_Socket * @class Q_Socket
*/ */
class Q_Socket class Q_Socket
{ {
/** @var resource IPC pipe to parent */ /** @var integer This socket's ID */
static $_pipe = null; public $id;
/** @var integer Current client's socket key */
static $_socketId = null; function __construct($id) { $this->id = $id; }
/** @var integer|null Ack ID from current message */
static $_ack = null; /** Get a socket instance by ID */
/** @var boolean True when running in-process (no fork) */ static function byId($id) { return new self($id); }
static $_directMode = false;
/** @var array Buffered outbound commands */ /** Send data to this socket's client */
static $_buffer = array(); function reply($data) { self::_cmd(array('cmd' => 'send', 'socketId' => $this->id, 'data' => $data)); }
/** Send data to a specific client by socket ID */
function send($socketId, $data) { self::_cmd(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data)); }
/** Broadcast to all clients in a room */
function broadcast($room, $data) { self::_cmd(array('cmd' => 'broadcast', 'room' => $room, 'data' => $data)); }
/** Broadcast to ALL connected clients */
function broadcastAll($data) { self::_cmd(array('cmd' => 'broadcastAll', 'data' => $data)); }
/** Join a room, optionally forwarding data to the room's join handler */
function join($room, $data = array()) { self::_cmd(array('cmd' => 'join', 'socketId' => $this->id, 'room' => $room, 'data' => $data)); }
/** Leave a room, optionally forwarding data to the room's leave handler */
function leave($room, $data = array()) { self::_cmd(array('cmd' => 'leave', 'socketId' => $this->id, 'room' => $room, 'data' => $data)); }
/** /**
* Send data to the client that owns this connection. * Call a method on the remote client. Blocks until the client responds.
* The client must have registered a handler via qs.handle('methodName', fn).
*
* @method __call
* @param {string} $method Method name to invoke on the client
* @param {array} $args Arguments first element is passed as data to client
* @return {mixed} Return value from the client handler, or null on timeout
*/ */
static function reply($data) function __call($method, $args)
{ {
self::send(self::$_socketId, $data); $rpcId = ++self::$_rpcCounter;
$data = isset($args[0]) ? $args[0] : array();
// Flush any pending commands first
self::flush();
// Write RPC request directly to pipe (not buffered — need immediate send)
$cmd = json_encode(array(
'cmd' => 'rpc', 'socketId' => $this->id,
'method' => $method, 'data' => $data, 'rpcId' => $rpcId,
), JSON_UNESCAPED_SLASHES) . "\n";
@fwrite(self::$_pipe, $cmd);
// Block reading pipe until we get our RPC response (timeout 5s)
$deadline = microtime(true) + 5.0;
while (microtime(true) < $deadline) {
$remaining = $deadline - microtime(true);
if ($remaining <= 0) break;
$read = array(self::$_pipe);
$w = $e = null;
$sec = (int) $remaining;
$usec = (int) (($remaining - $sec) * 1000000);
if (@stream_select($read, $w, $e, $sec, $usec) < 1) break;
$header = @fread(self::$_pipe, 4);
if (!$header || strlen($header) < 4) break;
$len = unpack('N', $header)[1];
if ($len <= 0 || $len > 10485760) break;
$json = '';
while (strlen($json) < $len) {
$chunk = @fread(self::$_pipe, $len - strlen($json));
if ($chunk === false || $chunk === '') break 2;
$json .= $chunk;
}
$msg = json_decode($json, true);
if (!$msg) continue;
// Is this our RPC response?
if (isset($msg['_rpc']) && $msg['_rpc'] === $rpcId) {
return isset($msg['result']) ? $msg['result'] : null;
} }
/** // Not our response — buffer for the main loop
* Send data to a specific connected client. self::$_messageQueue[] = $msg;
*/ }
static function send($socketId, $data) return null; // timeout
{
self::_command(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data));
} }
/** // ── Internal IPC plumbing (not part of the public API) ──
* Broadcast to all clients in a room/channel.
*/
static function broadcast($room, $data)
{
self::_command(array('cmd' => 'broadcast', 'room' => $room, 'data' => $data));
}
/** /** @internal */ static $_pipe = null;
* Broadcast to ALL connected WebSocket clients. /** @internal */ static $_ack = null;
*/ /** @internal */ static $_directMode = false;
static function broadcastAll($data) /** @internal */ static $_buffer = array();
{ /** @internal */ static $_rpcCounter = 0;
self::_command(array('cmd' => 'broadcastAll', 'data' => $data)); /** @internal */ static $_messageQueue = array();
}
/** /** @internal */
* Subscribe a client to a room/channel. static function _cmd($cmd)
*/
static function join($socketId, $room)
{
self::_command(array('cmd' => 'join', 'socketId' => $socketId, 'room' => $room));
}
/**
* Unsubscribe a client from a room/channel.
*/
static function leave($socketId, $room)
{
self::_command(array('cmd' => 'leave', 'socketId' => $socketId, 'room' => $room));
}
/**
* Buffer a command or execute directly in-process.
*/
private static function _command($cmd)
{ {
if (self::$_directMode) { if (self::$_directMode) {
Q_WebSocket::executeCommand($cmd); Q_WebSocket::executeCommand($cmd);
@@ -570,10 +674,7 @@ class Q_Socket
} }
} }
/** /** @internal Flush buffered commands to IPC pipe */
* Flush buffered commands to the IPC pipe.
* Called automatically after each handler invocation.
*/
static function flush() static function flush()
{ {
if (!self::$_pipe || empty(self::$_buffer)) return; if (!self::$_pipe || empty(self::$_buffer)) return;
@@ -586,6 +687,48 @@ class Q_Socket
} }
} }
// ── Q_Room ──────────────────────────────────────────
/**
* Room context. Passed to room handlers as $params['room'].
* Wraps IPC commands with room context for cleaner handler code.
*
* function chat_room_message(&$params, &$result) {
* extract($params); // $room, $event, $data
* $room->broadcast(['event' => 'chat/message', 'data' => $data]);
* }
*
* @class Q_Room
*/
class Q_Room
{
/** @var string Room name (e.g. 'chat/general') */
public $name;
/** @var integer Socket ID of the current message sender (0 for lifecycle events without a sender) */
public $socketId;
/** @var array Pattern params (e.g. ['room' => 'general'] from 'chat/$room') */
public $params;
function __construct($name, $socketId = 0, $params = array())
{
$this->name = $name;
$this->socketId = $socketId;
$this->params = $params;
}
/** Get a room instance by name */
static function byName($name) { return new self($name); }
/** Send to all members in this room */
function broadcast($data) { Q_Socket::_cmd(array('cmd' => 'broadcast', 'room' => $this->name, 'data' => $data)); }
/** Send to the member who sent the current message */
function reply($data) { Q_Socket::_cmd(array('cmd' => 'send', 'socketId' => $this->socketId, 'data' => $data)); }
/** Send to a specific member by socket ID */
function send($socketId, $data) { Q_Socket::_cmd(array('cmd' => 'send', 'socketId' => $socketId, 'data' => $data)); }
}
// ── Q_Request ─────────────────────────────────────── // ── Q_Request ───────────────────────────────────────
/** /**
+21 -1
View File
@@ -750,6 +750,25 @@ class Q_WebServer
$path = $parsed['path']; $path = $parsed['path'];
// 1. Dashboard + Panel + WebSocket + Health (/Q/*) // 1. Dashboard + Panel + WebSocket + Health (/Q/*)
// 1. Serve client JS files (before /Q/ check — socket.io.js is at /socket.io/)
$jsMap = array();
$jsPath = Q_Config::get('Q', 'socket', 'js', '/Q/socket.js');
if ($jsPath !== false) $jsMap[$jsPath] = __DIR__ . DS . 'socket.js';
$ioPath = Q_Config::get('Q', 'socket', 'io', '/socket.io');
if ($ioPath !== false) $jsMap[$ioPath . '/socket.io.js'] = __DIR__ . DS . 'socket.io.js';
if (isset($jsMap[$path])) {
$jsFile = $jsMap[$path];
if (file_exists($jsFile)) {
self::sendResponse($client, 200, file_get_contents($jsFile),
'application/javascript',
array('Cache-Control' => 'public, max-age=3600'));
} else {
self::sendResponse($client, 404, 'Not found');
}
return false;
}
// 2. Dashboard + Panel + WebSocket + Health (/Q/*)
if (strpos($path, '/Q/') === 0) { if (strpos($path, '/Q/') === 0) {
if ($path === '/Q/ws') { if ($path === '/Q/ws') {
$upgraded = Q_WebSocket::upgrade( $upgraded = Q_WebSocket::upgrade(
@@ -779,7 +798,8 @@ class Q_WebServer
$client, $parsed['headers'], $client, $parsed['headers'],
function ($sk, $msg) use ($path) { function ($sk, $msg) use ($path) {
Q_WebSocket::dispatchEvent($sk, $msg, $path); Q_WebSocket::dispatchEvent($sk, $msg, $path);
} },
null, $path
); );
return $upgraded; return $upgraded;
} }
+323 -44
View File
@@ -32,7 +32,7 @@ class Q_WebSocket
// ── Upgrade + framing (unchanged) ─────────────── // ── Upgrade + framing (unchanged) ───────────────
static function upgrade($socket, $headers, $onMessage = null, $channel = null) static function upgrade($socket, $headers, $onMessage = null, $channel = null, $path = '/')
{ {
$key = $headers['sec-websocket-key'] ?? null; $key = $headers['sec-websocket-key'] ?? null;
if (!$key) return false; if (!$key) return false;
@@ -46,10 +46,31 @@ class Q_WebSocket
$watcher = Q_Evented::onReadable($socket, function ($sock) use ($sk) { $watcher = Q_Evented::onReadable($socket, function ($sock) use ($sk) {
Q_WebSocket::onData($sk, $sock); Q_WebSocket::onData($sk, $sock);
}); });
// Socket.IO clients connect to configured path (default /socket.io)
// Set Q.socket.io to false to disable Socket.IO protocol
$ioPath = Q_Config::get('Q', 'socket', 'io', '/socket.io');
$proto = ($ioPath !== false && strpos($path, $ioPath) === 0) ? 'socketio' : 'json';
self::$clients[$sk] = array( self::$clients[$sk] = array(
'socket' => $socket, 'watcher' => $watcher, 'socket' => $socket, 'watcher' => $watcher,
'channels' => array(), 'buffer' => '', 'onMessage' => $onMessage 'channels' => array(), 'buffer' => '', 'onMessage' => $onMessage,
'protocol' => $proto,
); );
// Socket.IO: send Engine.IO OPEN handshake
if ($proto === 'socketio') {
$sid = base_convert(mt_rand(1000000, 9999999) . $sk, 10, 36);
$handshake = '0' . json_encode(array(
'sid' => $sid,
'upgrades' => array(),
'pingInterval' => 25000,
'pingTimeout' => 20000,
'maxPayload' => 1000000,
));
self::sendRaw($sk, $handshake);
}
if ($channel) self::subscribe($sk, $channel); if ($channel) self::subscribe($sk, $channel);
return true; return true;
} }
@@ -123,44 +144,44 @@ class Q_WebSocket
static function send($socketKey, $data) static function send($socketKey, $data)
{ {
if (!isset(self::$clients[$socketKey])) return; if (!isset(self::$clients[$socketKey])) return;
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $encoded = self::encodeSend($socketKey, $data);
self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $json); self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $encoded);
} }
static function broadcast($data) static function broadcast($data)
{ {
$json = is_string($data) ? $data : json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
foreach (self::$clients as $sk => $c) { foreach (self::$clients as $sk => $c) {
self::encodeAndSend($c['socket'], 0x1, $json); $encoded = self::encodeSend($sk, $data);
self::encodeAndSend($c['socket'], 0x1, $encoded);
} }
} }
static function broadcastTo($channel, $data) static function broadcastTo($channel, $data)
{ {
if (!isset(self::$channels[$channel])) return; 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 => $_) { foreach (self::$channels[$channel] as $sk => $_) {
if (isset(self::$clients[$sk])) { if (isset(self::$clients[$sk])) {
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $json); $encoded = self::encodeSend($sk, $data);
self::encodeAndSend(self::$clients[$sk]['socket'], 0x1, $encoded);
} }
} }
} }
static function subscribe($sk, $channel) static function subscribe($sk, $channel, $data = array())
{ {
if (!isset(self::$channels[$channel])) self::$channels[$channel] = array(); if (!isset(self::$channels[$channel])) self::$channels[$channel] = array();
self::$channels[$channel][$sk] = true; self::$channels[$channel][$sk] = true;
if (isset(self::$clients[$sk])) self::$clients[$sk]['channels'][$channel] = true; if (isset(self::$clients[$sk])) self::$clients[$sk]['channels'][$channel] = true;
// If a room worker exists for this channel, notify it // If a room worker exists for this channel, notify it
self::notifyRoomJoin($channel, $sk); self::notifyRoomJoin($channel, $sk, $data);
} }
static function unsubscribe($sk, $channel) static function unsubscribe($sk, $channel, $data = array())
{ {
unset(self::$channels[$channel][$sk]); unset(self::$channels[$channel][$sk]);
if (empty(self::$channels[$channel])) unset(self::$channels[$channel]); if (empty(self::$channels[$channel])) unset(self::$channels[$channel]);
if (isset(self::$clients[$sk])) unset(self::$clients[$sk]['channels'][$channel]); if (isset(self::$clients[$sk])) unset(self::$clients[$sk]['channels'][$channel]);
self::notifyRoomLeave($channel, $sk); self::notifyRoomLeave($channel, $sk, $data);
} }
static function disconnect($sk) static function disconnect($sk)
@@ -196,8 +217,22 @@ class Q_WebSocket
static function dispatchEvent($socketKey, $raw, $path = '/') static function dispatchEvent($socketKey, $raw, $path = '/')
{ {
$proto = self::$clients[$socketKey]['protocol'] ?? 'json';
if ($proto === 'socketio') {
$msg = self::parseSocketIO($socketKey, $raw);
if ($msg === null) return; // handled internally (ping/pong/connect)
} else {
// Bare WebSocket — plain JSON
$msg = json_decode($raw, true); $msg = json_decode($raw, true);
if (!$msg || empty($msg['event'])) return; if (!$msg) return;
// Ack-only response (client responding to server RPC)
if (isset($msg['ack']) && !isset($msg['event'])) {
self::handleRpcResponse($msg['ack'], $msg['data'] ?? null);
return;
}
if (empty($msg['event'])) return;
}
$event = $msg['event']; $event = $msg['event'];
@@ -231,6 +266,157 @@ class Q_WebSocket
} }
} }
// ── Socket.IO protocol support ──────────────────
/**
* Parse a Socket.IO/Engine.IO message. Returns normalized internal
* format or null if the message was handled internally (ping, connect).
*/
static function parseSocketIO($socketKey, $raw)
{
if ($raw === '') return null;
$eioType = $raw[0];
switch ($eioType) {
case '2': // Engine.IO ping
self::sendRaw($socketKey, '3'); // pong
return null;
case '3': // Engine.IO pong
return null;
case '5': // Engine.IO upgrade
return null;
case '4': // Engine.IO message → Socket.IO packet
break;
default:
return null;
}
// Strip Engine.IO prefix "4"
$sio = substr($raw, 1);
if ($sio === '' || $sio === false) return null;
$sioType = $sio[0];
$rest = substr($sio, 1);
// Extract namespace from packet (before comma or ack digits)
$ns = '';
if (isset($rest[0]) && $rest[0] === '/') {
$commaPos = strpos($rest, ',');
if ($commaPos !== false) {
$ns = substr($rest, 1, $commaPos - 1); // strip leading /
$rest = substr($rest, $commaPos + 1);
}
}
switch ($sioType) {
case '0': // CONNECT to namespace
$sid = base_convert(mt_rand(1000000, 9999999) . microtime(true) * 1000, 10, 36);
$nsPrefix = $ns ? '/' . $ns . ',' : '';
// Try connect handler (optional — auto-accepts if no handler)
$connectEvent = $ns ? $ns . '/connect' : 'connect';
if (Q::canHandle($connectEvent)) {
// Return as event so it dispatches to the handler
return array('event' => $connectEvent, 'data' => array(),
'_ns' => $ns, '_nsConnect' => true, '_nsSid' => $sid);
}
// Auto-accept: send CONNECT ack
self::sendRaw($socketKey, '40' . $nsPrefix . '{"sid":"' . $sid . '"}');
// Store namespace membership
if (!isset(self::$clients[$socketKey]['namespaces'])) {
self::$clients[$socketKey]['namespaces'] = array();
}
self::$clients[$socketKey]['namespaces'][$ns] = true;
return null;
case '1': // DISCONNECT from namespace
$disconnectEvent = $ns ? $ns . '/disconnect' : 'disconnect';
if (isset(self::$clients[$socketKey]['namespaces'])) {
unset(self::$clients[$socketKey]['namespaces'][$ns]);
}
return array('event' => '_disconnect', 'data' => array(), '_ns' => $ns);
case '2': // EVENT (possibly with ack)
// Extract optional ack ID (digits before JSON array)
$ackId = null;
$i = 0;
while ($i < strlen($rest) && ctype_digit($rest[$i])) $i++;
if ($i > 0) {
$ackId = (int) substr($rest, 0, $i);
$rest = substr($rest, $i);
}
$arr = json_decode($rest, true);
if (!is_array($arr) || empty($arr)) return null;
$eventName = array_shift($arr);
$data = isset($arr[0]) ? $arr[0] : array();
// Prepend namespace to event name
if ($ns) $eventName = $ns . '/' . $eventName;
$msg = array('event' => $eventName, 'data' => $data);
if ($ackId !== null) $msg['ack'] = $ackId;
return $msg;
case '3': // ACK (client responding to server RPC)
$i = 0;
while ($i < strlen($rest) && ctype_digit($rest[$i])) $i++;
$ackId = ($i > 0) ? (int) substr($rest, 0, $i) : null;
$rest = substr($rest, $i);
$arr = json_decode($rest, true);
$result = (is_array($arr) && !empty($arr)) ? $arr[0] : null;
if ($ackId !== null) {
self::handleRpcResponse($ackId, $result);
}
return null;
default:
return null;
}
}
/**
* Send raw text frame to a WebSocket client (no JSON wrapping).
* Used for Socket.IO protocol frames.
*/
static function sendRaw($socketKey, $text)
{
if (!isset(self::$clients[$socketKey]['socket'])) return;
self::encodeAndSend(self::$clients[$socketKey]['socket'], 0x1, $text);
}
/**
* Send a Socket.IO ACK response: 43<ackId>[data]
*/
/**
* Send a Socket.IO ACK response: 43<ackId>[data]
* or bare JSON: {"ack": ackId, "data": ...}
*/
static function sendAck($socketKey, $ackId, $data)
{
$proto = self::$clients[$socketKey]['protocol'] ?? 'json';
if ($proto === 'socketio') {
self::sendRaw($socketKey, '43' . $ackId . json_encode(array($data), JSON_UNESCAPED_SLASHES));
} else {
self::send($socketKey, array('ack' => $ackId, 'data' => $data));
}
}
/**
* Encode outgoing data for the client's protocol.
*/
static function encodeSend($socketKey, $data)
{
$proto = self::$clients[$socketKey]['protocol'] ?? 'json';
if ($proto === 'socketio') {
$event = $data['event'] ?? 'message';
$payload = $data['data'] ?? $data;
$arr = array($event, $payload);
return '42' . json_encode($arr, JSON_UNESCAPED_SLASHES);
}
// Bare WebSocket — plain JSON
return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
static function spawnWorker($socketKey, $path) static function spawnWorker($socketKey, $path)
{ {
if (!function_exists('pcntl_fork')) return; if (!function_exists('pcntl_fork')) return;
@@ -247,18 +433,24 @@ class Q_WebSocket
fclose($pair[0]); fclose($pair[0]);
$pipe = $pair[1]; $pipe = $pair[1];
Q_Socket::$_pipe = $pipe; Q_Socket::$_pipe = $pipe;
Q_Socket::$_socketId = $socketKey;
$socket = new Q_Socket($socketKey);
$connectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_connect', null); $connectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_connect', null);
if ($connectHandler) { if ($connectHandler) {
Q::event($connectHandler, array( Q::event($connectHandler, array(
'_socketId' => $socketKey, '_path' => $path, 'socket' => $socket, 'path' => $path,
'event' => '_connect', 'data' => array(), 'event' => '_connect', 'data' => array(),
)); ));
Q_Socket::flush(); Q_Socket::flush();
} }
while (true) { while (true) {
// Check message queue first (filled by __call when it
// reads non-RPC messages while waiting for a response)
if (!empty(Q_Socket::$_messageQueue)) {
$msg = array_shift(Q_Socket::$_messageQueue);
} else {
$header = @fread($pipe, 4); $header = @fread($pipe, 4);
if ($header === false || $header === '' || strlen($header) < 4) break; if ($header === false || $header === '' || strlen($header) < 4) break;
$len = unpack('N', $header)[1]; $len = unpack('N', $header)[1];
@@ -271,6 +463,7 @@ class Q_WebSocket
} }
$msg = json_decode($json, true); $msg = json_decode($json, true);
if (!$msg) continue; if (!$msg) continue;
}
$event = $msg['event'] ?? ''; $event = $msg['event'] ?? '';
if ($event === '_disconnect') break; if ($event === '_disconnect') break;
@@ -280,16 +473,18 @@ class Q_WebSocket
$result = null; $result = null;
$params = array( $params = array(
'_socketId' => $socketKey, 'socket' => $socket,
'_path' => $path, 'path' => $path,
'_ack' => Q_Socket::$_ack,
'event' => $event, 'event' => $event,
'data' => $msg['data'] ?? array(), 'data' => $msg['data'] ?? array(),
); );
Q::event($mapped, $params, false, false, $result); Q::event($mapped, $params, false, false, $result);
if (Q_Socket::$_ack !== null && $result !== null) { if (Q_Socket::$_ack !== null && $result !== null) {
Q_Socket::reply(array('ack' => Q_Socket::$_ack, 'data' => $result)); Q_Socket::_cmd(array(
'cmd' => 'ack', 'socketId' => $socket->id,
'ackId' => Q_Socket::$_ack, 'data' => $result,
));
} }
Q_Socket::flush(); Q_Socket::flush();
} }
@@ -297,7 +492,7 @@ class Q_WebSocket
$disconnectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_disconnect', null); $disconnectHandler = Q_Config::get('Q', 'webserver', 'sockets', 'events', '_disconnect', null);
if ($disconnectHandler) { if ($disconnectHandler) {
Q::event($disconnectHandler, array( Q::event($disconnectHandler, array(
'_socketId' => $socketKey, 'event' => '_disconnect', 'data' => array(), 'socket' => $socket, 'event' => '_disconnect', 'data' => array(),
)); ));
Q_Socket::flush(); Q_Socket::flush();
} }
@@ -423,7 +618,6 @@ class Q_WebSocket
fclose($pair[0]); fclose($pair[0]);
$pipe = $pair[1]; $pipe = $pair[1];
Q_Socket::$_pipe = $pipe; Q_Socket::$_pipe = $pipe;
Q_Socket::$_socketId = 0; // room process, no single socket
// Set up tick timer if configured // Set up tick timer if configured
$tickCallback = null; $tickCallback = null;
@@ -431,20 +625,20 @@ class Q_WebSocket
$tickCallback = function () use ($handler, $roomName, $params, $pipe) { $tickCallback = function () use ($handler, $roomName, $params, $pipe) {
Q_Socket::$_ack = null; Q_Socket::$_ack = null;
$result = null; $result = null;
$room = new Q_Room($roomName, 0, $params);
$p = array_merge($params, array( $p = array_merge($params, array(
'_room' => $roomName, 'event' => '_tick', 'room' => $room, 'event' => '_tick', 'data' => array(),
'data' => array(), '_socketId' => 0,
)); ));
Q::event($handler, $p, false, false, $result); Q::event($handler . '/tick', $p, false, false, $result);
Q_Socket::flush(); Q_Socket::flush();
}; };
} }
// Fire _init event // Fire _init event
$result = null; $result = null;
Q::event($handler, array_merge($params, array( $room = new Q_Room($roomName, 0, $params);
'_room' => $roomName, 'event' => '_init', 'data' => array(), Q::event($handler . '/init', array_merge($params, array(
'_socketId' => 0, 'room' => $room, 'event' => '_init', 'data' => array(),
)), false, false, $result); )), false, false, $result);
Q_Socket::flush(); Q_Socket::flush();
@@ -488,30 +682,34 @@ class Q_WebSocket
if ($event === '_shutdown') break 2; if ($event === '_shutdown') break 2;
Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null; Q_Socket::$_ack = isset($msg['ack']) ? $msg['ack'] : null;
Q_Socket::$_socketId = $msg['_socketId'] ?? 0; $senderSocketId = $msg['_socketId'] ?? 0;
$result = null; $result = null;
$room = new Q_Room($roomName, $senderSocketId, $params);
$p = array_merge($params, array( $p = array_merge($params, array(
'_room' => $roomName, 'room' => $room,
'_socketId' => Q_Socket::$_socketId,
'_ack' => Q_Socket::$_ack,
'event' => $event, 'event' => $event,
'data' => $msg['data'] ?? array(), 'data' => $msg['data'] ?? array(),
)); ));
Q::event($handler, $p, false, false, $result); // Lifecycle events: _join → handler/join
// User events: message → handler/message
$eventPath = $handler . '/' . ltrim($event, '_');
Q::event($eventPath, $p, false, false, $result);
if (Q_Socket::$_ack !== null && $result !== null) { if (Q_Socket::$_ack !== null && $result !== null) {
Q_Socket::send(Q_Socket::$_socketId, Q_Socket::_cmd(array(
array('ack' => Q_Socket::$_ack, 'data' => $result)); 'cmd' => 'ack', 'socketId' => $room->socketId,
'ackId' => Q_Socket::$_ack, 'data' => $result,
));
} }
Q_Socket::flush(); Q_Socket::flush();
} }
} }
// Fire _destroy event // Fire _destroy event
Q::event($handler, array_merge($params, array( $room = new Q_Room($roomName, 0, $params);
'_room' => $roomName, 'event' => '_destroy', 'data' => array(), Q::event($handler . '/destroy', array_merge($params, array(
'_socketId' => 0, 'room' => $room, 'event' => '_destroy', 'data' => array(),
)), false, false, $result); )), false, false, $result);
Q_Socket::flush(); Q_Socket::flush();
@@ -559,7 +757,7 @@ class Q_WebSocket
* @method notifyRoomJoin * @method notifyRoomJoin
* @static * @static
*/ */
static function notifyRoomJoin($channel, $socketKey) static function notifyRoomJoin($channel, $socketKey, $data = array())
{ {
$config = self::matchRoomPattern($channel); $config = self::matchRoomPattern($channel);
if (!$config) return; if (!$config) return;
@@ -572,7 +770,7 @@ class Q_WebSocket
self::$roomWorkers[$channel]['members'][$socketKey] = true; self::$roomWorkers[$channel]['members'][$socketKey] = true;
self::sendToRoomWorker($channel, array( self::sendToRoomWorker($channel, array(
'event' => '_join', 'data' => array(), 'event' => '_join', 'data' => $data,
'_socketId' => $socketKey, '_socketId' => $socketKey,
)); ));
} }
@@ -582,13 +780,13 @@ class Q_WebSocket
* @method notifyRoomLeave * @method notifyRoomLeave
* @static * @static
*/ */
static function notifyRoomLeave($channel, $socketKey) static function notifyRoomLeave($channel, $socketKey, $data = array())
{ {
if (!isset(self::$roomWorkers[$channel])) return; if (!isset(self::$roomWorkers[$channel])) return;
unset(self::$roomWorkers[$channel]['members'][$socketKey]); unset(self::$roomWorkers[$channel]['members'][$socketKey]);
self::sendToRoomWorker($channel, array( self::sendToRoomWorker($channel, array(
'event' => '_leave', 'data' => array(), 'event' => '_leave', 'data' => $data,
'_socketId' => $socketKey, '_socketId' => $socketKey,
)); ));
@@ -624,8 +822,9 @@ class Q_WebSocket
static function dispatchEventInProcess($eventName, $params, $socketKey, $ack) static function dispatchEventInProcess($eventName, $params, $socketKey, $ack)
{ {
Q_Socket::$_directMode = true; Q_Socket::$_directMode = true;
Q_Socket::$_socketId = $socketKey;
Q_Socket::$_ack = $ack; Q_Socket::$_ack = $ack;
$socket = new Q_Socket($socketKey);
$params['socket'] = $socket;
$result = null; $result = null;
Q::event($eventName, $params, false, false, $result); Q::event($eventName, $params, false, false, $result);
if ($ack !== null && $result !== null) { if ($ack !== null && $result !== null) {
@@ -636,6 +835,11 @@ class Q_WebSocket
// ── IPC command execution ─────────────────────── // ── IPC command execution ───────────────────────
static function clientCount()
{
return count(self::$clients);
}
static function executeCommand($cmd) static function executeCommand($cmd)
{ {
switch ($cmd['cmd'] ?? '') { switch ($cmd['cmd'] ?? '') {
@@ -649,11 +853,86 @@ class Q_WebSocket
self::broadcast($cmd['data']); self::broadcast($cmd['data']);
break; break;
case 'join': case 'join':
self::subscribe($cmd['socketId'], $cmd['room']); self::subscribe($cmd['socketId'], $cmd['room'], $cmd['data'] ?? array());
break; break;
case 'leave': case 'leave':
self::unsubscribe($cmd['socketId'], $cmd['room']); self::unsubscribe($cmd['socketId'], $cmd['room'], $cmd['data'] ?? array());
break;
case 'ack':
self::sendAck($cmd['socketId'], $cmd['ackId'], $cmd['data']);
break;
case 'rpc':
self::handleRpc($cmd);
break; break;
} }
} }
// ── Server→Client RPC ───────────────────────────
/** @internal Maps rpcAckId → ['pipe' => resource, 'rpcId' => int] */
static $pendingRpc = array();
/** @internal Counter for server→client ack IDs */
static $rpcAckCounter = 0;
/**
* Handle an RPC request from a child process.
* Sends the method call to the client with an ack ID, then routes
* the client's ack response back to the child's IPC pipe.
*/
static function handleRpc($cmd)
{
$socketKey = $cmd['socketId'];
$method = $cmd['method'];
$data = $cmd['data'] ?? array();
$rpcId = $cmd['rpcId'];
// Generate a unique ack ID for server→client
$ackId = ++self::$rpcAckCounter;
// Find which child pipe to route the response back to
$childPipe = null;
if (isset(self::$workers[$socketKey])) {
$childPipe = self::$workers[$socketKey]['pipe'];
}
if (!$childPipe) return;
// Store mapping so we can route the ack response back
self::$pendingRpc[$ackId] = array(
'pipe' => $childPipe,
'rpcId' => $rpcId,
);
// Send RPC call to client with ack ID
$proto = self::$clients[$socketKey]['protocol'] ?? 'json';
if ($proto === 'socketio') {
// Socket.IO: 42<ackId>["method", data]
$payload = '42' . $ackId . json_encode(array($method, $data), JSON_UNESCAPED_SLASHES);
self::sendRaw($socketKey, $payload);
} else {
// Bare: {"event":"method", "data":..., "ack": ackId}
self::send($socketKey, array('event' => $method, 'data' => $data, 'ack' => $ackId));
}
}
/**
* Route an ack response from a client back to the child that
* initiated the RPC call.
* @return boolean True if this was an RPC ack and was handled
*/
static function handleRpcResponse($ackId, $result)
{
if (!isset(self::$pendingRpc[$ackId])) return false;
$pending = self::$pendingRpc[$ackId];
unset(self::$pendingRpc[$ackId]);
// Send response back to child via IPC pipe
$response = json_encode(array(
'_rpc' => $pending['rpcId'],
'result' => $result,
), JSON_UNESCAPED_SLASHES);
$packet = pack('N', strlen($response)) . $response;
@fwrite($pending['pipe'], $packet);
return true;
}
} }
File diff suppressed because one or more lines are too long
+102
View File
@@ -0,0 +1,102 @@
/**
* QSocket minimal WebSocket client for Qbix Server.
* ~80 lines. No dependencies. Plain JSON over bare WebSocket.
*
* var socket = new QSocket('/ws');
* socket.on('chat/message', function(data) { ... });
* socket.emit('chat/message', {text: 'hi'}, function(res) { ... });
*/
(function(root) {
'use strict';
function QSocket(path, opts) {
opts = opts || {};
var self = this;
self._l = {};
self._h = {};
self._a = {};
self._n = 0;
self._q = [];
self._rc = opts.reconnect !== false;
self._d = opts.delay || 1000;
self._md = opts.maxDelay || 30000;
self._cd = self._d;
var loc = root.location || {};
var proto = (loc.protocol === 'https:') ? 'wss://' : 'ws://';
self._url = (path.indexOf('ws') === 0) ? path : proto + loc.host + path;
self._open = function() {
var ws = self.ws = new WebSocket(self._url);
ws.onopen = function() {
self._cd = self._d;
while (self._q.length) ws.send(self._q.shift());
self._emit('connect');
};
ws.onmessage = function(e) {
var m; try { m = JSON.parse(e.data); } catch(x) { return; }
// Ack response
if (m.ack != null && !m.event && self._a[m.ack]) {
self._a[m.ack](m.data); delete self._a[m.ack]; return;
}
// Server RPC call (event + ack, no pending callback)
if (m.event && m.ack != null && !self._a[m.ack]) {
var h = self._h[m.event], r;
try { r = h ? h(m.data) : null; } catch(x) { r = null; }
if (r && typeof r.then === 'function') {
r.then(function(v) { self._ack(m.ack, v); })
['catch'](function() { self._ack(m.ack, null); });
} else {
self._ack(m.ack, r);
}
return;
}
if (m.event) self._emit(m.event, m.data);
};
ws.onclose = function() {
self._emit('disconnect');
if (self._rc) {
self._cd = Math.min(self._cd * 1.5, self._md);
setTimeout(self._open, self._cd);
}
};
ws.onerror = function() { ws.close(); };
};
self._emit = function(ev, d) {
var ls = self._l[ev]; if (!ls) return;
for (var i = 0; i < ls.length; i++) ls[i](d);
};
self._ack = function(id, d) {
self._send(JSON.stringify({ack: id, data: d}));
};
self._send = function(s) {
if (self.ws && self.ws.readyState === 1) self.ws.send(s);
else self._q.push(s);
};
self._open();
}
QSocket.prototype.on = function(ev, fn) {
if (!this._l[ev]) this._l[ev] = [];
this._l[ev].push(fn); return this;
};
QSocket.prototype.off = function(ev, fn) {
if (!fn) { delete this._l[ev]; return this; }
if (this._l[ev]) this._l[ev] = this._l[ev].filter(function(f) { return f !== fn; });
return this;
};
QSocket.prototype.emit = function(ev, data, cb) {
var m = {event: ev, data: data != null ? data : null};
if (typeof cb === 'function') { m.ack = ++this._n; this._a[m.ack] = cb; }
this._send(JSON.stringify(m)); return this;
};
QSocket.prototype.handle = function(method, fn) {
this._h[method] = fn; return this;
};
QSocket.prototype.close = function() {
this._rc = false; if (this.ws) this.ws.close();
};
if (typeof module !== 'undefined' && module.exports) module.exports = QSocket;
else root.QSocket = QSocket;
})(typeof window !== 'undefined' ? window : this);