bun-webview2 0.0.0-stage → 0.3.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/CHANGELOG.md +40 -0
- package/LICENSE +28 -0
- package/README.md +184 -2
- package/native/webview.dll +0 -0
- package/package.json +41 -4
- package/src/cdp.ts +173 -0
- package/src/host-ffi.ts +244 -0
- package/src/index.ts +22 -0
- package/src/inject.ts +112 -0
- package/src/serve.ts +34 -0
- package/src/window.ts +540 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-10-13
|
|
4
|
+
|
|
5
|
+
- Window layer replaced: hosts now drive the [C `webview` library](https://github.com/webview/webview) through `bun:ffi` — zero npm runtime dependencies. The DLL (~540 KB) is built once with `zig c++` (no MSVC, no cmake); sources and pinned WebView2 SDK headers live in the build workspace.
|
|
6
|
+
- Command channel: `bwv2_host_cmd` bound via `webview_bind`; the host replies through `webview_return`, so the page-side promise the parent awaits resolves exactly when the host applies the command — no separate ack protocol. All host JS runs re-entrantly inside `webview_run`'s native Win32 loop; there is no JS event loop to wedge.
|
|
7
|
+
- Window controls (decorations, always-on-top, position, min/max, focus) via FFI'd user32 on the webview's HWND.
|
|
8
|
+
- Parents kill their host children on exit (no orphaned hosts wedging the shared browser for the data folder).
|
|
9
|
+
- Why the switch: the wry/tao host's Windows "non-blocking" pump (`run_return`) could freeze a host's entire JS event loop (verified upstream, root cause in webviewjs's `app.rs`), requiring three workarounds (page-relay commands, stdout acks, resend-until-ack). The C library has no JS loop at all, eliminating the failure class.
|
|
10
|
+
- Window events, split by layer: window-level events (`move`, `resize`, `focus`/`blur` via `WM_ACTIVATE`, `theme-changed`) are forwarded from a subclassed window procedure (`SetWindowLongPtrW` via FFI, chained with `CallWindowProcW`); page-surface events (`key-down`/`key-up`, `mouse-*`, `scroll`, `file-drop`) are DOM events captured by an injected bridge and forwarded through the existing CDP binding. The split exists because input over the page never reaches the top-level window procedure (the WebView2 child HWND consumes it) — verified by live interaction testing.
|
|
11
|
+
- `file-drop` delivers `File` descriptors (name/size/type) from the DOM drop event — not filesystem paths.
|
|
12
|
+
- Benchmarks unchanged by the event architecture (cold ~0.8 s, evaluate/IP C sub-millisecond).
|
|
13
|
+
|
|
14
|
+
## 0.2.1 — 2026-10-12
|
|
15
|
+
|
|
16
|
+
- Window commands no longer depend on the host process's JS event loop. On Windows, webviewjs's pump is tao's blocking `run_return()`, which can freeze a host's whole Bun loop (timers, sockets, stdin). Commands now ride the page: `window.ipc.postMessage(...)` via CDP, delivered natively by wry, acked on stdout — immune to the wedge. (stdin and TCP control channels both proved unreliable and were removed.)
|
|
17
|
+
- Hosts exit when their window closes (user close or `close` command) instead of lingering.
|
|
18
|
+
- Window events verified to flow across CDP-driven navigations in both wedged and healthy hosts (an earlier "events stop after navigation" claim was a test artifact — the probe resized to an already-current size, so no event could fire).
|
|
19
|
+
|
|
20
|
+
## 0.2.0 — 2026-10-12
|
|
21
|
+
|
|
22
|
+
- Native window events on `win.on(...)`, typed and forwarded from the host process: `move`, `resize`, `focus`, `blur`, `key-down`, `key-up`, `mouse-enter/leave/down/up`, `scroll`, `file-drop` (drag & drop), `file-hover`, `file-hover-cancel`, `theme-changed`.
|
|
23
|
+
- `win.cdp` — direct access to the live CDP connection (Network, DOM, Input, Emulation, …).
|
|
24
|
+
- `serveApp(handler)` — serve app content from Bun on a free loopback port (fetch-style `Request → Response`); the answer to custom protocols.
|
|
25
|
+
|
|
26
|
+
## 0.1.0 — 2026-10-12
|
|
27
|
+
|
|
28
|
+
Initial release.
|
|
29
|
+
|
|
30
|
+
- `Webview2Window` / `createWindow`: native WebView2 windows on Windows, created and controlled from Bun.
|
|
31
|
+
- One host process per window (`src/host.ts` via `@webviewjs/webview`): wry/tao only supports window creation before its event loop is ever pumped, so each window gets a fresh, proven creation context — plus per-window crash isolation.
|
|
32
|
+
- Page control over CDP loopback: `navigate`, `setHTML`, `evaluate`, `screenshot`.
|
|
33
|
+
- Page → Bun IPC via `win.expose()` / `window.bwv2.call()`, JSON round-tripped, error propagation included.
|
|
34
|
+
- Default hardening: Chromium context menu and DevTools/browser accelerators blocked (opt out with `contextMenu` / `devToolsKeys`).
|
|
35
|
+
- Native window controls proxied: title, size, position, decorations, always-on-top, focus, minimize, maximize.
|
|
36
|
+
- Content deliberately never flows through wry's `loadUrl`/`loadHtml` (first-navigation stall); everything is delivered via CDP.
|
|
37
|
+
- Targets are "primed" with one throwaway `about:blank` navigation before CDP setup: on a never-navigated webview, `Runtime.enable` and even `Page.navigate` can wedge forever.
|
|
38
|
+
- Per-app WebView2 data folder (`dataDir` option; default keyed to the project directory) so different Bun webview apps don't share one browser process; same-app second processes are treated as single-instance conflicts.
|
|
39
|
+
- `BWV2_HOST_EXE` lets compiled executables ship a pre-compiled host binary.
|
|
40
|
+
- Every CDP call rejects after a timeout (default 30s) instead of hanging forever on a wedged target; the target-wait failure names its likely causes (second app instance, hard-killed previous run) and remedies.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bun-webview2 contributors
|
|
4
|
+
|
|
5
|
+
The bundled native/webview.dll incorporates source code from the webview
|
|
6
|
+
project (https://github.com/webview/webview), Copyright (c) 2017 Serge
|
|
7
|
+
Zaitsev, Copyright (c) 2022 Steffen André Langnes, licensed under the MIT
|
|
8
|
+
License. The WebView2 SDK headers used to compile it are Copyright (c)
|
|
9
|
+
Microsoft Corporation and are used under their license; no Microsoft code is
|
|
10
|
+
redistributed in this package.
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,185 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bun-webview2
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Desktop apps with Bun on Windows — native **WebView2** windows you fully control from Bun, driven over the **Chrome DevTools Protocol**.
|
|
4
|
+
|
|
5
|
+
No browser UI. No tab strip, no address bar, no Edge "App bar", no first-run nags. Your window, your icon, your process — and sub-millisecond control of the page inside it.
|
|
6
|
+
|
|
7
|
+
Zero npm runtime dependencies: the window layer is the [C `webview` library](https://github.com/webview/webview) — a single ~540 KB DLL, built once with `zig c++` (no MSVC, no cmake) and driven directly through `bun:ffi`.
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add bun-webview2
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createWindow } from "bun-webview2";
|
|
17
|
+
|
|
18
|
+
const win = await createWindow({
|
|
19
|
+
title: "My App",
|
|
20
|
+
width: 900,
|
|
21
|
+
height: 600,
|
|
22
|
+
html: `<h1>Hello from Bun ${Bun.version}</h1>`,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Call Bun from the page
|
|
26
|
+
await win.expose("readFile", async (path: string) => Bun.file(path).text());
|
|
27
|
+
// ...then in the page: `await window.bwv2.call("readFile", "notes.txt")`
|
|
28
|
+
|
|
29
|
+
// Drive the page from Bun
|
|
30
|
+
await win.navigate("https://example.com");
|
|
31
|
+
const title = await win.evaluate<string>("document.title");
|
|
32
|
+
|
|
33
|
+
await Bun.write("shot.png", await win.screenshot());
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Serve your frontend from Bun itself — the custom-protocol answer:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createWindow, serveApp } from "bun-webview2";
|
|
40
|
+
|
|
41
|
+
const served = serveApp((req) =>
|
|
42
|
+
req.url.endsWith("/api/hello")
|
|
43
|
+
? Response.json({ hello: "world" })
|
|
44
|
+
: new Response("<h1>My App</h1>", { headers: { "Content-Type": "text/html" } }),
|
|
45
|
+
);
|
|
46
|
+
await createWindow({ url: served.url });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run it with `bun run`. Close the window (or call `win.close()`) and the process exits.
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
Each window is owned by a dedicated **host process** — a tiny Bun script shipped with the library (`src/host-ffi.ts`) that creates one native window through the C webview library via `bun:ffi` and nothing else. Your process drives the page over CDP:
|
|
54
|
+
|
|
55
|
+
```mermaid
|
|
56
|
+
graph TB
|
|
57
|
+
A["Your Bun app"] -- "spawns one per window" --> H1["host process #1\n(C webview lib via bun:ffi)"]
|
|
58
|
+
A -- "spawns one per window" --> H2["host process #2\n(C webview lib via bun:ffi)"]
|
|
59
|
+
A -- "CDP over ws://127.0.0.1:<port>" --> P1["page target #1"]
|
|
60
|
+
A -- "CDP over ws://127.0.0.1:<port>" --> P2["page target #2"]
|
|
61
|
+
H1 -- "renders" --> P1
|
|
62
|
+
H2 -- "renders" --> P2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **One host process per window** — buys per-window crash isolation: a dying window never takes your app down.
|
|
66
|
+
- **Debug port** — the library passes `--remote-debugging-port` and the data folder to each host via the WebView2 loader environment variables (`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` / `WEBVIEW2_USER_DATA_FOLDER`). All windows of an app share one WebView2 browser process and one port; each window claims its own page target.
|
|
67
|
+
- **Everything else is CDP.** Navigation, evaluation, IPC, and screenshots flow over a loopback WebSocket using Bun's built-in client. No native module is loaded in your process.
|
|
68
|
+
- **Window commands ride the page.** The host's only JS runs re-entrantly inside `webview_run` — the C library's plain Win32 `GetMessage` pump, so there is no JS event loop to lose at all. Commands are CDP-evaluated as `bwv2_host_cmd(...)` in the page; the C library delivers them to the host's bind callback natively, and `webview_return` resolves the page-side promise the parent awaits — a resolved call IS the delivery confirmation. (This design came from a hard lesson: the previous wry/tao-based host's "non-blocking" pump could freeze the whole JS event loop; the C library has no loop to freeze.)
|
|
69
|
+
- **Events are split by layer.** Window-level events (`move`, `resize`, `focus`/`blur` via `WM_ACTIVATE`, `theme-changed`) are forwarded from the host's subclassed window procedure. Page-surface events (`key-down`/`key-up`, `mouse-*`, `scroll`, `file-drop`) are captured as DOM events by an injected bridge and forwarded over the existing CDP binding — richer than Win32 could report (layout-aware `key`/`code`, readable `File` objects on drop) and working everywhere input actually lands, including cross-origin documents.
|
|
70
|
+
|
|
71
|
+
### Why all content flows through CDP (and why windows are "primed")
|
|
72
|
+
|
|
73
|
+
wry's own initial-navigation path (`NativeWindow.loadUrl` / `loadHtml`) can stall the first page load for seconds on some machines. CDP-driven navigation against the exact same WebView2 process commits in ~30 ms. bun-webview2 therefore never touches the wry navigation path — `html` and `url` options are delivered via CDP, and you get the fast path for free. (Measured on the reference machine: cold start → loaded page ≈ 1 s; `evaluate` round-trip ≈ 0.5–1 ms.)
|
|
74
|
+
|
|
75
|
+
Related, and also handled for you: on a never-navigated webview target, CDP commands that need a renderer (`Runtime.enable`, even `Page.navigate` itself) can wedge forever. The library sends one throwaway `about:blank` navigation before any other setup, which commits a document and makes the whole protocol deterministic. Window events keep flowing across CDP-driven navigations — verified with real size changes in both wedged and healthy hosts.
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
### `createWindow(options) → Promise<Webview2Window>`
|
|
80
|
+
|
|
81
|
+
`Webview2Window.create(...)` is identical. Resolves once the initial content has loaded.
|
|
82
|
+
|
|
83
|
+
| Option | Type | Default | Description |
|
|
84
|
+
|---|---|---|---|
|
|
85
|
+
| `title` | `string` | `"bun-webview2"` | Window title |
|
|
86
|
+
| `width` / `height` | `number` | `800` / `600` | Initial window size |
|
|
87
|
+
| `url` | `string` | — | Initial URL to load |
|
|
88
|
+
| `html` | `string` | — | Initial inline HTML (loaded as a `data:` URL) |
|
|
89
|
+
| `contextMenu` | `boolean` | `false` | Allow the native Chromium right-click menu (blocked by default) |
|
|
90
|
+
| `devToolsKeys` | `boolean` | `false` | Allow F12 / Ctrl+Shift+I/J/C / Ctrl+U/S/P (blocked by default) |
|
|
91
|
+
| `port` | `number` | auto | Fixed CDP port; all windows in a process share one port |
|
|
92
|
+
| `dataDir` | `string` | `%LOCALAPPDATA%\bun-webview2\<hash>` | WebView2 user-data folder (cookies, storage, cache); process-wide, first window wins |
|
|
93
|
+
| `navigationTimeoutSeconds` | `number` | `30` | `navigate()` timeout |
|
|
94
|
+
|
|
95
|
+
### `Webview2Window`
|
|
96
|
+
|
|
97
|
+
| Member | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `navigate(url)` | Navigate; resolves when the main frame's `load` event fires; rejects on failure/timeout |
|
|
100
|
+
| `setHTML(html)` | Replace the page with inline HTML |
|
|
101
|
+
| `evaluate<T>(expression)` | Evaluate an expression in the page; promises awaited; result JSON round-tripped |
|
|
102
|
+
| `expose(name, fn)` | Expose a Bun function to the page as `await window.bwv2.call(name, ...args)` |
|
|
103
|
+
| `screenshot({ format, quality })` | Capture the viewport as a PNG/JPEG `Blob` |
|
|
104
|
+
| `on(event, handler)` | Typed window + page events: `move`, `resize`, `focus`, `blur`, `key-down`, `key-up`, `mouse-enter`/`-leave`/`-down`/`-up`, `scroll`, `file-drop`, `theme-changed`. Window events come from the host's subclassed WndProc; page events are DOM events forwarded through the injected bridge (`file-drop` gives `File` descriptors — name/size/type — not paths). |
|
|
105
|
+
| | Nuances: `mouse-enter`/`-leave` fire on document-boundary crossings (the page surface); `file-drop` fires on the completed drop, not while hovering. |
|
|
106
|
+
| `cdp` | The live CDP connection — escape hatch to the full DevTools protocol (Network, DOM, Input, Emulation, …) |
|
|
107
|
+
| `url` | Current page URL (tracked across navigations, including page-initiated ones) |
|
|
108
|
+
| `closed` / `onClosed(cb)` / `close()` | Lifecycle |
|
|
109
|
+
| `setTitle`, `setSize`, `setPosition`, `setDecorations`, `setAlwaysOnTop`, `focus`, `minimize`, `maximize` | Native window controls |
|
|
110
|
+
|
|
111
|
+
### Page → Bun IPC
|
|
112
|
+
|
|
113
|
+
The library injects a bridge into every new document:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// Bun side
|
|
117
|
+
await win.expose("greet", async (name: string) => `hello ${name}`);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
// Page side — works in every frame, survives navigations
|
|
122
|
+
const greeting = await window.bwv2.call("greet", "bun");
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Arguments and return values are JSON round-tripped; thrown errors surface as rejected promises in the page.
|
|
126
|
+
|
|
127
|
+
## Hardening
|
|
128
|
+
|
|
129
|
+
bun-webview2 windows are app windows, so they ship app-hardened by default:
|
|
130
|
+
|
|
131
|
+
- The Chromium right-click menu (Back / Save as / Print / Inspect / …) is **blocked** — `contextMenu: true` restores it.
|
|
132
|
+
- DevTools and browser accelerators — F12, Ctrl+Shift+I/J/C, Ctrl+U/S/P — are **blocked** via injected key handlers. `devToolsKeys: true` restores them.
|
|
133
|
+
|
|
134
|
+
Residual, documented honestly: Ctrl+Shift+I/J/C are browser-reserved accelerators in some WebView2 builds and may still open DevTools even when blocked. For a hard guarantee, set the `DeveloperToolsAvailability=2` (+ `DeveloperToolsAvailabilityAllowlist` for your origin) Edge policy on target machines — that also blocks CDP inspection of other targets, so read the [policy doc](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-policies/developertoolsavailability) first.
|
|
135
|
+
|
|
136
|
+
## Requirements
|
|
137
|
+
|
|
138
|
+
- Windows 10/11 with the WebView2 runtime (preinstalled on Windows 11 and on any machine with Edge; [download link](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) for the rest).
|
|
139
|
+
- Bun 1.2+.
|
|
140
|
+
|
|
141
|
+
macOS/Linux are not supported by this package (its window layer is WebView2-specific).
|
|
142
|
+
|
|
143
|
+
## Security notes
|
|
144
|
+
|
|
145
|
+
- The CDP port is bound to `127.0.0.1`, but **anything running on the same machine can connect to it and control your page**. This is normal for local desktop tooling and the same trade-off every CDP-driven automation tool makes. Don't build a multi-user kiosk on it.
|
|
146
|
+
- Hardening is UX policy, not a security boundary: the injected key/context-menu blockers prevent accidents, not a determined local user.
|
|
147
|
+
|
|
148
|
+
## Data folder and single-instance behavior
|
|
149
|
+
|
|
150
|
+
Each app gets its own WebView2 data folder (overridable with `dataDir`), so two
|
|
151
|
+
*different* Bun webview apps can run side by side. But one WebView2 browser
|
|
152
|
+
process serves one data folder: launching a **second process of the same app**
|
|
153
|
+
attaches it to the first process's browser, and its CDP port never appears —
|
|
154
|
+
window creation fails with a timeout. Treat that as a single-instance guard;
|
|
155
|
+
route "new window" to the running instance instead of spawning again.
|
|
156
|
+
|
|
157
|
+
## Distribution
|
|
158
|
+
|
|
159
|
+
- Each window spawns a host process. In development that's `bun` running the library's `src/host.ts`; your machine needs Bun on the PATH-compatible `process.execPath`.
|
|
160
|
+
- For `bun build --compile` executables, compile the host separately and point the `BWV2_HOST_EXE` environment variable at it:
|
|
161
|
+
```bash
|
|
162
|
+
bun build --compile --minify src/host-ffi.ts --outfile host.exe # inside node_modules: copy out first
|
|
163
|
+
BWV2_HOST_EXE=./host.exe ./your-app.exe
|
|
164
|
+
```
|
|
165
|
+
- `native/webview.dll` (and the host script, unless compiled) must ship **alongside** your binary — `bun build --compile` embeds neither.
|
|
166
|
+
- Give the exe a custom icon with [rcedit](https://github.com/electron/rcedit); the window inherits the process icon.
|
|
167
|
+
- WebView2 writes its user-data folder per app; if you need to control where (e.g. Program Files installs), patch `userDataFolder` at the window layer.
|
|
168
|
+
|
|
169
|
+
## Costs, honestly
|
|
170
|
+
|
|
171
|
+
- Every window costs one Bun process (~40 MB) plus its share of the shared WebView2 browser tree. For 1–3 windows this is desktop-app normal.
|
|
172
|
+
- One WebView2 browser process per app means one app instance at a time (see the data-folder section above).
|
|
173
|
+
- The CDP debug port is a local-loopback control channel: fine for desktop apps, not for hostile multi-user machines (see security notes).
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bun x tsc --noEmit # typecheck
|
|
179
|
+
bun run example/verify.ts # end-to-end verification (opens windows, needs display + network)
|
|
180
|
+
bun run example/quickstart.ts # human demo
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT — see [LICENSE](./LICENSE).
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bun-webview2",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"main": "./src/index.ts",
|
|
5
|
+
"module": "./src/index.ts",
|
|
6
|
+
"devDependencies": {
|
|
7
|
+
"bun-types": "^1.4.3",
|
|
8
|
+
"typescript": "^7.0.2"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"description": "Desktop apps with Bun: native WebView2 windows you fully control, driven over the Chrome DevTools Protocol.",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/abmach/bun-webview2.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/abmach/bun-webview2/issues"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.2"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"native",
|
|
27
|
+
"README.md",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"keywords": [
|
|
32
|
+
"bun",
|
|
33
|
+
"webview2",
|
|
34
|
+
"webview",
|
|
35
|
+
"desktop",
|
|
36
|
+
"gui",
|
|
37
|
+
"cdp",
|
|
38
|
+
"windows"
|
|
39
|
+
],
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"type": "module",
|
|
42
|
+
"types": "./src/index.ts"
|
|
43
|
+
}
|
package/src/cdp.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Chrome DevTools Protocol client over Bun's native WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* One connection per page target. Supports command/response dispatch and
|
|
5
|
+
* event subscriptions. Deliberately tiny: bun-webview2 only needs
|
|
6
|
+
* Runtime, Page, and a handful of methods — nothing more.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CdpTargetInfo {
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly url: string;
|
|
13
|
+
readonly webSocketDebuggerUrl: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CdpResponse {
|
|
17
|
+
readonly id?: number;
|
|
18
|
+
readonly method?: string;
|
|
19
|
+
readonly params?: unknown;
|
|
20
|
+
readonly result?: unknown;
|
|
21
|
+
readonly error?: { readonly message: string; readonly data?: string };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CdpErrorPayload {
|
|
25
|
+
readonly code: number;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly data?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type CdpEventHandler = (params: unknown) => void;
|
|
31
|
+
|
|
32
|
+
/** Ask the OS for a free TCP port by briefly listening on port 0. */
|
|
33
|
+
export async function pickFreePort(): Promise<number> {
|
|
34
|
+
const listener = Bun.listen({
|
|
35
|
+
hostname: "127.0.0.1",
|
|
36
|
+
port: 0,
|
|
37
|
+
socket: { data() {}, open() {}, close() {} },
|
|
38
|
+
});
|
|
39
|
+
const port = listener.port;
|
|
40
|
+
listener.stop(true);
|
|
41
|
+
return port;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Poll the DevTools HTTP endpoint until it lists an unclaimed page target.
|
|
46
|
+
* The endpoint only exists once the WebView2 environment is live, which is
|
|
47
|
+
* asynchronous relative to NativeWindow construction.
|
|
48
|
+
*/
|
|
49
|
+
export async function waitForPageTarget(
|
|
50
|
+
port: number,
|
|
51
|
+
claimed: ReadonlySet<string>,
|
|
52
|
+
timeoutMs = 15_000,
|
|
53
|
+
dataDirHint?: string,
|
|
54
|
+
): Promise<CdpTargetInfo> {
|
|
55
|
+
const deadline = performance.now() + timeoutMs;
|
|
56
|
+
while (performance.now() < deadline) {
|
|
57
|
+
try {
|
|
58
|
+
const targets = (await (
|
|
59
|
+
await fetch(`http://127.0.0.1:${port}/json`, { signal: AbortSignal.timeout(500) })
|
|
60
|
+
).json()) as unknown;
|
|
61
|
+
if (Array.isArray(targets)) {
|
|
62
|
+
const page = targets.find(
|
|
63
|
+
(t): t is CdpTargetInfo =>
|
|
64
|
+
typeof t === "object" && t !== null &&
|
|
65
|
+
"type" in t && (t as CdpTargetInfo).type === "page" &&
|
|
66
|
+
"webSocketDebuggerUrl" in t && typeof (t as CdpTargetInfo).webSocketDebuggerUrl === "string" &&
|
|
67
|
+
!claimed.has((t as CdpTargetInfo).webSocketDebuggerUrl),
|
|
68
|
+
);
|
|
69
|
+
if (page) return page;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// endpoint not up yet — keep polling
|
|
73
|
+
}
|
|
74
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
75
|
+
}
|
|
76
|
+
throw new Error(
|
|
77
|
+
`bun-webview2: no CDP page target appeared on port ${port} within ${timeoutMs}ms. ` +
|
|
78
|
+
"Likely causes: another instance of this app is already running (apps are single-instance per data folder), " +
|
|
79
|
+
"or a previous run was hard-killed leaving a wedged WebView2 browser process. " +
|
|
80
|
+
(dataDirHint
|
|
81
|
+
? `Kill lingering msedgewebview2 processes for this data folder or delete it (${dataDirHint}), or pass a different dataDir.`
|
|
82
|
+
: "Pass a different dataDir."),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class CdpConnection {
|
|
87
|
+
readonly #ws: WebSocket;
|
|
88
|
+
readonly #pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
89
|
+
readonly #events = new Map<string, Set<CdpEventHandler>>();
|
|
90
|
+
#nextId = 1;
|
|
91
|
+
#closed = false;
|
|
92
|
+
|
|
93
|
+
constructor(readonly wsUrl: string) {
|
|
94
|
+
this.#ws = new WebSocket(wsUrl);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
connect(): Promise<void> {
|
|
98
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
99
|
+
this.#ws.addEventListener("open", () => resolve(), { once: true });
|
|
100
|
+
this.#ws.addEventListener("error", () => reject(new Error(`bun-webview2: failed to open CDP socket ${this.wsUrl}`)), { once: true });
|
|
101
|
+
this.#ws.addEventListener("message", (ev: MessageEvent) => this.#dispatch(ev));
|
|
102
|
+
this.#ws.addEventListener("close", () => this.#handleClose());
|
|
103
|
+
return promise;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#dispatch(ev: MessageEvent): void {
|
|
107
|
+
const msg = JSON.parse(String(ev.data)) as CdpResponse;
|
|
108
|
+
if (msg.id !== undefined) {
|
|
109
|
+
const entry = this.#pending.get(msg.id);
|
|
110
|
+
if (entry) {
|
|
111
|
+
this.#pending.delete(msg.id);
|
|
112
|
+
if (msg.error) entry.reject(new Error(`bun-webview2: CDP error: ${msg.error.message}`));
|
|
113
|
+
else entry.resolve(msg.result);
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (msg.method !== undefined) {
|
|
118
|
+
this.#events.get(msg.method)?.forEach((handler) => handler(msg.params));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#handleClose(): void {
|
|
123
|
+
this.#closed = true;
|
|
124
|
+
const err = new Error("bun-webview2: CDP connection closed");
|
|
125
|
+
for (const entry of this.#pending.values()) entry.reject(err);
|
|
126
|
+
this.#pending.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get closed(): boolean {
|
|
130
|
+
return this.#closed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Send a command and await its response result. Commands that never get a
|
|
134
|
+
* response become rejections after timeoutMs instead of hanging forever —
|
|
135
|
+
* wedged WebView2 targets are a real failure mode, not a theory. */
|
|
136
|
+
call(method: string, params: Record<string, unknown> = {}, timeoutMs = 30_000): Promise<unknown> {
|
|
137
|
+
if (this.#closed) return Promise.reject(new Error("bun-webview2: CDP connection closed"));
|
|
138
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
139
|
+
const id = this.#nextId++;
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
this.#pending.delete(id);
|
|
142
|
+
reject(new Error(`bun-webview2: CDP call ${method} timed out after ${timeoutMs}ms`));
|
|
143
|
+
}, timeoutMs);
|
|
144
|
+
this.#pending.set(id, {
|
|
145
|
+
resolve: (value: unknown) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
resolve(value);
|
|
148
|
+
},
|
|
149
|
+
reject: (err: Error) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
reject(err);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
this.#ws.send(JSON.stringify({ id, method, params }));
|
|
155
|
+
return promise;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Subscribe to a CDP event; returns an unsubscribe function. */
|
|
159
|
+
on(method: string, handler: CdpEventHandler): () => void {
|
|
160
|
+
let handlers = this.#events.get(method);
|
|
161
|
+
if (!handlers) {
|
|
162
|
+
handlers = new Set();
|
|
163
|
+
this.#events.set(method, handlers);
|
|
164
|
+
}
|
|
165
|
+
handlers.add(handler);
|
|
166
|
+
return () => handlers.delete(handler);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
close(): void {
|
|
170
|
+
if (!this.#closed) this.#ws.close();
|
|
171
|
+
this.#handleClose();
|
|
172
|
+
}
|
|
173
|
+
}
|