mirror of
https://github.com/Qbix/webserver.git
synced 2026-07-22 07:57:23 +02:00
Added support for autoloading, event handlers, and more
This commit is contained in:
@@ -31,7 +31,7 @@ But on **actual PHP workloads**, the bootstrap savings make this **2–5x faster
|
||||
- [vs FrankenPHP and Swoole](#️-vs-frankenphp-and-swoole)
|
||||
- [Features](#-features)
|
||||
- [Server Headers](#-server-headers--what-your-php-can-send)
|
||||
- [Project Structure](#-project-structure)
|
||||
- [For PHP Developers](#-for-php-developers--the-micro-framework)
|
||||
- [Configuration](#-configuration)
|
||||
- [Three Ways to Run](#-three-ways-to-run)
|
||||
- [Building](#-building)
|
||||
@@ -444,41 +444,85 @@ update themselves without any manual invalidation calls.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
## 📂 For PHP Developers — The Micro-Framework
|
||||
|
||||
A typical project looks like this:
|
||||
Qbix Server isn't just a static file server with PHP bolted on. It's a micro-framework
|
||||
where you **drop files into conventional directories** and things just work — classes
|
||||
autoload, events fire handlers, views render templates. No configuration needed for
|
||||
the basics.
|
||||
|
||||
### Project layout
|
||||
|
||||
```
|
||||
myproject/
|
||||
├── qbixserver.php ← server entry point (or use the PHAR)
|
||||
├── qbixserver.php ← server entry point (or use the PHAR)
|
||||
├── config/
|
||||
│ └── server.json ← server configuration
|
||||
├── web/ ← document root (publicly accessible)
|
||||
│ ├── index.html
|
||||
│ └── server.json ← server + app configuration
|
||||
├── web/ ← document root (publicly accessible)
|
||||
│ ├── index.html ← static files served directly
|
||||
│ ├── style.css
|
||||
│ ├── app.js
|
||||
│ ├── api.php ← executed as PHP
|
||||
│ └── uploads/ ← served as static files (or via X-Accel-Redirect)
|
||||
└── classes/ ← your PHP classes (for preloading)
|
||||
├── MyApp.php
|
||||
├── MyApp/
|
||||
│ ├── User.php
|
||||
│ ├── Feed.php
|
||||
│ └── Auth.php
|
||||
└── vendor/ ← composer dependencies
|
||||
└── autoload.php
|
||||
│ ├── api.php ← PHP scripts executed on request
|
||||
│ └── uploads/
|
||||
├── classes/ ← autoloaded classes (preloaded into workers)
|
||||
│ ├── MyApp/
|
||||
│ │ ├── User.php ← MyApp\User or MyApp_User
|
||||
│ │ ├── Feed.php
|
||||
│ │ └── Auth.php
|
||||
│ └── vendor/
|
||||
│ └── autoload.php ← Composer autoloader (optional)
|
||||
├── handlers/ ← event handlers (loaded on demand)
|
||||
│ └── MyApp/
|
||||
│ └── feed/
|
||||
│ ├── post.php ← handles "MyApp/feed/post" event
|
||||
│ └── validate.php ← handles "MyApp/feed/validate" event
|
||||
└── views/ ← PHP templates for Q::view()
|
||||
└── MyApp/
|
||||
└── feed/
|
||||
├── page.php
|
||||
└── item.php
|
||||
```
|
||||
|
||||
Only files under `web/` are accessible via HTTP. The `classes/` directory is for
|
||||
code that runs inside your PHP scripts — and for classes you want preloaded into
|
||||
the fork pool.
|
||||
Only `web/` is accessible via HTTP. Everything else is server-side only.
|
||||
|
||||
### Preloading classes
|
||||
### Classes — preloaded, both conventions
|
||||
|
||||
When you use `--workers=N`, the parent process loads classes before forking.
|
||||
Workers inherit everything via copy-on-write — zero bootstrap cost per request.
|
||||
Drop a PHP file in `classes/` and it's autoloaded. Both naming conventions work:
|
||||
|
||||
Configure preloading in `config/server.json`:
|
||||
```php
|
||||
<?php
|
||||
// classes/MyApp/User.php — namespace style (PSR-4)
|
||||
namespace MyApp;
|
||||
|
||||
class User {
|
||||
public static function fromSession(): ?self { /* ... */ }
|
||||
public static function find(string $id): ?self { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// classes/MyApp/Auth.php — underscore style (Qbix convention)
|
||||
class MyApp_Auth {
|
||||
static function check(): bool { return !empty($_SESSION['user_id']); }
|
||||
}
|
||||
```
|
||||
|
||||
Both are available immediately in your `web/*.php` scripts:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// web/profile.php — both class styles work, no require needed
|
||||
use MyApp\User;
|
||||
|
||||
$user = User::fromSession();
|
||||
$isAdmin = MyApp_Auth::check();
|
||||
```
|
||||
|
||||
The autoloader also bridges between conventions — if you define `MyApp_Auth`,
|
||||
it's also accessible as `MyApp\Auth`, and vice versa.
|
||||
|
||||
**Preloading** loads classes into memory before forking workers, so there's zero
|
||||
autoloader overhead per request:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -487,11 +531,9 @@ Configure preloading in `config/server.json`:
|
||||
"preload": {
|
||||
"autoload": "classes/vendor/autoload.php",
|
||||
"classes": [
|
||||
"MyApp",
|
||||
"MyApp\\User",
|
||||
"MyApp\\Feed",
|
||||
"MyApp\\Auth",
|
||||
"MyApp\\Database"
|
||||
"MyApp_Auth"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -500,37 +542,173 @@ Configure preloading in `config/server.json`:
|
||||
```
|
||||
|
||||
```bash
|
||||
# Start with 4 workers — classes loaded once, shared across all
|
||||
php qbixserver.php --root=./web --port=8080 --workers=4
|
||||
# Autoloader: autoload.php
|
||||
# Preloaded: 3 classes
|
||||
```
|
||||
|
||||
What happens at startup:
|
||||
Classes are **eager** — loaded once at startup, shared across all workers via
|
||||
copy-on-write. This is the "hot path" code that handles every request.
|
||||
|
||||
```
|
||||
1. Parent includes classes/vendor/autoload.php
|
||||
2. Parent loads each class in the preload list (triggers autoloader)
|
||||
3. All class definitions, constants, and autoloader maps are now in memory
|
||||
4. Parent calls pcntl_fork() × 4
|
||||
5. Each worker inherits everything — ready to handle requests immediately
|
||||
```
|
||||
### Handlers — loaded on demand
|
||||
|
||||
Your `web/api.php` can now use `MyApp\User` or `MyApp\Feed` without any `require`
|
||||
or autoloader overhead — the classes are already loaded:
|
||||
Handlers are the opposite of classes: they're loaded **only when their event fires**.
|
||||
Drop a file in `handlers/` and it's available as an event:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// web/api.php — classes are already in memory from preloading
|
||||
use MyApp\User;
|
||||
use MyApp\Feed;
|
||||
// handlers/MyApp/feed/post.php
|
||||
// Handles the "MyApp/feed/post" event
|
||||
// Function name = path with slashes replaced by underscores
|
||||
|
||||
$user = User::fromSession();
|
||||
$items = Feed::latest(20);
|
||||
function MyApp_feed_post(&$params, &$result) {
|
||||
$title = $params['title'] ?? 'Untitled';
|
||||
$userId = $params['userId'] ?? null;
|
||||
|
||||
// Validate, save to DB, whatever
|
||||
$id = saveFeedPost($userId, $title);
|
||||
|
||||
$result = ['id' => $id, 'title' => $title, 'saved' => true];
|
||||
return $result;
|
||||
}
|
||||
```
|
||||
|
||||
Fire it from anywhere:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// web/api.php
|
||||
$result = Q::event('MyApp/feed/post', [
|
||||
'title' => $_POST['title'],
|
||||
'userId' => $_SESSION['user_id'],
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Cache-Control: public, max-age=60');
|
||||
echo json_encode($items);
|
||||
echo json_encode($result);
|
||||
```
|
||||
|
||||
The handler file is `include`'d the first time the event fires, then the function
|
||||
stays in memory. If the event never fires, the file is never loaded. This is ideal
|
||||
for things like webhooks, admin actions, and error handlers — code that runs rarely
|
||||
but needs to be available.
|
||||
|
||||
**Check if a handler exists:**
|
||||
|
||||
```php
|
||||
if (Q::canHandle('MyApp/feed/post')) {
|
||||
Q::event('MyApp/feed/post', $params);
|
||||
}
|
||||
```
|
||||
|
||||
### Before/after hooks
|
||||
|
||||
You can attach hooks to any event via config — useful for validation, logging,
|
||||
access control, or cross-cutting concerns:
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"handlersBeforeEvent": {
|
||||
"MyApp/feed/post": ["MyApp/feed/validate"]
|
||||
},
|
||||
"handlersAfterEvent": {
|
||||
"MyApp/feed/post": ["MyApp/feed/notify"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// handlers/MyApp/feed/validate.php
|
||||
function MyApp_feed_validate(&$params, &$result) {
|
||||
if (empty($params['title'])) {
|
||||
$result = ['error' => 'Title required'];
|
||||
return false; // stops the event chain — main handler won't fire
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// handlers/MyApp/feed/notify.php
|
||||
function MyApp_feed_notify(&$params, &$result) {
|
||||
// Runs after the main handler
|
||||
if (!empty($result['saved'])) {
|
||||
sendNotification($params['userId'], "Post published: " . $result['title']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The chain is: **before hooks → main handler → after hooks**. Any before hook
|
||||
returning `false` stops the chain. This is the same pattern the full
|
||||
[Qbix Platform](https://github.com/Qbix/Platform) uses — your handlers
|
||||
work identically when you upgrade.
|
||||
|
||||
### Remote handlers
|
||||
|
||||
Handlers can also be URLs. If a handler name in the config starts with
|
||||
`http://` or `https://`, the server POSTs the event parameters as JSON
|
||||
to that URL instead of loading a local PHP file:
|
||||
|
||||
```json
|
||||
{
|
||||
"Q": {
|
||||
"handlersAfterEvent": {
|
||||
"MyApp/user/register": ["https://hooks.example.com/new-user"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `Q::event('MyApp/user/register', $params)` fires, the local handler
|
||||
runs first, then the server POSTs `$params` as JSON to the remote URL.
|
||||
This is webhooks built into the event system — no separate webhook
|
||||
infrastructure needed.
|
||||
|
||||
### Views — PHP templates
|
||||
|
||||
Render PHP templates from the `views/` directory:
|
||||
|
||||
```php
|
||||
<?php
|
||||
// views/MyApp/feed/item.php
|
||||
// Variables are extracted into scope from the $params array
|
||||
?>
|
||||
<article>
|
||||
<h2><?= htmlspecialchars($title) ?></h2>
|
||||
<p><?= htmlspecialchars($body) ?></p>
|
||||
<time><?= $time ?></time>
|
||||
</article>
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// web/feed.php
|
||||
$items = MyApp\Feed::latest(10);
|
||||
$html = '';
|
||||
foreach ($items as $item) {
|
||||
$html .= Q::view('MyApp/feed/item.php', $item);
|
||||
}
|
||||
echo Q::view('MyApp/feed/page.php', ['content' => $html]);
|
||||
```
|
||||
|
||||
Views are just PHP files — full language access, no template DSL to learn.
|
||||
|
||||
### The philosophy
|
||||
|
||||
| | Loaded when | Lives in | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Classes** | Startup (preloaded) | `classes/` | Models, services, utilities — your core code |
|
||||
| **Handlers** | First event fire (on demand) | `handlers/` | Actions, hooks, webhooks — code that responds to events |
|
||||
| **Views** | When rendered | `views/` | Templates — HTML with PHP |
|
||||
| **Scripts** | When requested via HTTP | `web/` | Entry points — the "controller" layer |
|
||||
| **Config** | Startup | `config/` | Settings, handler hooks, preload lists |
|
||||
|
||||
Classes are **eager**. Handlers are **lazy**. Scripts are **per-request**.
|
||||
Views are **on-demand**. This gives you the right loading strategy for each
|
||||
kind of code without thinking about it — just put files in the right directory.
|
||||
|
||||
### Workers: fork-per-request (truly shared-nothing)
|
||||
|
||||
Each worker handles exactly **one request**, then exits. The parent immediately
|
||||
@@ -554,6 +732,17 @@ single-threaded — fine for development and lightweight APIs. Superglobals
|
||||
variables persist (same as php-fpm). Use `--workers=N` in production for
|
||||
full isolation.
|
||||
|
||||
### Growing into the full Qbix Platform
|
||||
|
||||
The conventions above — `classes/`, `handlers/`, `views/`, `config/` — are
|
||||
the same ones the [Qbix Platform](https://github.com/Qbix/Platform) uses.
|
||||
When your project outgrows the micro-framework and you need user accounts,
|
||||
real-time streams, access control, payments, or a plugin system, you switch
|
||||
to `--app` mode and everything you've written keeps working. Your classes
|
||||
stay in `classes/`, your handlers stay in `handlers/`, your views stay in
|
||||
`views/`. You just gain access to Streams, Users, Assets, and the rest of
|
||||
the plugin ecosystem — without rewriting anything.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
@@ -784,7 +973,7 @@ you need — the CDN handles the protocol upgrade.
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
**For qbixserver.php and PHAR:**
|
||||
**Linux / macOS (recommended):**
|
||||
|
||||
- PHP 8.1 or later
|
||||
- Extensions: `sockets`, `pcntl` (for signals + workers), `openssl` (for HTTPS)
|
||||
@@ -801,6 +990,12 @@ sudo apt install php-cli php-sockets
|
||||
|
||||
- Nothing. The PHP runtime is included.
|
||||
|
||||
**Windows:** The server runs in single-threaded mode (`--workers=0` only).
|
||||
Static files, PHP scripts, WebSocket, caching, compression, access control —
|
||||
everything works. You lose fork-per-request isolation and signal-based graceful
|
||||
shutdown, because `pcntl` doesn't exist on Windows. Good for development; for
|
||||
production use Linux or macOS (or WSL).
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
Binary file not shown.
@@ -107,6 +107,11 @@ if (!$webDir || !is_dir($webDir)) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Initialize Q with the project root (parent of web/)
|
||||
// This sets up autoloading from classes/ and handlers from handlers/
|
||||
$projectRoot = dirname($webDir);
|
||||
Q::init($projectRoot);
|
||||
|
||||
// ── Load config ─────────────────────────────────────
|
||||
|
||||
// Default server config
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
<?php
|
||||
/**
|
||||
* Minimal Q shim for standalone Qbix Server.
|
||||
* Standalone Q shim for Qbix Server.
|
||||
*
|
||||
* Provides just enough of the Q framework for Q_WebServer and its
|
||||
* dependencies to function without the full Qbix Platform.
|
||||
* When running inside the full Platform, this file is never loaded —
|
||||
* the real Q class takes over.
|
||||
* Provides the core Q framework functionality needed to run
|
||||
* the server and user PHP scripts without the full Qbix Platform.
|
||||
* When running inside the full Platform (--app mode), this file
|
||||
* is never loaded — the real Q class takes over.
|
||||
*
|
||||
* Includes:
|
||||
* - Autoloader for both underscore (Q_WebServer) and namespace (MyApp\User) styles
|
||||
* - Q::ifset() for safe nested array/object access
|
||||
* - Q::event() with handlers/ folder convention
|
||||
* - Q::view() for rendering PHP templates
|
||||
* - Q_Config for JSON config file loading
|
||||
*
|
||||
* @module Q
|
||||
*/
|
||||
|
||||
if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
|
||||
@@ -13,26 +22,96 @@ if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
|
||||
class Q
|
||||
{
|
||||
/**
|
||||
* Safe nested array access. Returns $default if any key is missing.
|
||||
* Signature: Q::ifset($arr, 'key1', 'key2', ..., $default)
|
||||
* Directories to search for classes/ and handlers/
|
||||
* Set by the server at startup based on --root and project structure
|
||||
* @property $paths
|
||||
* @type array
|
||||
* @static
|
||||
*/
|
||||
static function ifset(&$arr)
|
||||
static $paths = array();
|
||||
|
||||
/**
|
||||
* Safe nested array/object access. Returns $default if any key is missing.
|
||||
*
|
||||
* Q::ifset($arr, 'key1', 'key2', $default)
|
||||
* Q::ifset($obj, 'prop', $default)
|
||||
*
|
||||
* @method ifset
|
||||
* @static
|
||||
* @param {&mixed} $ref The array or object to traverse
|
||||
* @return {mixed}
|
||||
*/
|
||||
static function ifset(&$ref)
|
||||
{
|
||||
$args = func_get_args();
|
||||
array_shift($args); // remove $arr
|
||||
$default = array_pop($args); // last arg is default
|
||||
$ref = &$arr;
|
||||
foreach ($args as $key) {
|
||||
if (!is_array($ref) || !array_key_exists($key, $ref)) {
|
||||
return $default;
|
||||
}
|
||||
$ref = &$ref[$key];
|
||||
$count = func_num_args();
|
||||
if ($count <= 2) {
|
||||
$args = func_get_args();
|
||||
$def = isset($args[1]) ? $args[1] : null;
|
||||
return isset($ref) ? $ref : $def;
|
||||
}
|
||||
return $ref;
|
||||
$args = func_get_args();
|
||||
$def = end($args);
|
||||
$path = array_slice($args, 1, -1);
|
||||
return self::getObject($ref, $path, $def);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON encode with error handling
|
||||
* Get a value deep inside an array or object.
|
||||
*
|
||||
* Q::getObject($data, ['users', 'alice', 'email'], 'default')
|
||||
*
|
||||
* @method getObject
|
||||
* @static
|
||||
* @param {&mixed} $ref The array or object to traverse
|
||||
* @param {array} $path Array of keys/properties to follow
|
||||
* @param {mixed} $def Default if path not found
|
||||
* @return {mixed}
|
||||
*/
|
||||
static function getObject(&$ref, $path, $def = null)
|
||||
{
|
||||
$cur = $ref;
|
||||
foreach ($path as $key) {
|
||||
if (is_array($cur)) {
|
||||
if (!array_key_exists($key, $cur)) return $def;
|
||||
$cur = $cur[$key];
|
||||
} elseif (is_object($cur)) {
|
||||
if (!isset($cur->$key)) return $def;
|
||||
$cur = $cur->$key;
|
||||
} else {
|
||||
return $def;
|
||||
}
|
||||
}
|
||||
return $cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value deep inside a nested array, creating intermediate arrays as needed.
|
||||
*
|
||||
* Q::setObject(['users', 'alice', 'email'], 'alice@example.com', $data)
|
||||
*
|
||||
* @method setObject
|
||||
* @static
|
||||
* @param {array} $path
|
||||
* @param {mixed} $value
|
||||
* @param {&array} $dest The target array (modified by reference)
|
||||
*/
|
||||
static function setObject($path, $value, &$dest)
|
||||
{
|
||||
if (is_string($path)) $path = array($path);
|
||||
$ref = &$dest;
|
||||
foreach ($path as $key) {
|
||||
if (!isset($ref[$key]) || !is_array($ref[$key])) {
|
||||
$ref[$key] = array();
|
||||
}
|
||||
$ref = &$ref[$key];
|
||||
}
|
||||
$ref = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON encode with unescaped slashes
|
||||
* @method json_encode
|
||||
* @static
|
||||
*/
|
||||
static function json_encode($value, $options = 0)
|
||||
{
|
||||
@@ -40,40 +119,302 @@ class Q
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an event. No-op in standalone mode.
|
||||
* JSON decode wrapper
|
||||
* @method json_decode
|
||||
* @static
|
||||
*/
|
||||
static function event($name, $params = array(), $type = '')
|
||||
static function json_decode($json, $assoc = false, $depth = 512, $options = 0)
|
||||
{
|
||||
// No event system in standalone mode
|
||||
return null;
|
||||
return json_decode($json, $assoc, $depth, $options);
|
||||
}
|
||||
|
||||
// ── Event system ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fire an event. Looks for handler functions in handlers/ directory.
|
||||
*
|
||||
* Handler for "MyApp/feed/post" lives at:
|
||||
* handlers/MyApp/feed/post.php
|
||||
* And defines:
|
||||
* function MyApp_feed_post($params) { ... }
|
||||
*
|
||||
* @method event
|
||||
* @static
|
||||
* @param {string} $eventName e.g. "MyApp/feed/post"
|
||||
* @param {array} $params Parameters passed to the handler
|
||||
* @param {string|boolean} $pure false=run handler, 'before'=before hooks only,
|
||||
* 'after'=after hooks only, true=both hooks but skip main handler
|
||||
* @param {boolean} $skipIncludes If true, only call already-defined functions
|
||||
* @param {mixed} &$result Reference for handlers to modify
|
||||
* @return {mixed} Whatever the handler returned
|
||||
*/
|
||||
static function event(
|
||||
$eventName,
|
||||
$params = array(),
|
||||
$pure = false,
|
||||
$skipIncludes = false,
|
||||
&$result = null)
|
||||
{
|
||||
if (!is_string($eventName) || !$eventName) return null;
|
||||
if (!is_array($params)) $params = array();
|
||||
|
||||
// Before hooks
|
||||
if ($pure !== 'after') {
|
||||
$handlers = Q_Config::get('Q', 'handlersBeforeEvent', $eventName, array());
|
||||
if (is_string($handlers)) $handlers = array($handlers);
|
||||
if (is_array($handlers)) {
|
||||
foreach ($handlers as $handler) {
|
||||
$r = self::handle($handler, $params, $skipIncludes, $result);
|
||||
if ($r === false) return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main handler
|
||||
if (!$pure) {
|
||||
$result = self::handle($eventName, $params, $skipIncludes, $result);
|
||||
}
|
||||
|
||||
// After hooks
|
||||
if ($pure !== 'before') {
|
||||
$handlers = Q_Config::get('Q', 'handlersAfterEvent', $eventName, array());
|
||||
if (is_string($handlers)) $handlers = array($handlers);
|
||||
if (is_array($handlers)) {
|
||||
foreach ($handlers as $handler) {
|
||||
$r = self::handle($handler, $params, $skipIncludes, $result);
|
||||
if ($r === false) return $result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoloader for Q_* classes
|
||||
* Check if a handler exists for an event name
|
||||
* @method canHandle
|
||||
* @static
|
||||
* @param {string} $eventName
|
||||
* @return {boolean}
|
||||
*/
|
||||
static function canHandle($eventName)
|
||||
{
|
||||
$parts = explode('/', $eventName);
|
||||
$funcName = str_replace('-', '_', implode('_', $parts));
|
||||
if (function_exists($funcName)) return true;
|
||||
|
||||
// Try to load from handlers/ directory
|
||||
$relPath = 'handlers' . DS . implode(DS, $parts) . '.php';
|
||||
foreach (self::$paths as $base) {
|
||||
$full = $base . DS . $relPath;
|
||||
if (file_exists($full)) {
|
||||
include_once $full;
|
||||
return function_exists($funcName);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a handler function. Loads from handlers/ directory if needed.
|
||||
* If $eventName starts with http:// or https://, POSTs params as JSON
|
||||
* to that URL (remote handler / webhook).
|
||||
* @method handle
|
||||
* @static
|
||||
* @param {string} $eventName
|
||||
* @param {array} &$params
|
||||
* @param {boolean} $skipIncludes
|
||||
* @param {mixed} &$result
|
||||
* @return {mixed}
|
||||
*/
|
||||
protected static function handle(
|
||||
$eventName, &$params = array(), $skipIncludes = false, &$result = null)
|
||||
{
|
||||
if (!$eventName) return null;
|
||||
|
||||
// Remote handler — POST params as JSON to URL
|
||||
if (strncmp($eventName, 'http://', 7) === 0
|
||||
|| strncmp($eventName, 'https://', 8) === 0
|
||||
) {
|
||||
return self::handleRemote($eventName, $params, $result);
|
||||
}
|
||||
|
||||
$parts = explode('/', $eventName);
|
||||
$funcName = str_replace('-', '_', implode('_', $parts));
|
||||
|
||||
if (!function_exists($funcName)) {
|
||||
if ($skipIncludes) return null;
|
||||
|
||||
// Try to load from handlers/ directory
|
||||
$relPath = 'handlers' . DS . implode(DS, $parts) . '.php';
|
||||
$loaded = false;
|
||||
foreach (self::$paths as $base) {
|
||||
$full = $base . DS . $relPath;
|
||||
if (file_exists($full)) {
|
||||
include_once $full;
|
||||
$loaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$loaded || !function_exists($funcName)) {
|
||||
return null; // no handler found — that's OK
|
||||
}
|
||||
}
|
||||
|
||||
$args = array(&$params, &$result);
|
||||
return call_user_func_array($funcName, $args);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST event params as JSON to a remote URL.
|
||||
* Used for webhook-style handlers configured in Q.handlersAfterEvent.
|
||||
* Non-blocking: uses a short timeout so it doesn't slow down the request.
|
||||
* @method handleRemote
|
||||
* @static
|
||||
* @param {string} $url
|
||||
* @param {array} &$params
|
||||
* @param {mixed} &$result
|
||||
* @return {mixed}
|
||||
*/
|
||||
protected static function handleRemote($url, &$params, &$result)
|
||||
{
|
||||
$json = json_encode($params, JSON_UNESCAPED_SLASHES);
|
||||
$opts = array('http' => array(
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n"
|
||||
. "Content-Length: " . strlen($json) . "\r\n"
|
||||
. "User-Agent: QbixServer/1.0\r\n",
|
||||
'content' => $json,
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
));
|
||||
$ctx = stream_context_create($opts);
|
||||
$response = @file_get_contents($url, false, $ctx);
|
||||
if ($response !== false) {
|
||||
$decoded = json_decode($response, true);
|
||||
if ($decoded !== null) {
|
||||
$result = $decoded;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a PHP view file. Searches views/ directories in $paths.
|
||||
*
|
||||
* echo Q::view('MyApp/feed/page.php', ['items' => $items]);
|
||||
*
|
||||
* @method view
|
||||
* @static
|
||||
* @param {string} $viewName Path relative to views/ directory
|
||||
* @param {array} $params Variables extracted into the view scope
|
||||
* @return {string} Rendered HTML
|
||||
*/
|
||||
static function view($viewName, $params = array())
|
||||
{
|
||||
$viewPath = str_replace('/', DS, $viewName);
|
||||
foreach (self::$paths as $base) {
|
||||
$full = $base . DS . 'views' . DS . $viewPath;
|
||||
if (file_exists($full)) {
|
||||
extract($params);
|
||||
ob_start();
|
||||
include $full;
|
||||
return ob_get_clean();
|
||||
}
|
||||
}
|
||||
return "<!-- view not found: $viewName -->";
|
||||
}
|
||||
|
||||
// ── Autoloader ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Autoloader that handles both conventions:
|
||||
* Q_WebServer → classes/Q/WebServer.php (underscore)
|
||||
* MyApp\User → classes/MyApp/User.php (namespace)
|
||||
* MyApp_Helper → classes/MyApp/Helper.php (underscore)
|
||||
*
|
||||
* Searches the src/ directory (for Q_ server classes) and all
|
||||
* directories in Q::$paths (for user classes).
|
||||
*
|
||||
* @method autoload
|
||||
* @static
|
||||
* @param {string} $className
|
||||
*/
|
||||
static function autoload($className)
|
||||
{
|
||||
if (strpos($className, 'Q_') !== 0 && $className !== 'Q_Config') return;
|
||||
$path = str_replace('_', DS, $className) . '.php';
|
||||
$full = dirname(__FILE__) . DS . $path;
|
||||
if (file_exists($full)) {
|
||||
require_once $full;
|
||||
// Split on both \ and _ to get path parts
|
||||
$parts = array();
|
||||
foreach (explode('\\', $className) as $nsPart) {
|
||||
$parts = array_merge($parts, explode('_', $nsPart));
|
||||
}
|
||||
$relPath = implode(DS, $parts) . '.php';
|
||||
|
||||
// 1. Search src/ directory (for Q_* server classes)
|
||||
$srcPath = dirname(__FILE__) . DS . $relPath;
|
||||
if (file_exists($srcPath)) {
|
||||
require_once $srcPath;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Search project classes/ directories
|
||||
foreach (self::$paths as $base) {
|
||||
$full = $base . DS . 'classes' . DS . $relPath;
|
||||
if (file_exists($full)) {
|
||||
require_once $full;
|
||||
// If loaded via underscore but also accessible via namespace, alias
|
||||
$underscoreName = implode('_', $parts);
|
||||
$namespaceName = implode('\\', $parts);
|
||||
if ($underscoreName !== $namespaceName) {
|
||||
if (class_exists($underscoreName, false)
|
||||
&& !class_exists($namespaceName, false)
|
||||
) {
|
||||
class_alias($underscoreName, $namespaceName);
|
||||
} elseif (class_exists($namespaceName, false)
|
||||
&& !class_exists($underscoreName, false)
|
||||
) {
|
||||
class_alias($namespaceName, $underscoreName);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Q paths from the project root directory.
|
||||
* Called by the server at startup.
|
||||
* @method init
|
||||
* @static
|
||||
* @param {string} $projectRoot The project root (parent of web/)
|
||||
*/
|
||||
static function init($projectRoot)
|
||||
{
|
||||
$projectRoot = rtrim($projectRoot, DS);
|
||||
if (!in_array($projectRoot, self::$paths)) {
|
||||
self::$paths[] = $projectRoot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spl_autoload_register(array('Q', 'autoload'));
|
||||
|
||||
// ── Q_Config ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimal Q_Config — reads JSON config files merged together.
|
||||
* JSON config file loader with deep merge.
|
||||
* Compatible with the full Qbix Platform's Q_Config API.
|
||||
*
|
||||
* @class Q_Config
|
||||
*/
|
||||
class Q_Config
|
||||
{
|
||||
private static $data = array();
|
||||
private static $loaded = false;
|
||||
|
||||
/**
|
||||
* Load config from JSON file(s)
|
||||
* Load and merge a JSON config file
|
||||
* @method load
|
||||
* @static
|
||||
* @param {string} $path Path to JSON file
|
||||
*/
|
||||
static function load($path)
|
||||
{
|
||||
@@ -82,11 +423,13 @@ class Q_Config
|
||||
if (is_array($json)) {
|
||||
self::$data = self::merge(self::$data, $json);
|
||||
}
|
||||
self::$loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a config value programmatically
|
||||
* Set a config value programmatically.
|
||||
* Q_Config::set('Q', 'webserver', 'port', 8080)
|
||||
* @method set
|
||||
* @static
|
||||
*/
|
||||
static function set(/* key1, key2, ..., value */)
|
||||
{
|
||||
@@ -103,8 +446,12 @@ class Q_Config
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a config value with default.
|
||||
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
|
||||
* Get a config value with a default.
|
||||
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
|
||||
* Last argument is the default.
|
||||
* @method get
|
||||
* @static
|
||||
* @return {mixed}
|
||||
*/
|
||||
static function get(/* key1, key2, ..., default */)
|
||||
{
|
||||
@@ -121,7 +468,12 @@ class Q_Config
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a config value or throw.
|
||||
* Get a config value or throw if missing.
|
||||
* Q_Config::expect('Q', 'app')
|
||||
* @method expect
|
||||
* @static
|
||||
* @return {mixed}
|
||||
* @throws {Exception}
|
||||
*/
|
||||
static function expect(/* key1, key2, ... */)
|
||||
{
|
||||
@@ -138,6 +490,9 @@ class Q_Config
|
||||
|
||||
/**
|
||||
* Get all config data
|
||||
* @method getAll
|
||||
* @static
|
||||
* @return {array}
|
||||
*/
|
||||
static function getAll()
|
||||
{
|
||||
@@ -145,7 +500,10 @@ class Q_Config
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge arrays (scalars overwrite, arrays merge recursively)
|
||||
* Deep merge: arrays merge recursively, scalars overwrite.
|
||||
* @method merge
|
||||
* @static
|
||||
* @private
|
||||
*/
|
||||
private static function merge($base, $overlay)
|
||||
{
|
||||
|
||||
@@ -92,6 +92,45 @@ class Q_WebServer
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preload classes (before forking) ─────────────
|
||||
$preload = Q_Config::get('Q', 'webserver', 'preload', array());
|
||||
if (!empty($preload)) {
|
||||
// Load the autoloader first (e.g. Composer's)
|
||||
$autoload = is_string($preload)
|
||||
? $preload
|
||||
: (isset($preload['autoload']) ? $preload['autoload'] : null);
|
||||
if ($autoload) {
|
||||
$autoloadPath = $autoload;
|
||||
// Resolve relative to the document root's parent (project root)
|
||||
if ($autoloadPath[0] !== '/' && $autoloadPath[0] !== '\\') {
|
||||
$projectRoot = dirname(rtrim(self::$rootDir, DS));
|
||||
$autoloadPath = $projectRoot . DS . $autoloadPath;
|
||||
}
|
||||
if (file_exists($autoloadPath)) {
|
||||
require_once $autoloadPath;
|
||||
$count = count(get_declared_classes());
|
||||
echo " Autoloader: " . basename($autoload) . "\n";
|
||||
} else {
|
||||
echo " Warning: autoload file not found: $autoloadPath\n";
|
||||
}
|
||||
}
|
||||
// Then load each named class (triggers the autoloader)
|
||||
$classes = isset($preload['classes']) ? $preload['classes'] : array();
|
||||
if (!empty($classes)) {
|
||||
$loaded = 0;
|
||||
foreach ($classes as $class) {
|
||||
if (!class_exists($class, true) && !interface_exists($class, true)
|
||||
&& !trait_exists($class, true)
|
||||
) {
|
||||
echo " Warning: could not preload $class\n";
|
||||
} else {
|
||||
$loaded++;
|
||||
}
|
||||
}
|
||||
echo " Preloaded: $loaded classes\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worker pool ──────────────────────────────────
|
||||
if ($workers > 0 && function_exists('pcntl_fork')) {
|
||||
self::$pool = new Q_WebServer_Pool($workers);
|
||||
|
||||
Reference in New Issue
Block a user