A tiny desktop framework using Bun and WebView. Light, simple, and just works. If your app needs more than this, use Electron or similar instead.
No IPC. Communication between the frontend and backend happens over a local WebSocket.
NPM Packages:
- https://www.npmjs.com/package/webview-bun (WebView)
- https://www.npmjs.com/package/@trayjs/trayjs (Tray integration)
- https://www.npmjs.com/package/node-notifier (Notification library)
- https://www.npmjs.com/package/protocol-registry (Registry for custom protocol handlers)
All of the above support Windows, macOS, and Linux.
- The main process runs a local
Bun.serve()that serves your static files (HTML, CSS, JS, images, fonts, etc.) and a WebSocket endpoint for RPC and events. - The webview window runs in a separate child process that connects back over that WebSocket.
- This keeps the server responsive even while the window's native event loop is running.
bun add buntop
bunx buntop init # interactive: scaffolds buntopconfig.json (and index.js if missing)
bunx buntop init --yes # non-interactive: writes a default config (also used automatically when stdin isn't a TTY)
bunx buntop build devimport { BunTop } from "buntop";
const app = new BunTop({ title: "My App", width: 800, height: 600 });
app.expose("getVersion", () => "1.0.0");
app.start({ path: "./ui" }); // directory containing index.html, or a single .html filestart() accepts one of:
path- an.htmlfile or a directory containingindex.html. Other local files referenced from it (.css,.js, images, fonts, etc.) are served alongside it.html- an inline HTML string.url- a remote URL to navigate to.
Pass sandbox: true to load less-trusted content (e.g. a remote url): this disables
the buntop IPC bridge, the /__buntop_ws and /__buntop_focus endpoints, and devtools,
so the page has no access to exposed functions or backend APIs.
const version = await buntop.call("getVersion");
window.addEventListener("buntop:tick", (e) => console.log(e.detail));app.expose(name, fn)- register a function callable from the frontend viabuntop.call(name, ...args).app.emit(event, data)- dispatch abuntop:<event>CustomEvent to the frontend.app.notify(options)- show a native OS notification. (docs)app.tray(options)- create a system tray icon and menu. (docs)app.protocol.register/deregister/isRegistered/getDefaultApp- manage custom URL protocol handlers. (docs)app.dialog.openFile/saveFile/openFolder/message/confirm- native file and message dialogs. (docs)app.updater.check/download/apply- check for, download, and install updates. (docs)app.open(target)- open a URL or file/folder path in the OS default browser/app.app.paths.home/appData/config/cache/temp/logs/documents/downloads/desktop/pictures/music/videos- standard OS directories. (docs)app.paths.userData/userCache/userLogs- per-app, per-channel directories ({appData|cache|logs}/{identifier}/{channel}, channel fromBUNTOP_CHANNEL, default "dev").identifiercomes from"identifier"inbuntopconfig.jsonfor compiled builds, falling back topackage.json's"name". (docs)app.moveToTrash(path)- move a file or folder to the OS trash/recycle bin.app.log.debug/info/warn/error(...)- rolling, newline-delimited JSON file logger (see "Logging").app.close()- close the window and exit cleanly.
When singleInstance is enabled, launching a second copy of the app focuses
the existing window (via window.focus()) and emits buntop:second-instance
with { argv } (the new process's argv.slice(2)) in the running instance,
then exits the new process. (docs)
new BunTop({
title: "My App",
width: 800,
height: 600,
sizeHint: "none", // "none" | "min" | "max" | "fixed"
debug: false, // enable webview devtools (always off in beta/release builds)
shareEnv: false, // include process.env in buntop.info on the frontend
singleInstance: false, // only allow one running instance per app + channel
log: {}, // logger options, or `false` to disable logging
});examples/minimal.js- the smallest possible app.examples/basic.js- exposed functions, events, and the frontendbuntopAPI.examples/tray.js- system tray icon, menu, and notifications.examples/notes-app/- a small but complete app: static site serving, paths, dialogs, tray, notifications, the trash, and single-instance handling.
The window closes and the process exits cleanly on:
- the window being closed by the user,
- Ctrl+C (SIGINT) or SIGTERM,
- an uncaught error,
- the backend process exiting for any other reason.
Set "icon" in buntopconfig.json to a single source .png (ideally 1024x1024).
The build script (bun run build:<channel>) uses icon-gen to generate, into
{outDir}/icons/:
icon.ico- used for the compiled exe (--windows-icon)icon.icns- for macOS app bundlesfavicon.ico- served automatically as/favicon.icoif your site/HTML doesn't have its ownicon16.png...icon1024.png- for tray icons, notifications, and Linux desktop files
{
"name": "my-app",
"title": "My App",
"icon": "./assets/icon.png",
"entry": "index.js"
}Then wire the generated files up:
- Compiled exe icon / title: set automatically from
"icon"and"title"for Windows targets. - Window / titlebar icon: not set by BunTop. Use an HTML
<link rel="icon">tag, or a<head>favicon, in your page. - Tray icon:
app.tray({ icon: { png: "./dist/icons/icon32.png", ico: "./dist/icons/icon.ico" } }). - Notification icon:
app.notify({ icon: "./dist/icons/icon128.png", ... }).
You can also set "icon" directly to a pre-made .ico file to skip generation.
app.log.info("ready", { port: app.server.port });
app.log.error("something broke", err);
console.log("log file:", app.log.path()); // {logs}/{identifier}/{channel}/app.log- Writes newline-delimited JSON (
{ time, level, message }) toapp.paths.userLogs(). - Rotates to
app.log.1,app.log.2, ... once the active file exceedsmaxSize(default 5MB), keepingmaxFilesrotated files (default 5). - Uncaught exceptions, unhandled rejections, server/socket errors, and webview crashes are logged automatically.
app.log.files()returns the active log file plus any rotated ones, useful for attaching to a bug report or crash uploader.- Pass
log: falseto disable logging entirely.
const update = await app.updater.check({
currentVersion: app.info().version,
source: { type: "github", repo: "owner/repo", asset: process.platform === "win32" ? /\.exe$/ : /linux/ },
// or: { type: "manifest", url: "https://example.com/update.json" }
// or: { type: "file", path: "./update.json" }
});
if (update.hasUpdate) {
const file = await app.updater.download(update, "./update-download");
app.updater.apply(file); // replaces the running binary and restarts (compiled builds only)
}Manifest/file sources are JSON: { "version": "1.2.3", "url": "https://.../app-linux", "notes": "..." }.
url may be http(s)://, a local path, or file://.
buntopconfig.json defines how bunx buntop build <channel> (dev, alpha, beta, release)
compiles your app with bun build --compile. Run bunx buntop init to generate a starter config.
{
"name": "buntop-app",
"entry": "examples/basic.js",
"outDir": "dist",
"buildBin": "build-bin",
"icon": "./examples/icon.png",
"license": "./LICENSE",
"identifier": "online.sectly.buntop-app",
"title": "BunTop App",
"description": "A standalone BunTop application",
"publisher": "Sectly",
"version": "0.0.1",
"copyright": "Copyright 2026 Sectly",
"channels": {
"dev": { "targets": ["current"], "minify": false, "sourcemap": true, "bytecode": false },
"release": { "targets": ["bun-windows-x64", "bun-linux-x64", "bun-linux-arm64", "bun-darwin-x64", "bun-darwin-arm64"], "minify": true, "sourcemap": false, "bytecode": false }
}
}title,description,publisher,version,copyrightare embedded as Windows executable metadata (--windows-title/-description/-publisher/-version/-copyright).iconis embedded as the.exeicon (--windows-icon) and used to generate the app/window/taskbar icons described above.identifier(e.g.online.sectly.buntop-app) is the app's bundle/package ID. It's embedded into the binary (used forapp.paths.userData/userCache/userLogs) and used as the macOSCFBundleIdentifierand Linux.desktopfilename. Defaults tocom.<publisher>.<name>.- The selected channel is embedded via
__BUNTOP_CHANNEL__and exposed at runtime (BUNTOP_CHANNEL); all Windows builds use--windows-hide-console(console output still works when run from a terminal);betaandreleasebuilds also have devtools disabled. - The build script prints per-target progress, timing, and output file sizes, and exits non-zero if any target fails to build.
beta and release builds also produce an installer/bundle for each target
alongside the portable executable (set "installer": false/true on a channel
to override). The compiled exe still works standalone if you'd rather ship that.
- Windows: an NSIS
*-setup.exewith Start Menu + Desktop shortcuts and an uninstaller.makensisis downloaded automatically and extracted intobuildBin(default./build-bin, configurable inbuntopconfig.json) on first use, so no manual NSIS install is needed and this works from any host OS. Safe to delete; it'll be re-downloaded on the next build.- The setup wizard lets the user choose a per-user install (
%LOCALAPPDATA%, no prompt) or an all-users install (Program Files, triggers a UAC prompt only in that case). - If a previous version is detected (via the registry), the installer offers to silently uninstall it first.
- If the app is currently running during install or uninstall, the user is prompted to close it before continuing.
- Installs/uninstalls a standard Add/Remove Programs entry.
- If
"license"is set, it's shown as a license-acceptance page.
- The setup wizard lets the user choose a per-user install (
- macOS: a
*.appbundle (withInfo.plistand.icnsicon, plus the license file inResourcesif"license"is set). Ifhdiutilis available (i.e. building on macOS), also produces a*.dmg. - Linux: an
*.AppDirwith a.desktopfile, icon,AppRunlauncher, and the license file if"license"is set, packaged as a*.tar.gz.
If a step can't be completed (e.g. .dmg generation off macOS), the build logs
a warning and skips just that part - the portable executable/bundle is still
built normally.
If BunTop can't start (unsupported OS, missing Bun runtime, missing system webview
dependencies, or a webview crash), it prints an error with install hints and writes
the failure to the log file (so it's visible even in --windows-hide-console builds),
then exits with a non-zero code instead of leaving a silent/blank window.
- SQLite (Bun's built-in SQLite)