From 6af41cc6757fb21d3ee50777cf8e41399c8ba983 Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 02:15:46 +0200 Subject: [PATCH] Phase 1 sub-commit 8: retire the Phase 0 spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework skeleton at framework/skeleton/ now exercises every transport channel the spike proved (HTTP /api/ping, Mercure publish, SSE subscribe, FrankenPHP child management, single-instance lock, clean shutdown) — but through the proper PhpQml.Bridge module rather than ad-hoc inline QML. Lessons from the spike are preserved in PLAN.md and in the framework code's comments where they apply. Closes Phase 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- spike/.gitignore | 2 - spike/Caddyfile | 28 --------- spike/README.md | 88 --------------------------- spike/php/index.php | 64 -------------------- spike/qt/CMakeLists.txt | 29 --------- spike/qt/Main.qml | 128 ---------------------------------------- spike/qt/Mercure.qml | 84 -------------------------- spike/qt/main.cpp | 72 ---------------------- spike/run.sh | 22 ------- 9 files changed, 517 deletions(-) delete mode 100644 spike/.gitignore delete mode 100644 spike/Caddyfile delete mode 100644 spike/README.md delete mode 100644 spike/php/index.php delete mode 100644 spike/qt/CMakeLists.txt delete mode 100644 spike/qt/Main.qml delete mode 100644 spike/qt/Mercure.qml delete mode 100644 spike/qt/main.cpp delete mode 100755 spike/run.sh diff --git a/spike/.gitignore b/spike/.gitignore deleted file mode 100644 index 3558c56..0000000 --- a/spike/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -bin/ -build/ diff --git a/spike/Caddyfile b/spike/Caddyfile deleted file mode 100644 index bb785eb..0000000 --- a/spike/Caddyfile +++ /dev/null @@ -1,28 +0,0 @@ -{ - auto_https off - admin off - frankenphp - order php_server before respond - order mercure after encode -} - -http://127.0.0.1:8765 { - root * php/ - encode gzip - - mercure { - transport local - publisher_jwt !ChangeThisDevKey! - subscriber_jwt !ChangeThisDevKey! - anonymous - publish_origins * - cors_origins * - } - - php_server - - log { - output stderr - format console - } -} diff --git a/spike/README.md b/spike/README.md deleted file mode 100644 index 56c97a3..0000000 --- a/spike/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# Phase 0 — Throwaway transport spike - -The smallest thing that proves the php-qml architecture's transport works end-to-end on Linux. - -A Qt/QML host spawns a bundled FrankenPHP process, hits `GET /api/ping` over local HTTP, and subscribes to Mercure SSE. Clicking "Ping" makes a request whose handler also publishes a Mercure event — that event then arrives back in the QML window via the SSE stream. - -Lifetime of this directory is short: it gets removed when Phase 1's framework skeleton supersedes it. See [PLAN.md §13](../PLAN.md#13-roadmap-to-poc). - -## Run it - -Prerequisites on the dev machine: - -- Linux (tested on openSUSE Tumbleweed) -- `cmake`, `qt6-base-devel`, `qt6-declarative-devel`, `qt6-quickcontrols2-devel`, `qt6-tools-devel`, `gcc-c++` -- `curl` (for the FrankenPHP download) - -Fetch FrankenPHP if `bin/frankenphp` is missing: - -```bash -curl -fsSL -o spike/bin/frankenphp \ - https://github.com/php/frankenphp/releases/download/v1.12.2/frankenphp-linux-x86_64 -chmod +x spike/bin/frankenphp -``` - -Then from the repo root: - -```bash -./spike/run.sh -``` - -The script builds the Qt binary on first run (into `spike/build/`) and launches it. The Qt host spawns FrankenPHP as a child; closing the window cleans both up. - -## What you should see - -A window with two status dots — backend and Mercure — that flip green within a second of launch. Clicking **Ping** appends two lines to the event log: an outgoing `→ ping` line with the response payload and timing, and an incoming `← event …` line carrying the Mercure-pushed copy of the same payload. The two arrive within ~50 ms of each other on a quiet machine. - -Killing the child manually from another terminal — `pkill -f spike/bin/frankenphp` — flips the status dots and the QML logs `· mercure → disconnected`. Re-running `run.sh` reconnects. - -## What's hardcoded - -| Thing | Value | -| --- | --- | -| Backend URL | `http://127.0.0.1:8765` | -| Mercure topic | `app://ping` | -| Mercure JWT (publisher + subscriber) | `!ChangeThisDevKey!` (anonymous subscribers are also enabled) | -| FrankenPHP version | `v1.12.2` (pinned in this README; `bin/frankenphp` is gitignored) | -| Port | `8765` (chosen to avoid the system FrankenPHP that already binds 8080 on this box) | - -No auth on `/api/ping`. No `Last-Event-ID` resume. No optimistic updates. All of those land in Phase 1+. - -## Project layout - -```text -spike/ - Caddyfile # FrankenPHP / Caddy / Mercure config - run.sh # build + run - bin/frankenphp # downloaded static binary (gitignored) - php/ - index.php # GET /api/ping → JSON pong + Mercure publish - qt/ - CMakeLists.txt - main.cpp # Qt host: spawns frankenphp, installs SIGTERM handler, prctl(PR_SET_PDEATHSIG) - Main.qml # window UI: status dots, Ping button, event log - Mercure.qml # pure-QML SSE client over XMLHttpRequest -``` - -## Findings worth carrying into Phase 1 - -- **The `mercure` Caddy directive is site-level, not global.** It goes inside the site block alongside `php_server`, not in the `{}` global block. -- **The `transport` directive is now scalar, not URL.** `transport_url local://local` is deprecated in FrankenPHP 1.12; use `transport local`. -- **A default Mercure config requires a transport.** Without one, FrankenPHP falls back to `bolt` and demands a database file, which fails with `invalid transport: timeout` on first boot. For dev / spikes, `transport local` (in-memory) is the right choice. -- **Subprocess cleanup needs both belt and suspenders.** Qt's `aboutToQuit` only fires for graceful exits; for `SIGTERM` we need an explicit signal handler that calls `QCoreApplication::quit()`. As a Linux safety net, `prctl(PR_SET_PDEATHSIG, SIGTERM)` in the child via `QProcess::setChildProcessModifier()` ensures the kernel reaps the child even if the parent crashes. -- **PHP 8.5 deprecates `curl_close()`.** It's been a no-op since 8.0; spike code now relies on the variable going out of scope. Worth scrubbing for in Phase 1's PHP code style rules. -- **The system can have a pre-existing FrankenPHP on `:8080`.** Hardcoded port choice for the spike is `:8765`; for the framework proper, pick the port at runtime per session (§3, *Startup*) or use a unix socket on Linux/macOS. -- **Pure-QML SSE works.** `XMLHttpRequest` in QML 6.11 does deliver partial responses during `readyState === LOADING`, so `Mercure.qml` parses the stream without a C++ helper. The C++ `MercureClient` planned in PLAN.md §7 is still the right call for Phase 1 (reconnect, `Last-Event-ID`, backpressure), but it's not blocked on a Qt limitation. -- **JWT minting in plain PHP is ten lines.** Symfony's `mercure-bundle` is convenience, not necessity — useful to know for the Phase 1 SessionAuthenticator. - -## Out of scope for this spike - -(All deferred to Phase 1+, see [PLAN.md §13](../PLAN.md#13-roadmap-to-poc).) - -- Symfony, Doctrine, any framework -- Per-session secret, real auth -- `Last-Event-ID` resume / reconnect with backoff -- Optimistic updates, `pending` role, `connectionState` enum -- Single-instance lock, deep links -- Packaging, auto-update, code signing -- Tests (manual run only) diff --git a/spike/php/index.php b/spike/php/index.php deleted file mode 100644 index bb8e6c1..0000000 --- a/spike/php/index.php +++ /dev/null @@ -1,64 +0,0 @@ - true, 'now' => date('c')]; - publishToMercure(MERCURE_TOPIC, $payload); - header('Content-Type: application/json'); - echo json_encode($payload); - return; -} - -http_response_code(404); -header('Content-Type: application/json'); -echo json_encode(['error' => 'not found', 'path' => $path]); - -function publishToMercure(string $topic, array $data): void -{ - $body = http_build_query([ - 'topic' => $topic, - 'data' => json_encode($data), - ]); - - $ch = curl_init(MERCURE_HUB); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $body, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/x-www-form-urlencoded', - 'Authorization: Bearer ' . mintPublisherJwt(), - ], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 5, - ]); - $resp = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - error_log("mercure publish topic=$topic http=$code body=" . ($resp === false ? 'false' : (string) $resp)); -} - -function mintPublisherJwt(): string -{ - $header = ['alg' => 'HS256', 'typ' => 'JWT']; - $payload = ['mercure' => ['publish' => ['*']]]; - - $h = b64url(json_encode($header)); - $p = b64url(json_encode($payload)); - $s = b64url(hash_hmac('sha256', "$h.$p", MERCURE_KEY, true)); - - return "$h.$p.$s"; -} - -function b64url(string $bytes): string -{ - return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); -} diff --git a/spike/qt/CMakeLists.txt b/spike/qt/CMakeLists.txt deleted file mode 100644 index 09e2b22..0000000 --- a/spike/qt/CMakeLists.txt +++ /dev/null @@ -1,29 +0,0 @@ -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 -) diff --git a/spike/qt/Main.qml b/spike/qt/Main.qml deleted file mode 100644 index 1ecc182..0000000 --- a/spike/qt/Main.qml +++ /dev/null @@ -1,128 +0,0 @@ -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…" - } - } - } - } -} diff --git a/spike/qt/Mercure.qml b/spike/qt/Mercure.qml deleted file mode 100644 index 8f76b4a..0000000 --- a/spike/qt/Mercure.qml +++ /dev/null @@ -1,84 +0,0 @@ -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) - } - } -} diff --git a/spike/qt/main.cpp b/spike/qt/main.cpp deleted file mode 100644 index 89b7fe4..0000000 --- a/spike/qt/main.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -// 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(); -} diff --git a/spike/run.sh b/spike/run.sh deleted file mode 100755 index 389d63a..0000000 --- a/spike/run.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Build (if needed) and run the Phase 0 spike. -# Toolchain expected on PATH: cmake, qmake6 (or qmake from qt6-base-devel), g++. -set -euo pipefail - -SPIKE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -BUILD_DIR="$SPIKE_DIR/build" -BIN="$BUILD_DIR/spike" - -if [ ! -x "$SPIKE_DIR/bin/frankenphp" ]; then - echo "missing $SPIKE_DIR/bin/frankenphp — run download from spike/README.md" >&2 - exit 1 -fi - -if [ ! -x "$BIN" ]; then - echo "==> configuring + building Qt host" - cmake -S "$SPIKE_DIR/qt" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug - cmake --build "$BUILD_DIR" --parallel -fi - -echo "==> launching spike" -exec "$BIN"