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)
|
- [vs FrankenPHP and Swoole](#️-vs-frankenphp-and-swoole)
|
||||||
- [Features](#-features)
|
- [Features](#-features)
|
||||||
- [Server Headers](#-server-headers--what-your-php-can-send)
|
- [Server Headers](#-server-headers--what-your-php-can-send)
|
||||||
- [Project Structure](#-project-structure)
|
- [For PHP Developers](#-for-php-developers--the-micro-framework)
|
||||||
- [Configuration](#-configuration)
|
- [Configuration](#-configuration)
|
||||||
- [Three Ways to Run](#-three-ways-to-run)
|
- [Three Ways to Run](#-three-ways-to-run)
|
||||||
- [Building](#-building)
|
- [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/
|
myproject/
|
||||||
├── qbixserver.php ← server entry point (or use the PHAR)
|
├── qbixserver.php ← server entry point (or use the PHAR)
|
||||||
├── config/
|
├── config/
|
||||||
│ └── server.json ← server configuration
|
│ └── server.json ← server + app configuration
|
||||||
├── web/ ← document root (publicly accessible)
|
├── web/ ← document root (publicly accessible)
|
||||||
│ ├── index.html
|
│ ├── index.html ← static files served directly
|
||||||
│ ├── style.css
|
│ ├── style.css
|
||||||
│ ├── app.js
|
│ ├── api.php ← PHP scripts executed on request
|
||||||
│ ├── api.php ← executed as PHP
|
│ └── uploads/
|
||||||
│ └── uploads/ ← served as static files (or via X-Accel-Redirect)
|
├── classes/ ← autoloaded classes (preloaded into workers)
|
||||||
└── classes/ ← your PHP classes (for preloading)
|
│ ├── MyApp/
|
||||||
├── MyApp.php
|
│ │ ├── User.php ← MyApp\User or MyApp_User
|
||||||
├── MyApp/
|
│ │ ├── Feed.php
|
||||||
│ ├── User.php
|
│ │ └── Auth.php
|
||||||
│ ├── Feed.php
|
│ └── vendor/
|
||||||
│ └── Auth.php
|
│ └── autoload.php ← Composer autoloader (optional)
|
||||||
└── vendor/ ← composer dependencies
|
├── handlers/ ← event handlers (loaded on demand)
|
||||||
└── autoload.php
|
│ └── 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
|
Only `web/` is accessible via HTTP. Everything else is server-side only.
|
||||||
code that runs inside your PHP scripts — and for classes you want preloaded into
|
|
||||||
the fork pool.
|
|
||||||
|
|
||||||
### Preloading classes
|
### Classes — preloaded, both conventions
|
||||||
|
|
||||||
When you use `--workers=N`, the parent process loads classes before forking.
|
Drop a PHP file in `classes/` and it's autoloaded. Both naming conventions work:
|
||||||
Workers inherit everything via copy-on-write — zero bootstrap cost per request.
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{
|
||||||
@@ -487,11 +531,9 @@ Configure preloading in `config/server.json`:
|
|||||||
"preload": {
|
"preload": {
|
||||||
"autoload": "classes/vendor/autoload.php",
|
"autoload": "classes/vendor/autoload.php",
|
||||||
"classes": [
|
"classes": [
|
||||||
"MyApp",
|
|
||||||
"MyApp\\User",
|
"MyApp\\User",
|
||||||
"MyApp\\Feed",
|
"MyApp\\Feed",
|
||||||
"MyApp\\Auth",
|
"MyApp_Auth"
|
||||||
"MyApp\\Database"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,37 +542,173 @@ Configure preloading in `config/server.json`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start with 4 workers — classes loaded once, shared across all
|
|
||||||
php qbixserver.php --root=./web --port=8080 --workers=4
|
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.
|
||||||
|
|
||||||
```
|
### Handlers — loaded on demand
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Your `web/api.php` can now use `MyApp\User` or `MyApp\Feed` without any `require`
|
Handlers are the opposite of classes: they're loaded **only when their event fires**.
|
||||||
or autoloader overhead — the classes are already loaded:
|
Drop a file in `handlers/` and it's available as an event:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
<?php
|
||||||
// web/api.php — classes are already in memory from preloading
|
// handlers/MyApp/feed/post.php
|
||||||
use MyApp\User;
|
// Handles the "MyApp/feed/post" event
|
||||||
use MyApp\Feed;
|
// Function name = path with slashes replaced by underscores
|
||||||
|
|
||||||
$user = User::fromSession();
|
function MyApp_feed_post(&$params, &$result) {
|
||||||
$items = Feed::latest(20);
|
$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('Content-Type: application/json');
|
||||||
header('Cache-Control: public, max-age=60');
|
echo json_encode($result);
|
||||||
echo json_encode($items);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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)
|
### Workers: fork-per-request (truly shared-nothing)
|
||||||
|
|
||||||
Each worker handles exactly **one request**, then exits. The parent immediately
|
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
|
variables persist (same as php-fpm). Use `--workers=N` in production for
|
||||||
full isolation.
|
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
|
## ⚙️ Configuration
|
||||||
@@ -784,7 +973,7 @@ you need — the CDN handles the protocol upgrade.
|
|||||||
|
|
||||||
## 📋 Requirements
|
## 📋 Requirements
|
||||||
|
|
||||||
**For qbixserver.php and PHAR:**
|
**Linux / macOS (recommended):**
|
||||||
|
|
||||||
- PHP 8.1 or later
|
- PHP 8.1 or later
|
||||||
- Extensions: `sockets`, `pcntl` (for signals + workers), `openssl` (for HTTPS)
|
- 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.
|
- 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
|
## 📄 License
|
||||||
|
|||||||
Binary file not shown.
@@ -107,6 +107,11 @@ if (!$webDir || !is_dir($webDir)) {
|
|||||||
exit(1);
|
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 ─────────────────────────────────────
|
// ── Load config ─────────────────────────────────────
|
||||||
|
|
||||||
// Default server config
|
// Default server config
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
<?php
|
<?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
|
* Provides the core Q framework functionality needed to run
|
||||||
* dependencies to function without the full Qbix Platform.
|
* the server and user PHP scripts without the full Qbix Platform.
|
||||||
* When running inside the full Platform, this file is never loaded —
|
* When running inside the full Platform (--app mode), this file
|
||||||
* the real Q class takes over.
|
* 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);
|
if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
|
||||||
@@ -13,26 +22,96 @@ if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
|
|||||||
class Q
|
class Q
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Safe nested array access. Returns $default if any key is missing.
|
* Directories to search for classes/ and handlers/
|
||||||
* Signature: Q::ifset($arr, 'key1', 'key2', ..., $default)
|
* 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();
|
$count = func_num_args();
|
||||||
array_shift($args); // remove $arr
|
if ($count <= 2) {
|
||||||
$default = array_pop($args); // last arg is default
|
$args = func_get_args();
|
||||||
$ref = &$arr;
|
$def = isset($args[1]) ? $args[1] : null;
|
||||||
foreach ($args as $key) {
|
return isset($ref) ? $ref : $def;
|
||||||
if (!is_array($ref) || !array_key_exists($key, $ref)) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
$ref = &$ref[$key];
|
|
||||||
}
|
}
|
||||||
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)
|
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 json_decode($json, $assoc, $depth, $options);
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
// ── 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)
|
static function autoload($className)
|
||||||
{
|
{
|
||||||
if (strpos($className, 'Q_') !== 0 && $className !== 'Q_Config') return;
|
// Split on both \ and _ to get path parts
|
||||||
$path = str_replace('_', DS, $className) . '.php';
|
$parts = array();
|
||||||
$full = dirname(__FILE__) . DS . $path;
|
foreach (explode('\\', $className) as $nsPart) {
|
||||||
if (file_exists($full)) {
|
$parts = array_merge($parts, explode('_', $nsPart));
|
||||||
require_once $full;
|
}
|
||||||
|
$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'));
|
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
|
class Q_Config
|
||||||
{
|
{
|
||||||
private static $data = array();
|
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)
|
static function load($path)
|
||||||
{
|
{
|
||||||
@@ -82,11 +423,13 @@ class Q_Config
|
|||||||
if (is_array($json)) {
|
if (is_array($json)) {
|
||||||
self::$data = self::merge(self::$data, $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 */)
|
static function set(/* key1, key2, ..., value */)
|
||||||
{
|
{
|
||||||
@@ -103,8 +446,12 @@ class Q_Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a config value with default.
|
* Get a config value with a default.
|
||||||
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
|
* Q_Config::get('Q', 'webserver', 'keepAlive', 'max', 100)
|
||||||
|
* Last argument is the default.
|
||||||
|
* @method get
|
||||||
|
* @static
|
||||||
|
* @return {mixed}
|
||||||
*/
|
*/
|
||||||
static function get(/* key1, key2, ..., default */)
|
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, ... */)
|
static function expect(/* key1, key2, ... */)
|
||||||
{
|
{
|
||||||
@@ -138,6 +490,9 @@ class Q_Config
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all config data
|
* Get all config data
|
||||||
|
* @method getAll
|
||||||
|
* @static
|
||||||
|
* @return {array}
|
||||||
*/
|
*/
|
||||||
static function getAll()
|
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)
|
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 ──────────────────────────────────
|
// ── Worker pool ──────────────────────────────────
|
||||||
if ($workers > 0 && function_exists('pcntl_fork')) {
|
if ($workers > 0 && function_exists('pcntl_fork')) {
|
||||||
self::$pool = new Q_WebServer_Pool($workers);
|
self::$pool = new Q_WebServer_Pool($workers);
|
||||||
|
|||||||
Reference in New Issue
Block a user