buninu-browser 0.2.3 → 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.
Files changed (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +17 -4
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. package/ai-debug.log +0 -5537
package/README.md CHANGED
@@ -97,6 +97,23 @@ the rest is passed on:
97
97
  casty's and the engine's options:
98
98
  `--casty --help`, `--headless --help`.
99
99
 
100
+ ### Websites with self-signed certificates
101
+
102
+ To browse a website with an untrusted self-signed HTTPS certificate, set
103
+ `NODE_TLS_REJECT_UNAUTHORIZED=0` for that invocation:
104
+
105
+ ```sh
106
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 npx buninu-browser https://localhost:8443
107
+
108
+ # From a local checkout
109
+ env NODE_TLS_REJECT_UNAUTHORIZED=0 bun index.js https://localhost:8443
110
+ ```
111
+
112
+ This disables TLS certificate verification for the process and inherited
113
+ child processes, including requests to other sites. Use it only for trusted
114
+ test sites; avoid exporting it globally. Without this variable, certificate
115
+ verification remains enabled.
116
+
100
117
  ### Requirements
101
118
 
102
119
  - [Bun](https://bun.sh) 1.4.2 or
@@ -201,6 +218,12 @@ not start a browser process or CDP listener.
201
218
 
202
219
  ### Page sandbox
203
220
 
221
+ Pages can copy text with `navigator.clipboard.writeText()` after a user click
222
+ or key press on HTTPS (or localhost). The controller writes the clipboard,
223
+ preferring `xclip -selection clipboard`, with platform clipboard commands as
224
+ fallbacks. Clipboard command access stays outside the page sandbox; unavailable
225
+ providers reject the promise instead of reporting a successful copy.
226
+
204
227
  Page JavaScript runs behind three layers:
205
228
 
206
229
  1. **SES and bindings.** The renderer calls `lockdown()`, and each document
@@ -231,7 +254,64 @@ threads:
231
254
 
232
255
  When idle, the main thread rasterizes a tile reaching half a viewport above
233
256
  and one viewport below the visible area, so the compositor can scroll
234
- without waiting. Screenshots are encoded with `Bun.Image`.
257
+ without waiting. Standard screenshots are encoded with `Bun.Image`.
258
+
259
+ Casty can negotiate raw RGBA/BGRA file screenshots with native bunterm. Raw
260
+ pixels travel over the compositor's binary channel to the controller, which
261
+ owns a private temporary directory and returns a versioned file descriptor
262
+ through CDP. The renderer cannot create files: its seccomp policy is unchanged.
263
+ Each delivery has a unique file, bounded to 16 outstanding files / 128 MiB;
264
+ abandoned files expire on subsequent writes after 60 seconds, and closing the
265
+ host removes the directory. Standard PNG/JPEG/WebP output and CLI screenshots
266
+ retain their existing behavior. Casty falls back to its configured codec when
267
+ raw transport is unsupported.
268
+
269
+ Standard CDP `clip` rectangles also apply to raw captures. Fractional CSS
270
+ width/height are retained until physical-pixel rounding, so a 1.75× viewport
271
+ can deliver exactly 1918×1053 pixels. At `clip.scale: 1`, a full-width crop
272
+ borrows a contiguous raster view instead of rescaling; encoded output uses
273
+ the same crop. `Page.getLayoutMetrics` provides the viewport position so
274
+ Casty can follow document scrolling.
275
+
276
+ Casty also sends a private `clip` on `Page.startScreencast`. It takes precedence over fallback max limits and crops at native pixel density, without resizing. Width/height are CSS units; output pixels still follow device scale. This extension is not standard CDP.
277
+
278
+ Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。
279
+
280
+ 標準 CDP `clip` 也適用於 raw 截圖;小數 CSS 尺寸保留到最後才換算成實體像素,
281
+ 避免字體大小改變後差一列而觸發 bunterm 縮放。`clip.scale: 1` 的全寬裁切
282
+ 直接借用連續像素,不重新縮放;Casty 透過 viewport 位置跟隨頁面捲動。
283
+
284
+ Read-only captures interrupted by an origin renderer swap retry once against
285
+ its replacement, after the new renderer has loaded its document. Scroll
286
+ commands are never replayed, avoiding duplicate input.
287
+
288
+ 跨來源導航更換 renderer 時,唯讀截圖會等待新的 renderer 載入文件後重試一次;
289
+ 捲動命令不會重送,避免重複套用輸入。
290
+
291
+ DOM-triggered rendering coalesces updates with a 16 ms minimum wait, increased
292
+ to twice the last style/layout rebuild time for expensive documents. This is
293
+ separate from Casty's screenshot interval and is not a 60 FPS guarantee:
294
+ raster work and frame delivery still take time. More frequent updates can
295
+ increase CPU usage on continuously animated pages.
296
+
297
+ DOM 變更觸發的繪製會以至少 16 ms 等待合併更新;排版昂貴時則延長為上次
298
+ 樣式/排版重建耗時的兩倍。這與 Casty 的截圖間隔不同,也不保證 60 FPS;
299
+ 光柵繪製與畫面傳遞仍需時間,持續動畫的 CPU 用量可能增加。
300
+
301
+ The compositor now connects to an authenticated, length-prefixed loopback
302
+ socket before the sandbox is installed, instead of reading numeric fd 3.
303
+ It connects directly to the controller, so a busy page main thread cannot
304
+ block screenshot or scroll requests. This removes the previous Windows-only
305
+ compositor disablement; the same tests can run on Windows, but this change
306
+ has so far been executed on Linux only. No additional native library is used.
307
+
308
+ 原生 bunterm 可與 Casty 協商 RGBA/BGRA 原始像素檔案。renderer 經二進位通道
309
+ 交給 controller 寫檔,不放寬 seccomp;標準截圖與不支援 raw 的終端維持原路徑。
310
+ compositor 改用沙箱啟用前建立、帶驗證的本機連線,不再依賴 fd 3,因此移除了
311
+ Windows 專用的停用條件;目前已實測 Linux,Windows 仍待真機驗證。
312
+
313
+ `bun tools/bench-raw-capture.js` measures changing-viewport capture delivery
314
+ (including controller file writes for raw), not terminal FPS.
235
315
 
236
316
  `<video>` can decode and display MP4 video frames with basic playback and
237
317
  seeking. Video bytes are fetched through the controller; the renderer uses
@@ -342,6 +422,11 @@ rendered video position, and the output queue/latency for diagnosis.
342
422
 
343
423
  ### Diagnostics
344
424
 
425
+ [`test/fixtures/browser-raw-demo.html`](test/fixtures/browser-raw-demo.html)
426
+ is a self-contained animated page for checking rendering, scrolling, and links
427
+ through Casty and bunterm. Serve it over a local HTTP server to reuse the visual
428
+ demo; it does not require external assets.
429
+
345
430
  casty does not show the browser's stderr. Set `BUNINU_LOG=1` to also write
346
431
  it to `buninu-browser.log` in the working directory, or set
347
432
  `BUNINU_LOG=<path>` to choose the file. The log includes:
package/lib/cdp-server.js CHANGED
@@ -44,9 +44,11 @@ export class CdpServer {
44
44
  for (const target of state.targets.values()) {
45
45
  if (target.screencast && target.screencastEmit) pushScreencastFrame(context, state, target);
46
46
  }
47
+ if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
47
48
  });
48
49
  context.onLifecycle?.((name) => {
49
50
  for (const target of state.targets.values()) emitLifecycle(target, name);
51
+ if (state.implicitTarget) emitLifecycle(state.implicitTarget, name);
50
52
  });
51
53
 
52
54
  const server = Bun.serve({
@@ -178,6 +180,7 @@ export class CdpServer {
178
180
  ...(sessionId ? { sessionId } : {}),
179
181
  });
180
182
  };
183
+ emit.owner = ws;
181
184
  const result = await dispatch(
182
185
  context,
183
186
  state,
@@ -206,6 +209,13 @@ export class CdpServer {
206
209
  }
207
210
  },
208
211
  close(ws) {
212
+ for (const target of state.targets.values()) {
213
+ if (target.screencastEmit?.owner === ws) stopScreencast(target);
214
+ if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
215
+ }
216
+ const implicit = state.implicitTarget;
217
+ if (implicit?.screencastEmit?.owner === ws) stopScreencast(implicit);
218
+ if (implicit?.lifecycleEmit?.owner === ws) implicit.lifecycleEmit = null;
209
219
  serverLog("[CdpServer] client disconnected");
210
220
  },
211
221
  },
