Skip to content

The schema module and QgisSdk

client.invoke<T>(...) is a transport, not a type guarantee: T is asserted by hand at the call site. For operations, generate the plugin’s schema module from its own declarations and build a QgisSdk on it (decision-44):

Terminal window
$ qgis-sdk bridge generate --plugin my_plugin.plugin:app
wrote web/src/bridge.schema.ts

The module is data, not code. It exports the declared value types, OPERATIONS and OperationId, one Operations interface (request and result per action), one Streams interface (request and event union per stream), and as its default export the parsed plugin description typed by both:

import { type BridgeSchema, defineBridgeSchema } from "@archont561/qgis-sdk";
export interface Operations {
readonly "sites.summary": { readonly request: SitesSummaryRequest; readonly result: SitesSummaryResult };
// ...
}
export interface Streams {
readonly "sites.watch": { readonly request: SitesWatchRequest; readonly event: SitesWatchEvent };
}
const schema: BridgeSchema<Operations, Streams> = defineBridgeSchema<Operations, Streams>({ /* description */ });
export default schema;

defineBridgeSchema is the module’s only import. It parses and freezes the description exactly as parsePluginDescription does and pins the two type parameters; there is no generated validator, because the SDK validates at run time from the schemas the description already carries. A page then needs three statements:

import { QgisSdk } from "@archont561/qgis-sdk";
import schema from "./bridge.schema";
const qgis = QgisSdk.fromBridgeSchema(schema);
const summary = await qgis.sites.summary({ layer_id: "sites" });

How does it connect, and what does it check?

Section titled “How does it connect, and what does it check?”

Without options fromBridgeSchema connects to the QWebChannel object named "bridge", which is what the Python runtime registers on the view. Outside a QGIS web view it throws QgisSdkWebChannelError (code: "no_web_channel") synchronously, so the page can say why instead of waiting on a channel that never comes. Pass { transport } to use another wire (the test harness below), { client } to share a BridgeClient the page already owns (then close() leaves it alone), { channelObject } for another registered name, { timeoutMs } for the per-call timeout of an owned client.

Every declared operation is a method nested under the segments of its id: qgis.open_fixture() for a plain id, qgis.sites.summary(...) for sites.summary, qgis.tools.measure.area(...) for a deeper one - the same shape as qgis.host.layers.list(...). An operation the plugin did not declare is not a member, and a misspelled or mistyped argument is a type error - including an unknown property inside a nested object. An action whose request declares no fields takes no argument. Two rules follow from nesting, checked by bridge generate and again by fromBridgeSchema (QgisSdkDeclarationError): an id may not be a prefix of another (sites cannot be a method and hold sites.summary), and an id’s first segment may not be a core member (client, host, schema, ready, close).

The method validates what it sends (strict: unknown arguments are refused before the transport is touched) and what comes back (lenient: a field a newer host added is tolerated, a missing or mistyped one is refused). Both refusals are QgisSdkContractError (code: "contract_validation_failed") naming the operation, the direction (request, result or event) and the issues, each with a path. Primitives, list/tuple, unions, Literal and nested dataclasses are checked; a reply the schema cannot describe never reaches the page.

A method the plugin marked with @stream(id="sites.watch", events={"data": SiteChange, "progress": ScanProgress}) becomes qgis.sites.watch(request, { onEvent, onTerminal }, options), returning the same StreamHandle as client.subscribe. onEvent narrows on event.kind; a frame whose kind the plugin did not declare, or whose payload does not match, ends the subscription with an error terminal instead of reaching the handler. The frame envelope - seq, gap, close() - stays in this package’s kernel.

qgis.host is the typed host surface of Host namespaces on the same client - layers, project, settings, notifications, styles, files and the network.fetch stream - plus granted, the host operation ids the handshake advertised for this view. A call outside the grants is refused by the host with its own failure code; the SDK does not pre-empt it.

qgis.ready resolves once the handshake answered; it rejects with the host’s QgisSdkBridgeRemoteError when the host refused the handshake and with QgisSdkBridgeProtocolError when the reply is not a handshake; qgis.client is the BridgeClient underneath for what no method covers; qgis.schema is the module the instance was built from. A plugin may not declare an operation named like one of these members (QgisSdkDeclarationError).

The file is generated source, never edited by hand: the plugin’s CI runs qgis-sdk bridge generate --check and fails when the checked-in copy differs. CONTRACT_FINGERPRINT identifies the wire contract the file was built from.

const handle = await qgis.sites.watch(
{ layer_id: "sites" },
{
onEvent: (event) => {
if (event.kind === "data") render(event.payload.change);
},
onTerminal: (terminal) => console.info("stream ended:", terminal.kind),
},
);

qgis-sdk bridge generate --out web/src/bridge.ts still renders what 0.3.0 rendered - the import-free module with createOperations(client), PluginOperations and one validator per type - and prints a note that the schema module is the current form. It stays for one minor. A page on the legacy client keeps working unchanged.