/** * @mandujs/core/desktop — window factory * * Wraps `webview-bun` (optional peer dependency, MIT, tr1ckydev/webview-bun * 2.4.0+). Phase 9c R0 diagnostic: * - docs/bun/phase-9-diagnostics/webview-bun-ffi.md * * Design rules: * 1. **Lazy import** — `webview-bun` must NOT be loaded when this module is * merely imported. A web-only project running `bun test` should pass * even if the peer is absent. The import happens on the first * `createWindow()` call, with a clear install-me error on failure. * 2. **No side-channel globals** — each handle is self-contained; multiple * windows are allowed in a single process (though not a common use * case). * 3. **Never surface the `Webview` instance** — consumers only see * {@link WindowHandle}. Backend swaps (Bun.WebView native, direct FFI) * stay transparent. * * Threading model: `webview-bun`'s `run()` is blocking and must be on the * thread that owns the window. For use with `Bun.serve()`, the standard * pattern is **Worker-based**: launch the server on the main thread, spawn * a Worker, and call `createWindow()` inside it. See `./worker.ts` for the * canonical entry. When `autoRun: false`, callers who control their own * event loop (e.g. running the window on the main thread while the HTTP * server sits in a Worker) can call `handle.run()` themselves. */ import type { WindowHandle, WindowOptions, WindowSizeHint, } from "./types.js"; // ─── Optional-peer loader ─────────────────────────────────────────────────── /** * Cached module once loaded. We do NOT pre-load at module evaluation — if * `webview-bun` is missing, `import @mandujs/core/desktop` must still succeed * (so `bun test` in a CI without the peer passes cleanly). */ type WebviewBunModule = { // We intentionally type the imported module as `any` here because // `webview-bun` publishes types that depend on its FFI pointers. A tighter // type contract is not worth pulling the peer's type graph into `core`. // Consumers never see this — they work against WindowHandle. Webview: new ( debug?: boolean, size?: { width: number; height: number; hint: number } | null, window?: unknown, ) => { title: string; size: { width: number; height: number; hint: number }; navigate(url: string): void; setHTML(html: string): void; init(source: string): void; eval(source: string): void; bind(name: string, cb: (...args: unknown[]) => unknown): void; unbind(name: string): void; run(): void; destroy(): void; }; SizeHint: { NONE: number; MIN: number; MAX: number; FIXED: number }; }; let webviewBunCache: WebviewBunModule | null = null; /** * Lazy-load `webview-bun`. Throws with an actionable error message when the * peer is missing — the only surface on which end users hit this is desktop * launch, so we can afford a long-form hint. * * @internal */ export async function _loadWebviewBun(): Promise { if (webviewBunCache) return webviewBunCache; try { // Dynamic import so `bun test` in a CI without `webview-bun` installed // still passes. The import specifier is a bare module — no file-path // probing — so bundlers can tree-shake the whole desktop subtree in a // web-only build. // // `@ts-ignore` is used because `webview-bun` is an OPTIONAL peer — tsc // must not hard-fail module resolution when the peer is absent. The // runtime behaviour is guarded: the try/catch below rethrows a clean // "please install" error if the import itself rejects at runtime. // @ts-ignore -- optional peer, may not be resolvable at typecheck time const mod = (await import("webview-bun")) as unknown as WebviewBunModule; webviewBunCache = mod; return mod; } catch (cause) { throw new Error( [ "[@mandujs/core/desktop] Failed to load the optional peer 'webview-bun'.", "Install it alongside Mandu for desktop targets:", "", " bun add webview-bun", "", "Then pin the version in package.json. Tested: ^2.4.0 (MIT).", "Docs: https://github.com/tr1ckydev/webview-bun", ].join("\n"), { cause }, ); } } /** * Reset the lazy-load cache. Tests only. * * @internal */ export function _resetWebviewBunCache(): void { webviewBunCache = null; } // ─── Size hint mapping ────────────────────────────────────────────────────── /** * Map the string hint to `webview-bun`'s `SizeHint` numeric enum. We accept * the string because (a) the string survives Worker `postMessage` cleanly * and (b) it doesn't pin our public API to the peer's enum numbering. * * @internal */ export function _mapSizeHint( hint: WindowSizeHint | undefined, enumRef: WebviewBunModule["SizeHint"], ): number { switch (hint) { case "fixed": return enumRef.FIXED; case "min": return enumRef.MIN; case "max": return enumRef.MAX; case "none": case undefined: default: return enumRef.NONE; } } // ─── Option validation ────────────────────────────────────────────────────── /** * Validates `options` before we touch the FFI peer. Throws `TypeError` on * the first problem found. * * @internal */ export function _validateOptions(options: WindowOptions): void { if (!options || typeof options !== "object") { throw new TypeError( "[@mandujs/core/desktop] createWindow: options must be an object.", ); } if (typeof options.url !== "string" || options.url.length === 0) { throw new TypeError( "[@mandujs/core/desktop] createWindow: 'url' must be a non-empty string.", ); } // Accept http/https/file/data — reject everything else. Remote URLs work // but are actively discouraged; document that elsewhere. const allowedProtocols = ["http:", "https:", "file:", "data:"]; let parsed: URL; try { parsed = new URL(options.url); } catch { throw new TypeError( `[@mandujs/core/desktop] createWindow: 'url' is not a valid URL: ${JSON.stringify( options.url, )}.`, ); } if (!allowedProtocols.includes(parsed.protocol)) { throw new TypeError( `[@mandujs/core/desktop] createWindow: 'url' protocol ${parsed.protocol} is not allowed (use http/https/file/data).`, ); } if (options.width !== undefined) { if ( typeof options.width !== "number" || !Number.isFinite(options.width) || options.width <= 0 ) { throw new TypeError( "[@mandujs/core/desktop] createWindow: 'width' must be a positive finite number.", ); } } if (options.height !== undefined) { if ( typeof options.height !== "number" || !Number.isFinite(options.height) || options.height <= 0 ) { throw new TypeError( "[@mandujs/core/desktop] createWindow: 'height' must be a positive finite number.", ); } } if ( options.hint !== undefined && !["none", "min", "max", "fixed"].includes(options.hint) ) { throw new TypeError( `[@mandujs/core/desktop] createWindow: 'hint' must be one of none|min|max|fixed (got ${JSON.stringify( options.hint, )}).`, ); } if (options.handlers !== undefined) { if (typeof options.handlers !== "object" || options.handlers === null) { throw new TypeError( "[@mandujs/core/desktop] createWindow: 'handlers' must be an object of functions.", ); } for (const [name, fn] of Object.entries(options.handlers)) { if (typeof fn !== "function") { throw new TypeError( `[@mandujs/core/desktop] createWindow: handlers.${name} must be a function.`, ); } } } } // ─── Defaults ─────────────────────────────────────────────────────────────── /** @internal */ export const _DEFAULTS: Required> = { title: "Mandu Desktop", width: 1024, height: 768, hint: "none", debug: false, }; // ─── createWindow ─────────────────────────────────────────────────────────── /** * Create a desktop window backed by the system WebView (WebView2 on Windows, * WKWebView on macOS, WebKitGTK on Linux). Optional peer `webview-bun` must * be installed. * * The returned {@link WindowHandle} does NOT auto-start the platform event * loop — callers must either call `handle.run()` (blocking) or await * `handle.closed`. In Worker-based setups the loop is typically started by * the Worker host (see `./worker.ts`). * * @example Main-thread use (window only, no HTTP server): * ```ts * import { createWindow } from "@mandujs/core/desktop"; * * const win = await createWindow({ * url: "https://example.com", * title: "Read later", * width: 1200, * height: 800, * }); * win.run(); // blocks until user closes * ``` * * @example With a Mandu server (Worker pattern — recommended): * ```ts * // main.ts * import { startServer } from "@mandujs/core"; * import manifest from "../../.mandu/manifest.json" with { type: "json" }; * * const server = startServer(manifest, { port: 0, hostname: "127.0.0.1" }); * const worker = new Worker(new URL("./worker.ts", import.meta.url)); * worker.postMessage({ * type: "open", * options: { url: `http://127.0.0.1:${server.server.port}`, title: "My App" }, * }); * ``` */ export async function createWindow( options: WindowOptions, ): Promise { _validateOptions(options); // Phase 11 C / M-02 — FFI fallback path. When `MANDU_DESKTOP_INLINE_FFI=1` // is set, OR when `webview-bun` dynamic import fails at runtime, we try // the `bun:ffi` fallback that binds directly to the upstream // `webview/webview` C library. The fallback is a supply-chain mitigation // for the webview-bun single-maintainer risk — see // `docs/bun/phase-9-diagnostics/webview-bun-ffi.md` §8. // // Behaviour matrix: // MANDU_DESKTOP_INLINE_FFI=1: // → SKIP webview-bun, go straight to FFI fallback. // webview-bun resolves cleanly: // → primary path (normal flow below). // webview-bun rejects with a module-not-found error AND fallback // succeeds: log a one-time hint, use fallback. // Both fail: rethrow the webview-bun "install me" error (the original // actionable hint). const forceFFI = process.env.MANDU_DESKTOP_INLINE_FFI === "1"; if (forceFFI) { const { createFallbackWebview } = await import("./webview-fallback.js"); return createFallbackWebview(options); } let peer: WebviewBunModule; try { peer = await _loadWebviewBun(); } catch (primaryError) { // Try the fallback. If it also fails, surface the PRIMARY error since // it carries the actionable "bun add webview-bun" hint users expect. try { const { createFallbackWebview } = await import("./webview-fallback.js"); return await createFallbackWebview(options); } catch { throw primaryError; } } const { Webview, SizeHint } = peer; const merged = { ..._DEFAULTS, ...options, }; const hintNum = _mapSizeHint(merged.hint, SizeHint); // Construct the webview. `webview-bun` uses constructor args for size+hint // and exposes setters for title/size post-construction. const wv = new Webview(merged.debug, { width: merged.width, height: merged.height, hint: hintNum, }); // Title must be set post-ctor — webview-bun API shape. try { wv.title = merged.title; } catch (error) { // Some libwebview builds throw if the window hasn't been realized yet; // best-effort, not fatal. if (merged.debug) { console.warn("[@mandujs/core/desktop] title set warning:", error); } } // Pre-register handlers BEFORE navigation so the page's first script doesn't // see an undefined global. if (options.handlers) { for (const [name, fn] of Object.entries(options.handlers)) { try { wv.bind(name, fn); } catch (error) { throw new Error( `[@mandujs/core/desktop] Failed to bind handler "${name}": ${ error instanceof Error ? error.message : String(error) }`, { cause: error }, ); } } } // Set up closed-signal wiring. `webview-bun` does not expose a native // close event, so we rely on `run()` returning OR an explicit `destroy()` // call to flip the flag. let closed = false; let resolveClosed: (() => void) | null = null; const closedPromise = new Promise((resolve) => { resolveClosed = resolve; }); const closeCallbacks: Array<() => void> = []; function markClosed(): void { if (closed) return; closed = true; // Run user callbacks first so their exceptions don't prevent Promise // resolution. We swallow exceptions to match `setTimeout` semantics. for (const cb of closeCallbacks) { try { cb(); } catch (error) { console.error( "[@mandujs/core/desktop] onClose callback threw:", error, ); } } if (options.onClose) { try { const result = options.onClose(); if (result instanceof Promise) { result.catch((error) => console.error( "[@mandujs/core/desktop] onClose (options) threw:", error, ), ); } } catch (error) { console.error( "[@mandujs/core/desktop] onClose (options) threw:", error, ); } } resolveClosed?.(); } // Navigate AFTER handlers are registered, so the first page load can // already call any bound globals. try { wv.navigate(merged.url); } catch (error) { // Navigation failure is fatal — tear down and rethrow. try { wv.destroy(); } catch { /* ignore cleanup errors */ } throw new Error( `[@mandujs/core/desktop] navigate() failed: ${ error instanceof Error ? error.message : String(error) }`, { cause: error }, ); } // Fire onReady on the next microtask so callers that chain `await // createWindow(...)` can attach listeners first. if (options.onReady) { queueMicrotask(() => { try { const result = options.onReady!(); if (result instanceof Promise) { result.catch((error) => console.error( "[@mandujs/core/desktop] onReady threw:", error, ), ); } } catch (error) { console.error("[@mandujs/core/desktop] onReady threw:", error); } }); } const handle: WindowHandle = { async close() { if (closed) return; try { wv.destroy(); } catch (error) { // `webview-bun` #35: destroy() from a timer doesn't always interrupt // run(). We still mark closed so the `closed` promise resolves — the // native run() will exit on its own once the user closes the shell. if (merged.debug) { console.warn("[@mandujs/core/desktop] destroy() warning:", error); } } markClosed(); }, onClose(cb: () => void) { if (closed) { // Match `addEventListener('load')` semantics on a ready document — // fire on the next microtask so ordering is deterministic. queueMicrotask(() => { try { cb(); } catch (error) { console.error( "[@mandujs/core/desktop] onClose callback threw:", error, ); } }); return; } closeCallbacks.push(cb); }, async eval(js: string) { if (closed) { throw new Error( "[@mandujs/core/desktop] eval() called on closed window.", ); } if (typeof js !== "string" || js.length === 0) { throw new TypeError( "[@mandujs/core/desktop] eval: 'js' must be a non-empty string.", ); } wv.eval(js); }, bind(name: string, fn: (...args: unknown[]) => unknown) { if (closed) { throw new Error( "[@mandujs/core/desktop] bind() called on closed window.", ); } if (typeof name !== "string" || name.length === 0) { throw new TypeError( "[@mandujs/core/desktop] bind: 'name' must be a non-empty string.", ); } if (typeof fn !== "function") { throw new TypeError( "[@mandujs/core/desktop] bind: 'fn' must be a function.", ); } wv.bind(name, fn); }, closed: closedPromise, run() { if (closed) { // No-op — already closed. `webview-bun`'s run() on a destroyed // instance would crash; avoid that class of footgun. return; } try { wv.run(); } finally { // run() returned → the window was closed (either natively or via // destroy()). Flip the flag. markClosed(); } }, }; return handle; }