Phase 1 sub-commit 5: Qt transport types
All checks were successful
CI / Quality (push) Successful in 5s
All checks were successful
CI / Quality (push) Successful in 5s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,10 @@ qt_add_qml_module(php_qml_bridge
|
|||||||
src/BackendConnection.cpp
|
src/BackendConnection.cpp
|
||||||
src/SingleInstance.h
|
src/SingleInstance.h
|
||||||
src/SingleInstance.cpp
|
src/SingleInstance.cpp
|
||||||
|
src/MercureClient.h
|
||||||
|
src/MercureClient.cpp
|
||||||
|
QML_FILES
|
||||||
|
qml/RestClient.qml
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(php_qml_bridge PUBLIC src/)
|
target_include_directories(php_qml_bridge PUBLIC src/)
|
||||||
|
|||||||
87
framework/qml/qml/RestClient.qml
Normal file
87
framework/qml/qml/RestClient.qml
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
197
framework/qml/src/MercureClient.cpp
Normal file
197
framework/qml/src/MercureClient.cpp
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
#include "MercureClient.h"
|
||||||
|
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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
|
||||||
95
framework/qml/src/MercureClient.h
Normal file
95
framework/qml/src/MercureClient.h
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QtQmlIntegration>
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user