Add Phase 0 spike: end-to-end transport verified

Bare PHP behind FrankenPHP plus a Qt/QML host that spawns it. GET /api/ping
publishes a Mercure event; the QML window receives it back over the SSE
stream. Findings (Caddy directive ordering, Mercure transport scalar,
PR_SET_PDEATHSIG for child cleanup, PHP 8.5 curl_close deprecation, port
collision with system FrankenPHP, pure-QML SSE viability) are recorded in
spike/README.md so Phase 1 starts from a known-good baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 00:15:50 +02:00
parent 0b394510bc
commit 9655b6fef9
9 changed files with 517 additions and 0 deletions

84
spike/qt/Mercure.qml Normal file
View File

@@ -0,0 +1,84 @@
import QtQuick
// Tiny SSE client implemented in pure QML.
// Caveat: relies on QML's XMLHttpRequest delivering partial responses
// during readyState === LOADING. If that proves unreliable, this gets
// replaced by a C++ class in Phase 1 (per PLAN.md §7, MercureClient).
QtObject {
id: root
property string url: ""
property bool active: false
signal eventReceived(string data, string id)
signal stateChanged(string state)
property var _xhr: null
property int _offset: 0
property string _accumulated: ""
function connect() {
if (active) return
_offset = 0
_accumulated = ""
const xhr = new XMLHttpRequest()
xhr.open("GET", url)
xhr.setRequestHeader("Accept", "text/event-stream")
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.LOADING ||
xhr.readyState === XMLHttpRequest.DONE) {
const text = xhr.responseText
if (text.length > _offset) {
const fresh = text.substring(_offset)
_offset = text.length
_ingest(fresh)
}
}
if (xhr.readyState === XMLHttpRequest.DONE) {
active = false
stateChanged("disconnected")
}
}
xhr.send()
_xhr = xhr
active = true
stateChanged("connected")
}
function disconnect() {
if (!active) return
if (_xhr) _xhr.abort()
active = false
stateChanged("disconnected")
}
function _ingest(chunk) {
_accumulated += chunk
const parts = _accumulated.split("\n\n")
_accumulated = parts.pop()
for (let i = 0; i < parts.length; ++i) {
_emit(parts[i])
}
}
function _emit(message) {
const lines = message.split("\n")
let id = ""
let dataLines = []
for (let i = 0; i < lines.length; ++i) {
const line = lines[i]
if (line.length === 0 || line.charAt(0) === ":") continue
const colon = line.indexOf(":")
if (colon < 0) continue
const field = line.substring(0, colon)
let value = line.substring(colon + 1)
if (value.length > 0 && value.charAt(0) === " ") value = value.substring(1)
if (field === "data") dataLines.push(value)
else if (field === "id") id = value
}
if (dataLines.length > 0) {
eventReceived(dataLines.join("\n"), id)
}
}
}