Skip to content

Typed QWebChannel Bridge

Typed QWebChannel Bridge

Build type-safe communication between QGIS Python and HTML/JS with auto-generated TypeScript types.

Overview

QGIS WebEngine plugins traditionally use QWebChannel with manual JS wiring:

<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script>
new QWebChannel(qt.webChannelTransport, channel => {
bridge = channel.objects.bridge;
bridge.get_layer(data => console.log(data));
});
</script>

@qgis-sdk/bridge improves this:

  • Auto-injects qrc:///qtwebchannel/qwebchannel.js with fallbacks (./qwebchannel.js, CDN)
  • Promisifies callback-style to Promise
  • Typed via auto-generated bridge.d.ts from Python
  • Framework adapters for React, Vue, Web Components
  • Python → JS via CustomEvent('qgis-message')

Python Bridge

Define your bridge as a plain Python class with type hints:

my_plugin/dialogs/web_dialog.py
from qgis_sdk.ui import WebDialog, web_bridge
from pathlib import Path
class MapBridge:
def get_layer(self) -> dict:
return {"name": "buildings", "count": 100}
def get_extent(self) -> dict:
return {"xmin": -180, "ymin": -90, "xmax": 180, "ymax": 90}
def log(self, msg: str) -> str:
print(f"[JS] {msg}")
return "ok"
def process(self, data: str) -> dict:
import json
return {"status": "ok", "action": json.loads(data).get("action")}
HTML_FILE = Path(__file__).parent.parent / "web" / "map.html"
def show_web_dialog(parent=None):
dlg = WebDialog.from_file(HTML_FILE, title="Map View", width=900, height=700, parent=parent)
dlg.set_bridge(MapBridge())
return dlg.exec()
# Decorator version (alternative)
web_dlg = WebDialog.from_file(HTML_FILE, title="Map View")
@web_bridge(web_dlg)
class Bridge:
def get_layer(self) -> dict:
return {"name": "buildings", "count": 100}

Register in QGIS:

from PyQt5.QtWebChannel import QWebChannel
channel = QWebChannel()
bridge = MapBridge()
channel.registerObject("bridge", bridge) # name must match JS
web_view.page().setWebChannel(channel)

Generate TypeScript Types

CLI

Terminal window
# Generate single .d.ts file
qgis-plugin bridge generate --bridge my_plugin.dialogs.web_dialog:Bridge --output web/bridge.d.ts
# Generate full package (bridge.d.ts + bridge.js + react.ts + vue.ts + webcomponents.ts)
qgis-plugin bridge generate --bridge my_plugin.dialogs.web_dialog:Bridge --output web/ --package
# Custom names
qgis-plugin bridge generate --bridge my_plugin.dialogs.web_dialog:MapBridge --output web/bridge.d.ts --name MapBridge --object-name bridge --framework react

Supports both notations:

  • module:Class → my_plugin.dialogs.web_dialog:Bridge
  • module.Class → my_plugin.dialogs.web_dialog.Bridge

Python API

from qgis_sdk.bridge import generate_ts_bridge, generate_js_wrapper, generate_package
from my_plugin.dialogs.web_dialog import Bridge
from pathlib import Path
# TS interface
ts_code = generate_ts_bridge(Bridge, name="Bridge")
Path("web/bridge.d.ts").write_text(ts_code)
# JS wrapper (standalone, no npm)
js_code = generate_js_wrapper(Bridge, name="Bridge", object_name="bridge")
Path("web/bridge.js").write_text(js_code)
# Full package
generate_package(Bridge, output_dir="frontend/src/qgis-bridge", name="Bridge")

Generated Output