@@ -350,6 +360,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
350
360
  const target = state.targets.get(params.targetId);
351
361
  if (!target) return { success: false };
352
362
 
363
+ stopScreencast(target);
353
364
  await ctx.close?.();
354
365
  state.initialNavigations.get(params.targetId)?.navigated.resolve();
355
366
  state.initialNavigations.get(params.targetId)?.settled.resolve();
@@ -450,7 +461,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
450
461
  // A javascript: URL runs in the current document: no new loader or history entry.
451
462
  if (/^\s*javascript:/i.test(String(params.url))) {
452
463
  await ctx.navigate?.(String(params.url).trim());
453
- if (target.screencast) await emitScreencastFrame(ctx, state, target, emit);
464
+ if (target.screencast) await pushScreencastFrame(ctx, state, target);
454
465
  return { frameId: target.frameId, loaderId: target.loaderId };
455
466
  }
456
467
  // Events of the next document belong to its new loader.
@@ -460,7 +471,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
460
471
  target.title = await getContextTitle(ctx, target);
461
472
  target.history[target.historyIndex].title = target.title;
462
473
  if (target.screencast) {
463
- await emitScreencastFrame(ctx, state, target, emit);
474
+ await pushScreencastFrame(ctx, state, target);
464
475
  }
465
476
  state.initialNavigations.get(target.targetId)?.navigated.resolve();
