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.
- package/README.md +86 -1
- package/lib/cdp-server.js +47 -14
- package/lib/clipboard.js +22 -0
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +40 -11
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +77 -16
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/raw-frames.js +47 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor-channel.js +103 -0
- package/lib/renderer/compositor.js +34 -40
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/flags.js +1 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +133 -98
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +52 -5
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +287 -99
- package/lib/renderer/page-renderer.js +663 -239
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/clipboard.test.js +35 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/browser-raw-demo.html +36 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/raw-capture-benchmark.json +76 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/raw-capture.test.js +152 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/test/window-resize.test.js +36 -0
- package/tools/bench-raw-capture.js +32 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
880
|
-
const
|
|
881
|
-
|
|
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
|
|
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",
|
package/lib/clipboard.js
ADDED
|
@@ -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
|
+
};
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -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 =
|
|
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(
|
|
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
|
+
};
|
package/lib/headless-shell.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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`
|