bun-app-window 0.6.2 → 0.8.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/dist/libshell.dll CHANGED
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bun-app-window",
4
- "version": "0.6.2",
4
+ "version": "0.8.0",
5
5
  "description": "Native WebView2 window shell for Bun — frameless or system-decorated windows, DPI-aware geometry, typed IPC, system-accurate title bar",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -14,8 +14,12 @@
14
14
  "bugs": {
15
15
  "url": "https://github.com/TiancongLx/bun-app-window/issues"
16
16
  },
17
- "os": ["win32"],
18
- "cpu": ["x64"],
17
+ "os": [
18
+ "win32"
19
+ ],
20
+ "cpu": [
21
+ "x64"
22
+ ],
19
23
  "keywords": [
20
24
  "bun",
21
25
  "webview",
@@ -61,10 +65,10 @@
61
65
  "pack": "bun pm pack"
62
66
  },
63
67
  "devDependencies": {
64
- "@types/bun": "^1.4.3",
68
+ "@types/bun": "^1.4.4",
65
69
  "typescript": "7.0.2"
66
70
  },
67
71
  "engines": {
68
- "bun": ">=1.4.3"
72
+ "bun": ">=1.4.4"
69
73
  }
70
74
  }
package/src/shell/app.ts CHANGED
@@ -30,11 +30,33 @@
30
30
  import { createIpcServer, type IpcServer, type ServiceMethods } from "./ipc";
31
31
  import { EVENT_CLOSED, attachEventPump, type EventPump } from "./events";
32
32
  import { openShell, type Shell } from "./index";
33
+ import { contentTypeFor } from "./pack";
33
34
  import { serveOnSafePort } from "./port";
34
35
  import { FLAG_CENTER, FLAG_RESIZABLE, type ShellOptions } from "./types";
35
36
  import { debugOf, type ShellDebug } from "./debug";
36
37
  import { attachPlacementStore, type PlacementStore } from "./state";
37
38
 
39
+ /**
40
+ * 页面侧异常。由骨架注入的监听脚本捕获后回传宿主。
41
+ *
42
+ * 回传走 HTTP POST 而非 IPC 通道:页面脚本在求值阶段抛出异常时,WebSocket 尚未
43
+ * 建立,任何依赖该通道的上报都无法送达,而这正是最需要观测的情形(ADR 0020)。
44
+ */
45
+ export type PageError = {
46
+ /** 来源:全局 `error` 事件,或未处理的 Promise 拒绝 */
47
+ kind: "error" | "unhandledrejection";
48
+ /** 错误消息。浏览器未提供时为固定占位文本,不会是空串 */
49
+ message: string;
50
+ /** 抛出位置的文件 URL(仅 `kind === "error"`,且浏览器提供时存在) */
51
+ source?: string;
52
+ /** 行号(同上) */
53
+ line?: number;
54
+ /** 列号(同上) */
55
+ column?: number;
56
+ /** 堆栈。跨源脚本可能为空 */
57
+ stack?: string;
58
+ };
59
+
38
60
  /** 骨架起出来的东西。`shell` 是完整的内核对象,骨架只是帮它把周边接好 */