/**
* Auto-generated from Python my_plugin.dialogs.web_dialog.Bridge
* Generated by qgis_sdk.bridge.generate_ts_bridge
*/
export interface Bridge {
get_layer(callback: (result: Record<string, any>) => void): void;
get_layer(): Promise<Record<string, any>>;
get_extent(callback: (result: Record<string, any>) => void): void;
get_extent(): Promise<Record<string, any>>;
log(msg: string, callback: (result: string) => void): void;
log(msg: string): Promise<string>;
process(data: string, callback: (result: Record<string, any>) => void): void;
process(data: string): Promise<Record<string, any>>;
}
export interface BridgeState {
bridge: Bridge | null;
ready: boolean;
}

Type mapping:

PythonTypeScript
strstring
int, floatnumber
boolboolean
dict, DictRecord<string, any>
list, List[X]any[] or X[]
Optional[X]X | null
Nonevoid
custom classany

JavaScript Usage

Install

Terminal window
npm install @qgis-sdk/bridge
# pnpm add @qgis-sdk/bridge
# bun add @qgis-sdk/bridge

Vanilla JS / TypeScript

import { createBridge } from '@qgis-sdk/bridge';
import type { Bridge } from './web/bridge.d.ts';
const bridge = await createBridge<Bridge>();
const layer = await bridge.get_layer();
console.log(layer.name, layer.count);
await bridge.log("hello from JS");
// Legacy callback style still works
bridge.get_layer((result) => {
console.log(result);
});

createBridge auto-injects qwebchannel.js:

  1. Checks if QWebChannel already global
  2. Tries qrc:///qtwebchannel/qwebchannel.js (Qt built-in, works in QGIS)
  3. Tries ./qwebchannel.js, ./web/qwebchannel.js, /qwebchannel.js
  4. Falls back to CDN jsdelivr.net / unpkg

Options:

import { createBridge } from '@qgis-sdk/bridge';
const bridge = await createBridge<Bridge>({
objectName: 'bridge', // default, must match Python channel.registerObject
qwebchannelSources: ['qrc:///qtwebchannel/qwebchannel.js', './qwebchannel.js'],
timeout: 10000, // ms
});
// Or shorthand
const bridge = await createBridge<Bridge>('bridge', { timeout: 5000 });

Python → JS Messages

Python:

import json
data = {"message": "hello from Python", "center": [51.5, -0.09], "zoom": 13}
web_view.page().runJavaScript(f"window.qgisBridge.onMessage({json.dumps(data)})")
# or
web_view.page().runJavaScript(f"window.dispatchEvent(new CustomEvent('qgis-message', {{detail: {json.dumps(data)}}}}))")

JS:

import { onQgisMessage } from '@qgis-sdk/bridge';
// Via helper
const unsubscribe = onQgisMessage((data) => {
console.log('from Python', data);
});
// Via CustomEvent (any framework)
window.addEventListener('qgis-message', (e: CustomEvent) => {
console.log(e.detail);
});
// Via global (legacy compatibility)
window.updateFromPython = (data) => {
console.log(data);
};
window.qgisBridge.onMessage = (data) => { /* ... */ };

Framework Adapters

React

import { useQgisBridge } from '@qgis-sdk/bridge/react';
import type { Bridge } from './web/bridge.d.ts';
import { useEffect, useState } from 'react';
function App() {
const { bridge, ready, error } = useQgisBridge<Bridge>();
const [layer, setLayer] = useState<any>(null);
useEffect(() => {
if (ready && bridge) {
bridge.get_layer().then(setLayer);
}
}, [ready]);
if (!ready) return <div>Connecting to QGIS... (auto-injects qrc:///qtwebchannel/qwebchannel.js)</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Layer: {layer?.name} ({layer?.count})</h2>
<button onClick={() => bridge?.log("hello from React")}>Send to Python</button>
</div>
);
}

For bundlers where React is imported:

import { useState, useEffect } from 'react';
import { createReactHook } from '@qgis-sdk/bridge/react';
import type { Bridge } from './bridge';
const useQgisBridge = createReactHook<Bridge>({ useState, useEffect });

Vite template (scaffolded via qgis-plugin new my_plugin --web --framework react):

