buninu-browser 0.2.2 → 0.2.4
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/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +18 -8
- package/lib/cdp-server.js +53 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +20 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +12 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +289 -131
- 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 +42 -0
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +22 -27
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +40 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +244 -82
- 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.js +27 -10
- package/lib/renderer/custom-element-reactions.js +42 -0
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +36 -5
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +33 -5
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +278 -147
- package/lib/renderer/page-renderer.js +840 -301
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- 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 +145 -0
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +632 -108
- 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/compositor.test.js +38 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +279 -1
- package/test/display-list.test.js +26 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/download.test.js +8 -0
- package/test/fetch-lifecycle.test.js +161 -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/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/headless-shell.test.js +22 -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-playback-events.test.js +22 -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 +160 -2
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -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 +27 -5
- package/test/raster-readback.test.js +40 -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 +168 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +136 -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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Decoded PCM kept in the controller is independent of the device queue.
|
|
2
|
+
// It can bridge renderer work without increasing pause/seek output latency.
|
|
3
|
+
export class PlaybackBuffer {
|
|
4
|
+
constructor(bytesPerSecond, frameBytes, leadSeconds) {
|
|
5
|
+
this.bytesPerSecond = bytesPerSecond;
|
|
6
|
+
this.frameBytes = frameBytes;
|
|
7
|
+
this.reserve = leadSeconds <= 0.15 ? 0 : this.align(bytesPerSecond * 2);
|
|
8
|
+
this.threshold = this.reserve ? this.align(bytesPerSecond * 0.4) : 0;
|
|
9
|
+
this.buffering = Boolean(this.reserve);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
align(bytes) {
|
|
13
|
+
return Math.max(0, Math.floor(bytes / this.frameBytes) * this.frameBytes);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ready(queued, inDevice, finishing) {
|
|
17
|
+
if (!this.reserve || finishing) return true;
|
|
18
|
+
if (queued < this.frameBytes && inDevice < this.frameBytes) this.buffering = true;
|
|
19
|
+
if (this.buffering && queued >= this.threshold) this.buffering = false;
|
|
20
|
+
return !this.buffering;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
request(queued, outstanding, inDevice, immediateNeed) {
|
|
24
|
+
// Requests stay bounded even when the producer or network stops replying.
|
|
25
|
+
return this.align(Math.max(immediateNeed, this.reserve - inDevice) - queued - outstanding);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* other channels carry a stream's audio. Audio is sent as s16le.
|
|
14
14
|
*/
|
|
15
15
|
import { connect } from "node:net";
|
|
16
|
+
import { PlaybackBuffer } from "./playback-buffer.js";
|
|
16
17
|
|
|
17
18
|
export { floatToS16 } from "./pcm.js";
|
|
18
19
|
|
|
@@ -332,9 +333,9 @@ export class PulseClient {
|
|
|
332
333
|
* to LEAD_SECONDS ahead of the playback clock, whatever the server asks for.
|
|
333
334
|
* Some servers ask for more at once and queue it themselves (jspulse's
|
|
334
335
|
* OpenSL ES backend requests again as soon as it receives), and then a pause
|
|
335
|
-
* would leave everything already sent still playing.
|
|
336
|
-
*
|
|
337
|
-
*
|
|
336
|
+
* would leave everything already sent still playing. The producer prepares a
|
|
337
|
+
* separate bounded PCM reserve here so renderer work cannot immediately starve
|
|
338
|
+
* the output. That reserve is discarded on close, not sent to the device.
|
|
338
339
|
*/
|
|
339
340
|
const LEAD_SECONDS = 0.5;
|
|
340
341
|
const PACE_MS = 50;
|
|
@@ -359,6 +360,7 @@ class PlaybackStream {
|
|
|
359
360
|
#onTimingError;
|
|
360
361
|
#timingPending = false;
|
|
361
362
|
#playedBytes = 0;
|
|
363
|
+
#producerBuffer;
|
|
362
364
|
killed = false;
|
|
363
365
|
|
|
364
366
|
constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError) {
|
|
@@ -371,6 +373,7 @@ class PlaybackStream {
|
|
|
371
373
|
this.#bytesPerSecond = rate * this.#frameBytes;
|
|
372
374
|
this.#onRequest = onRequest;
|
|
373
375
|
this.#leadSeconds = leadSeconds;
|
|
376
|
+
this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
|
|
374
377
|
this.#onTiming = onTiming;
|
|
375
378
|
this.#onTimingError = onTimingError;
|
|
376
379
|
this.#timer = setInterval(() => { this.#pace(); void this.updateTiming(); }, PACE_MS);
|
|
@@ -388,7 +391,8 @@ class PlaybackStream {
|
|
|
388
391
|
const played = Math.min(end, Math.max(0, Math.min(end, timing.readIndex) - timing.sinkSeconds * this.#bytesPerSecond));
|
|
389
392
|
this.#playedBytes = Math.max(this.#playedBytes, this.#frameAligned(played));
|
|
390
393
|
this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
|
|
391
|
-
sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing
|
|
394
|
+
sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing,
|
|
395
|
+
bufferedSeconds: this.#queuedBytes / this.#bytesPerSecond });
|
|
392
396
|
this.#pace();
|
|
393
397
|
} catch (error) {
|
|
394
398
|
if (!this.killed) this.#onTimingError?.(error);
|
|
@@ -436,7 +440,11 @@ class PlaybackStream {
|
|
|
436
440
|
// (a few at a time), so small packets leave them little to play from.
|
|
437
441
|
const minimum = this.#frameAligned(this.#bytesPerSecond * MIN_PACKET_SECONDS);
|
|
438
442
|
const maximum = this.#frameAligned(this.#bytesPerSecond * MAX_PACKET_SECONDS);
|
|
439
|
-
|
|
443
|
+
const inDevice = this.#onTiming ? Math.max(0, this.#offset - this.#playedBytes)
|
|
444
|
+
: this.#started === null ? 0 : Math.max(0, this.#offset - (performance.now() - this.#started) / 1000 * this.#bytesPerSecond);
|
|
445
|
+
const ready = this.#producerBuffer.ready(this.#queuedBytes, inDevice, this.finishing);
|
|
446
|
+
const previousOffset = this.#offset;
|
|
447
|
+
while (ready && this.#credit > 0 && this.#queuedBytes > 0 && this.#offset < allowed) {
|
|
440
448
|
const room = this.#frameAligned(Math.min(this.#credit, allowed - this.#offset, maximum, this.#queuedBytes));
|
|
441
449
|
if (room < this.#frameBytes || (room < minimum && !this.finishing && this.#queuedBytes < minimum)) break;
|
|
442
450
|
const packet = new Uint8Array(room);
|
|
@@ -454,9 +462,11 @@ class PlaybackStream {
|
|
|
454
462
|
this.#credit -= room;
|
|
455
463
|
this.#queuedBytes -= room;
|
|
456
464
|
}
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
465
|
+
// Predecode independently of server credits; only transmission uses the
|
|
466
|
+
// small device budget above. Keep outstanding requests in that budget too.
|
|
467
|
+
const immediateNeed = Math.min(this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000 - this.#offset);
|
|
468
|
+
const sentNow = inDevice + this.#offset - previousOffset;
|
|
469
|
+
const need = this.#producerBuffer.request(this.#queuedBytes, this.#outstanding, sentNow, immediateNeed);
|
|
460
470
|
if (need >= this.#bytesPerSecond * MIN_REQUEST_SECONDS && !this.finishing) {
|
|
461
471
|
this.#outstanding += need;
|
|
462
472
|
this.#onRequest(need);
|
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": {
|
|
@@ -686,6 +700,10 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
686
700
|
return {};
|
|
687
701
|
case "Input.dispatchMouseEvent": {
|
|
688
702
|
const target = getSessionTarget(state, sessionId);
|
|
703
|
+
if (params.type === "mouseMoved") {
|
|
704
|
+
await ctx.move?.(params.x, params.y);
|
|
705
|
+
return {};
|
|
706
|
+
}
|
|
689
707
|
if (params.type === "mouseWheel") {
|
|
690
708
|
await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0, { x: params.x, y: params.y });
|
|
691
709
|
return {};
|
|
@@ -703,7 +721,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
703
721
|
navigateTarget(state, target, navigation.url, emit, { addHistory: true });
|
|
704
722
|
target.title = navigation.title ?? await getContextTitle(ctx, target);
|
|
705
723
|
target.history[target.historyIndex].title = target.title;
|
|
706
|
-
if (target.screencast) await
|
|
724
|
+
if (target.screencast) await pushScreencastFrame(ctx, state, target);
|
|
707
725
|
}
|
|
708
726
|
}
|
|
709
727
|
return {};
|
|
@@ -719,6 +737,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
719
737
|
text: params.text ?? "",
|
|
720
738
|
code: params.code ?? "",
|
|
721
739
|
repeat: !!params.autoRepeat,
|
|
740
|
+
windowsVirtualKeyCode: params.windowsVirtualKeyCode,
|
|
722
741
|
});
|
|
723
742
|
target.cdpKeyDown = signature;
|
|
724
743
|
}
|
|
@@ -729,6 +748,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
729
748
|
text: params.text ?? "",
|
|
730
749
|
code: params.code ?? "",
|
|
731
750
|
repeat: !!params.autoRepeat,
|
|
751
|
+
windowsVirtualKeyCode: params.windowsVirtualKeyCode,
|
|
732
752
|
});
|
|
733
753
|
}
|
|
734
754
|
} else if (params.type === "keyUp") {
|
|
@@ -811,6 +831,7 @@ function createTarget(state, url = "about:blank", options = {}) {
|
|
|
811
831
|
deviceScaleFactor: 1,
|
|
812
832
|
},
|
|
813
833
|
screencast: null,
|
|
834
|
+
screencastFrames: new Set(),
|
|
814
835
|
};
|
|
815
836
|
state.targets.set(targetId, target);
|
|
816
837
|
state.initialNavigations.set(targetId, {
|
|
@@ -851,9 +872,20 @@ function emitLifecycle(target, name) {
|
|
|
851
872
|
emit("Page.lifecycleEvent", { frameId: target.frameId, loaderId: target.loaderId, name, timestamp });
|
|
852
873
|
}
|
|
853
874
|
|
|
854
|
-
|
|
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. */
|
|
855
886
|
async function pushScreencastFrame(ctx, state, target) {
|
|
856
|
-
if (target.
|
|
887
|
+
if (!target.screencast || !target.screencastEmit) return;
|
|
888
|
+
if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
|
|
857
889
|
target.screencastPending = true;
|
|
858
890
|
return;
|
|
859
891
|
}
|
|
@@ -861,8 +893,9 @@ async function pushScreencastFrame(ctx, state, target) {
|
|
|
861
893
|
try {
|
|
862
894
|
do {
|
|
863
895
|
target.screencastPending = false;
|
|
864
|
-
await emitScreencastFrame(ctx, state, target
|
|
865
|
-
} 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);
|
|
866
899
|
} catch (error) {
|
|
867
900
|
serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
|
|
868
901
|
} finally {
|
|
@@ -870,10 +903,14 @@ async function pushScreencastFrame(ctx, state, target) {
|
|
|
870
903
|
}
|
|
871
904
|
}
|
|
872
905
|
|
|
873
|
-
async function emitScreencastFrame(ctx, state, target
|
|
874
|
-
const
|
|
875
|
-
|
|
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;
|
|
876
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);
|
|
877
914
|
emit("Page.screencastFrame", {
|
|
878
915
|
data,
|
|
879
916
|
metadata: {
|
|
@@ -885,7 +922,7 @@ async function emitScreencastFrame(ctx, state, target, emit) {
|
|
|
885
922
|
scrollOffsetY: scrollOffset.y,
|
|
886
923
|
timestamp: performance.now() / 1000,
|
|
887
924
|
},
|
|
888
|
-
sessionId
|
|
925
|
+
sessionId,
|
|
889
926
|
});
|
|
890
927
|
}
|
|
891
928
|
|
|
@@ -994,6 +1031,8 @@ function getImplicitTarget(state) {
|
|
|
994
1031
|
|
|
995
1032
|
state.implicitTarget = {
|
|
996
1033
|
targetId: "implicit-target",
|
|
1034
|
+
screencast: null,
|
|
1035
|
+
screencastFrames: new Set(),
|
|
997
1036
|
frameId: "implicit-target",
|
|
998
1037
|
loaderId: "implicit-loader",
|
|
999
1038
|
type: "page",
|
|
@@ -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,5 +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";
|
|
4
|
+
import "./selector-pseudo-adapter.js";
|
|
5
|
+
import "./svg-attribute-adapter.js";
|
|
6
|
+
import "./document-base.js";
|
|
3
7
|
|
|
4
8
|
const IMPLICIT_HEAD_ELEMENTS = new Set([
|
|
5
9
|
"BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
|
|
@@ -11,8 +15,18 @@ const IMPLICIT_HEAD_ELEMENTS = new Set([
|
|
|
11
15
|
* Page JavaScript and subresource loading stay disabled here. They belong to
|
|
12
16
|
* the renderer scheduler and network service rather than the tree builder.
|
|
13
17
|
*/
|
|
14
|
-
export function parseHTMLDocument(html, url = "about:blank") {
|
|
15
|
-
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({
|
|
16
30
|
url,
|
|
17
31
|
settings: {
|
|
18
32
|
disableJavaScriptEvaluation: true,
|
|
@@ -20,12 +34,12 @@ export function parseHTMLDocument(html, url = "about:blank") {
|
|
|
20
34
|
disableCSSFileLoading: true,
|
|
21
35
|
disableIframePageLoading: true,
|
|
22
36
|
enableImageFileLoading: false,
|
|
37
|
+
// This DOM is only the tree builder. Link activation must not change
|
|
38
|
+
// its URL before our controller commits a navigation (downloads never
|
|
39
|
+
// commit). Otherwise later #links resolve against the download URL.
|
|
40
|
+
navigation: { disableFallbackToSetURL: true },
|
|
23
41
|
},
|
|
24
42
|
});
|
|
25
|
-
|
|
26
|
-
new HTMLParser(window).parse(String(html), window.document);
|
|
27
|
-
normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
|
|
28
|
-
return { window, document: window.document };
|
|
29
43
|
}
|
|
30
44
|
|
|
31
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
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
|
|
2
|
+
import { compileSelector, matchesCompiled } from "./selector-matcher.js";
|
|
3
|
+
|
|
4
|
+
// Happy DOM's :empty checks only element children. CSS also excludes elements
|
|
5
|
+
// with nonempty text nodes (including whitespace); comments do not count.
|
|
6
|
+
const original = SelectorItem.prototype.matchPseudoItem;
|
|
7
|
+
const selectorLists = new WeakMap();
|
|
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
|
+
}
|
|
26
|
+
if (pseudo.name === "empty") {
|
|
27
|
+
for (const child of element.childNodes) {
|
|
28
|
+
if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
|
|
29
|
+
}
|
|
30
|
+
return { priorityWeight: 10 };
|
|
31
|
+
}
|
|
32
|
+
return original.call(this, scope, element, siblings, pseudo, ignoreErrors);
|
|
33
|
+
};
|
|
@@ -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
|
@@ -173,7 +173,7 @@ export async function createBrowser({
|
|
|
173
173
|
recordFragment(url, fragmentFrom);
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
|
-
navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
176
|
+
navigate(url, { downloadName, post, referrer: host.documentUrl }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
177
177
|
},
|
|
178
178
|
onDownload: ({ url, name, data }) => {
|
|
179
179
|
if (host !== current) return;
|
|
@@ -259,7 +259,7 @@ export async function createBrowser({
|
|
|
259
259
|
* final origin. `invoke` pins the renderer (used when restoring a
|
|
260
260
|
* restarted renderer).
|
|
261
261
|
*/
|
|
262
|
-
async function load(url, invoke = null, downloadName = null, post = null) {
|
|
262
|
+
async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
|
|
263
263
|
const started = performance.now();
|
|
264
264
|
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
265
265
|
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
@@ -273,7 +273,7 @@ export async function createBrowser({
|
|
|
273
273
|
try {
|
|
274
274
|
const target = new URL(url);
|
|
275
275
|
const response = ["http:", "https:"].includes(target.protocol)
|
|
276
|
-
? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
|
|
276
|
+
? await network.navigate(target.href, { post, referrer: referrer ?? post?.referrer ?? null, onResponse: (raw, finalUrl) => {
|
|
277
277
|
if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
|
|
278
278
|
return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
|
|
279
279
|
attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
|
|
@@ -292,6 +292,7 @@ export async function createBrowser({
|
|
|
292
292
|
status: response.status,
|
|
293
293
|
statusText: response.statusText,
|
|
294
294
|
cookie: network.documentCookie(response.url),
|
|
295
|
+
referrer: response.referrer ?? "",
|
|
295
296
|
};
|
|
296
297
|
} catch (error) {
|
|
297
298
|
document = { url, error: String(error) };
|
|
@@ -324,7 +325,7 @@ export async function createBrowser({
|
|
|
324
325
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
325
326
|
}
|
|
326
327
|
|
|
327
|
-
async function navigate(url, { downloadName = null, post = null } = {}) {
|
|
328
|
+
async function navigate(url, { downloadName = null, post = null, referrer = null } = {}) {
|
|
328
329
|
const resolved = new URL(url, currentUrl).href;
|
|
329
330
|
// A javascript: URL runs in the current document; history does not change.
|
|
330
331
|
if (resolved.startsWith("javascript:")) {
|
|
@@ -336,7 +337,7 @@ export async function createBrowser({
|
|
|
336
337
|
if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
337
338
|
currentUrl = resolved;
|
|
338
339
|
} else {
|
|
339
|
-
const result = await load(resolved, null, downloadName, post);
|
|
340
|
+
const result = await load(resolved, null, downloadName, post, referrer);
|
|
340
341
|
if (result?.downloaded) return null;
|
|
341
342
|
// The document is at the URL redirects ended on; the request's fragment
|
|
342
343
|
// carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
|
|
@@ -406,6 +407,7 @@ export async function createBrowser({
|
|
|
406
407
|
screenshot: (options = {}) => composite("screenshot", {
|
|
407
408
|
format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
|
|
408
409
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
410
|
+
optimizeForSpeed: options.optimizeForSpeed === true,
|
|
409
411
|
}),
|
|
410
412
|
title: () => call("title"),
|
|
411
413
|
/**
|
|
@@ -455,6 +457,9 @@ export async function createBrowser({
|
|
|
455
457
|
lifecycleState() {
|
|
456
458
|
return [...reachedLifecycle(committed.host, committed.generation)];
|
|
457
459
|
},
|
|
460
|
+
move(x, y) {
|
|
461
|
+
return call("move", x / pageScale(), y / pageScale());
|
|
462
|
+
},
|
|
458
463
|
async click(x, y) {
|
|
459
464
|
const clicked = await call("click", x / pageScale(), y / pageScale());
|
|
460
465
|
const href = typeof clicked === "string" ? clicked : clicked?.url;
|
|
@@ -463,7 +468,8 @@ export async function createBrowser({
|
|
|
463
468
|
recordFragment(href, clicked.fragmentFrom);
|
|
464
469
|
return { url: href, title: await call("title") };
|
|
465
470
|
}
|
|
466
|
-
const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null
|
|
471
|
+
const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null,
|
|
472
|
+
referrer: current.documentUrl });
|
|
467
473
|
return result === null ? null : { url: href, title: await call("title") };
|
|
468
474
|
},
|
|
469
475
|
evaluate: (expression) => call("evaluate", String(expression)),
|