Project layout and configuration¶
This page covers the layout of a SynQt project on disk and the complete synqt.yaml
schema. A project is a set of entities, so the configuration describes the topology
(which entities exist, what they own, what they consume, how they bind), plus per entity
settings and the security policy. A fresh project runs with almost no configuration, and
this page states every default.
Directory layout¶
synqt new my-app scaffolds a project with the two starting entities (a client
and a web edge) and room to add more:
my-app/
synqt.yaml # project, topology, and security config
CMakeLists.txt # four lines, yours to extend; hands the build to generated/
.env.example # the env: references each entity expects, never the values
.qmlformat.ini # what check.qml_format holds the project's QML to
.gitignore
client/ # every client entity
app/ # the client entity itself, shipped to the browser
Main.qml
TodoView.qml
assets/
web/ # every web edge entity
edge/ # the edge: serves the client, faces the net
Edge.qml # the edge: what it exports, and its state (synqt.yaml says what
# may cross)
identity/ # optional identity hooks
.env # secrets for this entity only, read by its process alone
db/relational/ # every relational entity
store/ # added with: synqt add entity store --type relational
Store.qml
schema.sql
.env
service/ # every plain service entity
lobby/ # added with: synqt add entity lobby --cpp
lobby.h # the entity as a C++ class
lobby.cpp
Lobby.qml # optional, rooted at the class
CMakeLists.txt # optional: libraries and more sources
synqt/ # framework managed; toolchain and mesh CA
toolchain/ # the pinned Qt and Emscripten kits
mesh/ # the project private CA and per entity certs (never committed)
.synqt/ # written by the designer
design.json # where each entity sits on the canvas, and nothing else
generated/ # everything SynQt writes; never edited, never committed
synqt.cmake # the multi binary build, from the topology
client/app/main.cpp # one per entity, mirroring the entity folders
web/edge/main.cpp
tests/ # the test runner, when the project has tests
build/ # build outputs, one subfolder per entity
app/
edge/
store/
CMakePresets.json # written per build; CMake reads presets from here and nowhere else
CMakeUserPresets.json # the same, for the kit resolved on this machine
Principles:
- One folder per entity, inside the folder its type shares:
client/<name>/,web/<name>/,db/relational/<name>/, and so on. Everything the entity is made of is there and nowhere else, so a.qmlfile dropped beside it is importable with no wiring, and two databases never overwrite each other. The layout is fixed, not configurable, so nothing can point half the build at one directory and half at another. - The contract lives in
synqt.yaml. A connect point's contract is itsexport:block, not a file in the entity's folder. The build writes the generated form undergenerated/. Every consumer the connect point names sees it on the wire, so changing it breaks them, even though only the owner's entry mentions it. - One CMake file belongs to the project. The root
CMakeLists.txtis written once and never rewritten. It only includesgenerated/synqt.cmake, so a target you add below the include survives every build. It sits at the root, not ingenerated/, because the QML compiler names each compiled file after its path relative to the directory that declared the QML module. Declared one level down, a view inclient/app/compiles to a path containing.., a directory name Windows cannot create. - Services and the client never share a build. A service entity is never part of the
WebAssembly build, and the client is never part of a service build. A connect point's
serverfile compiles into its owner only, and never joins the client target, so it cannot leak into the client. - Secrets stay with their entity. Each entity has its own
.env, read only by its process. The build refuses any client target that references a secret (see security). - Generated files stay out of entity folders. The CMake, the presets and each entity's
main.cppgo togenerated/, which mirrors the entity folders so two entities of the same type keep their own. An entity folder holds only what its author wrote, and "do not edit generated files" means one path, not a list of filenames. - Derived and private files are git ignored:
generated/,build/, the toolchain cache insynqt/toolchain/, the private keys and the development CA undersynqt/mesh/, each entity's.env, and an embedded database'sdata/. Never commit a mesh private key.
The synqt.yaml schema¶
SynQt's configuration is YAML. It declares the project's entities, connect points and
security policy, and the validation at the end of this page checks it. The
schema is nested and repetitive: a project has many entities, each with several sub
sections (public, mesh, tls, env, settings, provider), and each connect point
and route is a small record. YAML expresses that nesting directly, with block lists
(- name: ...) for repeated parts and indented maps for grouped settings.
Conventions for the whole file:
- Lists of records (
entities,connect_points,identity.providers,routes) are YAML block sequences: each element starts with-and its keys are indented under it. Order never matters,routesincluded. When two routes match a path, the one with more literal segments wins. - Grouped settings (an entity's
public,mesh,tls,env,settingsandprovidersections) are maps nested under their record, so the entity name is never repeated inside them. - Scalars are plain. Unquoted strings (
name: web), booleans (true/false) and integers (port: 8443) all work. Quote a string only when it contains characters YAML treats specially. The examples quote URLs and CSP strings for readability. - Secrets are never literals. A credential is always an
env:reference (for exampleenv:DB_PASSWORD), and validation enforces it. The entity resolves the name when it starts, most specific source first: its own environment, then its env file (web/edge/.envfor an entity inweb/, or whereverenv: {file: ...}points), then the project.env. A variable the real environment already set wins over every file, so an entity can run with a container secret, a systemd unit or a CI secret store and no file on disk. - Comments use
#. The scaffolded file uses them to explain each default in place.
A minimal project needs a project block, two entities and one connect point. Everything
else has a default. The full schema follows, grouped by concern, with every default. Each
top level key below (project, scopes, entities, connect_points, security, mesh,
identity, router, routes, build, check) is a section of the same synqt.yaml.
project¶
The application's name and choices that affect every entity.
project:
name: my-app # required
version: 0.1.0
qt_version: 6.12.0 # pinned Qt; drives the Emscripten version too
name is the only required key. qt_version pins the toolchain: the Qt version every
entity builds against and, through it, the client's Emscripten version (see
build system and CLI). A synqt release builds one Qt, so synqt
check refuses any other value, and the build refuses an Emscripten other than the one
that Qt was built with.
organization names what the client's own settings are filed under: a Settings object in
QML, or QSettings in C++. It defaults to name. Qt keeps no settings on WebAssembly or
Windows without one, so the client always sets it. organization_domain is optional; macOS
files settings under the domain when one is set. Changing either after release leaves
visitors' saved settings under the old name.
The scaffold writes no origin_model. Without it, the project is same origin: the client
and the web edge share one origin, the session cookie is first party, and the content
security policy and the upgrade origin check stay simple. This page assumes that setup.
The other value, split_origin, is still validated, but you add it by hand after reading
serving the client from another origin, which
measures its cost.
Where the framework reads and writes¶
The project layout is fixed, so there is no paths section. The framework always looks
at the paths below, and a synqt.yaml describes an application, never a directory scheme.
| Directory | Holds |
|---|---|
<type>/<entity>/ |
one directory per entity, inside the folder its type shares: client/, web/, db/relational/, db/document/, cache/, api/, jobs/, monitor/, service/ (see entities) |
build/<entity>/ |
what synqt build produces, one deployable directory per entity |
synqt/mesh/ |
the project's private CA and per entity certificates (synqt/mesh/dev/ for the throwaway development CA) |
synqt/toolchain/ |
the pinned Qt and Emscripten kits synqt provisions |
.synqt/design.json |
where the designer last left each entity on its canvas |
Generated C++ (the rep files, the repc output, the Source helpers) is a build artifact. It
goes into the CMake binary directory under synqt_generated/<target>/, never into the
project tree, so nothing needs regenerating by hand or committing.
.synqt/design.json is the only file here that describes nothing in the running system.
It holds an x and y per entity, where the designer left each node on
its canvas. Only the editor reads it. An entity it does not mention gets the default
layout: the browser on the left, and everything the browser must not reach on the right.
Deleting it loses only the arrangement. It stays tracked, because a team usually wants
the same diagram on every screen; add it to your own .gitignore if you prefer.
scopes (browser user permissions)¶
The app's user permission levels. Connect point gates and identity mapping both use this list, so it is declared once, here.
order lists the scopes from least to most privileged. With hierarchical: true (the
default), hasScope("user") succeeds for any scope at or above user in order. Set it
to false for set based scopes: no scope implies another, and a check succeeds only on
the exact name the session holds. default is the scope of a new, unauthenticated browser
session.
entities (the topology)¶
A block sequence with one entry per entity. It defines every entity and, through the
connect points each one owns and consumes, the whole mesh topology. Every entity has a
name and a type; the other keys depend on the type.
name is also the entity's directory, its QML module, and the name other entities use to
reach it. An entity sits under its name inside its type's folder, with no separate path
key, so name: edge on a web_edge puts its QML in web/edge/, its secrets in
web/edge/.env, and its build output in build/edge/. A client's window is always
client/<name>/Main.qml, so nothing declares an entry point either.
Every other entity's own file is <type>/<name>/<Name>.qml. It is written when the entity
is created, rooted at the type the entity exports, and serves as both the entity and its
exported surface. The connect point's server file defaults to it, and a shared entity
(the default) has one instance for the whole process.
State that must outlive any one caller goes in a pragma Shared file beside it, with any
name (the arena's World.qml). This matters with shared: false, where each caller gets
its own <Name>.qml, so state the callers share cannot live there. pragma Shared is
SynQt's spelling of QML's pragma Singleton: synqt build rewrites the line to
pragma Singleton in the copy under generated/ that the engine loads, in the same pass
that makes a self-named root loadable. The line itself marks the file, so adding one needs
no declaration. The file is created when the entity starts, not when its first caller
arrives, so a mesh signal subscription or a loop started there misses nothing.
A shared file belongs to the entity, not to a caller, so Caller is not in scope there and
synqt check reports it. An authorization line there would look like a rule and fail with
a ReferenceError. Put it in the Source, where callers arrive.
A client entity:
entities:
- name: app
type: client # QML client: browser (WebAssembly) and/or desktop, connect only
targets: [wasm] # [wasm] (default); add "desktop" for a native app
An application client does not name its edge: the edge's bundles: and the topology
already say which edge serves it, so a second spelling of the same fact could only
disagree. A monitor's console client writes edge: naming the monitor, which the designer
reads to draw the link.
A web edge entity, with sub sections for its public (internet facing) side, its mesh (service to service) side, the public TLS and its env file:
- name: edge
type: web_edge # serves a client bundle and faces the internet
identity: true # serve the login routes here (the default wherever
# the project declares an `identity` section; set it
# to false on an edge that must not sign anyone in)
# replicas: 4
# Run this edge as N interchangeable processes behind a load balancer. Default 1,
# which is every project that does not write this. Above 1, `synqt check` proves
# the edge holds nothing a second process would need: every connect point it owns
# must have `behind:`, identity must be promoted to its own entity, and the device
# store must be one every replica can read. See
# https://synqt.org/deploying/#8-running-more-than-one-edge
# threads: 4
# Spread this edge's accepted browser sockets across N IO threads, in one process.
# Default 1, which is every project that does not write this. Everything you wrote
# stays put: the Sources, the QML engine and the entity singleton stay on the main
# thread, so unlike `replicas:` there is nothing for `synqt check` to prove and no
# `behind:` requirement. See
# https://synqt.org/deploying/#running-one-edge-on-more-than-one-core
public: # the internet facing side (delivery + browser wss)
host: 0.0.0.0 # default: all interfaces; the only public bind in the system
port: 8443 # default
serve_client: true # serve the client bundle from this entity
# trusted_proxies: [10.0.0.1, 10.0.0.0/24]
# The peers whose `X-Forwarded-For` this edge believes, as addresses or CIDR
# ranges. Empty (the default) means the connecting peer IS the visitor, which is
# true of an edge facing the internet directly and false of every connection at
# once as soon as a proxy or balancer sits in front. A header from a peer not on
# this list is ignored, because otherwise
# the per-IP connection cap and rate limits become a bucket each client picks.
# origin: https://app.example.com
# The origin browsers reach this edge at, which is a different question from the
# bind above and has a different answer whenever a proxy, a load balancer or a
# published container port sits in front. Three things are built out of it and
# every one is matched whole: the OAuth redirect_uri the provider compares
# character for character, what `self` expands to in security.allowed_origins
# when the upgrade checks the browser's Origin header, and the sync endpoint the
# CSP names. Write the scheme, host and port a visitor types, and nothing after
# them. Absent, the edge derives it from the bind, and a wildcard bind derives to
# localhost, which is right for a development run and for nothing else. Required
# with serve_client: false, because the app is then delivered from somewhere else
# and cannot read its edge off its own page.
client_route: /
sync_route: /sync # the WebSocket upgrade path
# tls_terminated_upstream: true
# Set this instead of the tls block below when a reverse proxy in front of the
# edge terminates TLS and the edge listens on plaintext loopback. A release
# build insists on one of the two, and will not assume either.
mesh: # how other entities reach this one (service to service)
transport: mtls # mtls (the default on every link) or local (opt in)
host: 10.0.0.10 # private interface, not the public one
port: 9443
tls: # the public TLS for the browser
cert_file: certs/edge/fullchain.pem
key_file: certs/edge/privkey.pem
env:
file: web/edge/.env
serve_client: false hands delivery to a CDN. It goes with split_origin, so
serving the client from another origin
describes it.
A database entity. The embedded default needs no provider section, and the type's own
settings go under settings:
- name: store
type: relational # a database entity; see the entity types page
# provider defaults to sqlite (embedded); no provider section needed for the default
# not a web_edge: never serves a client, never faces the internet
mesh:
transport: mtls # the default: mutual TLS, bound to loopback on one host
host: 127.0.0.1 # same host as the edge in this example
port: 9444
# For a cross host database, keep transport: mtls with host/port on a private
# interface. transport: local (with socket: synqt/mesh/store.sock) swaps
# this link to a permission protected local socket, which is faster, but the calling
# entity is then trusted by colocation rather than authenticated by certificate. Opt
# in only on a host where every process running as this user is trusted (see
# security).
env:
file: db/relational/store/.env
settings: # type specific settings (see docs/entities.md)
file: db/relational/store/data/app.db
journal_mode: wal
busy_timeout_ms: 5000
To back the same entity with a third party engine, add a provider section naming the
engine and its connection. The connect points, consumers and mesh stay the same. This is
the upgrade path described in providers:
- name: store
type: relational
provider:
name: postgres # masked behind this entity; consumers never know
host: db.internal # private address, never public
port: 5432
database: app
user: app
password: env:DB_PASSWORD # entity .env only, never a client target, never logged
sslmode: verify-full # the entity verifies the engine certificate
ca_cert: certs/db-ca.pem
pool_size: 8
A plain service can be written in C++ instead of QML (see writing an entity in C++):
Notes:
nameidentifies the entity everywhere: its directory, its build target, the accessor other entities use (capitalized, sostorebecomesStore), and the subject of its mesh certificate. So it has a fixed shape: it starts with a letter, contains only letters, digits, underscores and hyphens, and has at most 64 characters.caanddocker-caare taken, in any case, because the mesh directory keeps the project's certificate authority under those names.synqt checkrefuses anything else, so a space or a dot cannot cause a build failure far from the line that caused it.typeis the one field that says what an entity is:client,web_edge, or one of the types on the entities page (relational,document,cache,api,jobs,service). It decides the entity's folder, the helper the runtime puts in its QML, and whether it faces the internet. Without it, the entity is aservice: no engine, no browser-facing side, reachable only over the mesh.cppmakes aserviceentity a C++ class, named after the entity and kept in<name>.hand<name>.cppin its folder. Every.hand.cppdirectly in the folder is compiled in, and aCMakeLists.txtthere joins the build.<Name>.qmlbecomes optional. Leave the key out for an entity written in QML.providerselects the engine behind a type that has one (see providers). Omit it to use the type's embedded default.provider.namepicks the engine, and the other keys carry the connection.transportchooses how a mesh link travels.mtls, the default for every link, runs QtRO over TLS with mutual authentication against the project CA, bound to loopback when both entities share a host, soCaller.entityis always certificate authenticated.localuses QLocalServer and QLocalSocket (protected by filesystem permissions, no network). It is an explicit opt in for colocated, equally trusted entities, because a local socket identifies the connecting user, not the connecting entity (see security). SynQt never chooses it for you.meshon an entity says how other entities reach it; write it when a service moves to its own host. Sethostandportonce and every connect point it owns follows. A connect point may overridetransport,host,portorsocketfor its own link, key by key, so one entity can own a loopback link and a cross host link at once. Anything neither sets falls back to loopback, on a port derived from the connect point's position in the sorted list, so a single host project needs nomeshblock. A link whose host is not this machine is a cross host link, governed bymesh.require_mtls_cross_host.- Addresses, not names.
mesh.host,public.hostandnetwork.inbound.bindare read into aQHostAddress, which holds an address and resolves nothing, sodb.internaland evenlocalhostbind and dial nothing. Write127.0.0.1for this machine and0.0.0.0for every interface.synqt checkrefuses a name here, for the same reason it refuses one intrusted_proxies: resolving would bake one of the name's addresses into the build, a different deployment from the one written down. A provider's ownhostis different: its driver resolves names. - A client has no mesh section. It never listens and never joins the mesh; it reaches
exactly one web edge over wss. Its
targetschoose how the QML is packaged:wasmfor the browser,desktopfor a native Windows, macOS or Linux build. Adesktoptarget adds abuild.desktopsection. See desktop clients.
network: what an entity may reach, and who may reach it¶
Any entity may have a network: block; none needs one. Without it (the default for every
type), the entity is closed: it makes no outbound call, serves no public surface, and only
the consumers its connect points list can reach it. Opening it is a deployment decision,
written next to those consumer lists because it is the same kind of decision.
entities:
- name: gateway
type: api
network:
# Where this entity may call. Http refuses anything not under one of these.
# A bare prefix is the short form; a named entry adds the headers to send and a
# handle to call it by (Http.api("github")).
outbound:
- https://api.stripe.com/v1/
- name: github
url: https://api.github.com/
headers:
accept: application/vnd.github+json
authorization: env:GITHUB_TOKEN
# The public HTTP surface it serves. Omit the whole block and it serves none.
inbound:
port: 8443
bind: 0.0.0.0 # default
tls:
cert_file: certs/gateway/fullchain.pem
key_file: certs/gateway/privkey.pem
api_keys: env:GATEWAY_API_KEYS # comma-separated, from this entity's .env
key_header: X-API-Key # default
allowed_origins: [] # browser callers; default none
max_body_bytes: 1048576 # default
rate_per_minute: 600 # per caller address; default
max_connections: 4096 # sockets open at once; default, 0 disables
max_connections_per_ip: 64 # from one address; default, off behind a proxy
# trusted_proxies: [10.0.0.1, 10.0.0.0/24]
# The peers whose `X-Forwarded-For` this surface believes. Empty (the default)
# means the peer that connected is the caller, which is true of a port reached
# directly and false of every request at once behind a proxy.
reply_timeout_ms: 15000 # default, and 0 means the default rather than no deadline
outbound is a list of URL prefixes. Declaring the key puts the Http helper in the
entity's QML scope, and the list says what Http allows. The two are separate:
outbound: [] gives the entity the helper but lets it reach nowhere, so a call is refused
with a message naming the key to add, where a missing key would have been a
ReferenceError on an absent helper. Prefixes match the normalized URL, so a path traversal
cannot escape them.
An entry may be a record instead of a string, with a name, a url and headers. The
runtime attaches the headers to every call under that prefix, so an API key reaches an
upstream without the entity's QML ever holding it. Write the key as an env: reference,
read from the entity's environment at startup; synqt check refuses a literal
credential. Http.api(name)
resolves the name, so a call site writes only a path and the base URL stays in the
configuration.
inbound opens a port and puts the Api helper in scope; the entity's singleton declares
its routes on it (see the gateway). Everything a
caller controls is checked before a handler runs, in this order: rate limit, API key,
origin, body size.
rate_per_minute counts per address, and trusted_proxies decides which address. With no
proxy named, it is the connecting peer: correct when callers connect directly, but once a
proxy sits in front, every request comes from the proxy and everybody shares one budget.
Naming the proxy makes the limit count the address the proxy put in X-Forwarded-For, so
each caller gets their own budget again. The header is read only from a listed peer, and only its rightmost entry that is not itself a listed hop,
because everything further left is whatever the client sent. synqt check refuses an entry
that is not an address or CIDR range, instead of dropping it silently at startup, which
would leave the surface counting the proxy as every caller.
Each surface has its own list. An edge's browser side reads public.trusted_proxies, an
API surface reads this one, and neither implies the other: they are two listeners on two
ports, and a deployment can put a balancer in front of one while the other stays internal.
An entity with both that configures only the browser list gets a warning, because that is
usually an oversight.
A handler reads the resolved address as
request.client, the same address the rate
limit counts.
max_body_bytes is enforced by the transport, not checked afterwards: a body over the
limit is refused while it arrives, so an oversized request never reaches memory. An idle
timeout also closes a caller that opens a socket, sends half a request and stops.
max_connections and max_connections_per_ip count at accept, before any request exists,
because neither the rate limit nor the body limit sees a caller that opens a socket and
sends nothing, or one byte every few seconds to stay under the idle timeout. A socket over
the limit gets 429 and is closed, and each released socket admits the next. When
trusted_proxies names a proxy, the per-address limit is off: every socket then belongs to
the proxy, and a limit on it would refuse the whole API at the sixty-fifth caller. The
total limit still applies. Zero disables either.
A handler may answer later, as any handler that calls a connect point or an upstream does.
The connection stays open until reply_timeout_ms; after that the request fails with 504
and the failure is reported, so a handler that never answers costs a status code, not a
socket. 0 falls back to the default, with a one-time notice, because an unbounded wait
would let a silent handler hold its request and connection for the life of the process.
Validation of the block:
api_keysis required and must be anenv:reference. A surface without keys is refused unless it also sayspublic: true, because a forgotten line is how an internal API ends up answering the internet. A key written insynqt.yamlis refused too: it is a secret in a committed file.portis required, because a public surface must name its port.- Missing TLS is a warning, and the warning says what it costs: an API key travels in a
header, so anyone on the path can read it. Write
tls_terminated_upstream: truewhen a proxy in front terminates TLS, and the warning goes away. - An
http://prefix inoutboundis a warning, because the runtime refuses plaintext outbound calls in a release build: the call works in development and fails once you ship. - A client may declare neither half. A browser calls only its own edge and cannot listen.
- A web edge may not declare
inbound. It already serves the public through itspublic:andtls:blocks, and a second listener would be a second policy to keep in sync. - Every
trusted_proxiesentry must be an address or CIDR range. A host name is refused, not resolved: the runtime reads the list as addresses, so it would drop the name and count the proxy as every caller.
An entity with inbound links Qt HTTP Server, which is GPLv3 only, so its artifact is
GPLv3, as its generated THIRD-PARTY-LICENSES states. An outbound only entity does not
link it and stays LGPLv3. See licensing.
bundles: which scope is served which client¶
A web edge serves only the bundle that the caller's session scope maps to. The block goes on the edge entity, because delivery is the edge's job:
entities:
- name: edge
type: web_edge
bundles:
anonymous: landing/ # a directory under web/edge/
user: app # a client entity
moderator: app
A value containing / is a directory, relative to the edge entity's folder. A bare name is
a client entity. synqt check refuses anything that could be
read both ways instead of guessing.
Without a bundles: block, the project's one client goes to everybody.
Two consequences, the first being the reason the key exists:
- An under-scoped visitor never receives the file, not just a page they cannot navigate
to. A route
scope:only guards navigation, as the programming model says, so a privileged view in a shared bundle still ships to every visitor. A bundle boundary does not. - A file outside the caller's bundle gets
404, not403, so a private deployment does not confirm that an operator console exists.
When scopes.hierarchical is true (the default), a scope without its own bundle gets the
nearest one below it, so a project declares two bundles, not one per scope. Set-based
scopes have no "below", so an unmapped scope gets the default scope's bundle.
The edge picks the bundle when the page loads. If a scope change makes another bundle apply, the next full page load picks it up; nothing swaps a WebAssembly module under a running app.
A static bundle is any directory with an index.html: a landing page with a sign-in
button, a site generator's output, or a single form. It needs no build. A client entity is
a full SynQt client: the edge gives every visitor an anonymous session, so a client can
consume anonymous-scope connect points, and a landing page can show live public data
instead of a static poster. It needs a WebAssembly build.
With several client entities, each gets its own QML module and bundle directory
(build/client-<name>/). A project with one keeps build/client/.
connect_points (ownership and consumers)¶
A block sequence with one entry per connect point. An entity has at most one: the surface it exports, with exactly one owner and a list of consumers. The entry has no name of its own, because the owner names it.
connect_points:
- owner: edge # the entity holding the authoritative Source
consumers: [app] # the entities allowed to acquire the Replica
server: web/edge/Edge.qml
scope: user # for browser consumers: minimum session scope
export: | # what may cross it, and nothing else does
prop int count
model items(string[280] text, string[80] author, bool done)
slot add(string[280] text)
signal rejected(string[120] reason)
- owner: store
consumers: [edge] # only the edge may reach the store
server: db/relational/store/Store.qml
export: |
slot var list()
slot insert(string[280] text, string[64] ownerSub)
export is the shape of what crosses: a block of prop/model/slot/signal lines
(and any record they use), or just the name of a member the owner already implements.
The programming model gives
the full member grammar, the types a member can name, and what a bare name resolves to.
synqt check compares every line with the owner's Source, and a member the Source does not
implement is an error.
The point and its contract both take their owner's name, capitalized: owner: edge exports Edge, the QML type the owner's Source is rooted at.
That is also web/edge/Edge.qml, the edge's own file, because an entity and its exported
surface are one thing. The build writes the contract to
generated/<owner's folder>/Edge.syn, which nobody edits.
A second entry for the same owner is refused. Per-member <scope> serves two audiences on
one point; two truly separate surfaces are two entities.
server and scope are optional. server defaults to the type's .qml in the owner's
folder, so both lines above could be omitted; they show where the file goes.
Without scope, any session, anonymous included, may acquire the connect point. The slots
then enforce write protection, as in the examples.
A scope on the point is also the default for every member of its export:, and a member
may raise it with its own <scope> prefix: <admin> slot restock(string[64] sku, int
count). The point's scope decides who acquires it at all. A member's scope decides whether
anything about that member crosses to a caller who did. See
gating one member.
The number of Sources a point creates follows from shared: on the owning entity. Shared (the default) means one Source that everybody reaches through their
own mirror; shared: false means one Source per caller. See
the programming model.
Every caller reaches a Source of its own (a mirror, on a shared entity), because a Source
answering everybody directly could not know who was calling, and its slots would have no
Caller. State every caller shares belongs in the
owner entity's
singleton,
which outlives all callers. A Source is live state either way, and a per-caller Source
disappears when its caller closes their last link, so anything that must survive goes in
the singleton or behind a persistence connect point.
Validation derives the mesh links from owner and consumers. An entity may connect only
to an owner it consumes from, and an owner accepts connections only from listed consumers.
This is the deny by default topology.
security (browser hardening and connection gating)¶
The browser facing security policy: cross origin isolation, the content security policy, the upgrade origin allowlist, how the session credential travels, and the resource limits on the upgrade path. The defaults are safe; loosen them only for a clear reason.
security:
# Cross origin isolation. Required only for the multi threaded client.
cross_origin_isolation: false # COOP same-origin + COEP require-corp when true
# Content Security Policy for the served page. The edge computes the final header
# from this value. It appends the sync endpoint's explicit wss:// origin to
# connect-src (browsers differ on whether 'self' covers WebSocket schemes), and
# adds "worker-src 'self' blob:" when cross_origin_isolation is on ('self' covers the
# pinned kit's pthread workers and the shell cache's service worker, and blob: is a
# kept margin for a future toolchain, which the multi threaded proof checks by
# serving the bundle without it on every run, see docs/csp.md).
csp: >-
default-src 'self'; connect-src 'self'; img-src 'self' data:;
style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'
# Allowed Origin values for the browser wss upgrade (CSWSH protection).
# "self" expands to the web edge origin. With origin_model: split_origin you
# must add the client origin explicitly here.
allowed_origins: [self]
# How the browser presents its session credential at the wss upgrade.
# "cookie" is the httpOnly session cookie, and the only value this framework
# accepts. A subprotocol token cannot be built on Qt 6.12 (see below), so
# `synqt check` refuses the word rather than letting an edge accept it and keep
# reading the cookie anyway.
session_transport: cookie
handshake_timeout_ms: 10000
max_connections_per_ip: 20
max_connections_global: 1000
# Reject oversized frames (DoS guard). Also sets how much one connection may hold
# unread. The edge caps each browser socket's read buffer at four times this, and
# closes a connection that goes past it.
max_message_bytes: 1048576
# How many sessions the edge holds at once. A page load with no live cookie mints
# one, so this bounds the one table a stranger can grow. At the ceiling the oldest
# anonymous session nobody is connected on is let go of before anyone is refused.
# Zero removes the ceiling.
max_sessions: 100000
# The three below are Qt's own limits on the HTTP request, which the edge sets
# rather than leaving at the values Qt picked for a general-purpose server.
# How long a connection may sit idle before QHttpServer closes it.
keep_alive_timeout_s: 15
# Requests per second per peer. Zero, the default, leaves Qt's rate limiting off.
max_requests_per_second: 0
# The largest body the edge will read, answered with 413 past it. Left out, it is
# derived from what the entity accepts (see below).
# max_body_bytes: 65536
synqt build passes to the edge only the keys the project writes. Everything else keeps
the safe framework default. Limits are whole numbers, and zero is refused, not read as "no
limit" (the limits compare with >=, so zero would refuse the first connection). The
exception is max_requests_per_second, where zero means "off".
handshake_timeout_ms is how long an accepted socket may stay silent. The peer's first
byte cancels it, so it closes a connection that arrives and says nothing, never a transfer
in progress. keep_alive_timeout_s then closes a peer that sends part of a request and
stops. See denial of service and resource
limits.
When omitted, max_body_bytes is derived from what the entity accepts. An edge without
network.inbound has only its own routes, which carry a session token and a password
field, and gets 64 KiB. An edge with network.inbound gets that block's limit
(network.inbound.max_body_bytes, 1 MiB by default). This matters because the API checks
its own limit only after QHttpServer has read the body: at Qt's 32 MiB default, the edge
would buffer 32 MiB from a stranger only to refuse it for exceeding 1 MiB.
max_requests_per_second is off by default, and synqt check refuses it on an edge that
names public.trusted_proxies. Qt counts the connected address and ignores
X-Forwarded-For, so behind a balancer every visitor shares one budget, and the limit
throttles the whole site instead of the flood. Rate-limit at the balancer instead. The
edge's own max_connections_per_ip does not have this problem, because it counts the
address public.trusted_proxies resolves.
Under origin_model: split_origin, you list the client origin here yourself, and the edge
issues the session cookie as SameSite=None; Secure, derived from origin_model so no
second key can disagree. In both models, the origin check is the defense against
connection hijacking. See serving the client from another
origin.
synqt check refuses session_transport: subprotocol because of a Qt limit. Carrying the
session in Sec-WebSocket-Protocol requires the server to select one offered subprotocol
and echo it in the 101 response, and Qt 6.12 gives the edge no way to do that:
QHttpServerWebSocketUpgradeResponse::accept() takes no arguments, and the
QWebSocketServer that writes the response is private to QAbstractHttpServer, so
setSupportedSubprotocols() is out of reach. The upgrade completes with nothing
negotiated, and browsers disagree on what that means. Measured on 2026-07-28 against a real
edge, Chromium 149 closes the connection (code 1006, Sent non-empty
'Sec-WebSocket-Protocol' header but no response was received) while Firefox 151 opens it.
An edge that works in one engine and not the other is worse than a clear refusal. A test
keeps the Qt half of that measurement
(tests/webedge), and it
fails the day a Qt release makes this transport possible.
Both clients manage without it: a browser holds the httpOnly cookie, and a native desktop client, which terminates its own TLS, presents its stored session directly on the handshake.
Serving the client from another origin (deprecated)¶
This section is the exception. Everything above assumes the client and the web edge share an origin, which is the default. This section covers putting the client on a separate origin, usually a CDN.
split_origin is deprecated. It still builds, synqt check still validates it, and an
existing project keeps working in the browsers it works in today. synqt check warns
about it, because the mode depends on a third party cookie, and browsers are phasing
those out. Read the cost below, then what to do instead: new
projects should start there, and existing ones can move there without the client
noticing.
Two keys, written by hand, turn it on:
project:
origin_model: split_origin # the session cookie becomes a third party cookie
entities:
- name: edge
type: web_edge
public:
serve_client: false # a CDN delivers the bundle; the edge serves no files
origin: https://app.example.com
security:
allowed_origins: ["https://cdn.example.com"]
The edge then serves no bundle, and keeps client_route as a credential endpoint. It
answers a credentialed cross origin fetch with 204 and the session cookie, echoing the
requesting origin (only one listed in allowed_origins), never a wildcard. The generated
boot script makes that request before the app connects, and publishes public.origin to
the page; the client dials that instead of its own location. synqt check requires all
three keys above, because without any one of them the app loads perfectly and never
connects.
What it costs¶
The session cookie is a third party cookie, so browser policy decides whether it works.
Measured on 2026-07-28 in Chromium 149 and Firefox 151, and on 2026-07-31 in WebKit 26.5
on Linux and macOS runners, across two real sites over TLS (the rig and the full table
are in tests/split-origin):
| regime | what happens |
|---|---|
| Chromium and Firefox today | works. The bundle loads, the session mints, the wss upgrade carries it, and login works |
| WebKit, which is Safari's engine, today | nothing works. The session request comes back unreadable and the upgrade carries no credential, with or without Partitioned |
| third party cookies restricted | nothing works. The session request is ignored, the upgrade arrives with no credential, and the edge refuses it |
In the last two rows, the app appears on screen and stays disconnected for good, with no gradual degradation. The middle row is a shipping browser today, and the others show where browsers are heading.
The obvious fix fails too. Marking the cookie Partitioned (CHIPS) is the
standard way to keep a third party cookie alive, and it rescues the session bootstrap and
the upgrade under restriction. But it breaks login everywhere, even in browsers where the
plain cookie still works: the OAuth callback is a top level navigation to the edge, so the
cookie lands in the edge's own partition, which the client origin can never read. The
measurement shows the stored partition key, which is why the edge does not set the
attribute.
A fix for the login half exists and needs nothing from Qt: the callback could return the
session through the client. The edge would redirect to the client origin with a one time
code, and the page would exchange it there, so the cookie lands in the client's partition
and login works. But that fixes one engine. In the same measurement, Firefox stored the
Partitioned cookie with no partition key, meaning it ignored CHIPS, so under restriction
the mode still fails there whatever the callback does. WebKit, measured since, never reads
the cookie back from the client site, with or without the attribute. A redesign would fix
Chromium alone, so the mode is deprecated instead.
What to do instead¶
Keep the client and the edge on one origin, and put a node near the user that serves both.
A node that delivers the bundle and terminates the browser link on the same hostname acts
as a CDN for the browser, with no third party cookie. The session is first party again,
and none of the above applies. Whether that node owns its connect points or forwards them
to an edge behind it is an operational choice the client never sees: it reaches
everything through Server either way.
That is why split_origin is deprecated and left out of the scaffold. Several edges under
one origin (replicas), behind whatever
forwarder or CDN node the deployment already has, give what split origin was for without
a third party cookie in the critical path. split_origin still runs, but you carry the
browser policy risk.
mesh (service to service security)¶
The TLS policy for the whole mesh: the release build guarantee that links across hosts
use mutual TLS. Every entity verifies its peers against the project CA at
synqt/mesh/ca.crt, and holds its own certificate and key as synqt/mesh/<entity>.crt and
synqt/mesh/<entity>.key, which synqt mesh issues.
Certificate lifetime is fixed. Entity certificates last 398 days and the CA
twice that, and synqt mesh status warns 30 days before one expires. The 398 comes from
Apple's verifier, which refuses any TLS leaf issued after 2020-09-01 that is valid for
more than 398 days, whatever it chains to, so a macOS host can reject a longer lifetime
outright. A setting would only allow certificates that do not work.
The mesh CA private key stays where certificates are issued (a developer machine or a CI
secret store): never in a running entity, never committed. A running entity holds only its
own certificate and key, plus the CA certificate to verify peers. synqt dev keeps a
separate, throwaway development CA under synqt/mesh/dev/, created automatically so
development mesh links use mutual TLS with no setup. It is never valid for a release
build.
monitoring (optional operations record)¶
Without this block, the project has no monitor, and nothing is recorded or stored.
synqt add entity ops --type monitor adds one: it writes the entity, its console client,
the sign-in gate and this block:
monitoring:
entity: ops # the type: monitor entity every service reports to
levels: # optional, the lowest severity each category records
call: debug # lifecycle, transport, authorization, call, data,
data: off # application, and a level of `off` records nothing
capture_identity: acknowledged # optional, allows `capture` on a member carrying an
# identity field, which `synqt check` otherwise refuses
public: acknowledged # optional, allows the monitor to bind a non-loopback
# host, which `synqt check` otherwise refuses
entity is all the wiring. The link from every service to the monitor is derived from it,
not written: every entity needs that link, so nobody should have to remember to declare
it, and one forgotten declaration would leave a hole in the record exactly where the
misbehaving entity was. It is an ordinary mesh link, mutually authenticated and validated
by synqt check like any other.
Because it is a single line, it is also easy to forget, so synqt check warns about any
type: monitor entity this key does not name. Such a monitor builds, starts, hosts its
ingest point and serves its console, but its history stays empty because nothing ever
connects to it, which looks like a system where nothing happens. A second monitor beside a
wired one is reported the same way.
levels is read from the resolved topology at startup, so raising a category's level
needs a configuration change and a restart, not a rebuild.
Retention, the console's port and the exporters are settings on the monitor entity. See monitoring.
identity (optional login)¶
Without this section, the app has no login, and every browser session runs at
scopes.default. synqt add auth <provider> writes the section with hardened defaults.
Authentication covers it in full.
providers is a block sequence (one entry per OAuth provider); session and mapping are
nested maps:
identity:
required: false # if true, an unauthenticated browser acquires
# no scoped connect point at all
provider_entity: "" # empty means identity handled in process at the edge (default)
# or an entity name, and a dedicated auth entity owns identity
flow: authorization_code # server side OAuth2 with PKCE, and the only flow
# SynQt implements, anything else is refused
callback: /auth/callback
login: /auth/login
logout: /auth/logout
providers:
- name: github
authorize_url: https://github.com/login/oauth/authorize
token_url: https://github.com/login/oauth/access_token
userinfo_url: https://api.github.com/user
client_id: your-client-id
client_secret: env:GITHUB_CLIENT_SECRET # resolved from the edge .env only
scopes: [read:user, user:email]
session:
cookie_name: synqt_session
ttl_minutes: 720
refresh:
interval_seconds: 60 # how often to look for access tokens near expiry
margin_seconds: 120 # how far ahead of expiry to renew one
mapping:
hook: web/edge/identity/map.qml # optional QML returning a scope for an identity
dev_stub: # the development sign-in, `synqt dev` only
port: 8789 # loopback, and not a port another entity serves on
users: # identities rather than scopes, the mapping hook decides those
- { sub: dev, login: dev, name: Developer, email: dev@localhost }
desktop_session: memory # or `device`, and a native client stays signed in between
# launches, through the OS secure store
device: # read only under `desktop_session: device`
store: # a provider block, exactly like an entity's
name: sqlite
file: .synqt/devices.db
lifetime_days: 30
inactivity_days: 14
overlap_seconds: 120
min_binding: user # user | application, and `hardware` is reserved and refused
# until a store reports it (see desktop.md)
A github or google provider needs only a name, a client_id and a client_secret.
SynQt fills in the endpoints, scopes and field mapping synqt add auth would have written,
beneath whatever the project sets. Any other provider needs its endpoints written, because
SynQt has no defaults for it.
mapping accepts either the nested hook: above or the file directly
(mapping: web/edge/identity/map.qml). Both name the same QML.
dev_stub turns on the development sign-in,
a provider inside the edge on loopback, so you can try a scope-gated route before
registering an OAuth app. Both keys are optional (dev_stub: true takes the defaults), and
the framework writes the provider entry, not the project. Every part of the login except
the provider is the code that ships. A users entry names an identity, not a scope, so the
mapping hook decides each one's scope. synqt check refuses a users entry without a
sub (the mapping hook keys on it, so the entry would get the default scope and look
broken), a field the identity object lacks, and a port another entity already uses.
Three gates keep it out of a built deployment. synqt check --release reports that a
project has one, without refusing it.
refresh schedules the server side access token renewal described in
authentication. The values above are the defaults,
suited to a provider issuing hour long tokens. A provider with short lived tokens needs a
wider margin_seconds, and a zero or negative interval_seconds turns the sweep off. The
entity holding the tokens reads these keys: normally the edge, or the auth entity when
provider_entity is set.
provider_entity moves identity to its own entity, and that one line is the whole change.
The entity then owns an identity and a sessions connect point, every web edge that
serves login consumes both over the mesh, and synqt build writes the two links, the
Source QML for each, and the entity's main.cpp. You declare and write nothing else, so
one line replaces a rewrite. Declaring your own connect point named identity or
sessions is refused, because a move wired halfway around a name collision would look like
it worked. The named entity must exist and must be a separate service. Naming the web edge
is refused, because leaving the key empty already means that. Naming the client is
refused, because the client holds no secret and no mesh certificate.
The edge then receives only provider names: no client id, provider endpoint, secret or token. It handles the browser facing half (the login and callback routes, the session cookie) and asks the auth entity for every step that needs a secret. See where identity runs.
desktop_session is the only key here that stores something on a visitor's disk, so it is
opt in. Under device, a native client keeps a rotating, single-use device credential in
the OS secure store and spends it at the next launch for a fresh session. The session keeps
its own lifetime. store is an ordinary provider block (the same keys as an
entity's provider:, env: references included), and a second edge must be able to reach
it if the deployment ever runs two. Desktop clients
covers what each platform binds the credential to and why there is no file fallback.
synqt check refuses device without a store, device when no client entity lists the
desktop target, and min_binding: hardware: each produces a build where nobody ever
stays signed in, with no explanation. Every store SynQt ships stops below the hardware
level, so requiring it excludes every machine. A floor of application gets a warning instead,
because whether a machine reaches it depends on that machine, and the edge decides at
enrolment.
Two behaviors are fixed, because they are mandatory, and a key that could
contradict them would only allow mistakes. The session cookie's SameSite follows
project.origin_model (Lax for same_origin, None; Secure for
split_origin), and the session id always rotates on a privilege change.
The client secret is a variable name, never a value. The edge reads it from its
environment at startup, so it is in neither synqt.yaml nor the binary. Names resolve from
the entity's env file (web/edge/.env), then the project .env, and neither overrides a
variable the real environment already set, so a container or secret store always wins
over a file. A deployment that sets its variables directly needs no file.
privacy (what a visitor is told about their data)¶
Optional. It feeds three QML types, LegalFooter, CookieConsent and
DataErasureRequest, and the Privacy accessor behind them. Every value is public
information a visitor has a right to under Articles 13 and 14 of the GDPR, so all of it is
safe in a client served to anyone. See privacy and the GDPR.
privacy:
policy: /privacy # a route in this app, or an absolute URL
legal_notice: /legal # the imprint most member states require
contact: privacy@example.com # the controller contact
retention_days: 365 # how long this project keeps personal data
cookies: [] # non-essential cookie categories
erasure: true # offer a signed-in visitor an Article 17 request
| key | default | meaning |
|---|---|---|
policy |
none | Where the privacy policy is. LegalFooter leaves the link out when unset rather than pointing at a page that does not exist. |
legal_notice |
none | Where the legal notice is, on the same terms. |
contact |
none | The controller contact a visitor writes to. |
retention_days |
730 |
How long the project keeps personal data. The default fills a gap for a project that never named a period; a project that names one keeps what it named, shorter or longer. |
cookies |
[] |
The non-essential cookie categories this project sets. Empty means no consent banner, which is correct for a project whose only cookie is the session credential, since that one is strictly necessary and exempt under Article 5(3) of the ePrivacy Directive. |
erasure |
false |
Whether the client offers a signed-in visitor an erasure request. DataErasureRequest hands the request to a slot the app connects, so this is the project saying somebody acts on it. Turning it on without an identity: block is refused, because nobody there is ever signed in. |
router and routes (client navigation)¶
router holds the navigation mode, the fallback, the path prefix the app is served under,
and the remote-page palette. routes is a block sequence mapping paths to pages,
optionally scope gated. Both are top-level keys in synqt.yaml, beside project and
entities, not nested under a client block. Together they form the route table the
client's Router resolves every URL against.
Routes and URLs explains that resolution end to end.
router:
mode: history # the only mode: the router drives the browser History API
fallback: / # where a refused or unmatched path lands
base: / # the path prefix the app is served under
routes:
- path: /
view: Home.qml
- path: /c/:campaign # a path parameter, read in QML as Router.params.campaign
view: Campaign.qml
- path: /c/summary # more literal segments, so this one wins over /c/:campaign
view: Summary.qml
- path: /admin
view: Admin.qml
scope: admin # below this scope, the router redirects to fallback
- path: /tour
view: Tour.qml
graphics: accelerated # hidden behind a notice when the browser has no WebGL
router keys:
| Key | Default | Meaning |
|---|---|---|
mode |
history |
The only mode. The router drives the browser's History API, so every route is a real URL a visitor can bookmark, share, and refresh, and the web edge serves the application shell for any path it does not answer itself. |
fallback |
/ |
Where a navigation goes when the path matches no route, or matches a route whose scope the session lacks. It must itself be a declared route. |
base |
/ |
The path prefix the app is served under. An app deployed at /shop sets base: /shop, and everything else in the table stays in application paths: a route is still /c/:campaign, Router.path still reads /c/summer-sale, and only the address bar carries the prefix. A trailing slash is ignored. |
palette |
(none) | The list of QML modules a remote page may import, and the whole of what one may import. Required, and non-empty, once any route declares a remote:, and ignored when none does. It is a trust boundary. A delivered page that imports a module the palette does not list is refused rather than rendered. Example: palette: [QtQuick, QtQuick.Layouts]. |
routes keys, per entry:
| Key | Required | Meaning |
|---|---|---|
path |
yes | The route's path, absolute. Each segment is either a literal or a :name parameter that captures whatever is in that position. A parameter name starts with a letter or an underscore and continues with letters, digits, or underscores, and no name repeats within one path. Captured values are percent-decoded and arrive as Router.params. |
view |
one of view/remote |
The QML file compiled into the client bundle. Write it relative to the client entity's directory (Home.qml rather than client/app/Home.qml, and views/Home.qml for one in a subdirectory), with or without the .qml extension. synqt build compiles it into the client's QML module at that same relative path and the router loads it from there, so a view needs nothing beyond the file being there. Mutually exclusive with remote. |
remote |
one of view/remote |
The QML file the web edge delivers on demand, instead of compiling it in. Write it relative to the edge entity's pages/ directory (Campaign.qml names <edge>/pages/Campaign.qml). The edge sends it over the same authenticated wss link at navigation time, so it never enters the bundle and changes without a client rebuild. Mutually exclusive with view. See remote pages. |
seed |
no | The page seed hook the edge runs, after this route's scope check, to build the data a delivered page paints with on its first frame. Written project-root-relative (like identity.mapping), because a hook is edge code rather than a delivered page, as in seed: web/edge/campaign-seed.qml. Applies only to a remote: route. A seed: on a compiled-in route is refused, because it would never run. |
scope |
no | The scope a session must hold to reach this route. Omitted, the route is open to everyone, anonymous sessions included. On a remote: route the edge enforces it before delivery, so an under-scoped fetch is refused with no markup, no hash, and no seed. |
graphics |
no | accelerated or software. Whether this route needs a GPU-backed scene graph. Omitted, synqt build reads the route's QML and decides; write it to overrule that. See below. |
Every QML file under the client entity's directory goes into the client's QML module
automatically: Main.qml, the views the routes name, and everything those views use. A
Home.qml that instantiates a sibling Card.qml, or reads a Theme.qml declaring
pragma Shared, needs no declaration. The pragma line registers a shared file as a
singleton. Build output and vendored trees are excluded: build/, generated/,
CMakeFiles/, node_modules/, and any file or directory whose name starts with a dot.
Two QML files under the entity cannot share a base name, even in different directories. Qt
names a QML type after its file, so pages/Header.qml and widgets/Header.qml would both
register as Header in the same module, and one would silently hide the other.
synqt build refuses this and names both files; rename one.
synqt check refuses a route whose view is missing, naming the route and the file it
looked for. It also refuses a view outside the client entity's directory (an absolute
path, a ../ path, or a Windows drive path). Both synqt check and the generator refuse
a route with neither a view nor a remote, since it has nothing to show. A view needs no
entry in generated/synqt.cmake, which every build rewrites from synqt.yaml.
graphics: which routes need an accelerated scene graph¶
Qt Quick draws through the GPU pipeline the browser exposes as WebGL, and some visitors lack it, because a policy disables it or the browser blocks their driver. The client checks before it starts and falls back to Qt's raster adaptation, which handles ordinary 2D Qt Quick completely. This needs no configuration.
Three features need the accelerated pipeline and draw nothing on the raster adaptation: Qt Quick 3D, ShaderEffect and
Qt Quick Effects. A route using any
of them shows a notice explaining this instead of an empty area.
synqt build finds those routes by reading each route's QML, and reports its conclusion:
warn: routes: /tour needs the accelerated pipeline, so it is hidden on a client with
none. Write graphics: accelerated on this route to make that explicit, or
graphics: software to show it anyway
Write graphics: yourself to override it either way. The written value always wins, and
synqt build reports any disagreement:
- path: /gallery
view: Gallery.qml
graphics: accelerated # a Loader pulls in a 3D scene, which no scan can see
- path: /report
view: Report.qml
graphics: software # the scan is wrong about this one; show it anyway
The scan reads imports and type names, so it sees what a page declares, not what it loads at run time. Content it misses still gets an explanation: the client watches for Qt refusing to draw something and shows the same notice over the page, leaving everything that did render in place. The visitor learns a moment later than for a page the scan caught, but still learns.
client.graphics_notice replaces the built-in notice with your own QML file, relative to
the client entity's directory. It appears as the whole page for a refused route and over
the page otherwise, so write it to work in both positions.
Edge-delivered pages (remote:)¶
A remote: route stays out of the compiled client. Its file lives in the web edge entity's
pages/ directory: for an edge named edge, remote: Campaign.qml means
web/edge/pages/Campaign.qml. The edge holds these files and sends one over the same
authenticated wss link when a visitor navigates to its route, so a delivered page never
enters the bundle, and you can add or change it without rebuilding the client.
Remote pages covers the full feature: the palette trust boundary, the
page seed, and what a page's scope protects and what it does not.
remote: routes can change after the build, unlike view: routes. The edge sends the
connected client its route table, so the client learns from the edge which paths it
delivers, and a new remote: route works without a client rebuild. When the two tables
merge, the compiled-in one wins: a path the bundle declares as a view: stays, even if
the edge announces a remote: at the same path, so the edge can never hide a compiled-in
page.
An app with no routes has no route table, Router.pageComponent is null, and nothing
changes. Routing is opt in, and Main.qml alone is a complete client. An app that routes
puts one Loader on Router.pageComponent in Main.qml (see
rendering the current page) and keeps its
screens in the view files the table names, so Main.qml is the window, never a route's
view.
Three rules decide what a path resolves to:
- More literal segments win.
/c/summarybeats/c/:campaignin any order. Declaration order never decides a match, so moving a route in the file cannot change what an existing link does. - Empty segments do not count.
/c,/c/and/c//are the same route. Declaring two of them is an error, since only the first could ever match. - The query string is not part of the path. It is split off before matching and arrives
as
Router.query, so/searchand/search?q=hatare the same route.
A route guard only redirects. Every view's QML ships to every visitor. The data behind a privileged view is protected by the scope-gated connect point, which the edge refuses to an under-scoped session. See route guards.
Development settings live on the command line¶
synqt dev is a command, not a deployment, so development options go on its command line
instead of in a dev section:
| Flag | Default | Effect |
|---|---|---|
--port N |
8080 |
the port the dev edge serves the bundle and the sync endpoint on, at http://127.0.0.1:N/ |
--no-open |
opens a browser | do not open a browser tab |
--no-watch |
watches | serve once instead of watching sources and rebuilding on every edit |
--desktop |
browser | run the client as a native window against the same dev edge |
--identity-picker |
the project's own sign-in | replace every sign-in with one page listing the project's scopes, so a scope can be held without a provider (the scope picker) |
--profile NAME |
none | layer synqt.NAME.yaml over synqt.yaml, which is where a per developer override belongs |
Two things about a development run are fixed. The browser link uses plaintext, because
it runs on loopback and a self-signed certificate there teaches the wrong habit. Mesh links
keep mutual TLS, with a throwaway development CA that synqt dev creates (see
mesh), because developing without the deployment's
security means discovering it in production.
build¶
How each entity is compiled. Every key here shapes the WebAssembly client bundle; native entity binaries take their settings from the CMake build type alone.
build:
client_threads: single # single (default) or multi
client_asyncify: false # default; see below before turning it on
client_logging: console # console | qt | none (see below, the default follows the build type)
client_cache: service_worker # service_worker (default) | http (see below)
loading: # the page shown while the client loads (see below)
title: "Acme"
desktop: # the native desktop client target (see below)
edge_url: wss://app.example.com/sync
client_threads: multi implies security.cross_origin_isolation: true, and the build
checks it.
client_logging decides where the client's diagnostic output goes. In a release
WebAssembly build, Qt's default message handler does not reach the browser console, so
console.log (and qDebug) output silently disappears. The modes:
consoleroutes every message to the browser console, which makesconsole.logwork in WASM;qtkeeps Qt's default handler;nonedrops debug and info and keeps warnings and above, so no debug output ships to users.
Unset, the client uses console in a debug build and none in a release build, so logging
works in synqt dev and disappears from the shipped bundle.
client_asyncify (default false, not written in a scaffolded project) links the
WebAssembly client with Emscripten's asyncify. Most projects should leave it off: it adds
roughly a third to the transferred bundle and instruments every call that can suspend.
It changes the platform under your code. Qt's WebAssembly event dispatcher has two modes
and picks one at run time by probing the Emscripten runtime, so this is a link flag on your
client, with no change to the Qt kit. Without asyncify, the main thread cannot block:
exec() returns control to the browser, and the queue behind deleteLater() and every
Qt::QueuedConnection drains only when one zero-delay browser callback fires. If that
callback is lost, nothing re-arms it, and the queue stays stuck for the life of the page
while timers, sockets and property updates keep working. With asyncify, the main thread
suspends inside processEvents(), and any browser event resumes it and drains the queue,
so nothing depends on a single callback. Asyncify also lets QEventLoop::exec() run on the
main thread, which otherwise calls qFatal().
SynQt's own code runs without it. The framework resolves a returning slot's reply from the call's own
state, not from a queued signal, and defers object deletion with a timer, not a posted
event, so none of its code depends on that callback. Turn it on if your own client C++
queues connections on that path and you prefer the larger bundle to auditing them.
tests/transport-spike/FIREFOX-LINUX.md
has the measurement in both engines.
check¶
Checks synqt check runs on top of its standard validation.
qml_format only reports, never rewrites, and only as a warning: formatting is not
correctness, and people stop reading a check that fails on cosmetics. It needs the
project's .qmlformat.ini (written by synqt new), and is skipped with a note when the
file is missing, because qmlformat would otherwise use a per user settings file and give
different answers on every machine. Turn it off if you format your QML by hand for
readability: qmlformat reflows expressions, and no setting prevents it.
build.loading¶
The page a visitor sees while the client downloads and compiles. The client is large, so this page is the app's first impression. By default, it shows the SynQt mark on the SynQt gradient, with a progress bar that follows the real download.
build:
loading:
logo: assets/acme.svg # inlined into the page, the SynQt mark by default
background: "#101018" # any CSS background value, the SynQt gradient by default
title: "Acme" # the browser tab title while loading
The logo and styling are inlined into index.html, not linked, so the loading page needs
no extra request and paints immediately. The logo is inlined as markup, so it must be an
SVG.
background applies to the document as well as the loading overlay. The overlay hides as
soon as Qt reports the module loaded, a frame or two before the first QML paint. Without a
document background, the browser's default white flashes in that gap.
For a page the keys cannot express, hand over the whole document:
html replaces the generated page, so it cannot be combined with the other keys;
synqt check rejects the combination instead of silently ignoring them. A replacement page
must keep the boot script's contract: elements with the ids synqt-loading (the overlay,
hidden once the app starts), synqt-bar (the progress bar, whose width is set as a
percentage), synqt-status (the status text) and screen (the app's container), plus a
synqt-boot.js script. synqt check verifies all of that, and that the files named by
logo and html exist.
build.client_cache¶
How a returning visitor gets the client. The client is large, so this decides between an instant load and a full download.
service_worker precaches the shell and the module in the browser's CacheStorage and serves
them cache-first, so a repeat visit reaches the app without waiting on the network. In the
background, it fetches synqt-manifest.json and compares its build_id. Usually nothing
changed and it stops there; only a real change downloads the new module and signals an
update (see App). It needs a secure context, which https and
localhost provide. Elsewhere, the client falls back to the http behavior on its own.
http keeps only the edge's ETag layer: a repeat visit sends one conditional GET and gets
a bodiless 304 Not Modified. It is slower than the worker, but simpler, and uses no
CacheStorage quota. Choose it if your deployment does not allow service workers.
Either way, the edge sends Cache-Control: no-cache on every bundle file, which means
"revalidate", not "do not store". That keeps the 304 cheap and stops a browser from
holding on to a stale worker.
synqt dev always behaves as http, because a worker serving a cached shell would fight
the file watcher's live reload. The dev script also unregisters any worker a production
build left on the same origin.
build.desktop¶
A map nested under build, present only when the client entity lists desktop in its
targets. A native client is installed rather than served by the edge, so unlike the
browser client it cannot read the edge's address from the page that delivered it. This section gives it that
address. See desktop clients.
A desktop build produces an app for the machine it runs on, using that machine's host Qt
kit, so building for all three platforms means running
synqt build --client desktop on each. The CLI does not cross compile native desktop apps.
The app takes the client entity's name.
Configuration resolution order¶
The configuration is layered. Later sources override earlier ones, key by key:
- Framework defaults.
synqt.yaml.synqt.<profile>.yamlselected with--profile(for examplesynqt.production.yaml).- Environment variables
SYNQT_<SECTION>_<KEY>for CI and containers. - CLI flags.
A profile file has the same schema as synqt.yaml and holds only the keys it changes; the
rest come from the base file. Secrets never come from synqt.yaml or a profile file, only
from a per entity env file or the process environment, and only on the service entity that
needs them.
# synqt.production.yaml, applied with: synqt build --release --profile production
entities:
- name: edge # matched by name, and the rest of the entry is untouched
public:
port: 443
tls:
cert_file: certs/edge/fullchain.pem
key_file: certs/edge/privkey.pem
- name: database
mesh:
host: 10.0.0.10
entities merge entry by entry on name, and connect_points on owner, so a profile
can adjust one entity or one point without restating the topology. Every other list, such as a connect point's
consumers or scopes.order, is replaced whole, because its members and order are its
value. A profile changes and adds, and has no delete syntax: dropping a
consumer or an entity is a security change, and it belongs in the file that declares the
list, not in an overlay.
An environment override names a key inside a section the configuration already declares:
SYNQT_SECURITY_HANDSHAKE_TIMEOUT_MS=5000,
SYNQT_BUILD_DESKTOP_EDGE_URL=wss://app.example.com/sync (the nested path follows the
existing structure, so it sets build.desktop.edge_url, not build.desktop_edge_url). A
variable that names no declared section is ignored, which keeps the runtime's own
SYNQT_ROOT, SYNQT_EDGE_URL and SYNQT_TEST_* out of the topology. So is a bare section
such as SYNQT_ENTITIES, and any path that would reach into a list, so an entity's settings,
which sit in the entities list, are changed in a profile, never from the environment. The
value takes the key's existing type, so SYNQT_PROJECT_NAME=no stays the string no instead
of becoming false.
Every layer is validated. Profile files and SYNQT_... overrides follow the same rules as
synqt.yaml, so neither can slip in a literal password or a release edge without TLS.
synqt check, synqt doctor and every build report which layers they applied.
Validation¶
Before any build or run, the CLI validates the resolved configuration and stops at the first failure. These checks always run:
- A production build (or
synqt serve) with a web edge that neither carries atlsblock (cert_fileandkey_file) nor declarespublic.tls_terminated_upstream: trueis rejected: something must terminate TLS to the browser, and the configuration must say what. Likewise,require_mtls_cross_hostcannot be off in release. - A connect point whose
ownerorserverfile does not exist is rejected, as is anownerorconsumerthat is not a declared entity. So is a point that writes acontract:, because what crosses is the point's ownexport:block and the type it becomes is the owner's name. Theserverfile (<type>/<owner>/<Owner>.qmlwhen the point does not name one) must also be rooted at<Owner>. It is the owner's half of the point, and an owner with nothing to host would fail at start-up instead of at build time.synqt add connect-pointwrites that file, empty, with the point, so you normally never see this rule. - A connect point reachable by the
cliententity whoseownerlacks thetype: web_edgeis rejected (the browser can only reach a web edge). So is acliententity in a project with noweb_edgeentity: a browser reaches only a web edge, so that client has nothing to connect to. A client built only for thedesktoptarget is exempt, because no edge serves it: it connects to the edgebuild.desktop.edge_urlnames, which may belong to another deployment. That key is required instead. - A connect point owned by a
cliententity is rejected. An owner hosts the Source and listens for consumers, and a browser cannot listen: WebAssembly has no WebSocket server, so the client always connects out. The web edge owns every connect point the client uses, whichever way the data flows. - A connect point listing its own
owneramong itsconsumersis rejected. The owner holds the Source and never acquires a replica of it, so the entry only makes the consumer list look wider than it is. - An entity
namethat does not match the shape described above is rejected: a letter, then letters, digits, underscores and hyphens, up to 64 characters, and neithercanordocker-ca. The name is a directory, a build target, an accessor and a certificate subject, so a space or a dot would fail far from the line that caused it.synqt mesh certapplies the same rule to a name typed at its prompt, because that name reaches openssl and the mesh directory. - A name declared twice, entity or connect point, is rejected. Both are keyed by name, so the second declaration would silently replace the first, and a consumer list narrowed on the first would vanish.
- An
instanceon a connect point is rejected, with a message naming the entity to putshared:on instead. The entity decides it, and a line that does nothing looks exactly like one that works. - A
sharedvalue other than true or false is rejected, and so issharedon a client: a client is one browser and shares with nobody. cppis rejected unless it is true or false, on any type butservice, on the entityidentity.provider_entitynames, on a name C++ cannot spell as a class (a hyphen), and on an entity that owns no connect point, since its class is that point's Source. A C++ entity without<name>.hor<name>.cppis rejected, and so is a.h, a.cppor aCMakeLists.txtin the folder of an entity written in QML, which nothing would build.- A connect point
scopemissing fromscopes.orderis rejected, and so is a member gated on one.<root> slot purge()requires a scope no session can hold, so the member would reach nobody. - A
<scope>gate on a connect point no client consumes is rejected. A scope belongs to a user's session, and a calling entity has none, so the gate would refuse every caller. The message suggests gating a mesh member onCaller.entityinstead. - A gate below the point's own
scopeis reported. Under hierarchical scopes it is a warning (every caller that reached the point already holds it, so it refuses nobody); under set-based scopes it is an error (no caller can hold both). client_threads: multiwithcross_origin_isolation: falseis a warning: a threaded client needs isolation, so the build turns it on and says that it overrode thefalse.- A client entity whose
Main.qmlroot object is not a window (ApplicationWindoworWindow) is rejected.Main.qmlis the QML engine's root object, and the engine shows a root object only if it is a window, so aPageorItemroot builds, loads, logs nothing and renders a blank page. Routes name separate view files, andMain.qmlis the window that hosts them. - Any
env:reference used by a client target is rejected. - A client entity with
desktopintargetsbut nobuild.desktop.edge_urlis rejected, because a native client cannot discover its edge. In a release build theedge_urlmust bewss://(plaintext is allowed only against a development edge on localhost). A desktop target is still a client target: it may not reference a secret, and no serviceserverfile compiles into it. - An entity with
transport: mtlsthat has no issued cert insynqt/mesh/is rejected before start, with a hint to run the cert command (synqt devissues throwaway development certificates automatically). transport: localis never implicit; it must be written.synqt checkflags every local link, noting that the calling entity is trusted by colocation, not authenticated by certificate. A local link with more than one consumer is rejected: a local socket identifies nobody, so the owner names every caller after the point's single consumer, and a second consumer would appear as the first on every call.- A
mesh.host,public.host,network.inbound.bindor connect pointhostthat is a name instead of an address is rejected,localhostincluded. Each reaches the runtime as aQHostAddress, which resolves nothing, so a name binds and dials nothing. - An identity provider without its required
client_secretis rejected before the edge starts, not at the first login. A literal secret is rejected too: it must be anenv:reference, so the value stays out ofsynqt.yamland the binary. scopes.defaultmust appear inscopes.order; otherwise every new session would hold a scope that satisfies no check.- A
securitylimit (handshake_timeout_ms, the two connection limits,max_message_bytes) is rejected unless it is a whole number above zero: the limits compare with>=, so zero looks like "no limit" but refuses the first connection.security.max_sessionsis the exception: zero removes the limit, and a release build reports what that leaves unbounded. security.session_transportandidentity.flowmust name something this version implements (cookieandauthorization_code). The edge refuses a setting it cannot honor instead of dropping it, because an edge that silently runs something else looks exactly like one running what you asked for.- A provider
namenot available for the entity's type is rejected, with a list of the ones that are. Acustom:<Name>is checked for shape only, because an entity's registrations are known only when it starts. If the name matches nothing, the entity refuses to start and lists the providers registered for the family.synqt doctorreports a non default provider whose engine client or Qt SQL driver plugin is missing, and the entity refuses to start. - A plaintext or unverified provider connection to an external engine (no TLS, or verification disabled) is rejected in a release build. It is allowed only in development, on localhost.
- Any provider secret (a
passwordoruricarrying credentials) that is not anenv:reference, or that is referenced by a client target, is rejected.
The route table¶
synqt check also validates router and routes,
because a bad route table otherwise fails only in production. Two routes competing for one
path, a parameter nothing can bind, or a fallback pointing nowhere all build and load fine,
and misbehave only when a visitor reaches them. Each rule below fails the check, with the
message quoted:
| What is wrong | The message |
|---|---|
A route's path is not a string (a bare - path: reads as null) |
error: route path None must be a string starting with '/' |
A path is relative |
error: route path 'admin' must be absolute (start with '/') |
| Two routes declare the same path | error: duplicate route path '/c'; only the first declaration is ever reached |
| Two routes declare the same path spelled differently | error: duplicate route path '/c/' (the runtime reads it as '/c': an empty path segment does not make a distinct route); only the first declaration is ever reached |
A path claims a path the edge answers itself |
error: route path '/sync' is reserved by the web edge: a client route there is either answered by the edge itself or collides with the wss sync endpoint |
| A parameter name is not an identifier | error: route path '/c/:2campaign' has a malformed parameter ':2campaign'; a parameter name must be a letter or underscore, then letters, digits, or underscores |
| One path uses a parameter name twice | error: route path '/c/:id/:id' repeats the parameter name 'id' |
A non-remote route declares no view |
error: route '/admin' declares no view; there is nothing for the router to show there |
A view names a file that is not there |
error: route '/admin' names view 'Admin.qml': no such file 'client/app/Admin.qml' |
A view is written with the entity directory in it |
error: route '/admin' names view 'client/app/Admin.qml': no such file 'client/app/client/app/Admin.qml'; a view is named relative to the client entity's directory, so write it as 'Admin.qml' |
A view points outside the client entity's directory |
error: route '/admin' names view '../web/Admin.qml': a view is named relative to the client entity's directory ('client/app/'), so it cannot be an absolute or parent path |
router.fallback names no declared route |
error: router.fallback '/home' is not a declared route; a redirect to it would go nowhere |
router.base is not rooted |
error: router.base 'shop' must start with '/' |
router.mode is not history |
warn: router.mode 'hash' is not a mode SynQt has; the router always drives the History API ('history') and ignores this key |
Notes on three of them:
- The view rules keep a broken route out of the build. Every view a route names is
compiled into the client's QML module, so a missing view would otherwise stop CMake on a
generated file you do not own. Here, the message names the route and the file. A
viewwith or without.qml, and with or without a leading./, means the same file. A route without aviewis the one rule the generator repeats: nothing forcessynqt buildto runsynqt check, sosynqt buildstops with the same message. - The duplicate rule compares paths as the runtime splits them, where empty segments do
not count.
/cand/c/are the same route, and the message says so, instead of leaving you to wonder why two different strings collided. The fallback rule normalizes the same way, sofallback: /matches a route declared as/. - The reserved paths come from your configuration, not from a fixed list: each web
edge's
public.sync_route(default/sync), or/syncitself while the project has no web edge yet, plus, when the project has anidentitysection, itslogin,callbackandlogoutroutes. Move your login route and the new path is reserved. Delete theidentitysection and/auth/loginbecomes an ordinary route again.
The fallback rule applies only once at least one route exists. A project without routes
has nothing for a fallback to point at, and the client compiles an empty route table.
A remote: route has no compiled-in view, so the view rules above skip it (both "declares
no view" and the file-on-disk check); its file is validated as an edge-delivered page
instead (next section). A route with neither key is still refused, since it has nothing to
show.
Remote pages¶
synqt check also validates every route's remote: and seed:, because a bad
remote page builds and serves fine, and fails only the visitor who
navigates to it: a blank page (a missing file), a refused delivery (an import outside the
palette), or a page that silently shadows one already in the bundle. A delivered page's
file is checked under the edge entity's pages/ directory. A seed: resolves relative to
the project root, because a hook is edge code, not a delivered page. Each rule below fails
the check, with the message quoted:
| What is wrong | The message |
|---|---|
A seed: is not a string |
error: route '/c/:campaign' 'seed:' must be a string path to the hook QML, not True |
A seed: sits on a non-remote route |
error: route '/home' declares 'seed:' but no 'remote:'; a page seed only applies to an edge-delivered page |
A remote: route exists but the project has no web edge |
error: a route declares 'remote:' but the project has no web_edge entity |
A remote: route exists but router.palette is empty |
error: a route declares 'remote:' but router.palette is empty; a delivered page may only import declared modules |
A route sets both view: and remote: |
error: route '/c/:campaign' sets both 'view:' and 'remote:' |
A remote: route shadows a compiled-in route at the same path |
error: remote route '/c/:campaign' shadows a compiled-in route of the same path |
A seed: names a file that is not there |
error: page seed 'web/edge/campaign-seed.qml' for route '/c/:campaign' does not exist under <project-dir> |
A remote: names a page that is not there |
error: remote page 'Campaign.qml' for route '/c/:campaign' does not exist under <edge>/pages |
| A delivered page imports a module outside the palette | error: remote page 'Campaign.qml' imports 'QtWebEngine', which is not in router.palette |
Notes on two of them:
- The palette rule is a build-time convenience. The client's
QmlPaletteenforces the palette on a delivered page at run time, and is stricter than this scan. It reads the page as the QML lexer does: it strips comments and string literals first, ends a statement at a semicolon as well as a line break, handles a lone carriage return and a leading byte order mark, and refuses any quoted (path) import. A page this scan lets through on any of those points is still refused by the client, at navigation instead of at build time. - The shadow rule and the "sets both" rule catch opposite mistakes. A
remote:at the same path as a separateview:route is a shadow the edge can never win (the compiled-in route stays), and is reported here. A single route that sets both keys gets the "sets both" error instead.