diff --git a/spike/.gitignore b/spike/.gitignore new file mode 100644 index 0000000..3558c56 --- /dev/null +++ b/spike/.gitignore @@ -0,0 +1,2 @@ +bin/ +build/ diff --git a/spike/Caddyfile b/spike/Caddyfile new file mode 100644 index 0000000..bb785eb --- /dev/null +++ b/spike/Caddyfile @@ -0,0 +1,28 @@ +{ + 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 new file mode 100644 index 0000000..56c97a3 --- /dev/null +++ b/spike/README.md @@ -0,0 +1,88 @@ +# 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 new file mode 100644 index 0000000..bb8e6c1 --- /dev/null +++ b/spike/php/index.php @@ -0,0 +1,64 @@ + 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 new file mode 100644 index 0000000..09e2b22 --- /dev/null +++ b/spike/qt/CMakeLists.txt @@ -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 +) diff --git a/spike/qt/Main.qml b/spike/qt/Main.qml new file mode 100644 index 0000000..1ecc182 --- /dev/null +++ b/spike/qt/Main.qml @@ -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…" + } + } + } + } +} diff --git a/spike/qt/Mercure.qml b/spike/qt/Mercure.qml new file mode 100644 index 0000000..8f76b4a --- /dev/null +++ b/spike/qt/Mercure.qml @@ -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) + } + } +} diff --git a/spike/qt/main.cpp b/spike/qt/main.cpp new file mode 100644 index 0000000..89b7fe4 --- /dev/null +++ b/spike/qt/main.cpp @@ -0,0 +1,72 @@ +#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 new file mode 100755 index 0000000..389d63a --- /dev/null +++ b/spike/run.sh @@ -0,0 +1,22 @@ +#!/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"