466
477
 
@@ -512,6 +523,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
512
523
  }
513
524
  case "Page.startScreencast": {
514
525
  const target = getSessionTarget(state, sessionId);
526
+ stopScreencast(target);
515
527
  target.screencast = { ...params };
516
528
  target.screencastEmit = emit;
517
529
  // As in Chrome, the reply does not wait for the first frame (nor the
@@ -519,12 +531,14 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
519
531
  void pushScreencastFrame(ctx, state, target);
520
532
  return {};
521
533
  }
522
- case "Page.screencastFrameAck":
534
+ case "Page.screencastFrameAck": {
535
+ const target = getSessionTarget(state, sessionId);
536
+ if (target.screencastFrames.delete(params.sessionId) && target.screencastPending) void pushScreencastFrame(ctx, state, target);
523
537
  return {};
538
+ }
524
539
  case "Page.stopScreencast": {
525
540
  const target = getSessionTarget(state, sessionId);
526
- target.screencast = null;
527
- target.screencastEmit = null;
541
+ stopScreencast(target);
528
542
  return {};
529
543
  }
530
544
  case "Page.reload": {
@@ -707,7 +721,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
707
721
  navigateTarget(state, target, navigation.url, emit, { addHistory: true });
708
722
  target.title = navigation.title ?? await getContextTitle(ctx, target);
709
723
  target.history[target.historyIndex].title = target.title;
710
- if (target.screencast) await emitScreencastFrame(ctx, state, target, emit);
724
+ if (target.screencast) await pushScreencastFrame(ctx, state, target);
711
725
  }
712
726
  }
713
727
  return {};
@@ -817,6 +831,7 @@ function createTarget(state, url = "about:blank", options = {}) {
817
831
  deviceScaleFactor: 1,
818
832
  },
819
833
  screencast: null,
834
+ screencastFrames: new Set(),
820
835
  };
821
836
  state.targets.set(targetId, target);
