AphrodyBun GitHub

Runtime docs · Guides · Runtime & Debugging

Open a native Win32 window with bun:ffi

On Windows, bun:ffi can call user32.dll, kernel32.dll and gdi32.dll directly. No npm package or native addon is needed: register a window class, create the window, and run the message loop from JavaScript. The window procedure is a JSCallback that Windows calls synchronously from DispatchMessageW.

window.ts
import { dlopen, FFIType, JSCallback, ptr } from "bun:ffi";

const { ptr: p, u16, u32, i32, u64, i64, bool, void: none } = FFIType;
const { symbols: k32 } = dlopen("kernel32.dll", { GetModuleHandleW: { args: [p], returns: p } });
const { symbols: u } = dlopen("user32.dll", {
  RegisterClassExW: { args: [p], returns: u16 },
  CreateWindowExW: { args: [u32, p, p, u32, i32, i32, i32, i32, p, p, p, p], returns: p },
  ShowWindow: { args: [p, i32], returns: bool },
  DestroyWindow: { args: [p], returns: bool },
  DefWindowProcW: { args: [p, u32, u64, i64], returns: i64 },
  GetMessageW: { args: [p, p, u32, u32], returns: i32 },
  TranslateMessage: { args: [p], returns: bool },
  DispatchMessageW: { args: [p], returns: i64 },
  PostQuitMessage: { args: [i32], returns: none },
});

const WM_DESTROY = 0x0002;
const WM_KEYDOWN = 0x0100;
const VK_ESCAPE = 0x1b;
const wstr = (s: string) => Buffer.from(s + "\0", "utf16le");

const wndProc = new JSCallback(
  (hwnd, msg: number, wParam: bigint, lParam: bigint): bigint => {
    if (msg === WM_KEYDOWN && wParam === BigInt(VK_ESCAPE)) u.DestroyWindow(hwnd);
    else if (msg === WM_DESTROY) u.PostQuitMessage(0);
    else return u.DefWindowProcW(hwnd, msg, wParam, lParam);
    return 0n;
  },
  { args: [p, u32, u64, i64], returns: i64 },
);

const hInstance = k32.GetModuleHandleW(null);
const className = wstr("BunWindow");

// WNDCLASSEXW is 80 bytes on 64-bit Windows.
const wc = Buffer.alloc(80);
wc.writeUInt32LE(80, 0); // cbSize
wc.writeUInt32LE(0x0003, 4); // CS_HREDRAW | CS_VREDRAW
wc.writeBigUInt64LE(BigInt(wndProc.ptr!), 8); // lpfnWndProc
wc.writeBigUInt64LE(BigInt(hInstance ?? 0), 24); // hInstance
wc.writeBigUInt64LE(6n, 48); // hbrBackground = COLOR_WINDOW + 1
wc.writeBigUInt64LE(BigInt(ptr(className)), 64); // lpszClassName
u.RegisterClassExW(wc);

const CW_USEDEFAULT = -0x80000000;
const hwnd = u.CreateWindowExW(
  0,
  className,
  wstr("Hello from Bun"),
  0x00cf0000,
  CW_USEDEFAULT,
  CW_USEDEFAULT,
  640,
  400,
  null,
  null,
  hInstance,
  null,
);
u.ShowWindow(hwnd, 5); // SW_SHOW

// MSG is 48 bytes on 64-bit Windows.
const msg = Buffer.alloc(48);
while (u.GetMessageW(msg, null, 0, 0) > 0) {
  u.TranslateMessage(msg);
  u.DispatchMessageW(msg);
}
wndProc.close();
bun window.ts

GetMessageW blocks the JavaScript thread, so timers, promises and I/O callbacks do not run while the window is open. Use Win32 timers (SetTimer + WM_TIMER) for periodic work inside the loop.

Tested platforms

The fixture prints window created and window closed with exit code 0 on:

  • Windows 11: user32 and gdi32
  • Ubuntu 26.04: Wine 10.0 running the Windows bun.exe under xvfb-run

The Linux images are scripts/aphrody/gui/ubuntu.Dockerfile and scripts/aphrody/gui/alpine.Dockerfile.


A fuller version that paints text with GDI (BeginPaint, DrawTextW, SetTextColor) and accepts --title, --width, --height and --timeout lives in the Bun repository at test/js/bun/ffi/win32-window.fixture.ts.

See Docs > Runtime > FFI for the full bun:ffi reference.