klain: namespace

klain:webview — desktop applications

A compiled binary that opens a real window, renders an HTML/CSS/JS UI, and whose buttons call straight into your typed native code. This is the Tauri architecture — the system browser engine (WKWebView on macOS, WebKitGTK on Linux) for the UI, this compiler for the backend — but same-process: no separate renderer, no IPC serialization beyond a JSON string. Any SPA that builds to static assets (React, Vue, Svelte, vanilla) can be the frontend.

macOS needs zero dependencies (WebKit ships with the OS). Linux needs the WebKitGTK dev packages at build time.

import { Webview } from 'klain:webview'
import { readdirSync } from 'fs'

const w = new Webview({ title: 'My App', width: 900, height: 600 })

// Expose a native function to the page: window.listDir(...)
w.bind('listDir', (args: string): string => {
  const req = JSON.parse(args)          // the page's argument list, as JSON
  const entries = readdirSync(req[0])   // real native fs call
  return JSON.stringify(entries)        // resolves the page-side promise
})

w.html(`<!doctype html><button onclick="show()">List</button>
  <ul id="out"></ul>
  <script>
    async function show() {
      const names = await window.listDir(JSON.stringify(['.']))
      out.innerHTML = names.map(n => '<li>' + n + '</li>').join('')
    }
  <\/script>`)

w.run()   // blocks — the window is the program now

How the page talks to native code

Yes — it works like Electron/Tauri's exposed functions, but simpler. w.bind(name, cb) injects a window.name(...) function into every page. Calling it from page JS returns a Promise and invokes your native callback on the GUI thread. There is no contextBridge and no preload script — the binding is direct.

  • Page → native: await window.myFn(...). The callback receives the call's arguments as a JSON-array string; it returns a JSON string (or nothing), which resolves the page-side promise. Throwing rejects it. JSON.parse/ JSON.stringify are the whole contract — explicit, no hidden marshaling.
  • Native → page: w.eval(js) fires JavaScript into the page (thread-safe — a Worker can push UI updates), and every bound call's return value flows back as its promise result.

Inside a bound callback you have the full synchronous runtime — fs, spawnSync, crypto, the fetch client, everything already shipped. And callbacks can be async: a callback returning Promise<string> settles the page-side promise when it resolves.

Typed bindings — no hand-written JSON

Pass a bindings object to the constructor and the compiler does the marshaling: the page's arguments are decoded into your callback's declared parameter types and the return value is JSON-encoded automatically. The object's keys are the entire exposed surface — nothing reaches window unless you put it there, which is the security boundary. (w.bind() stays the raw string-in/out escape hatch; w.bindTyped(name, fn) is the imperative typed form.)

import { Webview } from 'klain:webview'

interface Point { x: number; y: number }

const w = new Webview({
  title: 'My App', width: 900, height: 600,
  bindings: {
    // args decode into (number, number); the number return JSON-encodes
    add: (a: number, b: number): number => a + b,
    // returns an object — encoded to JSON for the page
    mkPoint: (x: number, y: number): Point => ({ x, y }),
  },
})

// page:  const p = await window.mkPoint(4, 5)   // -> { x: 4, y: 5 }
w.run()

// The bindings value can also be a variable holding the API object —
// same typing, same allowlist — handy when the surface is assembled elsewhere:
//   const api = { doSth: () => 'did it', log: (m: string) => console.log(m) }
//   const w = new Webview({ title: 'My App', bindings: api })

The event loop runs on the GUI thread under run(). On macOS and Linux the GUI's main loop drives the runtime's whole loop, so timers, promise reactions, fetch, fs.promises and even an HTTP server work with no Worker. On Windows a page-driven tick pump runs timers and promise reactions; servers and other descriptor-driven loops there still belong in a Worker.

Serving a full SPA

Two patterns, both shipped:

  • Inline — w.html(...) with an HTML string (a single-file SPA build works here). The string is compiled into the binary as a constant, so this pattern is self-contained — no external files.
  • Embedded (single-file) — new Webview({ serve: './dist' }) compiles the whole built directory into the binary and serves it from an in-binary static server; the result is one self-contained executable with no dist/ beside it. This is the recommended way to ship a multi-asset SPA/SSG.
  • Served (external) — or run your own http.createServer in a Worker and navigate to it, if you'd rather serve from disk or a remote.
import { Webview } from 'klain:webview'

// Embeds ./dist into the binary at compile time and serves it — one file, no
// external assets at runtime.
const w = new Webview({ title: 'My App', width: 900, height: 600, serve: './dist' })
w.run()

Any framework build works — quasar build (SPA or SSG), vite build, … Binary assets (fonts/images) are served byte-exact. For lower-level control, import { embedDir } from 'klain:assets' gives an EmbeddedAssets handle with .get(path): ArrayBuffer.

The V1 surface

CallEffect
new Webview({ title, width, height, debug })Create the window (one per process in V1). debug: true enables devtools.
w.navigate(url) / w.html(doc)Load a URL / an inline document
w.bind(name, cb) / w.unbind(name)Expose / remove a window.name(...) native function
w.eval(js) / w.init(js)Run JS now / before every page load
w.setTitle(s) / w.setSize(w, h)Update the window chrome
w.run()Enter the GUI loop — blocks; statements after it are the shutdown path
w.terminate() / w.destroy()End run() / tear the window down

Typed bindings can be async too — a callback returning Promise<T> settles the page promise with the JSON-encoded T. And klainmain --emit-window-dts writes a Window.d.ts from your bindings, so the page-side SPA gets autocomplete and typechecking on window.* (and you get a machine-checkable record of the exact contract).

Not yet: one window per process (multi-window is a later stage).

Packaging — a double-clickable app

Add --package and the compiler wraps the binary into a desktop app for the host platform: a .app bundle on macOS (with an Info.plist and optional icon) or a .desktop launcher on Linux. On macOS the bundle is what gives the window proper foreground activation.

# compile + wrap into "Klain Demo.app" (double-clickable on macOS)
klainmain --package --app-name "Klain Demo" \
  --app-icon icon.png app.ts

open "Klain Demo.app"

Metadata comes from flags: --app-name, --app-id, --app-version, and --app-icon (a .icns or .png on macOS; a .png/.svg on Linux). Bare --package uses sensible defaults. It bundles the executable — self-contained inline-HTML apps package perfectly; bundling a served SPA's dist/ folder is a follow-up.