From 75840a240e28f13934cb5482cc366bdcde1bad9d Mon Sep 17 00:00:00 2001 From: magdev Date: Sat, 2 May 2026 01:21:59 +0200 Subject: [PATCH] Phase 1 sub-commit 5: Qt transport types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MercureClient is a single-topic SSE subscriber: opens a long-lived GET on the hub URL with the topic query and Accept: text/event-stream, parses the line protocol into update(data, id) signals, and reconnects with 1s→2s→…→30s exponential backoff on drop. Tracks lastEventId across reconnects and sends it as Last-Event-ID so the hub can replay missed messages — backing the "Sleep / wake" path in PLAN.md §3 *Edge cases*. One client per topic by design; multi-topic aggregation is Phase 2. RestClient.qml is a Promise-style XMLHttpRequest wrapper. Auto-attaches an RFC4122-v4 Idempotency-Key to every non-GET request (PLAN.md §4 and §7) so retries are safe by default. Maps application/problem+json error bodies into structured rejections for downstream UI. Standalone CMake build remains green. Co-Authored-By: Claude Opus 4.7 (1M context) --- framework/qml/CMakeLists.txt | 4 + framework/qml/qml/RestClient.qml | 87 ++++++++++++ framework/qml/src/MercureClient.cpp | 197 ++++++++++++++++++++++++++++ framework/qml/src/MercureClient.h | 95 ++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 framework/qml/qml/RestClient.qml create mode 100644 framework/qml/src/MercureClient.cpp create mode 100644 framework/qml/src/MercureClient.h diff --git a/framework/qml/CMakeLists.txt b/framework/qml/CMakeLists.txt index c6eb52d..c2059b7 100644 --- a/framework/qml/CMakeLists.txt +++ b/framework/qml/CMakeLists.txt @@ -29,6 +29,10 @@ qt_add_qml_module(php_qml_bridge src/BackendConnection.cpp src/SingleInstance.h src/SingleInstance.cpp + src/MercureClient.h + src/MercureClient.cpp + QML_FILES + qml/RestClient.qml ) target_include_directories(php_qml_bridge PUBLIC src/) diff --git a/framework/qml/qml/RestClient.qml b/framework/qml/qml/RestClient.qml new file mode 100644 index 0000000..83754ac --- /dev/null +++ b/framework/qml/qml/RestClient.qml @@ -0,0 +1,87 @@ +import QtQuick + +// Promise-style REST client. Auto-attaches an Idempotency-Key UUID to +// every non-GET request so retries (manual or driven by the Update +// Semantics layer in Phase 2) are safe by default. Maps `application/ +// problem+json` error bodies into structured rejections. +// +// See PLAN.md §4 (Communication Contract) and §7 (RestClient). +QtObject { + id: root + + property string baseUrl: "" + property string token: "" + + function get(path) { return _request("GET", path, null) } + function post(path, body) { return _request("POST", path, body) } + function patch(path, body) { return _request("PATCH", path, body) } + function del(path, body) { return _request("DELETE", path, body) } + + function _request(method, path, body) { + return new Promise(function(resolve, reject) { + const xhr = new XMLHttpRequest() + xhr.open(method, root.baseUrl + path) + + if (root.token) { + xhr.setRequestHeader("Authorization", "Bearer " + root.token) + } + if (method !== "GET") { + xhr.setRequestHeader("Idempotency-Key", _uuid4()) + if (body !== undefined && body !== null) { + xhr.setRequestHeader("Content-Type", "application/json") + } + } + xhr.setRequestHeader("Accept", "application/json") + + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return + + const status = xhr.status + const contentType = (xhr.getResponseHeader("Content-Type") || "").toLowerCase() + const isJson = contentType.indexOf("application/json") >= 0 + || contentType.indexOf("application/problem+json") >= 0 + + let parsed = xhr.responseText + if (isJson && parsed.length > 0) { + try { parsed = JSON.parse(parsed) } catch (e) { /* keep raw */ } + } + + if (status >= 200 && status < 300) { + resolve({ status: status, body: parsed, headers: xhr.getAllResponseHeaders() }) + return + } + + // Error path. Attach problem+json body if present. + const err = { + status: status, + problem: (typeof parsed === "object") ? parsed : null, + raw: xhr.responseText, + method: method, + path: path, + } + reject(err) + } + + xhr.send(method !== "GET" && body !== undefined && body !== null + ? JSON.stringify(body) + : null) + }) + } + + // RFC 4122 v4-shaped UUID. Math.random is fine here — the key only + // needs to be unique across in-flight retries from this client, not + // cryptographically unguessable. + function _uuid4() { + const hex = "0123456789abcdef" + let s = "" + for (let i = 0; i < 32; ++i) { + let n = Math.floor(Math.random() * 16) + if (i === 12) n = 4 + else if (i === 16) n = (n & 3) | 8 + s += hex.charAt(n) + } + return s.substring(0, 8) + "-" + s.substring(8, 12) + "-" + + s.substring(12, 16) + "-" + s.substring(16, 20) + "-" + + s.substring(20, 32) + } +} diff --git a/framework/qml/src/MercureClient.cpp b/framework/qml/src/MercureClient.cpp new file mode 100644 index 0000000..a7cf53c --- /dev/null +++ b/framework/qml/src/MercureClient.cpp @@ -0,0 +1,197 @@ +#include "MercureClient.h" + +#include +#include +#include +#include +#include +#include + +#include + +namespace PhpQml::Bridge { + +MercureClient::MercureClient(QObject* parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) +{ +} + +MercureClient::~MercureClient() +{ + teardownReply(); +} + +void MercureClient::setHubUrl(const QString& url) +{ + if (m_hubUrl == url) return; + m_hubUrl = url; + emit hubUrlChanged(); +} + +void MercureClient::setTopic(const QString& t) +{ + if (m_topic == t) return; + m_topic = t; + emit topicChanged(); +} + +void MercureClient::setToken(const QString& t) +{ + if (m_token == t) return; + m_token = t; + emit tokenChanged(); +} + +void MercureClient::start() +{ + m_userStopped = false; + m_currentBackoffMs = kInitialBackoffMs; + if (m_reconnectTimer) m_reconnectTimer->stop(); + doConnect(); +} + +void MercureClient::stop() +{ + m_userStopped = true; + if (m_reconnectTimer) m_reconnectTimer->stop(); + teardownReply(); + setActive(false); +} + +void MercureClient::doConnect() +{ + if (m_userStopped) return; + if (m_hubUrl.isEmpty() || m_topic.isEmpty()) return; + if (m_reply) return; // already connecting / connected + + QUrl u(m_hubUrl); + QUrlQuery query(u); + query.addQueryItem(QStringLiteral("topic"), m_topic); + u.setQuery(query); + + QNetworkRequest req(u); + req.setRawHeader("Accept", "text/event-stream"); + req.setRawHeader("Cache-Control", "no-cache"); + if (!m_lastEventId.isEmpty()) { + req.setRawHeader("Last-Event-ID", m_lastEventId.toUtf8()); + } + if (!m_token.isEmpty()) { + req.setRawHeader("Authorization", "Bearer " + m_token.toUtf8()); + } + // Disable transfer timeout — SSE is a long-lived stream. + req.setTransferTimeout(0); + + m_reply = m_nam->get(req); + connect(m_reply, &QNetworkReply::readyRead, this, &MercureClient::onReadyRead); + connect(m_reply, &QNetworkReply::finished, this, &MercureClient::onFinished); + + setActive(true); +} + +void MercureClient::onReadyRead() +{ + if (!m_reply) return; + + while (m_reply->canReadLine()) { + QByteArray line = m_reply->readLine(); + // Strip trailing line terminator (handles both \n and \r\n). + while (line.endsWith('\n') || line.endsWith('\r')) { + line.chop(1); + } + + if (line.isEmpty()) { + emitMessage(); + continue; + } + if (line.startsWith(':')) continue; // SSE comment + + const int colon = line.indexOf(':'); + if (colon < 0) continue; // malformed + const QByteArray field = line.left(colon); + QByteArray value = line.mid(colon + 1); + if (value.startsWith(' ')) value = value.mid(1); + + if (field == "data") { + m_dataLines << QString::fromUtf8(value); + } else if (field == "id") { + m_pendingEventId = value; + } + // event:, retry: are intentionally ignored for Phase 1. + } + + // Successful read — reset backoff so a future drop reconnects fast. + m_currentBackoffMs = kInitialBackoffMs; +} + +void MercureClient::emitMessage() +{ + if (m_dataLines.isEmpty() && m_pendingEventId.isEmpty()) return; + + if (!m_pendingEventId.isEmpty()) { + const QString id = QString::fromUtf8(m_pendingEventId); + if (id != m_lastEventId) { + m_lastEventId = id; + emit lastEventIdChanged(); + } + } + if (!m_dataLines.isEmpty()) { + emit update(m_dataLines.join(QChar('\n')), QString::fromUtf8(m_pendingEventId)); + } + + m_dataLines.clear(); + m_pendingEventId.clear(); +} + +void MercureClient::onFinished() +{ + if (!m_reply) return; + + const auto err = m_reply->error(); + const QString errStr = m_reply->errorString(); + teardownReply(); + setActive(false); + + if (m_userStopped) return; + + if (err != QNetworkReply::NoError && err != QNetworkReply::OperationCanceledError) { + emit error(errStr); + } + + scheduleReconnect(); +} + +void MercureClient::scheduleReconnect() +{ + if (m_userStopped) return; + if (m_hubUrl.isEmpty() || m_topic.isEmpty()) return; + + if (!m_reconnectTimer) { + m_reconnectTimer = new QTimer(this); + m_reconnectTimer->setSingleShot(true); + connect(m_reconnectTimer, &QTimer::timeout, this, &MercureClient::doConnect); + } + m_reconnectTimer->start(m_currentBackoffMs); + m_currentBackoffMs = std::min(m_currentBackoffMs * 2, kMaxBackoffMs); +} + +void MercureClient::teardownReply() +{ + if (m_reply) { + disconnect(m_reply, nullptr, this, nullptr); + m_reply->abort(); + m_reply->deleteLater(); + m_reply = nullptr; + } + m_dataLines.clear(); + m_pendingEventId.clear(); +} + +void MercureClient::setActive(bool a) +{ + if (m_active == a) return; + m_active = a; + emit activeChanged(); +} + +} // namespace PhpQml::Bridge diff --git a/framework/qml/src/MercureClient.h b/framework/qml/src/MercureClient.h new file mode 100644 index 0000000..dcf8953 --- /dev/null +++ b/framework/qml/src/MercureClient.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; +class QTimer; + +namespace PhpQml::Bridge { + +/// Single-topic Mercure SSE subscriber. +/// +/// Implements the `text/event-stream` line protocol on top of QNetworkReply, +/// with exponential reconnect (1s → 2s → … → cap 30s) and `Last-Event-ID` +/// resume across reconnects. See PLAN.md §7 (MercureClient) and §3 +/// *Edge cases — Sleep / wake* for the resume contract. +/// +/// One client = one topic. Application code instantiates one per +/// subscribed topic; multi-topic aggregation is a Phase 2 concern. +class MercureClient : public QObject +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString hubUrl READ hubUrl WRITE setHubUrl NOTIFY hubUrlChanged) + Q_PROPERTY(QString topic READ topic WRITE setTopic NOTIFY topicChanged) + Q_PROPERTY(QString token READ token WRITE setToken NOTIFY tokenChanged) + Q_PROPERTY(bool active READ active NOTIFY activeChanged) + Q_PROPERTY(QString lastEventId READ lastEventId NOTIFY lastEventIdChanged) + +public: + explicit MercureClient(QObject* parent = nullptr); + ~MercureClient() override; + + QString hubUrl() const { return m_hubUrl; } + void setHubUrl(const QString& url); + QString topic() const { return m_topic; } + void setTopic(const QString& t); + QString token() const { return m_token; } + void setToken(const QString& t); + bool active() const noexcept { return m_active; } + QString lastEventId() const { return m_lastEventId; } + + Q_INVOKABLE void start(); + Q_INVOKABLE void stop(); + +signals: + void hubUrlChanged(); + void topicChanged(); + void tokenChanged(); + void activeChanged(); + void lastEventIdChanged(); + + /// Emitted for every dispatched SSE event with a `data:` field. + /// `data` is the joined data lines as the hub sent them; `id` is + /// the SSE event id (often a UUIDv7). + void update(const QString& data, const QString& id); + void error(const QString& detail); + +private slots: + void onReadyRead(); + void onFinished(); + void doConnect(); + +private: + void scheduleReconnect(); + void teardownReply(); + void emitMessage(); + void setActive(bool a); + + QNetworkAccessManager* m_nam = nullptr; + QNetworkReply* m_reply = nullptr; + QTimer* m_reconnectTimer = nullptr; + + QString m_hubUrl; + QString m_topic; + QString m_token; + QString m_lastEventId; + + QStringList m_dataLines; + QByteArray m_pendingEventId; + + bool m_active = false; + bool m_userStopped = false; + int m_currentBackoffMs = kInitialBackoffMs; + + static constexpr int kInitialBackoffMs = 1000; + static constexpr int kMaxBackoffMs = 30000; +}; + +} // namespace PhpQml::Bridge