A couple days ago I made a simple POC of using Bun and OpenGL to create a native GUI application. I decided to go a little further and write a small cross-platform library so you can write fill OpenGL/WebGL apps including keyboard/mouse events.
I called it Glade, and you can see it on GitHub: github.com/vogtb/glade.
(Glade is a backronym for GL-assisted drawing environment, which seemed like a fun name for not-super-serious project.)
I sort of did something like this last year with Zig (Zig GUI from Scratch Part 1). But I never followed up with a Part 2, mostly because I attempted to use WebGPU/Dawn for the backend GL and it is wildly hard to define a JS-interop WASM-targeted interface based on the Dawn headers.
This actually ended up being far simpler than I thought. I have only couple main packages:
- @glade/core: Defines the cross-platform (ie Bun/Browser) GL context and window events that we’ll be implementing in our platform-specific packages.
- @glade/glfw: You need glfw as a dylib on your system, so I added github.com/glfw/glfw as a git submodule, and a pre-build step to build it as a dylib locally. More on this later
- @glade/browser: Uses DOM events bound to the window, and setup process that just uses the canvas to create the context.
- @glade/darwin: Uses the GL system bindings on macOS, and the @glade/glfw package to init the window, and bind events.
- @glade/platform: Proxies through
exportsin the package.json withtargets, sodarwinexports @glade/darwin, whilebrowserexports exports @glade/browser.
OpenGL/WebGL c-bindings
It’s fairly easy to match our GL interface to the OpenGL C bindings. It goes a little something like this:
// in @glade/darwin/gl.ts
export const lib = dlopen(GL_PATH, {
// State management
glEnable: { args: [FFIType.u32], returns: FFIType.void },
glDisable: { args: [FFIType.u32], returns: FFIType.void },
glIsEnabled: { args: [FFIType.u32], returns: FFIType.u8 },
glGetError: { args: [], returns: FFIType.u32 },
glHint: { args: [FFIType.u32, FFIType.u32], returns: FFIType.void },
});
// @glade/darwin/webgl2-context.ts
export class DarwinWebGL2RenderingContext {
// Canvas mock for compatibility
readonly canvas: HTMLCanvasElement;
drawingBufferWidth: number;
drawingBufferHeight: number;
drawingBufferColorSpace: PredefinedColorSpace = "srgb";
constructor(width: number, height: number) {
this.drawingBufferWidth = width;
this.drawingBufferHeight = height;
// Create a minimal canvas mock
this.canvas = {
width,
height,
clientWidth: width,
clientHeight: height,
} as HTMLCanvasElement;
}
// ============================================================
// WebGL Constants
// ============================================================
// Clear buffer bits
readonly DEPTH_BUFFER_BIT = 0x00000100;
readonly STENCIL_BUFFER_BIT = 0x00000400;
readonly COLOR_BUFFER_BIT = 0x00004000;
//... other consts ...
bindBuffer(target: GLenum, buffer: WebGLBuffer | null): void {
lib.symbols.glBindBuffer(target, getHandle(buffer));
}
bindFramebuffer(target: GLenum, framebuffer: WebGLFramebuffer | null): void {
lib.symbols.glBindFramebuffer(target, getHandle(framebuffer));
}
// ... link every single call in WebGLRenderingContext
}
Then we just sorta go to town by point every single method on WebGLRenderingContext to its C counterpart.
Mouse and keyboard events
The keyboard and mouse events aren’t super interesting, but are worth mentioning. It seemed easier to write event bindings for the browser that matched GLFW’s window event API rather than the other way around.
So we write a platform agnostic interface like this:
// Key action constants
export const KeyAction = {
Release: 0,
Press: 1,
Repeat: 2,
} as const;
export type KeyAction = (typeof KeyAction)[keyof typeof KeyAction];
// Mouse button constants
export const MouseButton = {
Left: 0,
Right: 1,
Middle: 2,
} as const;
export type MouseButton = (typeof MouseButton)[keyof typeof MouseButton];
// etc..
// etc..
// etc..
// Callback type definitions
export type KeyCallback = (event: KeyEvent) => void;
export type CharCallback = (event: CharEvent) => void;
export type MouseButtonCallback = (event: MouseButtonEvent) => void;
export type CursorMoveCallback = (event: CursorMoveEvent) => void;
export type ScrollCallback = (event: ScrollEvent) => void;
export type ResizeCallback = (event: ResizeEvent) => void;
export type CloseCallback = () => void;
export type FocusCallback = (event: FocusEvent) => void;
export type CursorEnterCallback = (event: CursorEnterEvent) => void;
export type RefreshCallback = () => void;
/**
* Event handler interface that contexts can implement.
* Returns a cleanup function to remove the listener.
*/
export interface EventTarget {
onKey(callback: KeyCallback): () => void;
onChar(callback: CharCallback): () => void;
onMouseButton(callback: MouseButtonCallback): () => void;
onCursorMove(callback: CursorMoveCallback): () => void;
onScroll(callback: ScrollCallback): () => void;
onResize(callback: ResizeCallback): () => void;
onClose(callback: CloseCallback): () => void;
onFocus(callback: FocusCallback): () => void;
onCursorEnter(callback: CursorEnterCallback): () => void;
onRefresh(callback: RefreshCallback): () => void;
}
And then adhere to it in our browser and darwin implementations:
// @glade/darwin/context.ts
export function createContext(options: DarwinContextOptions = {}): DarwinContext {
// ...
// ...
// glfw setup omitted for brevity...
// ...
// ...
return {
gl,
window,
width,
height,
destroy() {
// Clean up all registered callbacks
for (const cleanup of cleanups) {
cleanup();
}
cleanups.length = 0;
glfw.destroyWindow(window);
glfw.terminate();
},
onKey(callback: KeyCallback): () => void {
const cleanup = glfw.setKeyCallback(window, (_win, key, scancode, action, mods) => {
callback({
key,
scancode,
action: action as KeyAction,
mods,
});
});
cleanups.push(cleanup);
return () => {
cleanup();
const idx = cleanups.indexOf(cleanup);
if (idx >= 0) cleanups.splice(idx, 1);
};
},
// etc...
// etc...
// etc...
};
}
// @glade/browser/context.ts
createContext(options: BrowserContextOptions = {}): BrowserContext {
// ...
// ...
// canvas and window setup omitted for brevity...
// ...
// ...
return {
gl,
canvas,
width,
height,
destroy() {
// no-op on browser - page lifecycle handles cleanup.
},
onKey(callback: KeyCallback): () => void {
const handleKeyDown = (e: KeyboardEvent) => {
callback({
key: e.keyCode,
scancode: e.keyCode,
action: e.repeat ? KeyAction.Repeat : KeyAction.Press,
mods: getModifiers(e),
});
};
const handleKeyUp = (e: KeyboardEvent) => {
callback({
key: e.keyCode,
scancode: e.keyCode,
action: KeyAction.Release,
mods: getModifiers(e),
});
};
canvas.addEventListener("keydown", handleKeyDown);
canvas.addEventListener("keyup", handleKeyUp);
return () => {
canvas.removeEventListener("keydown", handleKeyDown);
canvas.removeEventListener("keyup", handleKeyUp);
};
},
// etc...
// etc...
// etc...
};
}
Embedding dylib at comptime
One of the more interesting parts here is how we use GLFW. Bun’s FFI (like most FFIs) only supports dynamic linking. So you have to point to something like /opt/homebrew/glfw/libglfw.dylib. This works well, and is what I did in the prototype. But if anyone downloads my package, they’ll have to run brew install glfw which is a pain. To avoid this, we use GLFW as a submodule, build it with a postinstall script, and point to ../../vendor/libglfw.dylib for GLFW_PATH when calling dlopen. Works like this:
import { dlopen, FFIType, ptr, type Pointer } from "bun:ffi";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
// Use vendored GLFW dylib built from submodule (at repo root)
const __dirname = dirname(fileURLToPath(import.meta.url));
const GLFW_PATH = join(__dirname, "..", "..", "vendor", "libglfw.dylib");
const lib = dlopen(GLFW_PATH, {
glfwInit: { args: [], returns: FFIType.i32 },
// ...
};
But there’s an interesting feature of Bun: you can use it to compile JS to static, self contained binaries. So if I wanted to distribute my GUI as a static bin, I could, except for the fact that I need the dylib on the system somewhere.
To solve for this we can use another fun Bun feature: file embedding. We use import ... with { type: "file" } to embed our pre-built libglfw.dylib directly in our binary at comptime. What’s cool is that at runtime we don’t even need to write it to disk. We can simply point to our imported file for dlopen. Like this:
import { dlopen, FFIType, ptr, JSCallback, type Pointer } from "bun:ffi";
// @ts-expect-error - Bun-specific import attribute
import GLFW_PATH from "../../vendor/libglfw.dylib" with { type: "file" };
console.log(`using embedded GLFW_PATH=${GLFW_PATH}`);
const lib = dlopen(GLFW_PATH, {
glfwInit: { args: [], returns: FFIType.i32 },
// ...
};
What Bun uses an embedded in-memory file system for files prefixed with /$bunfs. When we start our native app we see this:
~/dev/src/glade (main*) $ bun run --filter='*' build:native
@glade/demo build:native $ bun build ./demo.ts --compile --outfile=./dist/demo
│ [6ms] bundle 9 modules
│ [61ms] compile ./dist/demo
└─ Done in 78 ms
~/dev/src/glade (main*) $ bun run --filter='*' run:native
@glade/demo run:native $ ./dist/demo
│ using embedded GLFW_PATH=/$bunfs/root/libglfw-d9csjcyg.dylib
│ initializing WebGL2 demo...
│ demo initialized, rendering...
└─ Done in 2.53 s
And it just works. Cool!

Source code available at github.com/vogtb/glade.