39
61
  export type App = {
40
62
  /** 内核对象。标题、缩放、全屏、原生菜单、位置持久化都从这里拿 */
@@ -171,6 +193,37 @@ export type AppOptions = {
171
193
  * 从磁盘读静态资源、代理上游 API 这类事情写在这里。
172
194
  */
173
195
  routes?: (req: Request) => Response | undefined | Promise<Response | undefined>;
196
+ /**
197
+ * 静态资源:请求路径 → 文本内容。
198
+ *
199
+ * ```ts
200
+ * const built = await bundlePage({ entry, plugins: [myPlugin()] });
201
+ * const css = built.outputs.find((o) => o.path.endsWith(".css"));
202
+ * const app = await createApp({ html, page: built.code, assets: { "/app.css": css?.code ?? "" } });
203
+ * ```
204
+ *
205
+ * 纯字符串到字符串的映射,不含框架语义。解决的问题是「打包产生了多个产物,宿主需要
206
+ * 把它们都服务出去」——此前每个消费者要自己写一段 `routes` 回调,四行,但每次都容易
207
+ * 写错 `content-type`,而错误的表现是样式静默不生效。
208
+ *
209
+ * `content-type` 由扩展名推断(见 `contentTypeFor`),未识别的扩展名回退到
210
+ * `application/octet-stream` 而不是猜测。
211
+ *
212
+ * 调用方的 `routes` 优先于本项:`assets` 是便利设施,不是拦截层。
213
+ */
214
+ assets?: Record<string, string>;
215
+ /**
216
+ * 页面侧未捕获异常的回调。
217
+ *
218
+ * 骨架会在页面注入最小监听:`error` 事件与 `unhandledrejection` 都会回传,
219
+ * 后者覆盖异步代码的失败,前者覆盖脚本求值阶段的失败(模块加载失败、
220
+ * 顶层引用错误等)。回传走 HTTP POST,不依赖 WebSocket——脚本在求值时崩溃时
221
+ * 通道尚未建立,而那是最需要观测的情形。
222
+ *
223
+ * 未提供时骨架将错误打印到宿主 stderr。不提供即静默,会让「窗口空白但进程正常」
224
+ * 这一状态无法归因,因此默认行为是输出而不是丢弃。
225
+ */
226
+ onPageError?: (error: PageError) => void;
174
227
  /** 窗口建好、服务就绪后调用一次 */
175
228
  onReady?: (app: App) => void;
176
229
  };
@@ -228,6 +281,148 @@ export const PAGE_JS_PATH = "/page.js";
228
281
  */
229
282
  const PAGE_JS_CONTENT_TYPE = "text/javascript; charset=utf-8";
230
283
 
284
+ /**
285
+ * 页面异常回传路径。
286
+ *
287
+ * 独立于 `/ws`:页面脚本在求值阶段崩溃时 IPC 通道尚未建立,依赖通道的上报
288
+ * 无法送达,而那正是最需要观测的情形。HTTP POST 不要求任何前置连接。
289
+ *
290
+ * 以 `/__shell-` 为前缀,与调用方自定义路由的常规命名区分。
291
+ */
292
+ const PAGE_ERROR_PATH = "/__shell-page-error";
293
+
294
+ /**
295
+ * 页面异常回传的体积上限(字节)。
296
+ *
297
+ * 过大的载荷通常是堆栈里嵌入了序列化后的对象,对定位没有额外价值。
298
+ * 截断而不拒绝:诊断信息宁可少,不可因体积超限而整条丢失。
299
+ */
300
+ const PAGE_ERROR_MAX_BYTES = 8 * 1024;
301
+
302
+ /**
303
+ * 注入页面的异常监听脚本。
304
+ *
305
+ * 必须是普通脚本而非模块:模块脚本的执行时机晚于解析,无法捕获在其之前发生的
306
+ * 求值错误。同样必须位于页面自身脚本之前,因此插入点是 `<head>` 的开头。
307
+ *
308
+ * 上报失败一律静默:监听器自身抛错会造成递归,而递归的后果比丢一条日志严重。
309
+ */
310
+ function pageErrorRelayScript(): string {
311
+ return `<script>(function(){try{
312
+ var send=function(p){try{
313
+ var t=new URLSearchParams(location.search).get("t")||"";
314
+ fetch(${JSON.stringify(PAGE_ERROR_PATH)}+"?t="+encodeURIComponent(t),{
315
+ method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(p)
316
+ }).catch(function(){});}catch(e){}};
317
+ window.addEventListener("error",function(e){
318
+ send({kind:"error",message:e.message||"(no message)",
319
+ source:e.filename||"",line:e.lineno||0,column:e.colno||0,
320
+ stack:e.error&&e.error.stack?String(e.error.stack):""});});
321
+ window.addEventListener("unhandledrejection",function(e){
322
+ var r=e.reason;
323
+ send({kind:"unhandledrejection",
324
+ message:r&&r.message?String(r.message):String(r),
325
+ stack:r&&r.stack?String(r.stack):""});});
326
+ }catch(e){}})();</script>`;
327
+ }
328
+
329
+ /**
330
+ * 把监听脚本插入 HTML。
331
+ *
332
+ * 插入点优先 `<head>` 开头:监听器需要早于页面自身的任何脚本。没有 `<head>` 时
333
+ * 退到 `<html>` 之后,再无则置于最前。三种情况都保持原文不变,只做插入。
334
+ */
335
+ export function injectPageErrorRelay(html: string): string {
336
+ const script = pageErrorRelayScript();
337
+ const head = /<head[^>]*>/i.exec(html);
338
+ if (head) {
339
+ const at = head.index + head[0].length;
340
+ return html.slice(0, at) + script + html.slice(at);
341
+ }
342
+ const root = /<html[^>]*>/i.exec(html);
343
+ if (root) {
344
+ const at = root.index + root[0].length;
345
+ return html.slice(0, at) + script + html.slice(at);
346
+ }
347
+ return script + html;
348
+ }
349
+
350
+ /**
351
+ * 把回传的原始载荷归一成 `PageError`。
352
+ *
353
+ * 载荷由页面产生,字段不可信:类型不符的字段被丢弃而不是抛错。`message` 缺失时
354
+ * 填入固定占位文本,保证调用方拿到的对象始终有可读消息——一条消息为空的上报
355
+ * 与被丢弃没有区别。
356
+ */
357
+ export function parsePageError(raw: unknown): PageError | null {
358
+ if (!raw || typeof raw !== "object") {
359
+ return null;
360
+ }
361
+ const o = raw as Record<string, unknown>;
362
+ const kind = o.kind === "unhandledrejection" ? "unhandledrejection" : "error";
363
+ const out: PageError = {
364
+ kind,
365
+ message: typeof o.message === "string" && o.message ? o.message : "(no message)",
366
+ };
367
+ if (typeof o.source === "string" && o.source) {
368
+ out.source = o.source;
369
+ }
370
+ if (typeof o.line === "number" && Number.isFinite(o.line) && o.line > 0) {
371
+ out.line = o.line;
372
+ }
373
+ if (typeof o.column === "number" && Number.isFinite(o.column) && o.column > 0) {
374
+ out.column = o.column;
375
+ }
376
+ if (typeof o.stack === "string" && o.stack) {
377
+ out.stack = o.stack;
378
+ }
379
+ return out;
380
+ }
381
+
382
+ /**
383
+ * 校验 `html` 与 `page` 的一致性。
384
+ *
385
+ * 两者由调用方分别提供,而三种不一致的组合都不会报错:页面照常渲染,脚本不执行,
386
+ * 所有交互无响应,宿主日志为空(ADR 0020)。前两种可以静态判定,因此直接终止;
387
+ * 第三种依赖 `<script>` 的具体写法,判定置信度不足,只输出警告。
388
+ */
389
+ export function assertPageWiring(html: string, page: string | undefined): void {
390
+ const referenced = html.includes(PAGE_JS_PATH);
391
+
392
+ if (page !== undefined && !referenced) {
393
+ throw new Error(
394
+ `createApp: 提供了 page,但 html 中未引用 ${PAGE_JS_PATH}。\n` +
395
+ `页面脚本不会被加载,所有交互都不会有响应,且不会有任何报错。\n` +
396
+ `请在 html 中加入 <script type="module" src="${PAGE_JS_PATH}"></script>,或移除 page 参数。`,
397
+ );
398
+ }
399
+
400
+ if (page === undefined && referenced) {
401
+ throw new Error(
402
+ `createApp: html 引用了 ${PAGE_JS_PATH},但未提供 page 参数。\n` +
403
+ `该路径将返回 404,页面脚本加载失败,所有交互都不会有响应。\n` +
404
+ `请传入打包后的页面脚本,或从 html 中移除该引用。`,
405
+ );
406
+ }
407
+
408
+ if (!referenced) {
409
+ return;
410
+ }
411
+
412
+ // 只检查引用了页面脚本的那一个标签,避免被 HTML 中其他 <script> 干扰。
413
+ // PAGE_JS_PATH 是固定常量(/page.js),需要转义的只有其中的点号。
414
+ const escaped = PAGE_JS_PATH.replace(/\./g, "\\.");
415
+ const tag = new RegExp(`<script\\b[^>]*${escaped}[^>]*>`, "i").exec(html)?.[0];
416
+ if (tag !== undefined && !/\btype\s*=\s*["']module["']/i.test(tag)) {
417
+ // 不抛出:`<script>` 的合法写法不止一种,字符串判定的置信度不足以阻断启动。
418
+ console.warn(
419
+ `createApp: ${PAGE_JS_PATH} 的 <script> 标签缺少 type="module"。\n` +
420
+ `页面脚本是 ES 模块,以经典脚本加载会解析失败,脚本一行都不会执行,` +
421
+ `而页面仍会正常渲染。请补上 type="module"。`,
422
+ );
423
+ }
424
+ }
425
+
231
426
  /**
232
427
  * `/ws` 请求的处置结论。
233
428
  *
@@ -263,9 +458,16 @@ export function decideWs(
263
458
  */
264
459
  export async function createApp(opts: AppOptions): Promise<App> {
265
460
  if (!opts.html || typeof opts.html !== "string") {
266
- throw new Error("createApp: html is required (import it with { type: \"text\" })");
461
+ throw new Error('createApp: html is required (import it with { type: "text" })');
267
462
  }
268
463
 
464
+ // html 与 page 分别由调用方提供,两者的不一致不会产生任何运行时错误,
465
+ // 只会得到一个「渲染正常、交互全无」的窗口(ADR 0020)。
466
+ assertPageWiring(opts.html, opts.page);
467
+
468
+ // 注入只做一次:每次请求重新拼接会让 / 的响应内容随时间变化
469
+ const html = injectPageErrorRelay(opts.html);
470
+
269
471
  const token = crypto.randomUUID();
270
472
  const ipc = opts.service
271
473
  ? createIpcServer({
@@ -311,12 +513,63 @@ export async function createApp(opts: AppOptions): Promise<App> {
311
513
  break;
312
514
  }
313
515
 
516
+ // 页面异常回传。放在调用方路由之前:这是骨架自身的诊断通道,
517
+ // 不应被消费者的 catch-all 路由吞掉。
518
+ if (url.pathname === PAGE_ERROR_PATH) {
519
+ if (req.method !== "POST") {
520
+ return new Response("method not allowed", { status: 405 });
521
+ }
522
+ // 与 `/ws` 同一套 token:本地回环上的任何进程都能 POST 到该端口,
523
+ // 不加约束会让无关流量进入宿主日志。
524
+ if (url.searchParams.get("t") !== token) {
525
+ return new Response(null, { status: 403 });
526
+ }
527
+ let body = "";
528
+ try {
529
+ const text = await req.text();
530
+ body = text.length > PAGE_ERROR_MAX_BYTES ? text.slice(0, PAGE_ERROR_MAX_BYTES) : text;
531
+ } catch {
532
+ return new Response(null, { status: 400 });
533
+ }
534
+ let parsed: PageError | null = null;
535
+ try {
536
+ parsed = parsePageError(JSON.parse(body));
537
+ } catch {
538
+ parsed = null;
539
+ }
540
+ if (parsed === null) {
541
+ return new Response(null, { status: 400 });
542
+ }
543
+ if (opts.onPageError !== undefined) {
544
+ try {
545
+ opts.onPageError(parsed);
546
+ } catch {
547
+ // 回调自身抛错不应影响页面:错误处理的失败路径不能比原错误更响
548
+ }
549
+ } else {
550
+ // 未提供回调时输出而不是丢弃。默认静默会让「窗口空白但进程正常」
551
+ // 无法归因,而这正是引入该通道要解决的问题。
552
+ console.error(`[page] ${parsed.kind}: ${parsed.message}`);
553
+ }
554
+ return new Response(null, { status: 204 });
555
+ }
556
+
314
557
  // 调用方的路由优先,能覆盖 `/`(自绘入口页、SPA fallback 都靠这条)
315
558
  const custom = await opts.routes?.(req);
316
559
  if (custom) {
317
560
  return custom;
318
561
  }
319
562
 
563
+ // 静态资源。放在 routes 之后:assets 是便利设施,不是拦截层。
564
+ // 打包产生多个产物时(例如脚本 + 独立样式),调用方把非脚本产物放这里,
565
+ // content-type 由扩展名推断,避免每处各写一遍且写错的方式各不相同。
566
+ const asset = opts.assets?.[url.pathname];
567
+ if (asset !== undefined) {
568
+ return new Response(asset, {
569
+ headers: { "content-type": contentTypeFor(url.pathname) },
570
+ });
571
+ }
572
+
320
573
  // 页面脚本。给了 `page` 才有这一条;不给时该路径落到底部的 404,
321
574
  // 与引入本项之前的行为一致。
322
575
  if (opts.page !== undefined && url.pathname === PAGE_JS_PATH) {
@@ -326,7 +579,7 @@ export async function createApp(opts: AppOptions): Promise<App> {
326
579
  }
327
580
 
328
581
  if (url.pathname === "/") {
329
- return new Response(opts.html, {
582
+ return new Response(html, {
330
583
  headers: { "content-type": "text/html; charset=utf-8" },
331
584
  });
332
585
  }
@@ -6,11 +6,11 @@
6
6
  */
7
7
 
8
8
  declare module "*.dll" {
9
- const path: string;
10
- export default path;
9
+ const path: string;
10
+ export default path;
11
11
  }
12
12
 
13
13
  declare module "*.html" {
14
- const content: string;
15
- export default content;
14
+ const content: string;
15
+ export default content;
16
16
  }
@@ -24,18 +24,17 @@ const DLL_PATTERN = /^libshell.*\.dll$/i;
24
24
 
25
25
  /** 返回内嵌 DLL 的字节;不在编译产物内或未内嵌时返回 null */
26
26
  export async function embeddedDll(): Promise<Uint8Array | null> {
27
- const files = (Bun as unknown as { embeddedFiles?: ReadonlyArray<Blob & { name: string }> })
28
- .embeddedFiles;
29
- if (!files || files.length === 0) return null;
27
+ const files = (Bun as unknown as { embeddedFiles?: ReadonlyArray<Blob & { name: string }> }).embeddedFiles;
28
+ if (!files || files.length === 0) return null;
30
29
 
31
- const hit = files.find((f) => DLL_PATTERN.test(f.name));
32
- if (!hit) return null;
30
+ const hit = files.find((f) => DLL_PATTERN.test(f.name));
31
+ if (!hit) return null;
33
32
 
34
- const buf = new Uint8Array(await hit.arrayBuffer());
35
- if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) {
36
- throw new Error(`embedded native library is not a PE file (${hit.name})`);
37
- }
38
- return buf;
33
+ const buf = new Uint8Array(await hit.arrayBuffer());
34
+ if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) {
35
+ throw new Error(`embedded native library is not a PE file (${hit.name})`);
36
+ }
37
+ return buf;
39
38
  }
40
39
 
41
40
  /** 打包器记录的 DLL 路径(仅排障时有用,运行期不要用它读文件) */