bun-app-window 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # bun-app-window
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/bun-app-window?color=cb3837&logo=npm)](https://www.npmjs.com/package/bun-app-window)
4
- [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.3-f472b6?logo=bun&logoColor=white)](https://bun.sh)
4
+ [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.4-f472b6?logo=bun&logoColor=white)](https://bun.sh)
5
5
  [![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D7.0.2-3178C6?logo=typescript&logoColor=white)](#requirements--design-boundaries)
6
6
  [![Platform](https://img.shields.io/badge/Platform-Windows%20x64-0078D6?logo=windows&logoColor=white)](#requirements--design-boundaries)
7
7
  [![WebView2](https://img.shields.io/badge/WebView2-System%20Runtime-008080)](#requirements--design-boundaries)
@@ -26,13 +26,13 @@ await openShell({ url: `http://127.0.0.1:${server.port}`, flags: FLAG_FRAMELESS
26
26
 
27
27
  Before adopting this package, please review the following architectural constraints and trade-offs:
28
28
 
29
- | Constraint | Implication |
30
- | --- | --- |
31
- | Windows x64 only | Enforced via `os` and `cpu` fields in `package.json` (the platform-neutral package name is reserved for future expansion). A prebuilt `libshell.dll` is shipped in the package; no install-time compilation is required |
32
- | Bun runtime required | Ships raw TypeScript source files and requires Bun >= 1.4.3. While Node.js can install the package, it cannot execute it |
29
+ | Constraint | Implication |
30
+ | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | Windows x64 only | Enforced via `os` and `cpu` fields in `package.json` (the platform-neutral package name is reserved for future expansion). A prebuilt `libshell.dll` is shipped in the package; no install-time compilation is required |
32
+ | Bun runtime required | Ships raw TypeScript source files and requires Bun >= 1.4.4. While Node.js can install the package, it cannot execute it |
33
33
  | TypeScript >= 7.0.2 to type-check it | The API is designed and checked on TypeScript 7.0.2, pinned exactly in `devDependencies`; no compatibility work is done for older compilers. Above 7.0.2 the outcome is unasserted — `bun-types` only opens its `typesVersions: ">= 7.1"` gate there, which changes what a `with { type: "text" }` import resolves to |
34
- | Type-checking needs `@types/bun` | Because the package ships `.ts` sources, your own `bun check` compiles them too. Add `bun add -d @types/bun`; without it the checker reports `TS2868: Cannot find name 'Bun'` from inside `node_modules` |
35
- | Built on `bun:ffi` | `bun:ffi` is marked as experimental in Bun. While Bun recommends Node-API for production stability, this library chooses `bun:ffi` to achieve a zero-dependency footprint |
34
+ | Type-checking needs `@types/bun` | Because the package ships `.ts` sources, your own `bun check` compiles them too. Add `bun add -d @types/bun`; without it the checker reports `TS2868: Cannot find name 'Bun'` from inside `node_modules` |
35
+ | Built on `bun:ffi` | `bun:ffi` is marked as experimental in Bun. While Bun recommends Node-API for production stability, this library chooses `bun:ffi` to achieve a zero-dependency footprint |
36
36
 
37
37
  Runtime prerequisites and non-goals:
38
38
 
@@ -48,7 +48,7 @@ bun add bun-app-window
48
48
 
49
49
  ### What the tarball contains
50
50
 
51
- The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources — 22 files, 0.48 MB packed (1.39 MB unpacked). The native C++ layer (`native/`) and the vendored upstream headers (`vendor/`) are **not** shipped: they are only needed to rebuild `libshell.dll`, and rebuilding requires cloning the repository. `bun run build:native` and `build:exe` are therefore repository-only scripts that do nothing useful inside `node_modules`.
51
+ The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources — 22 files, 0.51 MB packed (1.50 MB unpacked). The native C++ layer (`native/`) and the vendored upstream headers (`vendor/`) are **not** shipped: they are only needed to rebuild `libshell.dll`, and rebuilding requires cloning the repository. `bun run build:native` and `build:exe` are therefore repository-only scripts that do nothing useful inside `node_modules`.
52
52
 
53
53
  ## Quick Start
54
54
 
@@ -56,22 +56,23 @@ The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript
56
56
  import { openShell, serveOnSafePort, FLAG_FRAMELESS, FLAG_CENTER } from "bun-app-window";
57
57
 
58
58
  const server = serveOnSafePort({
59
- fetch: () => new Response("<h1>Hello</h1>", {
60
- headers: { "content-type": "text/html; charset=utf-8" },
61
- }),
59
+ fetch: () =>
60
+ new Response("<h1>Hello</h1>", {
61
+ headers: { "content-type": "text/html; charset=utf-8" },
62
+ }),
62
63
  });
63
64
 
64
65
  const shell = await openShell({
65
- url: `http://127.0.0.1:${server.port}`,
66
- title: "My App",
67
- width: 1000,
68
- height: 680,
69
- flags: FLAG_FRAMELESS | FLAG_CENTER,
70
- minWidth: 480,
71
- minHeight: 320,
66
+ url: `http://127.0.0.1:${server.port}`,
67
+ title: "My App",
68
+ width: 1000,
69
+ height: 680,
70
+ flags: FLAG_FRAMELESS | FLAG_CENTER,
71
+ minWidth: 480,
72
+ minHeight: 320,
72
73
  });
73
74
 
74
- shell.post(JSON.stringify({ t: "ready" })); // Host -> Page one-way message
75
+ shell.post(JSON.stringify({ t: "ready" })); // Host -> Page one-way message
75
76
  shell.setTitle("Doing work…");
76
77
  ```
77
78
 
@@ -85,45 +86,49 @@ returns a stale message that looks like a fresh failure.
85
86
 
86
87
  ### Instance Methods & Properties
87
88
 
88
- | Member | Description |
89
- | --- | --- |
90
- | `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread |
91
- | `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent) |
92
- | `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready |
93
- | `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states). To wait for close, use `pollEvents()` and watch for `CLOSED` instead of polling this |
94
- | `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below |
95
- | `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }` |
96
- | `lastError()` | Returns the most recent failure message, or an empty string if no error occurred |
97
- | `setTitle` / `minimize` / `toggleMaximize` / `close` | Standard window control operations; each returns a status code `rc` |
98
- | `setMinSize(w, h)` | Sets the minimum window dimensions in logical pixels |
99
- | `drag()` | Initiates native window dragging from a page `pointerdown` event (preserves Windows Snap Layouts) |
100
- | `beginResize(edge)` | Initiates native border resizing from a page `pointermove` event (`edge` uses `RESIZE_EDGE.*` constants) |
101
- | `post(json)` | Sends a fire-and-forget JSON message from the host to the web page |
102
- | `setZoom(f)` / `getZoom()` | Gets or sets the page zoom factor (clamped automatically to `0.25`–`3.0`) |
103
- | `reload()` / `openDevTools()` / `toggleFullscreen()` | Equivalent to `Ctrl+R`, `F12`, and `F11` respectively |
104
- | `setFrameless(frameless)` / `isFrameless()` | Switches the window form at runtime and reports the current one. Only a window **created** with `FLAG_FRAMELESS` can switch; a framed-created window refuses with `rc=-1` and a reason in `lastError()`. See "Window Flags" below |
105
- | `setBrowserUi(mask)` | Disables specific default browser behaviors using a bitwise OR of `BROWSER_UI_*` flags |
106
- | `showContextMenu(x, y, items)` | Displays a native Win32 context menu at the specified screen coordinates (see below) |
107
- | `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread; **when exposed over IPC, pair it with `timeoutMs: 0`**, see below) |
108
- | `libInfo` | Metadata of the resolved native library (file path, resolution source, SHA-256 hash) |
109
- | `symbols` | Escape hatch providing direct access to the raw `dlopen` FFI symbols |
89
+ | Member | Description |
90
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
91
+ | `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread |
92
+ | `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent) |
93
+ | `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready |
94
+ | `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states). To wait for close, use `pollEvents()` and watch for `CLOSED` instead of polling this |
95
+ | `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below |
96
+ | `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }` |
97
+ | `getRect()` | Structured read of the current window rectangle — `{ x, y, width, height, maximized, dpi }` in physical pixels — or `null` when there is nothing usable: window not started, zero size/DPI, or the Win32 minimised sentinel rectangle |
98
+ | `getTitle()` | Reads the window's **actual** title (`GetWindowTextW`, not any cached input). `null` when there is no window **or** the title exceeds 511 characters — never a silently truncated string; `""` for an empty title. `setTitle()`'s `rc === 0` only proves the message was posted, so read here to know the effect happened |
99
+ | `getMinSize()` | Reads the **effective** minimum size as `{ width, height }` in logical pixels, or `null` when there is no window. Not "the last value you passed": `setMinSize(0, 0)` ignores non-positive values, so this is the one `WM_GETMINMAXINFO` actually uses |
100
+ | `getBrowserUi()` | The bitmask of browser behaviours already disabled. **Cumulative**: `BROWSER_UI_MENU` then `BROWSER_UI_KEYS` reads back as both, because `setBrowserUi()` only turns things off. Returns `0` before a window exists — an accurate answer, not a failure |
101
+ | `lastError()` | Returns the most recent failure message, or an empty string if no error occurred |
102
+ | `setTitle` / `minimize` / `toggleMaximize` / `close` | Standard window control operations; each returns a status code `rc` |
103
+ | `setMinSize(w, h)` | Sets the minimum window dimensions in logical pixels |
104
+ | `drag()` | Initiates native window dragging from a page `pointerdown` event (preserves Windows Snap Layouts) |
105
+ | `beginResize(edge)` | Initiates native border resizing from a page `pointermove` event (`edge` uses `RESIZE_EDGE.*` constants) |
106
+ | `post(json)` | Sends a fire-and-forget JSON message from the host to the web page |
107
+ | `setZoom(f)` / `getZoom()` | Gets or sets the page zoom factor (clamped automatically to `0.25`–`3.0`) |
108
+ | `reload()` / `openDevTools()` / `toggleFullscreen()` | Equivalent to `Ctrl+R`, `F12`, and `F11` respectively |
109
+ | `setFrameless(frameless)` / `isFrameless()` | Switches the window form at runtime and reports the current one. Only a window **created** with `FLAG_FRAMELESS` can switch; a framed-created window refuses with `rc=-1` and a reason in `lastError()`. See "Window Flags" below |
110
+ | `setBrowserUi(mask)` | Disables specific default browser behaviors using a bitwise OR of `BROWSER_UI_*` flags |
111
+ | `showContextMenu(x, y, items)` | Displays a native Win32 context menu at the specified screen coordinates (see below) |
112
+ | `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread; **when exposed over IPC, pair it with `timeoutMs: 0`**, see below) |
113
+ | `libInfo` | Metadata of the resolved native library (file path, resolution source, SHA-256 hash) |
114
+ | `symbols` | Escape hatch providing direct access to the raw `dlopen` FFI symbols |
110
115
 
111
116
  ### Window Flags
112
117
 
113
118
  Combine via bitwise OR (`|`) and pass to `opts.flags`:
114
119
 
115
- | Flag | Description |
116
- | --- | --- |
117
- | `FLAG_FRAMELESS` | Creates a frameless window without the default system title bar, delegating title bar rendering to the web UI. A window created this way can switch back to framed at runtime with `setFrameless(false)` |
118
- | `FLAG_RESIZABLE` | Allows user resizing |
119
- | `FLAG_CENTER` | Centers the window within the active monitor work area on startup (works in both standard and frameless modes) |
120
- | `FLAG_TOPMOST` | Keeps the window always on top |
121
- | `FLAG_DEFER_SHOW` | Creates the window hidden initially until `show()` is explicitly called |
122
- | `FLAG_ACCEPT_DROP` | Enables native file and folder drag-and-drop from Windows Explorer |
123
- | `FLAG_START_MAXIMIZED` | Opens the window directly in a maximized state without an intermediate normal-sized frame flash |
124
- | `FLAG_ALLOW_EXTERNAL_NAV` | Opts out of external-navigation interception — for apps that genuinely are browsers |
125
- | `FLAG_TITLEBAR_RESERVE` | In frameless mode, shifts the web content down by one title-bar height so the top strip belongs to the native window. Superseded by `FLAG_NATIVE_CAPTIONS`: the caption hit zone is pinned to client `y ∈ [0, 32)`, so this variant puts the page's own title bar 32 px away from where clicks are actually routed |
126
- | `FLAG_NATIVE_CAPTIONS` | In frameless mode, draws the three caption buttons on a layered child window owned by the shell and answers `HTMAXBUTTON` there. This is the only way Snap Layouts can trigger: the WebView2 render child lives in another process and always returns `HTCLIENT`. Pair it with `mountTitleBar({ captions: "native" })` so the page reserves `46 × 3` instead of drawing its own buttons |
120
+ | Flag | Description |
121
+ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
122
+ | `FLAG_FRAMELESS` | Creates a frameless window without the default system title bar, delegating title bar rendering to the web UI. A window created this way can switch back to framed at runtime with `setFrameless(false)` |
123
+ | `FLAG_RESIZABLE` | Allows user resizing |
124
+ | `FLAG_CENTER` | Centers the window within the active monitor work area on startup (works in both standard and frameless modes) |
125
+ | `FLAG_TOPMOST` | Keeps the window always on top |
126
+ | `FLAG_DEFER_SHOW` | Creates the window hidden initially until `show()` is explicitly called |
127
+ | `FLAG_ACCEPT_DROP` | Enables native file and folder drag-and-drop from Windows Explorer |
128
+ | `FLAG_START_MAXIMIZED` | Opens the window directly in a maximized state without an intermediate normal-sized frame flash |
129
+ | `FLAG_ALLOW_EXTERNAL_NAV` | Opts out of external-navigation interception — for apps that genuinely are browsers |
130
+ | `FLAG_TITLEBAR_RESERVE` | In frameless mode, shifts the web content down by one title-bar height so the top strip belongs to the native window. Superseded by `FLAG_NATIVE_CAPTIONS`: the caption hit zone is pinned to client `y ∈ [0, 32)`, so this variant puts the page's own title bar 32 px away from where clicks are actually routed |
131
+ | `FLAG_NATIVE_CAPTIONS` | In frameless mode, draws the three caption buttons on a layered child window owned by the shell and answers `HTMAXBUTTON` there. This is the only way Snap Layouts can trigger: the WebView2 render child lives in another process and always returns `HTCLIENT`. Pair it with `mountTitleBar({ captions: "native" })` so the page reserves `46 × 3` instead of drawing its own buttons |
127
132
 
128
133
  ### External navigation is blocked by default
129
134
 
@@ -170,18 +175,18 @@ import htmlText from "./index.html" with { type: "text" };
170
175
  const html = htmlText as unknown as string;
171
176
 
172
177
  const app = await createApp({
173
- html,
174
- title: "My App",
175
- flags: FLAG_FRAMELESS, // opt in to a self-drawn title bar
176
- browserUi: BROWSER_UI_ALL,
177
- service: {
178
- greet: (name: string) => `hi ${name}`,
179
- pickFolder: () => app.shell.pickFolder(),
180
- },
178
+ html,
179
+ title: "My App",
180
+ flags: FLAG_FRAMELESS, // opt in to a self-drawn title bar
181
+ browserUi: BROWSER_UI_ALL,
182
+ service: {
183
+ greet: (name: string) => `hi ${name}`,
184
+ pickFolder: () => app.shell.pickFolder(),
185
+ },
181
186
  });
182
187
 
183
- await app.wait(); // resolves when the window closes
184
- await app.stop(); // releases the event pump and the server; idempotent
188
+ await app.wait(); // resolves when the window closes
189
+ await app.stop(); // releases the event pump and the server; idempotent
185
190
  ```
186
191
 
187
192
  Pass `page` to serve a bundled page script without writing a route yourself. `createApp` answers
@@ -209,7 +214,7 @@ Two more things on `App` that save a call and a subpath import:
209
214
  ```ts
210
215
  // page.ts — inside index.html's bundle
211
216
  import { connect } from "bun-app-window/ipc";
212
- const api = await connect(); // token comes from the page URL's ?t=
217
+ const api = await connect(); // token comes from the page URL's ?t=
213
218
  await api.greet("Ada");
214
219
  ```
215
220
 
@@ -225,6 +230,20 @@ What it does for you:
225
230
  `server.stop()`.
226
231
  - Starts exactly one window-event pump: `app.events`. It is also what waits for close, so it is
227
232
  the single consumer. Pass `{ events: app.events }` when you need `attachPlacementStore`.
233
+ - Reports page-side errors back to the host. Uncaught exceptions and unhandled rejections are
234
+ captured by an injected listener and posted over HTTP — not over the WebSocket, which does not
235
+ exist yet when a page script fails during evaluation. Pass `onPageError` to receive them; without
236
+ a callback they go to the host's stderr rather than being discarded.
237
+ - Checks `html` against `page` before starting. Supplying one without the other is a configuration
238
+ error whose only symptom is a window that renders and responds to nothing, so it throws. A
239
+ reference without `type="module"` produces a warning instead: the legal ways to write a
240
+ `<script>` tag are too varied for a string match to justify blocking startup.
241
+ - Serves extra static files with `assets`. A compiling framework usually emits more than one
242
+ artifact — typically a script plus a stylesheet — and `assets` is a path-to-text map with the
243
+ `content-type` inferred from the extension. Before this, every consumer hand-wrote a `routes`
244
+ callback for the stylesheet, and the usual mistake (a wrong MIME type) makes the browser drop the
245
+ stylesheet silently. Unrecognised extensions fall back to `application/octet-stream` rather than
246
+ guessing. A caller's `routes` still wins, and unregistered paths are 404.
228
247
  - Startup failures leave nothing half-started: if `openShell` throws, the bound port is released.
229
248
 
230
249
  Notes:
@@ -247,7 +266,7 @@ Notes:
247
266
  - **Not re-exported from the main entry.** It is a subpath like `./state` and `./ipc`. It imports
248
267
  `openShell` from `./index`; exporting it from `index.ts` would make that a cycle.
249
268
 
250
- This is **not** a replacement for `Bun.WebView`. That one is a *headless* browser for automation,
269
+ This is **not** a replacement for `Bun.WebView`. That one is a _headless_ browser for automation,
251
270
  testing and scraping — it has no window, and it can only screenshot its own page. This package
252
271
  draws a real desktop window with a system title bar, DPI awareness, snap layouts and taskbar
253
272
  identity. The two do not compete.
@@ -267,28 +286,46 @@ export type Events = { windowState: { maximized: boolean } };
267
286
  import { createIpcServer } from "bun-app-window/ipc";
268
287
 
269
288
  const ipc = createIpcServer({
270
- service: { greet: (name: string) => `Hello ${name}` },
289
+ service: { greet: (name: string) => `Hello ${name}` },
271
290
  });
272
291
 
273
292
  serveOnSafePort({
274
- fetch(req) { /* Serve assets & authenticate */ },
275
- websocket: {
276
- open: (ws) => ipc.attach(ws),
277
- message: (ws, raw) => ipc.handle(ws, raw),
278
- close: (ws) => ipc.detach(ws),
279
- },
293
+ fetch(req) {
294
+ /* Serve assets & authenticate */
295
+ },
296
+ websocket: {
297
+ open: (ws) => ipc.attach(ws),
298
+ message: (ws, raw) => ipc.handle(ws, raw),
299
+ close: (ws) => ipc.detach(ws),
300
+ },
280
301
  });
281
302
 
282
- ipc.emit("windowState", { maximized: false }); // Push event to connected pages
303
+ ipc.emit("windowState", { maximized: false }); // Push event to connected pages
283
304
  ```
284
305
 
306
+ #### Delivering state to late connections
307
+
308
+ A page loads _after_ the window is created. Anything the host emits between `createApp` returning
309
+ and the page's WebSocket opening is delivered to zero connections and is not replayed — so a page
310
+ that reads its initial state from events alone stays empty, with no error on either side.
311
+
312
+ Mark state-shaped events as sticky and the server replays the last payload to each new connection:
313
+
314
+ ```ts
315
+ ipc.emit("windowState", { maximized: false }, { sticky: true }); // Replayed on attach()
316
+ ```
317
+
318
+ Sticky is **opt-in** and keeps one payload per event name, not a history. Events that are
319
+ notifications rather than state should stay non-sticky: a late connection would otherwise receive
320
+ one stale message that reads like a fresh one.
321
+
285
322
  ```ts
286
323
  // Page
287
324
  import { connect } from "bun-app-window/ipc";
288
325
  import type { Api, Events } from "./api";
289
326
 
290
327
  const api = await connect<Api, Events>();
291
- await api.greet("Ada"); // Inferred return type: string
328
+ await api.greet("Ada"); // Inferred return type: string
292
329
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
293
330
  ```
294
331
 
@@ -319,17 +356,22 @@ The client is a `Proxy`: a handful of names resolve to the client itself and nev
319
356
  method. If your service exposes a method under one of these names, **the server-side method becomes
320
357
  unreachable** — the page calls the client's own member instead, and neither end reports an error.
321
358
 
322
- | Name | What the page actually gets |
323
- | --- | --- |
324
- | `on` / `off` | Event subscription helpers |
325
- | `close()` | Closes the WebSocket channel — every later call is rejected |
326
- | `connected` | `boolean`: whether the channel is open |
327
- | `pending` | `number` of calls still awaiting a reply |
328
- | `then` | `undefined`, so `await client` cannot hang on a never-resolving thenable |
359
+ | Name | What the page actually gets |
360
+ | ------------ | ------------------------------------------------------------------------ |
361
+ | `on` / `off` | Event subscription helpers |
362
+ | `close()` | Closes the WebSocket channel — every later call is rejected |
363
+ | `connected` | `boolean`: whether the channel is open |
364
+ | `pending` | `number` of calls still awaiting a reply |
365
+ | `then` | `undefined`, so `await client` cannot hang on a never-resolving thenable |
329
366
 
330
367
  Symbol keys are reserved as well. Pick a different service name (`winClose` instead of `close`) and
331
368
  map it back on the page side — see the title bar section below.
332
369
 
370
+ Because the failure above is invisible at runtime, `createIpcServer` **throws at creation time** when
371
+ the service table contains any of these names. The error names the offending keys and suggests a
372
+ replacement. Pass `{ rejectReservedNames: false }` only when the table is generated from external
373
+ input and validated by other means.
374
+
333
375
  ### `bun-app-window/titlebar` — Windows 11 Title Bar
334
376
 
335
377
  Mounts a customizable Windows 11 title bar inside frameless windows. The IPC service is **flat** —
@@ -339,12 +381,12 @@ so the host exposes `winClose` and the page maps it back:
339
381
  ```ts
340
382
  // Host — flat service; `close` is reserved by the IPC client
341
383
  createIpcServer({
342
- service: {
343
- minimize: () => shell.minimize(),
344
- toggleMaximize: () => shell.toggleMaximize(),
345
- winClose: () => shell.close(),
346
- drag: () => shell.drag(),
347
- },
384
+ service: {
385
+ minimize: () => shell.minimize(),
386
+ toggleMaximize: () => shell.toggleMaximize(),
387
+ winClose: () => shell.close(),
388
+ drag: () => shell.drag(),
389
+ },
348
390
  });
349
391
  ```
350
392
 
@@ -353,14 +395,14 @@ createIpcServer({
353
395
  import { mountTitleBar, bridgeFromWindowService } from "bun-app-window/titlebar";
354
396
 
355
397
  const bar = mountTitleBar({
356
- title: "My App",
357
- icon: "/icon.svg",
358
- bridge: bridgeFromWindowService({
359
- minimize: () => api.minimize(),
360
- toggleMaximize: () => api.toggleMaximize(),
361
- close: () => api.winClose(), // mapped back to the bridge's own name
362
- drag: () => api.drag(),
363
- }),
398
+ title: "My App",
399
+ icon: "/icon.svg",
400
+ bridge: bridgeFromWindowService({
401
+ minimize: () => api.minimize(),
402
+ toggleMaximize: () => api.toggleMaximize(),
403
+ close: () => api.winClose(), // mapped back to the bridge's own name
404
+ drag: () => api.drag(),
405
+ }),
364
406
  });
365
407
 
366
408
  api.on("windowState", (s) => bar.setMaximized(s.maximized));
@@ -388,7 +430,7 @@ if (saved) restorePlacement(shell, saved);
388
430
 
389
431
  shell.show();
390
432
 
391
- const store = attachPlacementStore(shell, FILE); // Subscribes to window events; writes once the drag settles
433
+ const store = attachPlacementStore(shell, FILE); // Subscribes to window events; writes once the drag settles
392
434
  process.on("beforeExit", () => store.flush());
393
435
  ```
394
436
 
@@ -426,8 +468,10 @@ event stream directly:
426
468
  import { EVENT_CLOSED, EVENT_STATE } from "bun-app-window/events";
427
469
 
428
470
  for (const e of shell.pollEvents()) {
429
- if (e.type === EVENT_CLOSED) break; // window is gone, the native singleton is released
430
- if (e.type === EVENT_STATE && e.maximized) { /* ... */ }
471
+ if (e.type === EVENT_CLOSED) break; // window is gone, the native singleton is released
472
+ if (e.type === EVENT_STATE && e.maximized) {
473
+ /* ... */
474
+ }
431
475
  }
432
476
  ```
433
477
 
@@ -439,7 +483,9 @@ exactly one consumer. For a fixed cadence plus multi-subscriber fan-out, use
439
483
  import { EVENT_CLOSED, attachEventPump } from "bun-app-window/events";
440
484
 
441
485
  const pump = attachEventPump(shell, { intervalMs: 16 });
442
- pump.on((e) => { if (e.type === EVENT_CLOSED) pump.stop(); });
486
+ pump.on((e) => {
487
+ if (e.type === EVENT_CLOSED) pump.stop();
488
+ });
443
489
  pump.onGap((missing) => console.warn(`missed ${missing} events`));
444
490
  ```
445
491
 
@@ -461,8 +507,8 @@ Exposes internal metrics and hit-testing utilities. Note: This module is exclude
461
507
  import { debugOf } from "bun-app-window/debug";
462
508
 
463
509
  const d = debugOf(shell);
464
- d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
465
- d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
510
+ d.metrics(); // → "pos=196,196 win=1960x1400 client=1960x1400 work=3072x1824 border=13x13 round=1"
511
+ d.dpi(); // → "awareness=-1 dpi=192 scale=200%"
466
512
  d.hittest(x, y);
467
513
  ```
468
514
 
@@ -511,6 +557,60 @@ await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" }
511
557
  `bytes` is a UTF-8 byte count, not `string.length`; the two differ on any source containing non-ASCII
512
558
  text, and a reported size that only looks right is worse than no reported size.
513
559
 
560
+ #### Compiling frameworks: `plugins`, `define`, `loader`
561
+
562
+ These three are passed straight through to `Bun.build`; nothing about them is specific to any
563
+ framework, and this package ships no plugins of its own.
564
+
565
+ ```ts
566
+ const built = await bundlePage({
567
+ entry: "src/main.ts",
568
+ plugins: [myFrameworkPlugin()], // .vue / .svelte / .tsx — whatever your plugin handles
569
+ define: { __VUE_OPTIONS_API__: "false" }, // compile-time constants
570
+ loader: { ".thing": "ts" }, // custom extensions
571
+ });
572
+ ```
573
+
574
+ #### `alias` is implemented here, because Bun has no such option
575
+
576
+ `BuildConfig` in Bun 1.4.4 has no `alias` property, and passing one is not an error — it is silently
577
+ ignored. Four forms were tried and all failed to resolve. So this package backs `alias` with its own
578
+ `onResolve` plugin:
579
+
580
+ ```ts
581
+ await bundlePage({ entry, alias: { "@": "./src", lib: "./src/lib" } });
582
+ ```
583
+
584
+ | Specifier | Rewritten? |
585
+ | ------------------------------------- | ---------- |
586
+ | equal to a key, or prefixed by `key/` | yes |
587
+ | starts with `.` or `/` | no |
588
+ | has a `scheme:` prefix | no |
589
+
590
+ **The default is empty.** `$lib` is a SvelteKit convention and `@/` is some project's `tsconfig`
591
+ alias; neither belongs here. Aliases merge with your plugins rather than replacing them, and the
592
+ plugin is only registered when you pass `alias`.
593
+
594
+ #### Multiple outputs
595
+
596
+ A compiling framework often produces more than one artifact — a script plus a stylesheet is the
597
+ common shape. `outputs` lists all of them with `path` / `kind` / `code` / `bytes`, and `code` /
598
+ `bytes` refer to the `entry-point` artifact specifically (not `outputs[0]`, because output order is
599
+ not a contract).
600
+
601
+ `writePageBundle` writes every artifact: the entry to `out`, the rest to the same directory with the
602
+ extension substituted. `page.bundle.txt` therefore yields `page.bundle.css`, **not** `main.css` — the
603
+ entry's filename must not leak into a path that HTML and `assets` reference. It returns
604
+ `written: Array<{ path, bytes }>` so cleanup removes every file it created.
605
+
606
+ ```text
607
+ $ bun run bundle:page
608
+ 109,888 B dist/page.bundle.txt ← entry-point
609
+ 133 B dist/page.bundle.css ← asset
610
+ ```
611
+
612
+ Serve the extra files with `createApp({ assets })` — see the `app` section above.
613
+
514
614
  Failure throws with Bun's full diagnostics. Both shapes are handled: compile-time problems come back
515
615
  as `{ success: false, logs }`, while an unreachable entry point **throws an `AggregateError`** whose
516
616
  `message` is only `Bundle failed` and whose detail lives on `.errors`.
@@ -541,13 +641,13 @@ const shell = await openShell({ url, browserUi: BROWSER_UI_ALL });
541
641
 
542
642
  Individual flags can also be combined via bitwise OR:
543
643
 
544
- | Flag | Disabled Behavior |
545
- | --- | --- |
546
- | `BROWSER_UI_MENU` | Default Edge right-click context menu |
547
- | `BROWSER_UI_KEYS` | Built-in browser shortcuts (`Ctrl+P`, `Ctrl+F`, `Ctrl+S`) |
548
- | `BROWSER_UI_STATUSBAR` | Bottom-left URL hover status bar |
549
- | `BROWSER_UI_SWIPE` | Trackpad horizontal swipe back/forward navigation |
550
- | `BROWSER_UI_AUTOFILL` | Form autofill popups |
644
+ | Flag | Disabled Behavior |
645
+ | ---------------------- | --------------------------------------------------------- |
646
+ | `BROWSER_UI_MENU` | Default Edge right-click context menu |
647
+ | `BROWSER_UI_KEYS` | Built-in browser shortcuts (`Ctrl+P`, `Ctrl+F`, `Ctrl+S`) |
648
+ | `BROWSER_UI_STATUSBAR` | Bottom-left URL hover status bar |
649
+ | `BROWSER_UI_SWIPE` | Trackpad horizontal swipe back/forward navigation |
650
+ | `BROWSER_UI_AUTOFILL` | Form autofill popups |
551
651
 
552
652
  Note: `F11`, `F12`, and `Ctrl±` zoom shortcuts are handled directly by the native shell and remain active regardless of these flags.
553
653
 
@@ -558,26 +658,22 @@ After disabling the default browser menu, render native Win32 popup menus (`Trac
558
658
  ```ts
559
659
  // Host: expose the menu capability on the flat service
560
660
  createIpcServer({
561
- service: {
562
- showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
563
- },
661
+ service: {
662
+ showContextMenu: (x: number, y: number, items: string) => shell.showContextMenu(x, y, items),
663
+ },
564
664
  });
565
665
  ```
566
666
 
567
667
  ```ts
568
668
  // Page: trigger the native menu
569
669
  document.addEventListener("contextmenu", async (e) => {
570
- e.preventDefault();
571
- await api.showContextMenu(
572
- Math.round(e.screenX),
573
- Math.round(e.screenY),
574
- "open|Open\nrefresh|Refresh\n-\nquit|Quit"
575
- );
670
+ e.preventDefault();
671
+ await api.showContextMenu(Math.round(e.screenX), Math.round(e.screenY), "open|Open\nrefresh|Refresh\n-\nquit|Quit");
576
672
  });
577
673
 
578
674
  // Page: handle menu item selection
579
675
  window.addEventListener("shell-msg", (e) => {
580
- if (e.detail.t === "menu") console.log("Selected ID:", e.detail.id);
676
+ if (e.detail.t === "menu") console.log("Selected ID:", e.detail.id);
581
677
  });
582
678
  ```
583
679
 
@@ -597,7 +693,7 @@ const shell = await openShell({ url, flags: FLAG_ACCEPT_DROP });
597
693
  ```js
598
694
  // Page
599
695
  window.addEventListener("shell-msg", (e) => {
600
- if (e.detail.t === "drop") console.log("Dropped paths:", e.detail.paths);
696
+ if (e.detail.t === "drop") console.log("Dropped paths:", e.detail.paths);
601
697
  });
602
698
  ```
603
699
 
@@ -605,10 +701,10 @@ Files **and folders** are forwarded by a script the shell injects, so the page d
605
701
 
606
702
  ```js
607
703
  window.addEventListener("shell-msg", (e) => {
608
- if (e.detail.t !== "drop") return;
609
- for (const p of e.detail.paths) {
610
- console.log(e.detail.dirs.includes(p) ? "folder" : "file", p);
611
- }
704
+ if (e.detail.t !== "drop") return;
705
+ for (const p of e.detail.paths) {
706
+ console.log(e.detail.dirs.includes(p) ? "folder" : "file", p);
707
+ }
612
708
  });
613
709
  ```
614
710
 
@@ -619,14 +715,27 @@ When running in an elevated administrator process (High Integrity Level) while E
619
715
  The shell does not inject any implicit DOM nodes or stylesheets into your page. To make your web UI feel like a native desktop app, include the following baseline rules:
620
716
 
621
717
  ```css
622
- html, body { cursor: default; }
623
- body { user-select: none; }
624
- input, textarea, .log { user-select: text; cursor: auto; }
625
- button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
718
+ html,
719
+ body {
720
+ cursor: default;
721
+ }
722
+ body {
723
+ user-select: none;
724
+ }
725
+ input,
726
+ textarea,
727
+ .log {
728
+ user-select: text;
729
+ cursor: auto;
730
+ }
731
+ button:focus-visible {
732
+ outline: 2px solid var(--accent);
733
+ outline-offset: 2px;
734
+ }
626
735
  ```
627
736
 
628
737
  ```html
629
- <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off">
738
+ <input type="text" spellcheck="false" autocomplete="off" autocapitalize="off" />
630
739
  ```
631
740
 
632
741
  Setting `spellcheck="false"` is particularly important—red squiggly underlines beneath file paths and numeric inputs immediately give away a web view.
@@ -637,7 +746,7 @@ When bundling with `bun build --compile`, import `bun-app-window/embedded` to em
637
746
 
638
747
  ```ts
639
748
  import { createShell } from "bun-app-window";
640
- import { embeddedDll } from "bun-app-window/embedded"; // Embeds the DLL into the binary
749
+ import { embeddedDll } from "bun-app-window/embedded"; // Embeds the DLL into the binary
641
750
 
642
751
  const shell = await createShell({ url, embedded: embeddedDll });
643
752
  ```
@@ -677,18 +786,18 @@ At runtime, `libshell.dll` is located in the following order:
677
786
 
678
787
  ### Built-in Keyboard & Mouse Shortcuts
679
788
 
680
- | Input | Behavior |
681
- | --- | --- |
682
- | `Ctrl +` / `Ctrl -` / `Ctrl 0` | Zoom in / Zoom out / Reset zoom to 100% |
683
- | `Ctrl + Mouse Wheel` | Zoom in / Zoom out |
684
- | `F11` | Toggle fullscreen mode (hides window borders and covers the taskbar) |
685
- | `F5` / `Ctrl + R` | Reload page |
686
- | `F12` | Open WebView2 Developer Tools |
687
- | `Alt + Space` / Right-click title bar | Open native Windows system menu |
789
+ | Input | Behavior |
790
+ | ------------------------------------- | -------------------------------------------------------------------- |
791
+ | `Ctrl +` / `Ctrl -` / `Ctrl 0` | Zoom in / Zoom out / Reset zoom to 100% |
792
+ | `Ctrl + Mouse Wheel` | Zoom in / Zoom out |
793
+ | `F11` | Toggle fullscreen mode (hides window borders and covers the taskbar) |
794
+ | `F5` / `Ctrl + R` | Reload page |
795
+ | `F12` | Open WebView2 Developer Tools |
796
+ | `Alt + Space` / Right-click title bar | Open native Windows system menu |
688
797
 
689
798
  ## Development & Verification
690
799
 
691
- Built with Bun 1.4.3 and TypeScript 7.0.2 with full strict type-checking enabled.
800
+ Built with Bun 1.4.4 and TypeScript 7.0.2 with full strict type-checking enabled.
692
801
 
693
802
  **This project has no remote CI.** GitHub Actions cannot allocate runners for this account (billing lock), and the project is a single-platform, single-developer package with no external service dependencies — so the gates run locally instead. The recommended entry point is:
694
803
 
@@ -715,10 +824,10 @@ Whenever `src/shell/` or `package.json` is modified, the consumer acceptance sta
715
824
 
716
825
  For window enumeration (`EnumWindows`) or automated screenshot scripts, filter by the corresponding Win32 window class name:
717
826
 
718
- | Window Mode | Window Class Name |
719
- | --- | --- |
720
- | Standard framed (without `FLAG_FRAMELESS`) | `webview` |
721
- | Frameless (with `FLAG_FRAMELESS`) | `webview_shell` |
827
+ | Window Mode | Window Class Name |
828
+ | ------------------------------------------ | ----------------- |
829
+ | Standard framed (without `FLAG_FRAMELESS`) | `webview` |
830
+ | Frameless (with `FLAG_FRAMELESS`) | `webview_shell` |
722
831
 
723
832
  ## Acknowledgments
724
833