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:

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:

  1. De browser opent wss://<host>/websocket. De cookies voor die oorsprong, inclusief een token cookie, worden meegestuurd met het upgradeverzoek.
  2. De daemon leest de token cookie. Als deze ontbreekt, wordt de upgrade geweigerd met 401.
  3. De daemon voert websocket::auth($wsHost, $wsToken, $wsSocket) uit op zijn pool, wat jouw wsAuth aanroept.
  4. wsAuth valideert de token tegen %user, %session->token of een aangepaste lookup. Geef een foutmelding (error('unauthorized')) om te weigeren: een gegooide auth mislukt de upgrade. Bij succes opent de socket en wordt wsConnect uitgevoerd.

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:

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



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

We gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.