14: WebSocket
Realtime in Phlo draait via Phlo Realtime, de WebSocket-server die is ingebouwd in de Phlo Daemon. Eén Node-proces beheert de socketverbindingen over elke vhost en voert elk evenement op je PHP-app uit via de eigen worker pool van de daemon. Je app implementeert vier hook-functies en zendt uit vanuit PHP met wsCast().
14.1: Streaming eerst
Niet alles dat realtime is, heeft een socket nodig. Voor eenrichtingsupdates via een enkele aanvraag opent chunk() (de chunk resource) een streamingresponse en verzendt elke oproep onmiddellijk als één JSON-regel via gewone HTTP; phlo.js past de commando's toe zodra ze binnenkomen:
route async POST report::generate {
foreach ($this->steps AS $i => $step){
$step->run
chunk(inner: arr('#progress' => $i + 1 .'/'. count($this->steps)))
}
apply(toast: 'Done')
}
%res->streaming = true geeft een route hetzelfde gedrag met gewone apply() aanroepen, en klassieke SSE (text/event-stream) vult dezelfde eenrichtingsslot. Al deze houden een PHP worker vast zolang de respons streamt: precies goed voor eindig werk dat één client bekijkt (AI token streams, imports, rapporten), en precies verkeerd voor een permanente verbinding die wacht op gebeurtenissen. Dat is waar Phlo Realtime voor is: de daemon houdt de open sockets vast, jouw PHP draait alleen wanneer er een gebeurtenis aankomt, en een uitzending bereikt elke verbonden client in plaats van alleen degene die vroeg. Streaming volgt de aanvraag; broadcasting volgt de vloot van verbindingen.
14.2: Wat Phlo Realtime is
Phlo Realtime is de WebSocket-laag van de daemon (gebouwd op de ws bibliotheek), niet een apart proces. De enkele daemon op poort 3001 bedient de hele stack: hij accepteert de socket-upgrades, routeert op basis van de Host header van de handshake, houdt het clientregister bij, draait de /message broadcast bridge en dispatcht elk socket-evenement naar jouw PHP op dezelfde worker pool die de daemon al voor alles gebruikt.
Omdat die dispatch in-process is, is er niets te verbinden tussen "de socketlaag" en "de PHP-laag": ze zijn één proces. Elk evenement (auth, connect, receive, close) draait de bijpassende websocket::<hook> target, en de uitvoeringsmodus is wat de daemon al doet voor die host:
- One-shot (een
build: truehost, d.w.z. dev). Een nieuwe PHP-proces per evenement, de app wordt elke keer opgestart: doodsimpel, volledig geïsoleerd, hot-reload. Ideaal voor ontwikkeling en hosts met weinig verkeer. - Resident pool (een release host). De pool van de daemon houdt workers warm en beantwoordt evenementen via een pijp, geen opstart per evenement. De pool schaalt zichzelf op naar de vraag en weer terug naar beneden wanneer inactief, dus er is geen aantal workers dat geconfigureerd moet worden. Worker-veilige handlers zijn van toepassing, dezelfde discipline als de FrankenPHP worker modus: geen verzoek- of gebruikersstatus in
statics, altijd commit of rollback van DB-werk.
Hoe dan ook, elk evenement geeft de handler de volledige levenscyclus van het verzoek: DB, sessie, resources, alles is eenvoudig beschikbaar. De modus volgt de build-vlag van de host (gesteld in de entry van de host in config/daemon.js, zie hieronder); de handlercode is identiek.
14.3: Installatie
De daemon is een Node-service die buiten het Phlo-framework leeft. Voer het uit, wijs je reverse proxy erop aan, en dat is de hele realtime setup.
git clone https://github.com/q-ainl/phlo-daemon.git <daemon>
cd <daemon>
npm install
Het neemt een poort, de PHP-binaire en de hostmap:
// <daemon>/config.js
require('./phlo-daemon.js')(3001, '/usr/bin/php-zts', {
'demo.example.nl': { app: '/var/www/demo/www/app.php', build: true },
})
Het derde argument is de hostmap: het koppelt elke Host aan zijn app.php pad en een build vlag, en wordt gedeclareerd in config/daemon.js. De daemon laadt het bij het opstarten, zodat het altijd weet welke hosts bestaan en of elke host een one-shot of pooled is. Een host zonder invoer faalt de dispatch, wat de upgrade faalt.
Voer het uit onder een procesbeheerder (systemd / pm2 / supervisord); de phlo-daemon README beschrijft het pm2-patroon en het /message brugcontract:
node <daemon>/config.js
Voor productie, stuur wss:// via je reverse proxy (Caddy, Nginx, FrankenPHP) naar 127.0.0.1:3001 voor het pad /websocket. Je app declareert dezelfde poort als de daemon constante in www/app.php:
phlo_app(
app: __DIR__.'/../',
daemon: 3001,
)14.4: App hooks
In je app-bron definieer je vier functies; Phlo's websocket resource roept ze aan als ze bestaan. Plaats ze in een bestand zoals app.ws.phlo: noem het bestand niet websocket.phlo, omdat die klassenaam in conflict komt met de websocket resource van de engine wanneer deze wordt geladen.
function wsConnect($wsHost, $wsToken, $wsSocket){
%log->info('ws connect', socket: $wsSocket)
return true
}
function wsAuth($wsHost, $wsToken, $wsSocket){
$user = %user->byToken($wsToken)
if (!$user) error('unauthorized')
%session->user = $user
return true
}
function wsReceive($wsHost, $wsToken, $wsSocket, ...$data){
$type = $data['type'] ?? null
if ($type === 'ping') return wsCast(wsTarget: $wsSocket, pong: time())
if ($type === 'chat.send') chat::send($data['text'], from: %session->user->id)
}
function wsClose($wsHost, $wsToken, $wsSocket){
%log->info('ws close', socket: $wsSocket)
}
| Haak | Wanneer | Opmerkingen |
|---|---|---|
wsAuth |
Bij de handshake, voordat de socket wordt geaccepteerd | Valideer $wsToken; geef een foutmelding om de verbinding te weigeren |
wsConnect |
Direct nadat de socket is geaccepteerd | Opzetten (aanwezigheid, logging); uitzenden met wsCast() |
wsReceive |
Voor elk volgend bericht (JSON-gecodeerd en verspreid) | Reageer met wsCast(); afgedrukte regels worden teruggestuurd naar de afzender |
wsClose |
De verbinding sluit | Opruimen (aanwezigheid); uitzenden met wsCast() |
$wsSocket is een ondoorzichtige stringidentificator die je kunt gebruiken om precies naar deze client terug te zenden.
De verbinding-contextargumenten zijn ws-geprefixed uit convenie ($wsHost, $wsToken, $wsSocket), precies zoals wsCast. Dit is niet cosmetisch: wsReceive verspreidt de JSON-lading in benoemde argumenten (...$data), dus een ongeprefixed $host/$token/$socket parameter zou fataal botsen met een lading die een host, token of socket sleutel bevat. Houd de prefix en je payload-sleutels blijven vrij.
14.5: Auth flow
De daemon authenticates tijdens de handshake, voordat hij de socket accepteert:
- De browser opent
wss://<host>/websocket. De cookies voor die oorsprong, inclusief eentokencookie, worden meegestuurd met het upgradeverzoek. - De daemon leest de
tokencookie. Als deze ontbreekt, wordt de upgrade geweigerd met401. - De daemon voert
websocket::auth($wsHost, $wsToken, $wsSocket)uit op zijn pool, wat jouwwsAuthaanroept. wsAuthvalideert de token tegen%user,%session->tokenof een aangepaste lookup. Geef een foutmelding (error('unauthorized')) om te weigeren: een gegooide auth mislukt de upgrade. Bij succes opent de socket en wordtwsConnectuitgevoerd.
De token komt doorgaans van %user->token (per ingelogde gebruiker) of een API-sleutel, ingesteld als de token cookie wanneer de pagina wordt weergegeven. De browser stuurt deze automatisch mee tijdens de WS-handshake; de client stuurt geen aparte auth-bericht.
14.6: Broadcasting from PHP
wsCast() is a regular function (resource wsCast). It does a POST to the daemon's internal /message bridge, which pushes it on to the right sockets.
wsCast(wsTarget: 'all', toast: 'New message received')
wsCast(wsTarget: 'socket:'.$wsSocket, path: '/inbox')
wsCast(wsTarget: 'token:'.$token, inner: ['#count' => $newCount])
| Argument | Default | Meaning |
|---|---|---|
wsTarget |
'all' |
'all', 'token:<id>', 'token:not:<id>' or 'socket:<id>' |
wsHost |
host |
Vhost the broadcast applies to (default: current host) |
wsPort |
daemon (constant from app config) |
The daemon's port |
wsExcept |
none | One socket id to skip, whichever target selected it |
...$data |
none | Named args become the payload, usually apply() commands |
A token is a principal, not a connection: one user with two tabs is one token with two sockets. So token:not: is how you reach other users, and wsExcept is how you reach the sender's other screens. Pass the $wsSocket your hook was handed:
function wsReceive($wsHost, $wsToken, $wsSocket, ...$data){
wsCast(wsTarget: 'token:'.$wsToken, wsExcept: $wsSocket, inner: $data['inner'])
}
Spell the payload out there; ...$data after a named argument is a fatal PHP error.
The payload is passed through to the client and applied to the DOM automatically by phlo.js: the same apply() protocol you know from async routes.
No retry, no dead-letter, no ACK. If the daemon is down, the POST fails silently. For guaranteed delivery (financial events): combine with a DB queue.
14.7: Client side
The client itself does nothing special. Add DOM/websocket to your resources in data/app.json:
{
"resources": [..., "DOM/websocket", "wsCast"]
}
DOM/websocket injects a script that:
- connects automatically to
wss://<host>/websocket - pipes incoming messages straight through
apply(),inner:,outer:,class:,toast:,path:work the same as with async routes - reconnects with exponential backoff (333 ms, 999 ms, ...)
If you want to send from JS: app.websocket.send({type: 'chat.send', text: 'hi'}).
To react to the connection itself, subscribe rather than assign:
const off = app.websocket.on('connect', () => app.websocket.send({want: 'state'}), element)
app.websocket.connect = fn still works, but it is a single field: a second feature assigning it replaces the first without a word. on() takes as many subscribers as you like, fires straight away when the socket is already open (so a page that mounts late still gets its moment), drops a subscriber once the element you passed leaves the document, and returns an unsubscribe function. close and error work the same way.
14.8: Mini voorbeeld: aanwezigheid
Toon "wie online is" zonder polling.
function wsConnect($wsHost, $wsToken, $wsSocket){
%apcu->set("presence:$wsSocket", time(), 3600)
wsCast(wsTarget: 'all', inner: ['#online-count' => static::count()])
return true
}
function wsClose($wsHost, $wsToken, $wsSocket){
%apcu->delete("presence:$wsSocket")
wsCast(wsTarget: 'all', inner: ['#online-count' => static::count()])
}
static count(){
$keys = %apcu->keys('presence:')
return count($keys)
}
De server houdt geen staat bij; APCu telt sockets per host. Bij een PHP-herstart leegt de cache zichzelf, wat prima is, omdat een lege aanwezigheid een acceptabele gedegradeerde staat is.
14.9: Known limitations
- One-shot mode costs a PHP startup per event. Fine for inbox, presence and notifications; for high-frequency telemetry run the host as a release build so the daemon serves it from its resident pool, which removes that cost. A pooled worker handles one event at a time and the pool sizes itself to demand, so keep long-running handlers off the hot path (restart the workers after a deploy so they reload).
- No versioning on payloads, when refactoring: migrate all clients at once.
- One process for the stack. The daemon owns the sockets and runs the PHP; if it crashes, realtime (and any pooled dispatch) is down until restart. Run it under a process supervisor.
- No built-in encryption, use your reverse proxy for TLS termination (
wss://).
Reference. The realtime and DOM resources are documented per node in the Manual, generated from the resource files so it never drifts from the code.
Laatst bijgewerkt op 23-08-2026