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:
29
spike/qt/CMakeLists.txt
Normal file
29
spike/qt/CMakeLists.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
project(php_qml_spike LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Network Qml)
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.5)
|
||||
|
||||
qt_add_executable(spike main.cpp)
|
||||
|
||||
qt_add_qml_module(spike
|
||||
URI Spike
|
||||
VERSION 1.0
|
||||
QML_FILES
|
||||
Main.qml
|
||||
Mercure.qml
|
||||
)
|
||||
|
||||
target_link_libraries(spike PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Quick
|
||||
Qt6::QuickControls2
|
||||
Qt6::Network
|
||||
Qt6::Qml
|
||||
)
|
||||
128
spike/qt/Main.qml
Normal file
128
spike/qt/Main.qml
Normal file
@@ -0,0 +1,128 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Window
|
||||
|
||||
ApplicationWindow {
|
||||
id: window
|
||||
width: 720
|
||||
height: 520
|
||||
visible: true
|
||||
title: "php-qml — Phase 0 spike"
|
||||
|
||||
readonly property string baseUrl: "http://127.0.0.1:8765"
|
||||
readonly property string topic: "app://ping"
|
||||
|
||||
property string status: "starting…"
|
||||
property string lastResponse: ""
|
||||
property string mercureState: "disconnected"
|
||||
|
||||
Mercure {
|
||||
id: mercure
|
||||
url: window.baseUrl + "/.well-known/mercure?topic=" + encodeURIComponent(window.topic)
|
||||
onEventReceived: function(data, id) {
|
||||
log.append("← event " + id.split(":").pop() + " " + data)
|
||||
}
|
||||
onStateChanged: function(s) {
|
||||
window.mercureState = s
|
||||
log.append("· mercure → " + s)
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: bootProbe
|
||||
interval: 250
|
||||
running: window.status !== "online"
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("GET", window.baseUrl + "/api/ping")
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
||||
if (window.status !== "online") {
|
||||
window.status = "online"
|
||||
log.append("· backend ready")
|
||||
mercure.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
xhr.send()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
spacing: 12
|
||||
|
||||
RowLayout {
|
||||
spacing: 10
|
||||
Layout.fillWidth: true
|
||||
|
||||
Rectangle {
|
||||
width: 12; height: 12; radius: 6
|
||||
color: window.status === "online" ? "#3ab36c" : "#d89614"
|
||||
}
|
||||
Label { text: "Backend: " + window.status }
|
||||
|
||||
Item { width: 12 }
|
||||
|
||||
Rectangle {
|
||||
width: 12; height: 12; radius: 6
|
||||
color: window.mercureState === "connected" ? "#3ab36c" : "#666"
|
||||
}
|
||||
Label { text: "Mercure: " + window.mercureState }
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Button {
|
||||
text: "Ping"
|
||||
enabled: window.status === "online"
|
||||
onClicked: {
|
||||
const t0 = Date.now()
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("GET", window.baseUrl + "/api/ping")
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
const dt = Date.now() - t0
|
||||
if (xhr.status === 200) {
|
||||
window.lastResponse = xhr.responseText
|
||||
log.append("→ ping " + dt + "ms " + xhr.responseText)
|
||||
} else {
|
||||
log.append("× ping failed status=" + xhr.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
xhr.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Last response: " + (window.lastResponse || "—")
|
||||
color: "#888"
|
||||
font.pixelSize: 12
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Frame {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
padding: 0
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
|
||||
TextArea {
|
||||
id: log
|
||||
readOnly: true
|
||||
wrapMode: TextArea.Wrap
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 12
|
||||
placeholderText: "events will appear here…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
spike/qt/Mercure.qml
Normal file
84
spike/qt/Mercure.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
spike/qt/main.cpp
Normal file
72
spike/qt/main.cpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#include <QCoreApplication>
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QProcess>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QObject>
|
||||
#include <QDebug>
|
||||
|
||||
#include <csignal>
|
||||
#include <sys/prctl.h>
|
||||
|
||||
// Resolve the spike root by walking up from the running binary
|
||||
// until we find a directory containing Caddyfile + bin/frankenphp.
|
||||
static QString resolveSpikeRoot()
|
||||
{
|
||||
QDir d(QCoreApplication::applicationDirPath());
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
if (QFileInfo(d.filePath("Caddyfile")).exists() &&
|
||||
QFileInfo(d.filePath("bin/frankenphp")).exists()) {
|
||||
return d.absolutePath();
|
||||
}
|
||||
if (!d.cdUp()) break;
|
||||
}
|
||||
qWarning() << "Could not locate spike root from"
|
||||
<< QCoreApplication::applicationDirPath();
|
||||
return QCoreApplication::applicationDirPath();
|
||||
}
|
||||
|
||||
// SIGTERM / SIGINT → graceful Qt quit so aboutToQuit cleanup runs.
|
||||
static void installSignalHandlers()
|
||||
{
|
||||
auto handler = +[](int) { QCoreApplication::quit(); };
|
||||
std::signal(SIGTERM, handler);
|
||||
std::signal(SIGINT, handler);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QGuiApplication app(argc, argv);
|
||||
installSignalHandlers();
|
||||
|
||||
const QString spikeRoot = resolveSpikeRoot();
|
||||
qInfo() << "spike root:" << spikeRoot;
|
||||
|
||||
QProcess franken;
|
||||
franken.setProgram(spikeRoot + "/bin/frankenphp");
|
||||
franken.setArguments({"run", "--config", spikeRoot + "/Caddyfile"});
|
||||
franken.setWorkingDirectory(spikeRoot);
|
||||
franken.setProcessChannelMode(QProcess::ForwardedChannels);
|
||||
|
||||
// Linux belt-and-suspenders: ask the kernel to SIGTERM the child
|
||||
// when this process dies, regardless of how (crash, kill -9, etc.).
|
||||
franken.setChildProcessModifier([] {
|
||||
prctl(PR_SET_PDEATHSIG, SIGTERM);
|
||||
});
|
||||
|
||||
franken.start();
|
||||
|
||||
QObject::connect(&app, &QCoreApplication::aboutToQuit, [&]() {
|
||||
if (franken.state() == QProcess::NotRunning) return;
|
||||
franken.terminate();
|
||||
if (!franken.waitForFinished(2000)) franken.kill();
|
||||
});
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
engine.loadFromModule("Spike", "Main");
|
||||
|
||||
if (engine.rootObjects().isEmpty()) return -1;
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
Reference in New Issue
Block a user