QML / one toolchain / zero third party servers
Build complete web systems with QML, with no third party servers to stand up.
SynQt (pronounced synced) builds a system from entities: a browser client, a web edge, a database, and whatever else you need. Each entity is its own binary, and all of them share one toolchain and one security model.
Why SynQt¶
-
One language, front to back
Write the UI and the server logic in QML. Two entities talk through typed connect points, which the configuration names and guards.
-
Live by default
A few lines of QML give you a value that updates in every browser the moment it changes, with no wiring to write and nothing polling.
-
Batteries included, no third party servers
Add a database, a cache, a document store, a gateway or a jobs runner as an entity. It runs an embedded engine, or puts PostgreSQL, MongoDB or Redis behind the same interface with one config value.
-
Web and desktop, one codebase
The client is a Qt app. Ship it to the browser as WebAssembly, and build the same QML as a native app for Windows, macOS and Linux. Both talk to the same edge under the same security model.
-
Secure at every link
Every mesh link is mutual TLS unless you opt it into a local socket by name, and a release build refuses to serve the browser without TLS.
synqt checkflags every exception. -
One click, one trace
Add a monitor and every entity reports to it. A click in the browser becomes one trace through every entity it touched. Recording starts when you add a monitor, and raising a category's level during an incident takes a restart, not a rebuild.
A closer look¶
Contracts and connect points¶
Two entities talk through a connect point: a typed, live object that one entity owns and the others mirror. Properties and signals flow from the owner to every consumer. Slot calls flow the other way, and the owner decides whether to honor them.
Security by default¶
TLS everywhere, mutual TLS between entities, a topology that denies by default, and a contract format that sends only what it declares. A project has no insecure setting to ship by accident.
One toolchain¶
The synqt CLI pins the exact Qt and Emscripten versions your project needs and prints
the commands that install them. It builds every entity, native and WebAssembly, and runs
them together with file watching and hot reload.
What it looks like¶
The example is a chat room. A line typed in one window appears in every window that has the room open, on any machine.
The drawing below is this project, drawn by the design editor's own code. The boxes show which side of the network each entity is on, and each line carries the contract its two ends share. Only the web edge faces the internet. The database sits in the mesh, and only the edge can reach it.
A visitor who has not signed in sees a sign-in page. The room's connect point is gated
scope: user, so a signed-out session never acquires it and has nothing to get past.
Signing in fills the same window with the room. The sign-in runs on the edge, at the door
mark on its rim; hover the mark to read what it does. A moderator gets one extra member,
erase, and only the contract can grant it.
Nine files make the whole system: synqt.yaml, which says what crosses each link, one QML
file per entity, three more QML files the client's window opens, the edge's sign-in mapping,
and the database table.
- Open a file. Hover or focus an entity, a contract mark, or a row of the project tree. The file stays open until you pick another.
- List an entity's files. Hover the entity in the drawing. Its files appear under its name.
- Read a note. A line marked on its left has a note: hover it. A line ending in an arrow links to the page that covers it, in this guide or in the C++ reference.
The button under the drawing opens the same project in the online designer. It runs in the browser with nothing installed and draws with the same code. Move the entities, add one, and export the result as a project.
- synqt.yaml
- client
- app
- Main.qml
- User.qml
- Message.qml
- Admin.qml
- web
- edge
- Edge.qml
- identity/map.qml
- db/relational
- store
- Store.qml
- schema.sql
project:
name: chat
qt_version: 6.12.0
scopes: { order: [anonymous, user, admin], default: anonymous }
identity:
providers: [{ name: github, client_id: ..., client_secret: env:SECRET }]
mapping: web/edge/identity/map.qml
entities:
- { name: app, type: client }
- name: edge
type: web_edge
identity: true
public: { port: 8443, sync_route: /sync }
- { name: store, type: relational, provider: { name: sqlite } }
connect_points:
- owner: edge
consumers: [app]
scope: user
export: |
model messages(int id, string[40] who, string[280] body, bool staff)
slot say(string[280] body)
<admin> slot erase(int id)
- owner: store
consumers: [edge]
export: |
prop var[24000] lines
slot say(string[40] who, string[280] body, bool staff)
slot erase(int id)
- One version pins the whole toolchain: Qt, the Emscripten it builds with, and every entity.
- The scope ladder. Every session sits on one rung, and a connect point can require a minimum.
- The one place that decides who is a moderator. It turns a verified login into a scope, and the rest of the system reads that answer.
- The room, built to WebAssembly. The same QML also builds as a native app for Windows, macOS and Linux, against the same edge.
- The only entity that faces the internet, on the only public port.
- This edge runs the OAuth exchange and keeps the sessions. `Session.login()` in the client lands here.
- A database entity. It embeds SQLite by default. One config value puts PostgreSQL or MySQL behind the same interface.
- The gate on the whole point. A session that has not signed in never acquires it.
- The browser's only way in. An entity missing from this list cannot open the connect point.
- The gate is on the member. A caller without `<admin>` does not have the slot, so the edge refuses the call before it runs. The QML behind it needs no check.
- These roles are all a message can carry to a browser. `said_at` is in the table and not here, so it never leaves the mesh.
- Only the edge can reach the database. The browser is on no list here, so it has no request to make.
- The room, held by the database and mirrored by the edge. The number in brackets is the size limit the owner enforces, in bytes on the wire.
import SynQt
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: window
visible: true
title: qsTr("The chat room")
ColumnLayout {
anchors.centerIn: parent
visible: !Session.hasScope("user")
spacing: 24
Label {
Layout.alignment: Qt.AlignHCenter
font.pixelSize: 32
text: qsTr("One room. Everybody in it sees the same thing.")
}
Button {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Sign in with GitHub")
onClicked: Session.login()
}
}
User {
anchors.fill: parent
visible: Session.hasScope("user")
}
}
- Brings in the runtime accessors (`Server`, `Session`, `Router`) and the contracts this entity consumes.
- One window. Signing in swaps what it shows, with no second page or redirect: a signed-out session has no `Server.messages` to reach.
- The sign-in page, which is all a signed-out visitor sees. It is a binding, so it hides itself as soon as the session holds `<user>`.
- The flow runs on the edge. The browser never sees a token or a secret, and ends up holding only a session cookie.
- The room, in `User.qml` next to this file. Every `*.qml` beside `Main.qml` is a type named after its file. `synqt build` compiles them into the entity's QML module, so there is nothing to import or register.
import SynQt
import QtQuick.Controls
import QtQuick.Layouts
ColumnLayout {
ListView {
id: messages
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
model: Server.messages
delegate: Message {
width: messages.width
}
}
TextField {
id: draft
Layout.fillWidth: true
placeholderText: qsTr("Say something")
onAccepted: {
Server.say(draft.text);
draft.clear();
}
}
}
- A live model, and the whole of the sync. When the owner replaces the rows, every open tab redraws, with no polling.
- One row per message, drawn by `Message.qml`.
- Runs on the owner, which may refuse it. The browser sends the text and nothing else.
import SynQt
import QtQuick.Controls
Item {
id: line
required property var model
height: 26
Label {
x: 8
y: (line.height - height) / 2
width: 132
elide: Text.ElideRight
color: line.model.staff ? "#d0342c" : line.palette.windowText
text: line.model.who
}
Label {
x: 148
y: (line.height - height) / 2
width: line.width - 148 - 88
elide: Text.ElideRight
text: line.model.body
}
Admin {
x: line.width - width - 8
height: line.height
messageId: line.model.id
}
}
- A delegate is recycled, so it keeps no state of its own. It takes `var model` instead of one property per role, because one role is `id`, a QML keyword.
- A moderator's line is red. The edge sets `staff` from the session it verified and is its only writer. The browser has no say.
- The moderator's Erase button, in `Admin.qml`.
import SynQt
import QtQuick.Controls
Button {
id: control
required property int messageId
visible: Session.hasScope("admin")
text: qsTr("Erase")
onClicked: Server.erase(control.messageId)
}
- The message this button erases, passed in by the row. A recycled delegate cannot know which line it sits on.
- A courtesy. An ordinary session never acquired `erase`, so hiding the button only avoids offering it.
- The contract declares `<admin> slot erase`. An ordinary session never acquired it, so it has nothing to call.
import SynQt
Edge {
messagesRows: Store.lines
function say(body) {
Store.say(Caller.identity.login, body, Caller.hasScope("admin"));
}
function erase(id) {
Store.erase(id);
}
}
- The Source of the point this entity owns. The edge decides everything about a message except its text. The type is the entity's name capitalised, because the owner names the point.
- Bind the model once and the room is live. Every browser mirrors it, so when the database reassigns the list, every browser redraws, with no broadcast to write.
- The caller, taken from the session this edge verified. A browser cannot read or set this value, so `say` takes only a line of text.
- The only place that can mark a message as staff. The argument comes from the verified session alone, so asking cannot make anyone a moderator.
- Only a caller holding `<admin>` reaches this, because the contract says so. The function needs no check.
import SynQt
IdentityMapping {
id: mapping
readonly property var moderators: ["octocat"]
function scopeFor(identity): int {
return mapping.moderators.indexOf(identity.login) >= 0
? Scope.Admin : Scope.User;
}
}
- Runs on the edge after each sign-in, with the identity the provider verified. Its answer is the session's scope.
- Keyed on `identity.login`. A GitHub account can keep `identity.email` private, so email is never the key.
- `Scope` is generated from `scopes.order` in `synqt.yaml`. A scope the project never declared cannot be written here.
import SynQt
Store {
id: log
function say(who, body, staff) {
Db.exec("INSERT INTO messages (who, body, staff, said_at) "
+ "VALUES (?, ?, ?, datetime('now'))",
[who, body, staff ? 1 : 0]);
log.refresh();
}
function erase(id) {
Db.exec("UPDATE messages SET body = 'deleted by a moderator' WHERE id = ?",
[id]);
log.refresh();
}
function refresh() {
const rows = Db.query("SELECT id, who, body, staff FROM messages "
+ "ORDER BY id DESC LIMIT 50");
log.lines = rows.map(row => ({ id: row.id, who: row.who, body: row.body,
staff: row.staff !== 0 }));
}
lines: []
Component.onCompleted: log.refresh()
}
- The Source of the point the database owns, and its only surface. The conversation lives here, and it is the only part of the system that survives a restart.
- Always parameterized. The values travel beside the statement, so an apostrophe in a message stays an apostrophe. Writes are serialized on this entity's event loop.
- Erasing keeps the line in place and says it was deleted, so the conversation has no hole in it.
- Reads the room and publishes it in one step. It runs after every write and once at startup, so the room changes by one path only.
- `said_at` is in the table but not in this SELECT or in the contract. The boundary keeps the declared roles and drops the rest.
- The room, held once and reassigned whole. That reassignment is the synchronisation.
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
who TEXT NOT NULL,
body TEXT NOT NULL,
staff INTEGER NOT NULL DEFAULT 0,
said_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS messages_by_time
ON messages (said_at);
- Applied at startup, forward only. A migration adds a file and leaves this one as it is.
- In the table and not in the contract, so it stays inside the mesh. A browser never receives a column it is not told about.
Hover a marked line to read its note. A line ending in an arrow opens the page that covers it.
Where to go next¶
- Getting started: install
synqtand run your first project. - Framework: the full reference, from the entity model to the security design.
- Examples: complete systems.
- Contributing: a map of the codebase, for working on SynQt itself.
- C++ reference: the class and member reference for the runtime.