bun-app-window 0.3.0 → 0.4.1

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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * app.ts — 应用骨架:把「起服务 → 开窗 → 接线 → 等退出」收成一次调用
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 用这个包做第一个应用时,真正要写的业务代码可能只有 30 行,但**宿主样板**是
7
+ * 另外 180 行:挑安全端口、生成鉴权 token、处理 WebSocket 升级、把 HTML 当字符串
8
+ * 吐出去、把 `createIpcServer` 接到 Bun 的 websocket 回调上、拿窗口 flags、
9
+ * 处理 `openShell` 失败、等窗口关闭(`EVENT_CLOSED`)、按顺序 `stop()` 两次。
10
+ *
11
+ * 每一段单看都不难,但它们**必须按特定顺序且互相依赖**——`stop()` 不先等窗口
12
+ * 销毁就去关服务,下次启动会撞上「进程级单例还在」;WS 升级不在 `fetch` 里做,
13
+ * 鉴权就没有落点。这些顺序关系在 `src/host.ts` 里被验证过一遍,但没有沉淀成
14
+ * 可复用的东西,所以每个消费者都要再踩一次。
15
+ *
16
+ * ## 两个设计选择
17
+ *
18
+ * **不导出到主入口。** 它和 `./state`、`./ipc`、`./titlebar` 一样是可选件,
19
+ * 走独立子路径 `bun-app-window/app`。好处不只是「按需引入」——`app.ts` 从
20
+ * `./index` 导入 `openShell`,如果 `index.ts` 反过来导出 `app`,就成了循环。
21
+ * 单向依赖比运行时安全的循环更值得要。
22
+ *
23
+ * **静态资源只吃内联字符串。** 用 `import html from "./index.html" with { type: "text" }`
24
+ * 得到的是编译期常量:不碰文件系统、不产生临时文件、`bun build --compile` 后仍然成立。
25
+ * 从目录读则要处理 `import.meta.dir` 在打包后指向 `B:\~BUN\root` 的问题
26
+ * (`src/host.ts` 里那段 `PACKED` 判断就是为它存在的)。那 5 行留给消费者自己写,
27
+ * 用 `routes` 就能接。
28
+ */
29
+
30
+ import { createIpcServer, type IpcServer, type ServiceMethods } from "./ipc";
31
+ import { EVENT_CLOSED, attachEventPump, type EventPump } from "./events";
32
+ import { openShell, type Shell } from "./index";
33
+ import { serveOnSafePort } from "./port";
34
+ import { FLAG_CENTER, FLAG_RESIZABLE, type ShellOptions } from "./types";
35
+
36
+ /** 骨架起出来的东西。`shell` 是完整的内核对象,骨架只是帮它把周边接好 */
37
+ export type App = {
38
+ /** 内核对象。标题、缩放、全屏、原生菜单、位置持久化都从这里拿 */
39
+ readonly shell: Shell;
40
+ /**
41
+ * 窗口生命周期事件流。骨架替你起的**唯一**一个消费者。
42
+ *
43
+ * `shell.pollEvents()` 是消费式的,一个窗口只能有一个消费者。要用
44
+ * `attachPlacementStore` 时传 `{ events: app.events }`,别再自己 attach
45
+ * 一个泵——两个泵会各自拿到一部分事件。
46
+ *
47
+ * 定时器是 keepAlive 的(不 unref):`wait()` 靠它让进程活到窗口关闭。
48
+ */
49
+ readonly events: EventPump;
50
+ /** 本地服务实际绑定的端口(已避开浏览器受限端口表) */
51
+ readonly port: number;
52
+ /** 页面 URL:带鉴权 token 的查询参数,直接交给 `openShell` 用的就是它 */
53
+ readonly url: string;
54
+ /** 页面 URL 里的 token。页面侧 `connect()` 默认从这里取 */
55
+ readonly token: string;
56
+ /** 给了 `service` 才有;否则为 null */
57
+ readonly ipc: IpcServer | null;
58
+ /**
59
+ * 停止窗口与本地服务。
60
+ *
61
+ * 顺序是有意的:先 `shell.stop()`,再**等 `EVENT_CLOSED`**,最后才关服务。
62
+ * 原生层是进程级单例,`shell_stop()` 只是投了一个 `WM_QUIT`;抢在前面关服务
63
+ * 或重新 start,会撞上「shell already started」(详见 `pitfalls` 2.4g / 7.1)。
64
+ *
65
+ * 等的是事件不是 `isAlive()`:`CLOSED` 由原生侧在 teardown 全部做完、
66
+ * `g_webview` 已置空之后写进队列,它出现的时刻就等于「单例已释放」。
67
+ * 仍然保留超时上限——事件通道若因故失效,宿主不能被永久挂住。
68
+ *
69
+ * 幂等:重复调用立即返回。
70
+ */
71
+ stop(): Promise<void>;
72
+ /**
73
+ * 等窗口被关闭(用户点 X,或调用了 `stop()`)。
74
+ *
75
+ * 宿主进程不会因为窗口没了就自动退出——它自己起的本地服务还在跑。所以
76
+ * 「应用该结束了」的判据是收到 `EVENT_CLOSED`,不是任何信号。
77
+ *
78
+ * 这个 promise 靠 `events` 的 keepAlive 定时器撑住事件循环;别在等它的同时
79
+ * 把 `events.stop()` 掉,那样两边都收不到关闭信号。
80
+ */
81
+ wait(): Promise<void>;
82
+ };
83
+
84
+ export type AppOptions = {
85
+ /**
86
+ * 页面 HTML,用 `with { type: "text" }` 内联。
87
+ *
88
+ * ```ts
89
+ * import html from "./index.html" with { type: "text" };
90
+ * ```
91
+ */
92
+ html: string;
93
+ title?: string;
94
+ width?: number;
95
+ height?: number;
96
+ /**
97
+ * 窗口 flags。缺省 `FLAG_RESIZABLE | FLAG_CENTER`(带边框)。
98
+ *
99
+ * 想要自绘标题栏时显式加 `FLAG_FRAMELESS`——骨架不默认它,因为无边框窗口
100
+ * 没有系统标题栏就**拖不动**,而那需要页面先挂上 `mountTitleBar`。
101
+ * 默认值选「即使页面什么都不做也能用」的那一侧。
102
+ */
103
+ flags?: number;
104
+ minWidth?: number;
105
+ minHeight?: number;
106
+ /** 关掉哪些浏览器行为,取 `BROWSER_UI_*` 的按位或 */
107
+ browserUi?: number;
108
+ appName?: string;
109
+ nativeLibPath?: string;
110
+ embedded?: () => Promise<Uint8Array | null>;
111
+ /**
112
+ * IPC 服务方法表。给了就自动挂 `/ws`(含 token 校验)。
113
+ *
114
+ * 页面侧:
115
+ *
116
+ * ```ts
117
+ * import { connect } from "bun-app-window/ipc";
118
+ * const api = await connect(); // 自动从 ?t= 取 token
119
+ * ```
120
+ */
121
+ service?: ServiceMethods;
122
+ /** IPC 错误里是否回传 stack。默认关闭 */
123
+ exposeStack?: boolean;
124
+ /**
125
+ * 额外路由。返回 `undefined` 表示「不归我管」,交回骨架的默认处理
126
+ * (`/` 吐 HTML、`/ws` 处理升级、其余 404)。
127
+ *
128
+ * 从磁盘读静态资源、代理上游 API 这类事情写在这里。
129
+ */
130
+ routes?: (req: Request) => Response | undefined | Promise<Response | undefined>;
131
+ /** 窗口建好、服务就绪后调用一次 */
132
+ onReady?: (app: App) => void;
133
+ };
134
+
135
+ /** 事件拉取节奏,约一帧 */
136
+ const EVENT_POLL_MS = 16;
137
+ /**
138
+ * 等 `EVENT_CLOSED` 的上限。
139
+ *
140
+ * 实测关闭约 50 ms,5 s 是三个数量级的余量。存在的理由不是「怕关得慢」,
141
+ * 而是「事件通道若因故失效,宿主不能被永久挂住」——超时后照旧关服务。
142
+ */
143
+ const CLOSE_TIMEOUT_MS = 5000;
144
+
145
+ /** 一个只能 resolve 一次的 promise。等窗口关闭用它,比轮询一个布尔值清楚 */
146
+ function defer(): { promise: Promise<void>; resolve: () => void } {
147
+ let resolve: () => void = () => {};
148
+ const promise = new Promise<void>((r) => {
149
+ resolve = r;
150
+ });
151
+ return { promise, resolve };
152
+ }
153
+
154
+ /**
155
+ * 不给 `flags` 时用的默认值:**带边框** + 可缩放 + 居中。
156
+ *
157
+ * 刻意不默认 `FLAG_FRAMELESS`。无边框窗口没有系统标题栏就拖不动,必须由页面先挂上
158
+ * `mountTitleBar` 才能用;把它设成默认值会让「只写了 HTML」的应用一上来就无法拖动。
159
+ * 默认值应当选「页面什么都不做也能用」的那一侧。
160
+ */
161
+ export const DEFAULT_APP_FLAGS = FLAG_RESIZABLE | FLAG_CENTER;
162
+
163
+ /** 页面 URL。token 放查询参数里,页面侧 `connect()` 默认从这里取 */
164
+ export function buildAppUrl(port: number, token: string): string {
165
+ return `http://127.0.0.1:${port}/?t=${token}`;
166
+ }
167
+
168
+ /**
169
+ * `/ws` 请求的处置结论。
170
+ *
171
+ * 抽成纯函数是因为这是整个骨架里**唯一有分支的判据**,而它有三个容易写反的点:
172
+ * 路径要先比、`ipc` 没开时不能当 403(那是 404)、token 不匹配必须在**升级之前**
173
+ * 拒绝——升级之后就没有机会说不了。
174
+ *
175
+ * token 用普通 `!==` 比较而不是恒定时间比较:它是一次性的本地回环随机值,
176
+ * 不是长期凭据,时序侧信道在这里没有可换取的东西。
177
+ */
178
+ export type WsDecision = "not-ws" | "ipc-disabled" | "forbidden" | "upgrade";
179
+
180
+ export function decideWs(
181
+ pathname: string,
182
+ presented: string | null,
183
+ expected: string,
184
+ ipcEnabled: boolean,
185
+ ): WsDecision {
186
+ if (pathname !== "/ws") {
187
+ return "not-ws";
188
+ }
189
+ if (!ipcEnabled) {
190
+ return "ipc-disabled";
191
+ }
192
+ return presented === expected ? "upgrade" : "forbidden";
193
+ }
194
+
195
+ /**
196
+ * 起服务、开窗、接线,返回一个可以 `wait()` 的 App。
197
+ *
198
+ * 失败时抛错,且**不留下半启动状态**——本地服务会被关掉。理由是调用方拿到异常后
199
+ * 唯一合理的动作是退出,而一个已经绑好的端口会让「重试一次」也失败。
200
+ */
201
+ export async function createApp(opts: AppOptions): Promise<App> {
202
+ if (!opts.html || typeof opts.html !== "string") {
203
+ throw new Error("createApp: html is required (import it with { type: \"text\" })");
204
+ }
205
+
206
+ const token = crypto.randomUUID();
207
+ const ipc = opts.service
208
+ ? createIpcServer({
209
+ service: opts.service,
210
+ ...(opts.exposeStack === undefined ? {} : { exposeStack: opts.exposeStack }),
211
+ })
212
+ : null;
213
+
214
+ const server = serveOnSafePort({
215
+ fetch(req, srv) {
216
+ return handle(req, srv);
217
+ },
218
+ websocket: {
219
+ open(ws: unknown) {
220
+ ipc?.attach(ws as { send(d: string): void });
221
+ },
222
+ message(ws: unknown, raw: string | Buffer) {
223
+ void ipc?.handle(ws as { send(d: string): void }, raw);
224
+ },
225
+ close(ws: unknown) {
226
+ ipc?.detach(ws as { send(d: string): void });
227
+ },
228
+ } as never,
229
+ });
230
+
231
+ async function handle(req: Request, srv: unknown): Promise<Response | undefined> {
232
+ const url = new URL(req.url);
233
+
234
+ // IPC 通道。校验必须在这里做——升级之后就没有机会拒绝了
235
+ switch (decideWs(url.pathname, url.searchParams.get("t"), token, ipc !== null)) {
236
+ case "ipc-disabled":
237
+ return new Response("ipc not enabled", { status: 404 });
238
+ case "forbidden":
239
+ return new Response(null, { status: 403 });
240
+ case "upgrade": {
241
+ const upgrade = (srv as { upgrade(r: Request, o: unknown): boolean }).upgrade;
242
+ if (upgrade.call(srv, req, { data: {} })) {
243
+ return undefined; // 已升级,不要再返回 Response
244
+ }
245
+ return new Response(null, { status: 400 });
246
+ }
247
+ default:
248
+ break;
249
+ }
250
+
251
+ // 调用方的路由优先,能覆盖 `/`(自绘入口页、SPA fallback 都靠这条)
252
+ const custom = await opts.routes?.(req);
253
+ if (custom) {
254
+ return custom;
255
+ }
256
+
257
+ if (url.pathname === "/") {
258
+ return new Response(opts.html, {
259
+ headers: { "content-type": "text/html; charset=utf-8" },
260
+ });
261
+ }
262
+
263
+ return new Response("not found", { status: 404 });
264
+ }
265
+
266
+ // `Bun.serve` 的 `port` 类型是 `number | undefined`。`serveOnSafePort` 已经
267
+ // 确认绑定成功,但这里不能顺手断言:一旦为 undefined,拼出来的 URL 会是
268
+ // `http://127.0.0.1:undefined/?t=...`,而 `openShell` 会把它当合法 URL 交给
269
+ // WebView2,表现为一个空白窗口——比抛错难查得多。
270
+ const port = server.port;
271
+ if (port === undefined) {
272
+ server.stop(true);
273
+ throw new Error("createApp: local server did not report a bound port");
274
+ }
275
+ const url = buildAppUrl(port, token);
276
+
277
+ const shellOpts: ShellOptions = {
278
+ url,
279
+ title: opts.title ?? "bun-app-window",
280
+ width: opts.width ?? 1000,
281
+ height: opts.height ?? 680,
282
+ flags: opts.flags ?? DEFAULT_APP_FLAGS,
283
+ ...(opts.minWidth === undefined ? {} : { minWidth: opts.minWidth }),
284
+ ...(opts.minHeight === undefined ? {} : { minHeight: opts.minHeight }),
285
+ ...(opts.browserUi === undefined ? {} : { browserUi: opts.browserUi }),
286
+ ...(opts.appName === undefined ? {} : { appName: opts.appName }),
287
+ ...(opts.nativeLibPath === undefined ? {} : { nativeLibPath: opts.nativeLibPath }),
288
+ ...(opts.embedded === undefined ? {} : { embedded: opts.embedded }),
289
+ };
290
+
291
+ let shell: Shell;
292
+ try {
293
+ shell = await openShell(shellOpts);
294
+ } catch (err) {
295
+ // 不留半启动状态:窗口没起来,端口也不该继续占着
296
+ server.stop(true);
297
+ throw err;
298
+ }
299
+
300
+ let stopped = false;
301
+
302
+ // 窗口事件的唯一消费者。`keepAlive: true` 是 `wait()` 成立的前提:
303
+ // unref 的定时器不撑事件循环,而「等一个永不兑现的 promise」也撑不住——
304
+ // 宿主会在窗口还开着的时候直接退出。
305
+ const events = attachEventPump(shell, { intervalMs: EVENT_POLL_MS, keepAlive: true });
306
+ const closed = defer();
307
+ events.on((e) => {
308
+ if (e.type === EVENT_CLOSED) {
309
+ closed.resolve();
310
+ }
311
+ });
312
+
313
+ const app: App = {
314
+ shell,
315
+ events,
316
+ port,
317
+ url,
318
+ token,
319
+ ipc,
320
+ async stop(): Promise<void> {
321
+ if (stopped) {
322
+ return;
323
+ }
324
+ stopped = true;
325
+ shell.stop();
326
+ // 等 `EVENT_CLOSED` 而不是轮询 `isAlive()`:CLOSED 由原生侧在 teardown
327
+ // 全部做完、g_webview 已置空之后写进队列,它出现的时刻就等于「单例已释放」。
328
+ // 超时上限保留,且超时后也 resolve 一次——否则 `wait()` 会永远挂着。
329
+ await Promise.race([closed.promise, Bun.sleep(CLOSE_TIMEOUT_MS)]);
330
+ closed.resolve();
331
+ events.stop();
332
+ server.stop(true);
333
+ },
334
+ async wait(): Promise<void> {
335
+ if (stopped) {
336
+ return;
337
+ }
338
+ await closed.promise;
339
+ },
340
+ };
341
+
342
+ opts.onReady?.(app);
343
+ return app;
344
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * events.ts — 窗口生命周期事件:解码、游标与拉取节奏
3
+ *
4
+ * ## 为什么是队列 + 拉取,而不是回调
5
+ *
6
+ * `webview_run` 会阻塞原生 UI 线程,而 `bun:ffi` 的 `JSCallback` 依赖空闲的 JS
7
+ * 主线程。二者叠加就是跨线程重入死锁(ADR 0002、`AGENTS.md` §5 第 5 条)。所以
8
+ * 原生层只能往一个定长环里写,宿主在自己的节奏里批量拉走——`shell_poll_events`。
9
+ *
10
+ * ## 单读者
11
+ *
12
+ * `shell_poll_events` 是**消费式**的:读走的记录不会再来。一个进程里只应有一个
13
+ * 消费者。要么用本模块的 `attachEventPump()`,要么自己按节奏调 `shell.pollEvents()`,
14
+ * 别两者都来——两个消费者会各自拿到一部分,谁也看不全。
15
+ *
16
+ * 需要多路关心同一批事件时(例如「位置持久化」与「宿主等关闭」),用一个 pump
17
+ * 加多个 `on()` 订阅,而不是起两个 pump。
18
+ *
19
+ * ## 序号与缺口
20
+ *
21
+ * 环满时丢最旧(原生侧裁断)。丢掉的记录不会伪装成还在,代价是 `seq` 出现断档——
22
+ * 这正是「丢了几条」的精确来源:`EventCursor` 把它换算成 `gaps`,比一条合成的
23
+ * 「溢出事件」信息量更大,也少一个要定义 `seq` 的特殊值。
24
+ *
25
+ * 新一次 `shell_start_ex` 会把 `seq` 归零。`seq` 回退即判定为重启(`restarted`),
26
+ * 缺口不跨重启点计算。
27
+ */
28
+
29
+ /**
30
+ * 一条事件记录的字节数。
31
+ *
32
+ * 与 `native/shell.cc` 的 `EventRecord` 严格一一对应(那边有
33
+ * `static_assert(sizeof(EventRecord) == 32)`)。改布局就是改 ABI。
34
+ */
35
+ export const EVENT_RECORD_BYTES = 32;
36
+
37
+ /** 窗口状态变化。载荷是抓取那一刻的快照,不是增量 */
38
+ export const EVENT_STATE = 1;
39
+ /** 窗口已销毁且原生单例已释放。终态:此后不再有任何事件 */
40
+ export const EVENT_CLOSED = 2;
41
+
42
+ /** 最大化(对应 `SHELL_EV_MAXIMIZED`) */
43
+ export const EV_MAXIMIZED = 1;
44
+ /** 最小化 */
45
+ export const EV_MINIMIZED = 2;
46
+ /** 前台焦点 */
47
+ export const EV_FOCUSED = 4;
48
+ /** 系统深色主题 */
49
+ export const EV_DARK = 8;
50
+
51
+ /** 状态快照:`EVENT_STATE` 的载荷 */
52
+ export type StateEvent = {
53
+ readonly type: typeof EVENT_STATE;
54
+ /** 单调递增,从 1 起;新一次启动归零 */
55
+ readonly seq: number;
56
+ /** 客户区宽(物理像素) */
57
+ readonly width: number;
58
+ /** 客户区高(物理像素) */
59
+ readonly height: number;
60
+ /** 窗口 DPI;页面 CSS 像素 = 物理像素 × 96 / dpi */
61
+ readonly dpi: number;
62
+ readonly maximized: boolean;
63
+ readonly minimized: boolean;
64
+ readonly focused: boolean;
65
+ readonly dark: boolean;
66
+ };
67
+
68
+ /** 终态:`EVENT_CLOSED` 的载荷 */
69
+ export type ClosedEvent = {
70
+ readonly type: typeof EVENT_CLOSED;
71
+ readonly seq: number;
72
+ };
73
+
74
+ export type WindowEvent = StateEvent | ClosedEvent;
75
+
76
+ /** 原生事件队列的容量。宿主一次拉取超过它没有意义 */
77
+ export const EVENT_QUEUE_CAPACITY = 128;
78
+
79
+ /**
80
+ * 把原生写出的定长缓冲解成事件数组。
81
+ *
82
+ * `count` 来自 `shell_poll_events` 的返回值。它按契约不会超过 `buf` 能容纳的条数,
83
+ * 但这里仍然夹一次——`count` 与 `buf` 由两处代码分别决定,夹一下的成本是一次除法,
84
+ * 换来的是「原生改了布局、TS 忘了同步」时不会越界读。
85
+ *
86
+ * 未知 `type` 直接跳过而不是抛错:旧宿主遇到将来新增的事件类型,应该继续工作。
87
+ */
88
+ export function decodeEvents(buf: Uint8Array, count: number): WindowEvent[] {
89
+ const room = Math.floor(buf.byteLength / EVENT_RECORD_BYTES);
90
+ const n = Math.min(Math.max(0, Math.trunc(count)), room);
91
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
92
+ const out: WindowEvent[] = [];
93
+
94
+ for (let i = 0; i < n; i++) {
95
+ const at = i * EVENT_RECORD_BYTES;
96
+ const seq = view.getUint32(at, true);
97
+ const type = view.getUint32(at + 4, true);
98
+
99
+ if (type === EVENT_CLOSED) {
100
+ out.push({ type: EVENT_CLOSED, seq });
101
+ continue;
102
+ }
103
+ if (type !== EVENT_STATE) {
104
+ continue;
105
+ }
106
+
107
+ const flags = view.getUint32(at + 24, true);
108
+ out.push({
109
+ type: EVENT_STATE,
110
+ seq,
111
+ width: view.getInt32(at + 8, true),
112
+ height: view.getInt32(at + 12, true),
113
+ dpi: view.getInt32(at + 16, true),
114
+ maximized: (flags & EV_MAXIMIZED) !== 0,
115
+ minimized: (flags & EV_MINIMIZED) !== 0,
116
+ focused: (flags & EV_FOCUSED) !== 0,
117
+ dark: (flags & EV_DARK) !== 0,
118
+ });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** 只要求「能拉一批事件」,不绑定 `Shell`——便于测试与自定义源 */
124
+ export type EventSource = {
125
+ pollEvents(max?: number): WindowEvent[];
126
+ };
127
+
128
+ export type EventBatch = {
129
+ readonly events: readonly WindowEvent[];
130
+ /** 每项是「这里丢了几条」。空数组表示序号连续 */
131
+ readonly gaps: readonly number[];
132
+ /** `seq` 回退,说明原生侧重启过 */
133
+ readonly restarted: boolean;
134
+ };
135
+
136
+ /**
137
+ * 事件游标:把「序号」翻译成「有没有丢」。
138
+ *
139
+ * 抽成独立对象是因为这是本模块唯一有状态、且状态正确性不能靠端到端验证的部分——
140
+ * 真窗口能证明「拉到事件了」,证明不了「丢了事件时能看出来」。
141
+ */
142
+ export class EventCursor {
143
+ private last = 0;
144
+
145
+ /** 最近一次收到的 `seq`;0 表示还没收到过 */
146
+ get seq(): number {
147
+ return this.last;
148
+ }
149
+
150
+ /** 丢弃已记录的位置(换了一个窗口重新开始看时用) */
151
+ reset(): void {
152
+ this.last = 0;
153
+ }
154
+
155
+ push(events: readonly WindowEvent[]): EventBatch {
156
+ const gaps: number[] = [];
157
+ let restarted = false;
158
+
159
+ for (const e of events) {
160
+ // 序号回退 = 原生侧重启(`reset_events()` 把序号归零)。
161
+ // 重启后的第一条不检查缺口:新的一轮从 1 开始,把它当成「前面丢了 N 条」
162
+ // 会把重启本身误报成丢事件。
163
+ let afterRestart = false;
164
+ if (this.last > 0 && e.seq <= this.last) {
165
+ restarted = true;
166
+ this.last = 0;
167
+ afterRestart = true;
168
+ }
169
+ // last 为 0 且不是重启后的第一条(宿主从未读过)时,首条不是 1 同样说明有丢弃
170
+ if (!afterRestart && e.seq > this.last + 1) {
171
+ gaps.push(e.seq - this.last - 1);
172
+ }
173
+ this.last = e.seq;
174
+ }
175
+
176
+ return { events, gaps, restarted };
177
+ }
178
+ }
179
+
180
+ export type EventPump = {
181
+ /** 订阅。返回退订函数 */
182
+ on(fn: (e: WindowEvent) => void): () => void;
183
+ /** 订阅缺口。丢了多久之后不会再补,只会告诉你有多少条没看见 */
184
+ onGap(fn: (missing: number) => void): () => void;
185
+ stop(): void;
186
+ readonly running: boolean;
187
+ /** 已消费到的最新序号 */
188
+ readonly seq: number;
189
+ };
190
+
191
+ export type EventPumpOptions = {
192
+ /** 拉取节奏。默认 16 ms,约一帧 */
193
+ intervalMs?: number;
194
+ /** 一次最多拉多少条。默认 64,不超过 `EVENT_QUEUE_CAPACITY` */
195
+ maxBatch?: number;
196
+ /**
197
+ * 是否让定时器阻止进程退出。默认 `false`(unref)。
198
+ *
199
+ * 设 `true` 的场景只有一种:**靠这个 pump 等窗口关闭**。unref 的定时器不
200
+ * 阻止退出,而一个永不兑现的 promise 也撑不住事件循环——宿主会在窗口还开着
201
+ * 的时候直接退出。`./app` 的骨架因此传 `true`。
202
+ */
203
+ keepAlive?: boolean;
204
+ };
205
+
206
+ /**
207
+ * 按固定节奏从原生侧拉取事件,并分发给订阅者。
208
+ *
209
+ * 收到 `EVENT_CLOSED` 后自动 `stop()`:那是终态,之后再拉只有空数组,留着定时器
210
+ * 只是白烧 CPU。
211
+ *
212
+ * 每个窗口只应有一个 pump(见模块头「单读者」)。
213
+ */
214
+ export function attachEventPump(src: EventSource, opts: EventPumpOptions = {}): EventPump {
215
+ const intervalMs = opts.intervalMs ?? 16;
216
+ // 原生环就 EVENT_QUEUE_CAPACITY 条,要更多没有意义:多出来的缓冲永远不会被写满
217
+ const maxBatch = Math.min(Math.max(1, opts.maxBatch ?? 64), EVENT_QUEUE_CAPACITY);
218
+ const keepAlive = opts.keepAlive ?? false;
219
+
220
+ const cursor = new EventCursor();
221
+ const listeners = new Set<(e: WindowEvent) => void>();
222
+ const gapListeners = new Set<(missing: number) => void>();
223
+ let stopped = false;
224
+
225
+ function stop(): void {
226
+ if (stopped) {
227
+ return;
228
+ }
229
+ stopped = true;
230
+ clearInterval(timer);
231
+ }
232
+
233
+ const tick = (): void => {
234
+ if (stopped) {
235
+ return;
236
+ }
237
+ let batch: EventBatch;
238
+ try {
239
+ batch = cursor.push(src.pollEvents(maxBatch));
240
+ } catch {
241
+ // 原生库已被卸载、窗口正在销毁:这一拍跳过,下一拍再说。
242
+ // 定时器回调里抛出的异常会直接终止进程,而这里没有任何值得终止的事。
243
+ return;
244
+ }
245
+
246
+ if (batch.gaps.length > 0) {
247
+ for (const missing of batch.gaps) {
248
+ for (const fn of gapListeners) fn(missing);
249
+ }
250
+ }
251
+
252
+ for (const e of batch.events) {
253
+ for (const fn of listeners) fn(e);
254
+ if (e.type === EVENT_CLOSED) {
255
+ stop();
256
+ return;
257
+ }
258
+ }
259
+ };
260
+
261
+ const timer = setInterval(tick, intervalMs);
262
+ if (!keepAlive) unrefTimer(timer);
263
+
264
+ return {
265
+ on(fn) {
266
+ listeners.add(fn);
267
+ return () => {
268
+ listeners.delete(fn);
269
+ };
270
+ },
271
+ onGap(fn) {
272
+ gapListeners.add(fn);
273
+ return () => {
274
+ gapListeners.delete(fn);
275
+ };
276
+ },
277
+ stop,
278
+ get running() {
279
+ return !stopped;
280
+ },
281
+ get seq() {
282
+ return cursor.seq;
283
+ },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 让定时器不阻止进程退出。
289
+ *
290
+ * Bun 与 Node 的定时器对象都有 `unref()`,但类型库里 `setInterval` 的返回值在
291
+ * DOM 与运行时两种声明下不同,所以按结构判断而不是按类型断言。
292
+ */
293
+ export function unrefTimer(timer: unknown): void {
294
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
295
+ (timer as { unref(): void }).unref();
296
+ }
297
+ }