Bridge widgets
A bridge is a GTK widget that also behaves like a DOM element. You get a real
HTMLCanvasElement (or <video>, or <iframe>) whose drawing surface is a GTK widget you
can mount in any container. Browser-shaped code like canvas.getContext('webgl') or
video.srcObject = stream drives the GTK surface directly, with no WebKit.WebView wrapper
and no separate renderer process.
DOM ↔ GTK Bridges
Standard DOM elements, rendered natively
App code uses standard HTML elements. Each bridge translates the DOM lifecycle to a GTK widget — Cairo for 2D, OpenGL via libepoxy for WebGL, GStreamer for video, WebKit for iframes.
HTMLCanvasElement (2D) Gtk.DrawingArea @gjsify/canvas2d Cairo + PangoCairo
HTMLCanvasElement (WebGL/WebGL2) Gtk.GLArea @gjsify/webgl Vala/gwebgl + libepoxy
HTMLIFrameElement WebKit.WebView @gjsify/iframe WebKit 6.0
HTMLVideoElement Gtk.Picture @gjsify/video GStreamer + gtk4paintablesink
Native Vala bridges
@gjsify/terminal-native Posix.isatty + ioctl TIOCGWINSZ + termios for @gjsify/tty
@gjsify/sab-native Cross-process shared memory + futex atomics for @gjsify/worker_threads
@gjsify/tls-native Direct OpenSSL access for @gjsify/tls
@gjsify/http-soup-bridge Native Soup integration helpers for @gjsify/http
@gjsify/http2-native nghttp2 bridge for h2c, push streams, flow control
@gjsify/webrtc-native GLib.Idle signal marshalling for webrtcbin's streaming-thread callbacks
Every bridge is a subclass of the GTK widget it renders into, so bridge.set_size_request(),
bridge.add_css_class() and win.set_child(bridge) all work the way you expect.
They follow the GTK layer rather than one runtime. Build --app gjs and the gi:// imports
below resolve in the gjs host itself; build --app node and the same source runs on Node,
Bun and Deno with gi:// going through @gjsify/node-gi.
That is the shape. How far each bridge is measured is a separate question and the answer
differs per package, so go by the numbers rather than the shape. The headless Canvas 2D core
(@gjsify/canvas2d-core) is measured at 578/578 on Node, Bun and Deno; the GTK-backed
@gjsify/canvas2d at 191/191 on Node. @gjsify/webgl and @gjsify/iframe run their suites
on gjs and carry no node-family measurement, and @gjsify/video ships no suite at all, so
off gjs treat those three as untested rather than promised. The iframe showcases say so in
their own manifest: they declare gjs only, so gjsify showcase <name> --runtime node
refuses them up front instead of crashing.
The shape of every bridge
Section titled “The shape of every bridge”Three steps, the same for all four:
import Adw from 'gi://Adw?version=1';import { WebGLBridge } from '@gjsify/webgl';
const bridge = new WebGLBridge();
bridge.installGlobals(); // 1. optional: expose the browser globalsbridge.onReady((canvas, gl) => { // 2. the canvas and context are usable now gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);});
const win = new Adw.ApplicationWindow({ application: app });win.set_child(bridge); // 3. mount it like any other widgetwin.present();Wait for onReady. The underlying surface does not exist until GTK realizes the widget,
so bridge.canvas is null before that. onReady fires as soon as the surface exists, and
runs your callback immediately if it already does. Reading the canvas or the draw context
earlier gives you a zero-sized surface at best.
installGlobals() is optional. Call it when you run library code written for a browser
(Three.js, Excalibur, p5.js) which reaches for requestAnimationFrame, performance.now()
or the WebGL context constructors on globalThis. Skip it for code you wrote yourself that
takes the canvas as an argument. Without it, browser-written code fails with
TypeError: requestAnimationFrame is not a function.
Draw with Canvas 2D
Section titled “Draw with Canvas 2D”gjsify install @gjsify/canvas2dimport Adw from 'gi://Adw?version=1';import { Canvas2DBridge } from '@gjsify/canvas2d';
const bridge = new Canvas2DBridge();bridge.installGlobals();bridge.onReady((canvas, ctx) => { ctx.fillStyle = '#3584e4'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = '24px Adwaita Sans'; ctx.fillStyle = '#fff'; ctx.fillText('Hello from GTK', 20, 40);});
const win = new Adw.ApplicationWindow({ application: app });win.set_default_size(600, 400);win.set_child(bridge);win.present();Canvas2DBridge is a Gtk.DrawingArea backed by Cairo and PangoCairo. The canvas is created
on the first draw, sized to the widget’s allocation, and its Cairo surface is blitted to the
screen on every frame.
After you draw outside a requestAnimationFrame loop, call bridge.queue_draw() to push the
new surface to the screen.
On a resize the bridge writes the new allocation to canvas.width / canvas.height,
dispatches a DOM resize event on the canvas, notifies any ResizeObserver, and then runs
your onResize callbacks. The widget owns those two numbers: a size you assign yourself is
replaced by the allocation on the next draw.
Draw with WebGL
Section titled “Draw with WebGL”gjsify install @gjsify/webglimport Adw from 'gi://Adw?version=1';import { WebGLBridge } from '@gjsify/webgl';
const bridge = new WebGLBridge();bridge.installGlobals();bridge.onReady((canvas, gl) => { gl.clearColor(0.0, 0.4, 0.6, 1.0); gl.clear(gl.COLOR_BUFFER_BIT);});
const win = new Adw.ApplicationWindow({ application: app });win.set_default_size(800, 600);win.set_child(bridge);win.present();WebGLBridge is a Gtk.GLArea exposing the WebGL 1 and WebGL 2 API through a Vala
extension (gwebgl) over libepoxy. requestAnimationFrame is wired to the GTK frame clock,
so your loop runs at vsync without any extra setup.
The drawing buffer is GTK’s, not yours. canvas.width and canvas.height are live
readings of it in device pixels, the widget allocation multiplied by the surface scale
factor, and they have no setter: assigning to them is ignored. A library that sizes its
viewport from those properties therefore follows the widget on its own. When you want the
allocation in logical pixels instead, read canvas.clientWidth / clientHeight.
What does have to follow a resize is your own GL state, so do that in onResize. Take the
numbers from the canvas rather than from the callback arguments, which are logical pixels:
on a scale-factor-3 display a viewport computed from those covers a ninth of the widget and
the rest stays at the clear colour.
let gl: WebGLRenderingContext | null = null;bridge.onReady((_canvas, ctx) => { gl = ctx; });
bridge.onResize(() => { const canvas = bridge.canvas; if (!gl || !canvas) return; gl.viewport(0, 0, canvas.width, canvas.height); // device pixels // …and your camera aspect / projection matrix});Check what you actually got
Section titled “Check what you actually got”Some hosts have no usable GPU, and a CPU rasteriser makes every draw call succeed while
running orders of magnitude slower. That looks like a frozen window rather than a slow one.
rendererInfo tells you which you got:
bridge.onReady(() => { console.log(bridge.rendererInfo); // { vendor: '…', renderer: '…' } or null});The bridge also warns on stderr once when it detects a software renderer. Treat the string as a report, not a capability check: if your app switches renderers, key that on a measured frame budget instead.
Play video
Section titled “Play video”gjsify install @gjsify/videoimport Adw from 'gi://Adw?version=1';import { VideoBridge } from '@gjsify/video';
const bridge = new VideoBridge();bridge.showControls(true);bridge.onReady(async (video) => { // A camera / WebRTC track: video.srcObject = await navigator.mediaDevices.getUserMedia({ video: true }); // …or a file or URL, played through a GStreamer playbin: // video.src = 'https://example.com/clip.mp4';});
const win = new Adw.ApplicationWindow({ application: app });win.set_child(bridge);win.present();VideoBridge is a Gtk.Box containing a Gtk.Overlay over a Gtk.Picture, rendered
through GStreamer’s gtk4paintablesink. The overlay is what lets showControls(true) add a
play/pause, seek, time and volume bar that auto-hides after two seconds of mouse inactivity.
There is no draw context here. You hand it a source through srcObject or src and
GStreamer does the rest. bridge.videoElement (also bridge.element) gives you the
HTMLVideoElement outside an onReady callback, and bridge.environment gives you the
bridge’s own document / window pair if you want DOM-shaped code scoped to this widget
instead of to globalThis.
installGlobals() is rarely needed here: it only publishes HTMLVideoElement and a
performance shim.
Embed a web page
Section titled “Embed a web page”gjsify install @gjsify/iframeimport Adw from 'gi://Adw?version=1';import { IFrameBridge } from '@gjsify/iframe';
const bridge = new IFrameBridge();bridge.onReady((iframe) => { iframe.contentWindow?.addEventListener('message', handler);});bridge.loadHtml('<h1>Hello from WebKit</h1>');// or: bridge.loadUri('https://example.com/');
const win = new Adw.ApplicationWindow({ application: app });win.set_child(bridge);win.present();IFrameBridge is the one that really does ship a browser: it extends WebKit.WebView. Use it
to embed genuine web content (a third-party site, a sandboxed app, an HTML report) inside a
GTK app. The other three bridges are for the opposite job, running browser-shaped code
directly on GTK with no browser engine underneath.
Use loadUri(url) and loadHtml(html, baseUri?) to navigate, because they keep WebKit and
the iframe element’s attributes in step. bridge.goBack(), bridge.goForward() and
bridge.reload() drive WebKit’s own history, guarded by bridge.canGoBack /
bridge.canGoForward. bridge.currentUri, bridge.pageTitle and bridge.lastLoadError
report the current state, and bridge.postMessage(data, targetOrigin) is the short form of
bridge.iframeElement.contentWindow.postMessage(...).
onReady here means “content finished loading”. If a page has already loaded, the callback
runs on the next load rather than immediately.
Handle input
Section titled “Handle input”The two canvas bridges and the video bridge attach GTK event controllers and re-dispatch
them as standard DOM events on the element, so listeners written for a browser work
unchanged. (IFrameBridge needs none of this: WebKit delivers input to the page itself.)
bridge.onReady((canvas) => { canvas.addEventListener('pointermove', (e) => console.log(e.clientX, e.clientY)); canvas.addEventListener('keydown', (e) => console.log(e.key, e.code)); canvas.addEventListener('wheel', (e) => zoom(e.deltaY));});Pointer, mouse, wheel, keyboard and focus events are covered: pointermove / pointerdown /
pointerup / pointerenter / pointerleave, the matching mouse* events plus click,
dblclick and contextmenu, wheel, keydown / keyup, and focus / blur with their
bubbling focusin / focusout pairs.
Resize with the window
Section titled “Resize with the window”Four ways to hear about a resize, all fed from the same GTK signal. Pick whichever suits the consumer:
| Pathway | Use it when |
|---|---|
bridge.connect('resize', (w, width, height) => …) | You are already writing GTK code. This is the Gtk.DrawingArea / Gtk.GLArea signal, so it is on the two canvas bridges. |
bridge.onResize((width, height) => …) | Same payload, less ceremony, and available on the video bridge too. |
canvas.addEventListener('resize', …) | The consumer is a DOM-shaped library. |
new ResizeObserver(cb).observe(canvas) | The library uses the standard observer API. |
ResizeObserver also works when a library observes the canvas’s parent rather than the
canvas. The bridge walks up the parent chain and notifies every ancestor’s subscribers, which
is what makes Excalibur’s DisplayMode.FillContainer reflow correctly:
import { WebGLBridge } from '@gjsify/webgl';import Excalibur from 'excalibur';
const bridge = new WebGLBridge();bridge.installGlobals();
bridge.onReady((canvas) => { const engine = new Excalibur.Engine({ canvasElement: canvas, displayMode: Excalibur.DisplayMode.FillContainer, }); engine.start();});Resize the widget itself with bridge.set_size_request(width, height) or by changing the
parent layout. Assigning to canvas.width gets you nowhere on either canvas bridge:
Canvas2DBridge replaces your value with the allocation on the next draw, and on
WebGLBridge the property has no setter at all.
Two bridges in one app
Section titled “Two bridges in one app”installGlobals() writes to globalThis, so a second call replaces the first bridge’s
requestAnimationFrame. That is fine when one bridge drives everything, and a problem when
you have two independent animations running side by side.
If you need both, don’t call installGlobals() at all. Keep the canvas and the context that
onReady handed you, pass them into your code explicitly, and drive each loop from its own
bridge.requestAnimationFrame(...).
Keep the bridge alive
Section titled “Keep the bridge alive”Hold the bridge in the same scope as the window that contains it, or store both on the same object. The canvas, the GL/Cairo/GStreamer pipeline and the DOM element are torn down when the widget is unrealized or garbage-collected. A bridge assigned to a variable that goes out of scope before the window does can be collected out from under you.
Globals and the /register subpaths
Section titled “Globals and the /register subpaths”Importing @gjsify/canvas2d or @gjsify/iframe has no side effects: you get the named
exports and nothing lands on globalThis. The browser globals each package owns
(ImageData, Path2D, HTMLIFrameElement, the WebGL context constructors) live behind a
/register subpath, and gjsify build’s default --globals auto injects the ones your
bundle actually references. See
How it works if you want to pin them
by hand or build with --globals none.
See also
Section titled “See also”@gjsify/canvas2d@gjsify/webgl@gjsify/video@gjsify/iframe- DOM & Graphics packages for the polyfills behind the elements.
- Canvas2D fireworks, Excalibur jelly jumper and Three.js teapot are runnable showcases built on these bridges.