bunite-core 0.0.4 → 0.0.6

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bunite-core",
3
3
  "description": "Uniting UI and Bun",
4
- "version": "0.0.4",
4
+ "version": "0.0.6",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "setup:cef": "bun ../tools/bunite-dev/scripts/setup-cef.ts",
@@ -22,7 +22,7 @@ import { log, logLevelToInt } from "../../shared/log";
22
22
 
23
23
  import type { LogLevel } from "../../shared/log";
24
24
 
25
- type AppInitOptions = NativeBootstrapOptions & {
25
+ type AppOptions = NativeBootstrapOptions & {
26
26
  userDataDir?: string;
27
27
  cefDir?: string;
28
28
  exitOnLastWindowClosed?: boolean;
@@ -31,123 +31,116 @@ type AppInitOptions = NativeBootstrapOptions & {
31
31
 
32
32
  export type GlobalIPCHandler = (params: unknown, ctx: { viewId: number }) => unknown | Promise<unknown>;
33
33
 
34
- class AppRuntime {
35
- private initPromise: Promise<void> | null = null;
34
+ export class AppRuntime {
36
35
  private stubKeepAliveTimer: ReturnType<typeof setInterval> | null = null;
37
36
  private readonly globalIPCHandlers = new Map<string, GlobalIPCHandler>();
38
37
  private exitOnLastWindowClosed = true;
39
38
  private quitting = false;
40
39
 
41
- async init(options: AppInitOptions = {}) {
42
- if (!this.initPromise) {
43
- this.initPromise = (async () => {
44
- if (options.exitOnLastWindowClosed !== undefined) {
45
- this.exitOnLastWindowClosed = options.exitOnLastWindowClosed;
46
- }
40
+ readonly ready: Promise<void>;
47
41
 
48
- if (options.logLevel) {
49
- log.setLevel(options.logLevel);
50
- }
42
+ constructor(options: AppOptions = {}) {
43
+ this.ready = this.bootstrap(options);
44
+ }
51
45
 
52
- if (options.cefDir) {
53
- process.env.BUNITE_CEF_DIR = options.cefDir;
54
- }
46
+ private async bootstrap(options: AppOptions) {
47
+ if (options.exitOnLastWindowClosed !== undefined) {
48
+ this.exitOnLastWindowClosed = options.exitOnLastWindowClosed;
49
+ }
55
50
 
56
- if (options.userDataDir) {
57
- process.env.BUNITE_USER_DATA_DIR = options.userDataDir;
58
- } else if (!process.env.BUNITE_USER_DATA_DIR) {
59
- // XDG_DATA_HOME takes priority on any platform, then OS convention
60
- const appDataDir = process.env.XDG_DATA_HOME
61
- ?? (process.platform === "win32"
62
- ? (process.env.APPDATA ?? join(process.env.USERPROFILE ?? "", "AppData", "Roaming"))
63
- : process.platform === "darwin"
64
- ? join(process.env.HOME ?? "", "Library", "Application Support")
65
- : join(process.env.HOME ?? "", ".local", "share"));
66
- let name = "bunite-app";
67
- try {
68
- // Walk up from entry script to find nearest package.json
69
- let dir = getBaseDir();
70
- while (dir) {
71
- const pkgPath = join(dir, "package.json");
72
- if (existsSync(pkgPath)) {
73
- name = JSON.parse(require("node:fs").readFileSync(pkgPath, "utf8")).name ?? name;
74
- break;
75
- }
76
- const parent = resolve(dir, "..");
77
- if (parent === dir) break;
78
- dir = parent;
79
- }
80
- } catch {}
81
- process.env.BUNITE_USER_DATA_DIR = join(appDataDir, name);
82
- }
51
+ if (options.logLevel) {
52
+ log.setLevel(options.logLevel);
53
+ }
83
54
 
84
- const runtime = await initNativeRuntime({
85
- allowStub: options.allowStub,
86
- hideConsole: options.hideConsole,
87
- popupBlocking: options.popupBlocking,
88
- chromiumFlags: options.chromiumFlags
89
- });
55
+ if (options.cefDir) {
56
+ process.env.BUNITE_CEF_DIR = options.cefDir;
57
+ }
90
58
 
91
- if (options.logLevel && runtime.nativeLoaded) {
92
- setNativeLogLevel(logLevelToInt(options.logLevel));
59
+ if (options.userDataDir) {
60
+ process.env.BUNITE_USER_DATA_DIR = options.userDataDir;
61
+ } else if (!process.env.BUNITE_USER_DATA_DIR) {
62
+ const appDataDir = process.env.XDG_DATA_HOME
63
+ ?? (process.platform === "win32"
64
+ ? (process.env.APPDATA ?? join(process.env.USERPROFILE ?? "", "AppData", "Roaming"))
65
+ : process.platform === "darwin"
66
+ ? join(process.env.HOME ?? "", "Library", "Application Support")
67
+ : join(process.env.HOME ?? "", ".local", "share"));
68
+ let name = "bunite-app";
69
+ try {
70
+ let dir = getBaseDir();
71
+ while (dir) {
72
+ const pkgPath = join(dir, "package.json");
73
+ if (existsSync(pkgPath)) {
74
+ name = JSON.parse(require("node:fs").readFileSync(pkgPath, "utf8")).name ?? name;
75
+ break;
76
+ }
77
+ const parent = resolve(dir, "..");
78
+ if (parent === dir) break;
79
+ dir = parent;
93
80
  }
81
+ } catch {}
82
+ process.env.BUNITE_USER_DATA_DIR = join(appDataDir, name);
83
+ }
94
84
 
95
- attachGlobalIPCResolver((channel) => this.getGlobalIPCHandler(channel));
85
+ const runtime = await initNativeRuntime({
86
+ allowStub: options.allowStub,
87
+ hideConsole: options.hideConsole,
88
+ popupBlocking: options.popupBlocking,
89
+ chromiumFlags: options.chromiumFlags
90
+ });
96
91
 
97
- for (const [channel, handler] of getSurfaceIPCHandlers()) {
98
- this.globalIPCHandlers.set(channel, handler);
99
- }
100
- for (const [channel, handler] of getWebviewIPCHandlers()) {
101
- this.globalIPCHandlers.set(channel, handler);
102
- }
92
+ if (options.logLevel && runtime.nativeLoaded) {
93
+ setNativeLogLevel(logLevelToInt(options.logLevel));
94
+ }
103
95
 
104
- this.globalIPCHandlers.set("__bunite:messageBoxResponse", (params) => {
105
- const { requestId, response } = params as { requestId: number; response: number };
106
- handleMessageBoxResponse(requestId, response);
107
- return {};
108
- });
96
+ attachGlobalIPCResolver((channel) => this.getGlobalIPCHandler(channel));
109
97
 
110
- setRouteRequestHandler((requestId, path) => this.handleRouteRequest(requestId, path));
98
+ for (const [channel, handler] of getSurfaceIPCHandlers()) {
99
+ this.globalIPCHandlers.set(channel, handler);
100
+ }
101
+ for (const [channel, handler] of getWebviewIPCHandlers()) {
102
+ this.globalIPCHandlers.set(channel, handler);
103
+ }
111
104
 
112
- // Replay appres routes registered before init
113
- for (const path of this.appresHandlers.keys()) {
114
- getNativeLibrary()?.symbols.bunite_register_appres_route(toCString(path));
115
- }
105
+ this.globalIPCHandlers.set("__bunite:messageBoxResponse", (params) => {
106
+ const { requestId, response } = params as { requestId: number; response: number };
107
+ handleMessageBoxResponse(requestId, response);
108
+ return {};
109
+ });
116
110
 
111
+ setRouteRequestHandler((requestId, path) => this.handleRouteRequest(requestId, path));
117
112
 
118
- if (this.exitOnLastWindowClosed && runtime.nativeLoaded) {
119
- buniteEventEmitter.on("all-windows-closed", () => {
120
- if (this.quitting) {
121
- return;
122
- }
123
- queueMicrotask(() => {
124
- if (this.quitting) {
125
- return;
126
- }
127
- // Recheck: a new window may have been created since the event
128
- if (BrowserWindow.getAll().length === 0) {
129
- this.quit();
130
- }
131
- });
132
- });
133
- }
113
+ for (const path of this.appresHandlers.keys()) {
114
+ getNativeLibrary()?.symbols.bunite_register_appres_route(toCString(path));
115
+ }
134
116
 
135
- ensureRPCServer();
136
- buniteEventEmitter.emitEvent(
137
- new BuniteEvent("ready", {
138
- usingStub: runtime.usingStub,
139
- artifacts: runtime.artifacts
140
- })
141
- );
142
- })();
117
+ if (this.exitOnLastWindowClosed && runtime.nativeLoaded) {
118
+ buniteEventEmitter.on("all-windows-closed", () => {
119
+ if (this.quitting) {
120
+ return;
121
+ }
122
+ queueMicrotask(() => {
123
+ if (this.quitting) {
124
+ return;
125
+ }
126
+ if (BrowserWindow.getAll().length === 0) {
127
+ this.quit();
128
+ }
129
+ });
130
+ });
143
131
  }
144
132
 
145
- await this.initPromise;
133
+ ensureRPCServer();
134
+ buniteEventEmitter.emitEvent(
135
+ new BuniteEvent("ready", {
136
+ usingStub: runtime.usingStub,
137
+ artifacts: runtime.artifacts
138
+ })
139
+ );
146
140
  }
147
141
 
148
142
  on(name: string, handler: (payload: unknown) => void) {
149
143
  if (name === "before-quit") {
150
- // before-quit listeners receive the BuniteEvent directly so they can set event.response
151
144
  buniteEventEmitter.on(name, handler);
152
145
  return () => buniteEventEmitter.off(name, handler);
153
146
  }
@@ -188,7 +181,6 @@ class AppRuntime {
188
181
  clearInterval(this.stubKeepAliveTimer);
189
182
  this.stubKeepAliveTimer = null;
190
183
  }
191
- // bunite_quit() blocks until native shutdown completes or times out
192
184
  getNativeLibrary()?.symbols.bunite_quit();
193
185
  process.exitCode = code;
194
186
  process.exit(code);
@@ -238,7 +230,6 @@ class AppRuntime {
238
230
  getNativeLibrary()?.symbols.bunite_complete_route_request(requestId, toCString(html));
239
231
  }
240
232
 
241
- /** Resolve a path relative to the entry script (dev) or executable (compiled). */
242
233
  resolve(relativePath: string): string {
243
234
  if (isAbsolute(relativePath)) return relativePath;
244
235
  return resolve(getBaseDir(), relativePath);
@@ -278,5 +269,3 @@ class AppRuntime {
278
269
  return this.cachedCefVersion;
279
270
  }
280
271
  }
281
-
282
- export const app = new AppRuntime();
@@ -2,7 +2,8 @@ import { ptr } from "bun:ffi";
2
2
  import { buildViewPreloadScript } from "../preload/inline";
3
3
  import { log } from "../../shared/log";
4
4
  import { buniteEventEmitter } from "../events/eventEmitter";
5
- import { defineBuniteRPC, type BuniteRPCConfig, type BuniteRPCSchema, type RPCWithTransport } from "../../shared/rpc";
5
+ import { defineBuniteRPC, type BuniteRPCConfig, type BuniteRPCSchema, type RPCWithTransport, type RPCRequestHandler } from "../../shared/rpc";
6
+ import { createWebRPCHandler } from "../../shared/webRpcHandler";
6
7
  import { ensureNativeRuntime, getNativeLibrary, toCString, waitForViewReady, cancelWaitForViewReady } from "../proc/native";
7
8
  import { attachBrowserViewRegistry, getRPCPort, sendMessageToView } from "./Socket";
8
9
  import { randomBytes } from "node:crypto";
@@ -163,7 +164,10 @@ export class BrowserView<T extends RPCWithTransport = RPCWithTransport> {
163
164
  static defineRPC<Schema extends BuniteRPCSchema>(
164
165
  config: BuniteRPCConfig<Schema, "bun">
165
166
  ) {
166
- return defineBuniteRPC("bun", config);
167
+ const rpc = defineBuniteRPC("bun", config);
168
+ return Object.assign(rpc, {
169
+ webHandler: createWebRPCHandler((config.handlers.requests ?? {}) as any)
170
+ });
167
171
  }
168
172
 
169
173
  handleIncomingRPC(message: unknown) {
package/src/bun/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { app } from "./core/App";
1
+ import { AppRuntime } from "./core/App";
2
2
  import { BrowserWindow, type WindowOptionsType } from "./core/BrowserWindow";
3
3
  import { BrowserView, type BrowserViewOptions } from "./core/BrowserView";
4
4
  import * as Utils from "./core/Utils";
@@ -17,7 +17,7 @@ import type { MessageBoxOptions, MessageBoxResponse } from "./core/Utils";
17
17
  import { log, type LogLevel } from "../shared/log";
18
18
 
19
19
  export {
20
- app,
20
+ AppRuntime,
21
21
  BrowserWindow,
22
22
  BrowserView,
23
23
  Utils,
@@ -0,0 +1,48 @@
1
+ import { decodeRPCPacket, encodeRPCPacket, asUint8Array } from "./rpcWire";
2
+
3
+ type WebRPCHandlers = Record<string, (params?: unknown) => unknown>;
4
+
5
+ /** Minimal ws interface compatible with Bun's ServerWebSocket. */
6
+ type WebRPCSocket = { send(data: Uint8Array): void | number };
7
+
8
+ function errorMessage(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ export function createWebRPCHandler(handlers: WebRPCHandlers) {
13
+ return {
14
+ message(ws: WebRPCSocket, raw: string | Buffer) {
15
+ if (typeof raw === "string") return;
16
+
17
+ let packet;
18
+ try {
19
+ packet = decodeRPCPacket(asUint8Array(raw));
20
+ } catch {
21
+ return;
22
+ }
23
+
24
+ if (packet.type !== "request" || typeof packet.method !== "string") return;
25
+
26
+ const handler = handlers[packet.method];
27
+ if (!handler) {
28
+ ws.send(
29
+ encodeRPCPacket({ type: "response", id: packet.id, success: false, error: `Unknown method: ${packet.method}` })
30
+ );
31
+ return;
32
+ }
33
+
34
+ try {
35
+ Promise.resolve(handler(packet.params)).then(
36
+ (payload) =>
37
+ ws.send(encodeRPCPacket({ type: "response", id: packet.id, success: true, payload })),
38
+ (error) =>
39
+ ws.send(encodeRPCPacket({ type: "response", id: packet.id, success: false, error: errorMessage(error) }))
40
+ );
41
+ } catch (error) {
42
+ ws.send(
43
+ encodeRPCPacket({ type: "response", id: packet.id, success: false, error: errorMessage(error) })
44
+ );
45
+ }
46
+ }
47
+ };
48
+ }
@@ -1,11 +1,4 @@
1
- /**
2
- * <bunite-webview> iframe polyfill — registered automatically via the
3
- * bunite-dev vite plugin when the native bunite runtime is not available.
4
- *
5
- * In desktop CEF the native element is already registered by the preload
6
- * runtime, so this module does nothing.
7
- */
8
-
1
+ // Iframe-based fallback for web browsers. No-op when the native element is already registered by the CEF preload.
9
2
  if (
10
3
  typeof customElements !== "undefined" &&
11
4
  !customElements.get("bunite-webview")
package/src/view/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import "../shared/webviewPolyfill";
1
2
  import {
2
3
  defineBuniteRPC,
3
4
  type BuniteRPCConfig,
@@ -25,6 +26,8 @@ const buniteWindow = window as BuniteWindowGlobals;
25
26
  const WEBVIEW_ID = buniteWindow.__buniteWebviewId;
26
27
  const RPC_SOCKET_PORT = buniteWindow.__buniteRpcSocketPort;
27
28
 
29
+ const isNative = WEBVIEW_ID != null && RPC_SOCKET_PORT != null;
30
+
28
31
  function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
29
32
  const copy = new Uint8Array(bytes.byteLength);
30
33
  copy.set(bytes);
@@ -35,6 +38,7 @@ export class BuniteView<T extends RPCWithTransport> {
35
38
  bunSocket?: WebSocket;
36
39
  rpc?: T;
37
40
  rpcHandler?: (message: unknown) => void;
41
+ private pendingPackets: RPCPacket[] = [];
38
42
 
39
43
  constructor(config: { rpc: T }) {
40
44
  this.rpc = config.rpc;
@@ -43,69 +47,96 @@ export class BuniteView<T extends RPCWithTransport> {
43
47
 
44
48
  init() {
45
49
  this.initSocketToBun();
46
- buniteWindow.__bunite ??= {};
47
- buniteWindow.__bunite.receiveMessageFromBun = this.receiveMessageFromBun.bind(this);
50
+ if (isNative) {
51
+ buniteWindow.__bunite ??= {};
52
+ buniteWindow.__bunite.receiveMessageFromBun = this.receiveMessageFromBun.bind(this);
53
+ }
48
54
  this.rpc?.setTransport(this.createTransport());
49
55
  }
50
56
 
51
- initSocketToBun() {
52
- if (WEBVIEW_ID == null || RPC_SOCKET_PORT == null) {
53
- log.warn("Preload globals are missing. BuniteView will stay disconnected until native preload wiring is implemented.");
54
- return;
57
+ private sendPacket(packet: RPCPacket) {
58
+ if (isNative) {
59
+ void this.bunBridge(packet).catch((error) => {
60
+ log.error("Failed to send RPC packet", error);
61
+ });
62
+ } else {
63
+ this.bunSocket!.send(toArrayBuffer(encodeRPCPacket(packet)));
55
64
  }
65
+ }
56
66
 
57
- // Share a single WebSocket with the preload's bunite.invoke.
58
- // Whichever side (BuniteView or bunite.invoke) opens the socket first,
59
- // the other reuses it via __bunite._socket.
60
- const globals = buniteWindow as any;
61
- globals.__bunite ??= {};
62
- const existing = globals.__bunite._socket;
63
- if (existing && existing.readyState <= WebSocket.OPEN) {
64
- this.bunSocket = existing;
65
- } else {
66
- const socket = new WebSocket(
67
- `ws://localhost:${RPC_SOCKET_PORT}/socket?webviewId=${WEBVIEW_ID}`
68
- );
67
+ initSocketToBun() {
68
+ if (!isNative) {
69
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
70
+ const socket = new WebSocket(`${proto}//${location.host}/rpc`);
69
71
  socket.binaryType = "arraybuffer";
70
72
  this.bunSocket = socket;
71
- globals.__bunite._socket = socket;
72
- }
73
73
 
74
- this.bunSocket!.addEventListener("message", async (event) => {
75
- const binaryMessage = await this.messageToUint8Array(event.data);
76
- if (!binaryMessage) {
77
- return;
74
+ socket.addEventListener("message", async (event) => {
75
+ const bytes = await this.messageToUint8Array(event.data);
76
+ if (!bytes) return;
77
+ try {
78
+ this.rpcHandler?.(decodeRPCPacket(bytes));
79
+ } catch (error) {
80
+ log.error("Failed to parse WebSocket message", error);
81
+ }
82
+ });
83
+ } else {
84
+ // Share a single WebSocket with the preload's bunite.invoke.
85
+ const globals = buniteWindow as any;
86
+ globals.__bunite ??= {};
87
+ const existing = globals.__bunite._socket;
88
+ if (existing && existing.readyState <= WebSocket.OPEN) {
89
+ this.bunSocket = existing;
90
+ } else {
91
+ const socket = new WebSocket(
92
+ `ws://localhost:${RPC_SOCKET_PORT}/socket?webviewId=${WEBVIEW_ID}`
93
+ );
94
+ socket.binaryType = "arraybuffer";
95
+ this.bunSocket = socket;
96
+ globals.__bunite._socket = socket;
78
97
  }
79
98
 
80
- try {
81
- const decrypt = buniteWindow.__bunite_decrypt;
82
- if (!decrypt) {
83
- log.error("No decrypt function available in preload globals");
84
- return;
85
- }
86
- const decrypted = await decrypt(binaryMessage);
87
- const packet = decodeRPCPacket(decrypted);
88
- // Skip global IPC responses — those are handled by bunite.invoke in the preload
89
- if ((packet as any).scope === "global") {
90
- return;
99
+ this.bunSocket!.addEventListener("message", async (event) => {
100
+ const binaryMessage = await this.messageToUint8Array(event.data);
101
+ if (!binaryMessage) return;
102
+
103
+ try {
104
+ const decrypt = buniteWindow.__bunite_decrypt;
105
+ if (!decrypt) {
106
+ log.error("No decrypt function available in preload globals");
107
+ return;
108
+ }
109
+ const decrypted = await decrypt(binaryMessage);
110
+ const packet = decodeRPCPacket(decrypted);
111
+ if ((packet as any).scope === "global") return;
112
+ this.rpcHandler?.(packet);
113
+ } catch (error) {
114
+ log.error("Failed to parse message from Bun", error);
91
115
  }
92
- this.rpcHandler?.(packet);
93
- } catch (error) {
94
- log.error("Failed to parse message from Bun", error);
116
+ });
117
+ }
118
+
119
+ this.bunSocket!.addEventListener("open", () => {
120
+ for (const packet of this.pendingPackets) this.sendPacket(packet);
121
+ this.pendingPackets = [];
122
+ });
123
+
124
+ this.bunSocket!.addEventListener("error", () => {
125
+ log.error("RPC WebSocket error");
126
+ });
127
+
128
+ this.bunSocket!.addEventListener("close", () => {
129
+ if (this.pendingPackets.length > 0) {
130
+ log.error(`RPC WebSocket closed with ${this.pendingPackets.length} pending packets`);
131
+ this.pendingPackets = [];
95
132
  }
96
133
  });
97
134
  }
98
135
 
99
136
  async messageToUint8Array(data: unknown) {
100
- if (data instanceof ArrayBuffer) {
101
- return new Uint8Array(data);
102
- }
103
- if (data instanceof Blob) {
104
- return new Uint8Array(await data.arrayBuffer());
105
- }
106
- if (data instanceof Uint8Array) {
107
- return data;
108
- }
137
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
138
+ if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
139
+ if (data instanceof Uint8Array) return data;
109
140
  return null;
110
141
  }
111
142
 
@@ -113,9 +144,9 @@ export class BuniteView<T extends RPCWithTransport> {
113
144
  return {
114
145
  send: (message) => {
115
146
  if (this.bunSocket?.readyState === WebSocket.OPEN) {
116
- void this.bunBridge(message).catch((error) => {
117
- log.error("Failed to send RPC packet", error);
118
- });
147
+ this.sendPacket(message);
148
+ } else if (this.bunSocket?.readyState === WebSocket.CONNECTING) {
149
+ this.pendingPackets.push(message);
119
150
  }
120
151
  },
121
152
  registerHandler: (handler: (packet: any) => void) => {
@@ -132,9 +163,7 @@ export class BuniteView<T extends RPCWithTransport> {
132
163
  }
133
164
 
134
165
  async bunBridge(message: RPCPacket) {
135
- if (this.bunSocket?.readyState !== WebSocket.OPEN) {
136
- return;
137
- }
166
+ if (this.bunSocket?.readyState !== WebSocket.OPEN) return;
138
167
 
139
168
  const encrypt = buniteWindow.__bunite_encrypt;
140
169
  if (!encrypt) {
@@ -149,7 +178,9 @@ export class BuniteView<T extends RPCWithTransport> {
149
178
  static defineRPC<Schema extends BuniteRPCSchema>(
150
179
  config: BuniteRPCConfig<Schema, "webview">
151
180
  ) {
152
- return defineBuniteRPC("webview", config);
181
+ const rpc = defineBuniteRPC("webview", config);
182
+ new BuniteView({ rpc });
183
+ return rpc;
153
184
  }
154
185
  }
155
186