{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@qgis-sdk/bridge": "^0.1.0"
}
}

Vue

<script setup lang="ts">
import { ref, watch } from 'vue';
import { useQgisBridge } from '@qgis-sdk/bridge/vue';
import type { Bridge } from './web/bridge.d.ts';
const { bridge, ready, error } = useQgisBridge<Bridge>();
const layer = ref<any>(null);
watch(ready, async (isReady) => {
if (isReady && bridge.value) {
layer.value = await bridge.value.get_layer();
}
});
</script>
<template>
<div v-if="!ready">Connecting to QGIS...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<div v-else>
<p>Layer: {{ layer?.name }} ({{ layer?.count }})</p>
<button @click="bridge?.value?.log('hello from Vue')">Send to Python</button>
</div>
</template>

For bundlers:

import { ref, onMounted } from 'vue';
import { createVueComposable } from '@qgis-sdk/bridge/vue';
const useQgisBridge = createVueComposable<Bridge>({ ref, onMounted });

Web Components

HTML:

<qgis-bridge object-name="bridge" id="qgisBridge"></qgis-bridge>
<script type="module">
import '@qgis-sdk/bridge/webcomponents';
const el = document.getElementById('qgisBridge');
el.addEventListener('qgis-bridge-ready', async (e) => {
const layer = await e.detail.bridge.get_layer();
console.log(layer);
});
el.addEventListener('qgis-message', (e) => {
console.log('from Python', e.detail);
});
</script>

JS Mixin:

import { withQgisBridge } from '@qgis-sdk/bridge/webcomponents';
class MyMap extends withQgisBridge(HTMLElement) {
onBridgeReady(bridge) {
bridge.get_layer().then(layer => this.render(layer));
}
onQgisMessage(data) {
console.log('from Python', data);
}
}
customElements.define('my-map', MyMap);

Scaffold Integration

qgis-plugin new with --web now always generates web/bridge.d.ts:

Terminal window
qgis-plugin new my_plugin --web --framework react
# Creates:
# my_plugin/web/map.html (Leaflet + @qgis-sdk/bridge comment)
# my_plugin/web/react.html (React CDN + useQgisBridge)
# my_plugin/web/bridge.d.ts (typed interface)
# my_plugin/web/bridge.ts (usage examples)
# my_plugin/frontend/package.json (with @qgis-sdk/bridge)
# my_plugin/frontend/src/App.tsx (typed hook)
qgis-plugin new my_plugin --web --framework vue
qgis-plugin new my_plugin --web --framework webcomponents
qgis-plugin new my_plugin --web # all 4 examples

Production build:

Terminal window
cd my_plugin/frontend
npm install
npm run build # -> my_plugin/web/dist/

Then in Python:

from qgis_sdk.ui import WebDialog
dlg = WebDialog.from_file("web/dist/index.html", title="My App")

Offline / QGIS Bundle

Qt provides qwebchannel.js at qrc:///qtwebchannel/qwebchannel.js — no need to bundle. For offline dev outside QGIS:

Terminal window
find /usr -name qwebchannel.js 2>/dev/null
cp /usr/share/qt5/.../qwebchannel.js ./web/

@qgis-sdk/bridge auto-tries local ./qwebchannel.js before CDN, so offline works.

API Reference

createBridge<T>(objectName?, options?)

  • objectName: default 'bridge', must match channel.registerObject in Python
  • options.qwebchannelSources: custom sources array
  • options.timeout: ms before rejecting (default 10000)
  • Returns Promise<T> with promisified methods

loadQWebChannel(sources?)

Loads qwebchannel.js from first available source. Returns Promise<void>.

onQgisMessage(handler)

Register handler for Python → JS messages. Returns unsubscribe function.

QWEBCHANNEL_SOURCES

Default sources tried: qrc:///qtwebchannel/qwebchannel.js, ./qwebchannel.js, ./web/qwebchannel.js, /qwebchannel.js, CDN.

Next Steps