822
837
  state.initialNavigations.set(targetId, {
@@ -857,9 +872,20 @@ function emitLifecycle(target, name) {
857
872
  emit("Page.lifecycleEvent", { frameId: target.frameId, loaderId: target.loaderId, name, timestamp });
858
873
  }
859
874
 
860
- /** One screencast frame at a time; changes during a capture coalesce into one more. */
875
+ // Bound the amount of image data queued to a slow CDP client. Two outstanding
876
+ // frames keep the pipe moving while acknowledging one; later updates coalesce.
877
+ const MAX_SCREENCAST_FRAMES = 2;
878
+ function stopScreencast(target) {
879
+ target.screencast = null;
880
+ target.screencastEmit = null;
881
+ target.screencastPending = false;
882
+ target.screencastFrames.clear();
883
+ }
884
+
885
+ /** One capture at a time, with bounded unacknowledged frames. */
861
886
  async function pushScreencastFrame(ctx, state, target) {
862
- if (target.screencastBusy) {
887
+ if (!target.screencast || !target.screencastEmit) return;
888
+ if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
863
889
  target.screencastPending = true;
864
890
  return;
865
891
  }
@@ -867,8 +893,9 @@ async function pushScreencastFrame(ctx, state, target) {
867
893
  try {
868
894
  do {
869
895
  target.screencastPending = false;
870
- await emitScreencastFrame(ctx, state, target, target.screencastEmit);
871
- } while (target.screencastPending && target.screencast && target.screencastEmit);
896
+ await emitScreencastFrame(ctx, state, target);
897
+ } while (target.screencastPending && target.screencast && target.screencastEmit
898
+ && target.screencastFrames.size < MAX_SCREENCAST_FRAMES);
872
899
  } catch (error) {
873
900
  serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
874
901
  } finally {
@@ -876,10 +903,14 @@ async function pushScreencastFrame(ctx, state, target) {
876
903
  }
877
904
  }
878
905
 
879
- async function emitScreencastFrame(ctx, state, target, emit) {
880
- const data = await screenshotData(ctx, target, target.screencast ?? {});
881
- if (!data) return;
906
+ async function emitScreencastFrame(ctx, state, target) {
907
+ const stream = target.screencast, emit = target.screencastEmit;
908
+ const data = await screenshotData(ctx, target, stream);
909
+ if (!data || stream !== target.screencast || emit !== target.screencastEmit) return;
882
910
  const scrollOffset = await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
911
+ if (stream !== target.screencast || emit !== target.screencastEmit) return;
912
+ const sessionId = state.nextScreencastFrameId++;
913
+ target.screencastFrames.add(sessionId);
883
914
  emit("Page.screencastFrame", {
884
915
  data,
885
916
  metadata: {
@@ -891,7 +922,7 @@ async function emitScreencastFrame(ctx, state, target, emit) {
891
922
  scrollOffsetY: scrollOffset.y,
892
923
  timestamp: performance.now() / 1000,
893
924
  },
894
- sessionId: state.nextScreencastFrameId++,
925
+ sessionId,
895
926
  });
896
927
  }
897
928
 
@@ -1000,6 +1031,8 @@ function getImplicitTarget(state) {
1000
1031
 
1001
1032
  state.implicitTarget = {
1002
1033
  targetId: "implicit-target",
1034
+ screencast: null,
1035
+ screencastFrames: new Set(),
1003
1036
  frameId: "implicit-target",
1004
1037
  loaderId: "implicit-loader",
1005
1038
  type: "page",
@@ -0,0 +1,22 @@
1
+ // Host-side text clipboard. No shell interpolation; page bytes go only to stdin.
2
+ export function clipboardCommands(platform = process.platform) {
3
+ return [
4
+ ['xclip','-selection','clipboard'],
5
+ ...(platform === 'win32' ? [['powershell.exe','-NoProfile','-NonInteractive','-Command',
6
+ '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())']]
7
+ : platform === 'darwin' ? [['pbcopy']] : [['wl-copy'],['termux-clipboard-set']]),
8
+ ];
9
+ }
10
+ export async function writeClipboard(text, {which = Bun.which, spawn = Bun.spawn, platform = process.platform} = {}) {
11
+ if(typeof text !== 'string' || Buffer.byteLength(text)>1024*1024)throw Error('Clipboard text exceeds limit');
12
+ for(const command of clipboardCommands(platform)) {
13
+ const executable=which(command[0]); if(!executable)continue;
14
+ let child,timer;
15
+ try {
16
+ child=spawn([executable,...command.slice(1)],{stdin:Buffer.from(text),stdout:'ignore',stderr:'ignore'});
17
+ timer=setTimeout(()=>child.kill(9),3000);
18
+ if(await child.exited === 0)return;
19
+ } catch {} finally {clearTimeout(timer);}
20
+ }
21
+ throw Error('No working clipboard command (xclip preferred)');
22
+ }
@@ -0,0 +1,33 @@
1
+ import Node from "../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts";
2
+ import Document from "../../vendor/happy-dom/packages/happy-dom/src/nodes/document/Document.ts";
3
+ import HTMLHyperlinkElementUtility from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementUtility.ts";
4
+
5
+ /** HTML document base URL, shared by the inert DOM and its page bindings. */
6
+ export function documentBaseURL(node) {
7
+ const document = node?.nodeType === 9 ? node : node?.ownerDocument;
8
+ const fallback = document?.URL ?? "about:blank";
9
+ const base = document?.querySelector?.("base[href]");
10
+ if (!base) return fallback;
11
+ try {
12
+ const url = new URL(base.getAttribute("href"), fallback);
13
+ return url.protocol === "data:" || url.protocol === "javascript:" ? fallback : url.href;
14
+ } catch { return fallback; }
15
+ }
16
+
17
+ // Happy DOM selects the first <base>, including ones with only a target.
18
+ // Correcting both accessors keeps native URL attributes and renderer loads
19
+ // on the same base, without modifying the vendored implementation.
20
+ for (const prototype of [Node.prototype, Document.prototype]) {
21
+ Object.defineProperty(prototype, "baseURI", {
22
+ configurable: true, get() { return documentBaseURL(this); },
23
+ });
24
+ }
25
+
26
+ // Hyperlink component accessors use this shared method, so pathname/search
27
+ // setters and origin/hash getters resolve consistently with href itself.
28
+ HTMLHyperlinkElementUtility.prototype.getHref = function () {
29
+ const value = this.element.getAttribute("href");
30
+ if (value === null) return "";
31
+ try { return new URL(value, documentBaseURL(this.element)).href; }
32
+ catch { return value; }
33
+ };
@@ -1,6 +1,9 @@
1
1
  import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
2
2
  import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
3
+ import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
3
4
  import "./selector-pseudo-adapter.js";
5
+ import "./svg-attribute-adapter.js";
6
+ import "./document-base.js";
4
7
 
5
8
  const IMPLICIT_HEAD_ELEMENTS = new Set([
6
9
  "BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
@@ -12,8 +15,18 @@ const IMPLICIT_HEAD_ELEMENTS = new Set([
12
15
  * Page JavaScript and subresource loading stay disabled here. They belong to
13
16
  * the renderer scheduler and network service rather than the tree builder.
14
17
  */
15
- export function parseHTMLDocument(html, url = "about:blank") {
16
- const window = new Window({
18
+ export function parseHTMLDocument(html, url = "about:blank", referrer = "") {
19
+ const window = createInertWindow(url);
20
+ window.document[PropertySymbol.referrer] = referrer;
21
+
22
+ new HTMLParser(window).parse(String(html), window.document);
23
+ normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
24
+ return { window, document: window.document };
25
+ }
26
+
27
+ /** An isolated parser context: no script execution, resource loads or page custom elements. */
28
+ export function createInertWindow(url = "about:blank") {
29
+ return new Window({
17
30
  url,
18
31
  settings: {
19
32
  disableJavaScriptEvaluation: true,
@@ -27,10 +40,6 @@ export function parseHTMLDocument(html, url = "about:blank") {
27
40
  navigation: { disableFallbackToSetURL: true },
28
41
  },
29
42
  });
30
-
31
- new HTMLParser(window).parse(String(html), window.document);
32
- normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
33
- return { window, document: window.document };
34
43
  }
35
44
 
36
45
  /**
@@ -37,9 +37,9 @@ export function compileSelector(element, selector) {
37
37
  }
38
38
 
39
39
  /** Whether `element` matches a compiled selector, as element.matches(selector) would. */
40
- export function matchesCompiled(element, compiled) {
40
+ export function matchesCompiled(element, compiled, scope = element) {
41
41
  for (const items of compiled) {
42
- if (matchItems(element, element, items, 0, null)) return true;
42
+ if (matchItems(scope, element, items, 0, null)) return true;
43
43
  }
44
44
  return false;
45
45
  }
@@ -1,9 +1,28 @@
1
1
  import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
2
+ import { compileSelector, matchesCompiled } from "./selector-matcher.js";
2
3
 
3
4
  // Happy DOM's :empty checks only element children. CSS also excludes elements
4
5
  // with nonempty text nodes (including whitespace); comments do not count.
5
6
  const original = SelectorItem.prototype.matchPseudoItem;
7
+ const selectorLists = new WeakMap();
6
8
  SelectorItem.prototype.matchPseudoItem = function (scope, element, siblings, pseudo, ignoreErrors) {
9
+ if (pseudo.name === "focus-within") {
10
+ const focused = element.ownerDocument?.activeElement;
11
+ return focused && (focused === element || element.contains(focused)) ? {priorityWeight:10} : null;
12
+ }
13
+ // Functional selector lists match their complete complex selectors. The
14
+ // upstream per-item path tests only a group's first compound, so e.g.
15
+ // :where(h2 code) incorrectly matches the h2 instead of its code child.
16
+ if (["is", "where", "not"].includes(pseudo.name) && pseudo.arguments) {
17
+ let compiled = selectorLists.get(pseudo);
18
+ if (compiled === undefined) {
19
+ compiled = compileSelector(element, pseudo.arguments) ?? null;
20
+ selectorLists.set(pseudo, compiled);
21
+ }
22
+ const matches = compiled && matchesCompiled(element, compiled, scope);
23
+ return (pseudo.name === "not" ? !matches : matches)
24
+ ? { priorityWeight: pseudo.name === "where" ? 0 : 10 } : null;
25
+ }
7
26
  if (pseudo.name === "empty") {
8
27
  for (const child of element.childNodes) {
9
28
  if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
@@ -0,0 +1,50 @@
1
+ import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
2
+
3
+ // HTML tokenization ASCII-lowercases every attribute, including foreign
4
+ // content. Tree construction then restores the standard SVG spellings.
5
+ // https://html.spec.whatwg.org/multipage/parsing.html#adjust-svg-attributes
6
+ const adjustedNames = new Map(`attributeName attributeType baseFrequency baseProfile calcMode clipPathUnits
7
+ diffuseConstant edgeMode filterUnits glyphRef gradientTransform gradientUnits kernelMatrix kernelUnitLength
8
+ keyPoints keySplines keyTimes lengthAdjust limitingConeAngle markerHeight markerUnits markerWidth
9
+ maskContentUnits maskUnits numOctaves pathLength patternContentUnits patternTransform patternUnits
10
+ pointsAtX pointsAtY pointsAtZ preserveAlpha preserveAspectRatio primitiveUnits refX refY repeatCount repeatDur
11
+ requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod startOffset stdDeviation
12
+ stitchTiles surfaceScale systemLanguage tableValues targetX targetY textLength viewBox viewTarget
13
+ xChannelSelector yChannelSelector zoomAndPan`.trim().split(/\s+/).map(name => [name.toLowerCase(), name]));
14
+ const space = character => character === " " || character === "\t" || character === "\n" || character === "\r" || character === "\f";
15
+
16
+ function adjustNames(source) {
17
+ let index = 0, copied = 0, output = "";
18
+ while (index < source.length) {
19
+ while (space(source[index]) || source[index] === "/") index++;
20
+ const start = index;
21
+ while (index < source.length && !space(source[index]) && source[index] !== "=" && source[index] !== "/" && source[index] !== ">") index++;
22
+ if (index === start) { index++; continue; }
23
+ const name = source.slice(start, index);
24
+ const lower = name.replace(/[A-Z]/g, character => character.toLowerCase());
25
+ const adjusted = adjustedNames.get(lower) ?? lower;
26
+ if (name !== adjusted) {
27
+ output += source.slice(copied, start) + adjusted;
28
+ copied = index;
29
+ }
30
+ while (space(source[index])) index++;
31
+ if (source[index] !== "=") continue;
32
+ index++;
33
+ while (space(source[index])) index++;
34
+ const quote = source[index];
35
+ if (quote === '"' || quote === "'") {
36
+ index++;
37
+ while (index < source.length && source[index] !== quote) index++;
38
+ if (index < source.length) index++;
39
+ } else {
40
+ while (index < source.length && !space(source[index]) && source[index] !== ">") index++;
41
+ }
42
+ }
43
+ return copied ? output + source.slice(copied) : source;
44
+ }
45
+
46
+ const parseAttributes = HTMLParser.prototype.parseEndOfStartTag;
47
+ HTMLParser.prototype.parseEndOfStartTag = function (source, selfClosing) {
48
+ if (source && this.nextElement?.namespaceURI === "http://www.w3.org/2000/svg") source = adjustNames(source);
49
+ return parseAttributes.call(this, source, selfClosing);
50
+ };
@@ -101,6 +101,7 @@ export async function createBrowser({
101
101
  let closed = false;
102
102
  // Settles when the first renderer shows about:blank (assigned below).
103
103
  let rendererReady = null;
104
+ const documentReady = new WeakMap();
104
105
  // Renderer-independent state replayed into every new renderer.
105
106
  const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
106
107
  let userAgent = webkit ? initialUserAgent : null;
@@ -173,7 +174,7 @@ export async function createBrowser({
173
174
  recordFragment(url, fragmentFrom);
174
175
  return;
175
176
  }
176
- navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
177
+ navigate(url, { downloadName, post, referrer: host.documentUrl }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
177
178
  },
178
179
  onDownload: ({ url, name, data }) => {
179
180
  if (host !== current) return;
@@ -231,6 +232,8 @@ export async function createBrowser({
231
232
  await next.start();
232
233
  await replayState((method, ...params) => next.call(method, ...params));
233
234
  const previous = current;
235
+ const ready=Promise.withResolvers();
236
+ documentReady.set(next,ready);
234
237
  current = next;
235
238
  currentKey = key;
236
239
  previous?.close();
@@ -259,7 +262,7 @@ export async function createBrowser({
259
262
  * final origin. `invoke` pins the renderer (used when restoring a
260
263
  * restarted renderer).
261
264
  */
262
- async function load(url, invoke = null, downloadName = null, post = null) {
265
+ async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
263
266
  const started = performance.now();
264
267
  if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
265
268
  return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
@@ -273,7 +276,7 @@ export async function createBrowser({
273
276
  try {
274
277
  const target = new URL(url);
275
278
  const response = ["http:", "https:"].includes(target.protocol)
276
- ? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
279
+ ? await network.navigate(target.href, { post, referrer: referrer ?? post?.referrer ?? null, onResponse: (raw, finalUrl) => {
277
280
  if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
278
281
  return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
279
282
  attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
@@ -292,6 +295,7 @@ export async function createBrowser({
292
295
  status: response.status,
293
296
  statusText: response.statusText,
294
297
  cookie: network.documentCookie(response.url),
298
+ referrer: response.referrer ?? "",
295
299
  };
296
300
  } catch (error) {
297
301
  document = { url, error: String(error) };
@@ -304,7 +308,10 @@ export async function createBrowser({
304
308
  + `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
305
309
  if (!invoke) await rendererReady;
306
310
  const host = invoke ? current : await rendererFor(document.url);
307
- const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
311
+ let generation;
312
+ try {
313
+ generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
314
+ } finally { documentReady.get(host)?.resolve(); }
308
315
  committed = { host, generation };
309
316
  for (const name of reachedLifecycle(host, generation)) notify(lifecycleListeners, name);
310
317
  writeLog("navigation", `${document.url}: first paint in ${Math.round(performance.now() - fetched)}ms`);
@@ -314,7 +321,20 @@ export async function createBrowser({
314
321
  // answered even while the renderer's main thread is busy.
315
322
  const composite = async (method, ...params) => {
316
323
  await rendererReady;
317
- return current.composite(method, ...params);
324
+ // An origin change retires the old compositor. Read-only requests may
325
+ // follow that swap once; scrolling must never be applied twice.
326
+ const readOnly = method === "screenshot" || method === "scrollOffset";
327
+ for (let attempt=0; ; attempt++) {
328
+ const host=current;
329
+ await documentReady.get(host)?.promise;
330
+ if (closed) throw Error("Browser is closed");
331
+ try {
332
+ if (host !== current) throw Error("Capture superseded by navigation");
333
+ return await host.composite(method, ...params);
334
+ } catch (error) {
335
+ if (closed || !readOnly || host === current || attempt >= 1) throw error;
336
+ }
337
+ }
318
338
  };
319
339
 
320
340
  async function rememberScroll() {
@@ -324,7 +344,7 @@ export async function createBrowser({
324
344
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
325
345
  }
326
346
 
327
- async function navigate(url, { downloadName = null, post = null } = {}) {
347
+ async function navigate(url, { downloadName = null, post = null, referrer = null } = {}) {
328
348
  const resolved = new URL(url, currentUrl).href;
329
349
  // A javascript: URL runs in the current document; history does not change.
330
350
  if (resolved.startsWith("javascript:")) {
@@ -336,7 +356,7 @@ export async function createBrowser({
336
356
  if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
337
357
  currentUrl = resolved;
338
358
  } else {
339
- const result = await load(resolved, null, downloadName, post);
359
+ const result = await load(resolved, null, downloadName, post, referrer);
340
360
  if (result?.downloaded) return null;
341
361
  // The document is at the URL redirects ended on; the request's fragment
342
362
  // carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
@@ -404,8 +424,10 @@ export async function createBrowser({
404
424
  applyState("setInitScripts", () => [[...initScripts.values()]]);
405
425
  },
406
426
  screenshot: (options = {}) => composite("screenshot", {
407
- format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
427
+ format: ["jpeg", "webp", "rgba", "bgra"].includes(options.format) ? options.format : "png",
408
428
  quality: Number.isFinite(options.quality) ? options.quality : 80,
429
+ optimizeForSpeed: options.optimizeForSpeed === true,
430
+ clip: options.clip ? {...options.clip,x:options.clip.x/pageScale(),y:options.clip.y/pageScale(),width:options.clip.width/pageScale(),height:options.clip.height/pageScale()} : undefined,
409
431
  }),
410
432
  title: () => call("title"),
411
433
  /**
@@ -466,11 +488,17 @@ export async function createBrowser({
466
488
  recordFragment(href, clicked.fragmentFrom);
467
489
  return { url: href, title: await call("title") };
468
490
  }
469
- const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null });
491
+ const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null,
492
+ referrer: current.documentUrl });
470
493
  return result === null ? null : { url: href, title: await call("title") };
471
494
  },
472
495
  evaluate: (expression) => call("evaluate", String(expression)),
473
- cdp() {
496
+ async cdp(method) {
497
+ if(method==='Page.getLayoutMetrics') {
498
+ const offset=await composite('scrollOffset');
499
+ const visual={pageX:offset.x*pageScale(),pageY:offset.y*pageScale(),offsetX:0,offsetY:0,clientWidth:viewport.width,clientHeight:viewport.height,scale:1,zoom:1};
500
+ return {cssVisualViewport:visual,cssLayoutViewport:visual,visualViewport:visual,layoutViewport:visual};
501
+ }
474
502
  return {};
475
503
  },
476
504
  };
@@ -481,7 +509,8 @@ export async function createBrowser({
481
509
  const first = rendererFor("about:blank");
482
510
  prepareSpare();
483
511
  await first;
484
- await current.call("loadDocument", { url: "about:blank" });
512
+ try { await current.call("loadDocument", { url: "about:blank" }); }
513
+ finally { documentReady.get(current)?.resolve(); }
485
514
  })();
486
515
  if (waitForRenderer) await rendererReady;
487
516
  else rendererReady.catch(() => {}); // the caller awaits `ready`