buninu-browser 0.2.1 → 0.2.3

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 (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Web IDL interface objects for the engine's singleton platform objects
3
+ * (navigator, screen, performance...).
4
+ *
5
+ * In a browser these objects have no own properties: attributes are
6
+ * accessors and operations are methods on the interface prototype
7
+ * (Navigator.prototype.userAgent is a getter), each throwing
8
+ * "Illegal invocation" for a receiver that is not a platform object of that
9
+ * interface, and Object.prototype.toString reads the interface name from
10
+ * Symbol.toStringTag. The interface object itself throws when called or
11
+ * constructed. Pages and integrity checks inspect exactly this shape.
12
+ */
13
+
14
+ /**
15
+ * @param {string} name interface name
16
+ * @param {object} template members as an object literal: accessors stay
17
+ * accessors, functions become operations, other values read-only attributes
18
+ * @param {{ parent?: object | null, base?: (prototype: object) => object }} [options]
19
+ * `parent` is the prototype's prototype; `base` makes an instance with the
20
+ * given prototype (an event target's constructor, for one)
21
+ * Accessors and operations run with `this` set to the instance's slots
22
+ * object (the template itself unless `create` was given one), so a template
23
+ * may read its other members, or per-instance data, through `this`.
24
+ *
25
+ * @returns {{ Interface: Function, prototype: object, instance: object, create: (slots?: object) => object }}
26
+ */
27
+ export function platformObject(name, template, { parent = Object.prototype, base = (prototype) => Object.create(prototype) } = {}) {
28
+ const instances = new WeakMap();
29
+ const check = (self) => {
30
+ const slots = instances.get(self);
31
+ if (!slots) throw new TypeError("Illegal invocation");
32
+ return slots;
33
+ };
34
+ // An object literal method has no prototype and is not a constructor,
35
+ // as built-in operations and accessors are.
36
+ // Interface objects are constructors that refuse to construct.
37
+ const Interface = { [name]: function () { throw new TypeError("Illegal constructor"); } }[name];
38
+ const prototype = Object.create(parent);
39
+ for (const key of Reflect.ownKeys(template)) {
40
+ const descriptor = Object.getOwnPropertyDescriptor(template, key);
41
+ if (!descriptor) continue;
42
+ if (descriptor.get || descriptor.set) {
43
+ const { get, set } = descriptor;
44
+ const member = {};
45
+ Object.defineProperty(member, key, {
46
+ get: get ? accessor({ get [key]() { return get.call(check(this)); } }, key).get : undefined,
47
+ set: set ? accessor({ set [key](value) { set.call(check(this), value); } }, key).set : undefined,
48
+ });
49
+ Object.defineProperty(prototype, key, { ...Object.getOwnPropertyDescriptor(member, key), enumerable: true, configurable: true });
50
+ } else if (typeof descriptor.value === "function") {
51
+ const operation = descriptor.value;
52
+ const method = { [key](...args) { return Reflect.apply(operation, check(this), args); } }[key];
53
+ Object.defineProperty(method, "length", { value: operation.length });
54
+ Object.defineProperty(prototype, key, { value: method, writable: true, enumerable: true, configurable: true });
55
+ } else {
56
+ const { value } = descriptor;
57
+ Object.defineProperty(prototype, key, {
58
+ get: accessor({ get [key]() { check(this); return value; } }, key).get,
59
+ enumerable: true,
60
+ configurable: true,
61
+ });
62
+ }
63
+ }
64
+ Object.defineProperty(prototype, "constructor", { value: Interface, writable: true, configurable: true });
65
+ Object.defineProperty(prototype, Symbol.toStringTag, { value: name, configurable: true });
66
+ Object.defineProperty(Interface, "prototype", { value: prototype, writable: false, enumerable: false, configurable: false });
67
+ const create = (slots = template) => {
68
+ const object = base(prototype);
69
+ instances.set(object, slots);
70
+ return object;
71
+ };
72
+ return { Interface, prototype, instance: create(), create };
73
+ }
74
+
75
+ const accessor = (object, key) => Object.getOwnPropertyDescriptor(object, key);
76
+
77
+ /**
78
+ * window.localStorage and sessionStorage (HTML §12.2.1): the methods on
79
+ * Storage.prototype, and the stored items as each storage's own properties
80
+ * (`localStorage.theme` reads an item; assigning one stores a string).
81
+ *
82
+ * @param {object} parent Object.prototype of the page
83
+ * @returns {{ Storage: Function, prototype: object, create: (map: Map<string, string>) => object }}
84
+ */
85
+ export function storageInterface(parent = Object.prototype) {
86
+ const maps = new WeakMap();
87
+ const mapOf = (self) => {
88
+ const map = maps.get(self);
89
+ if (!map) throw new TypeError("Illegal invocation");
90
+ return map;
91
+ };
92
+ const Storage = { Storage: function () { throw new TypeError("Illegal constructor"); } }.Storage;
93
+ const members = {
94
+ get length() { return mapOf(this).size; },
95
+ key(index) { return [...mapOf(this).keys()][Number(index)] ?? null; },
96
+ getItem(key) { const map = mapOf(this); return map.has(String(key)) ? map.get(String(key)) : null; },
97
+ setItem(key, value) { mapOf(this).set(String(key), String(value)); },
98
+ removeItem(key) { mapOf(this).delete(String(key)); },
99
+ clear() { mapOf(this).clear(); },
100
+ };
101
+ const prototype = Object.create(parent);
102
+ for (const key of Reflect.ownKeys(members)) {
103
+ const descriptor = Object.getOwnPropertyDescriptor(members, key);
104
+ if (!descriptor) continue;
105
+ Object.defineProperty(prototype, key, { ...descriptor, enumerable: true, configurable: true });
106
+ }
107
+ Object.defineProperty(prototype, "constructor", { value: Storage, writable: true, configurable: true });
108
+ Object.defineProperty(prototype, Symbol.toStringTag, { value: "Storage", configurable: true });
109
+ Object.defineProperty(Storage, "prototype", { value: prototype });
110
+ const create = (map) => {
111
+ const target = Object.create(prototype);
112
+ const named = (key) => typeof key === "string" && !(key in prototype);
113
+ const proxy = new Proxy(target, {
114
+ get(object, key) {
115
+ if (named(key) && map.has(key)) return map.get(key);
116
+ return Reflect.get(object, key, proxy);
117
+ },
118
+ set(object, key, value) {
119
+ if (!named(key)) return Reflect.set(object, key, value, proxy);
120
+ map.set(key, String(value));
121
+ return true;
122
+ },
123
+ has(object, key) {
124
+ return (named(key) && map.has(key)) || Reflect.has(object, key);
125
+ },
126
+ deleteProperty(object, key) {
127
+ if (named(key)) map.delete(key);
128
+ return Reflect.deleteProperty(object, key);
129
+ },
130
+ ownKeys(object) {
131
+ return [...map.keys(), ...Reflect.ownKeys(object)];
132
+ },
133
+ getOwnPropertyDescriptor(object, key) {
134
+ if (named(key) && map.has(key)) return { value: map.get(key), writable: true, enumerable: true, configurable: true };
135
+ return Reflect.getOwnPropertyDescriptor(object, key);
136
+ },
137
+ defineProperty(object, key, descriptor) {
138
+ if (!named(key) || !("value" in descriptor)) return Reflect.defineProperty(object, key, descriptor);
139
+ map.set(key, String(descriptor.value));
140
+ return true;
141
+ },
142
+ });
143
+ maps.set(proxy, map);
144
+ return proxy;
145
+ };
146
+ return { Storage, prototype, create };
147
+ }
148
+
149
+ /** ECMAScript's own global properties: not enumerable on a browser's window. */
150
+ const LANGUAGE_GLOBALS = new Set(["globalThis", "Infinity", "NaN", "undefined", "eval", "isFinite", "isNaN",
151
+ "parseFloat", "parseInt", "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "unescape"]);
152
+
153
+ /** Namespace objects (Web IDL §3.13) that are lowercase, so not caught by the capitalized rule. */
154
+ const NAMESPACE_GLOBALS = new Set(["console"]);
155
+
156
+ /** An interface constant such as Node.ELEMENT_NODE. */
157
+ const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
158
+
159
+ /**
160
+ * The Web IDL shape of a page's global object, applied before page script:
161
+ *
162
+ * - every interface prototype names its interface for Object.prototype.toString
163
+ * (`[object Window]`, `[object HTMLDivElement]`),
164
+ * - language globals and interface objects (constructors and namespaces,
165
+ * which are capitalized) are not enumerable, while the window's own
166
+ * attributes and operations (document, setTimeout, onload...) are,
167
+ * - an interface prototype's constructor is not enumerable (§3.7.3), and
168
+ * - constants are read-only and non-configurable on both the interface
169
+ * object and its prototype (§3.7.6).
170
+ *
171
+ * @param {object} global the page global
172
+ * @param {object[]} intrinsicPrototypes prototypes that belong to the
173
+ * language (Object.prototype...), left without a tag
174
+ */
175
+ function applyInterfaceShape(Interface, prototype) {
176
+ const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor");
177
+ if (constructor?.enumerable && constructor.configurable) {
178
+ Object.defineProperty(prototype, "constructor", { ...constructor, enumerable: false });
179
+ }
180
+ for (const object of [Interface, prototype]) {
181
+ for (const key of Object.getOwnPropertyNames(object)) {
182
+ if (!CONSTANT_NAME.test(key)) continue;
183
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
184
+ if (!descriptor.configurable || typeof descriptor.value !== "number") continue;
185
+ Object.defineProperty(object, key, { value: descriptor.value, enumerable: true, writable: false, configurable: false });
186
+ }
187
+ }
188
+ }
189
+
190
+ export function applyGlobalShape(global, intrinsicPrototypes = []) {
191
+ const skip = new Set(intrinsicPrototypes);
192
+ for (const key of Reflect.ownKeys(global)) {
193
+ if (typeof key !== "string") continue;
194
+ const descriptor = Object.getOwnPropertyDescriptor(global, key);
195
+ if (!descriptor) continue;
196
+ const value = descriptor.value;
197
+ const isInterface = /^[A-Z]/.test(key) && (typeof value === "function" || (value && typeof value === "object"));
198
+ if (typeof value === "function" && /^[A-Z]/.test(key) && value.prototype && typeof value.prototype === "object"
199
+ && !skip.has(value.prototype) && !Object.hasOwn(value.prototype, Symbol.toStringTag)
200
+ && Object.isExtensible(value.prototype)) {
201
+ Object.defineProperty(value.prototype, Symbol.toStringTag, { value: key, configurable: true });
202
+ }
203
+ if ((isInterface || LANGUAGE_GLOBALS.has(key) || NAMESPACE_GLOBALS.has(key)) && descriptor.enumerable && descriptor.configurable) {
204
+ Object.defineProperty(global, key, { ...descriptor, enumerable: false });
205
+ }
206
+ if (typeof value === "function" && /^[A-Z]/.test(key) && value.prototype && typeof value.prototype === "object"
207
+ && !skip.has(value.prototype)) {
208
+ applyInterfaceShape(value, value.prototype);
209
+ }
210
+ }
211
+ const windowPrototype = Object.getPrototypeOf(global);
212
+ if (windowPrototype && !Object.hasOwn(windowPrototype, Symbol.toStringTag) && Object.isExtensible(windowPrototype)) {
213
+ Object.defineProperty(windowPrototype, Symbol.toStringTag, { value: "Window", configurable: true });
214
+ }
215
+ }
@@ -0,0 +1,12 @@
1
+ // Preserve CDP's virtual key codes; infer them for the direct press API too.
2
+ const codes = { Backspace:8, Tab:9, Enter:13, Shift:16, Control:17, Alt:18,
3
+ Escape:27, ' ':32, Space:32, PageUp:33, PageDown:34, End:35, Home:36,
4
+ ArrowLeft:37, ArrowUp:38, ArrowRight:39, ArrowDown:40, Insert:45, Delete:46 };
5
+ export function keyboardInput(key, options = {}) {
6
+ key = String(key);
7
+ const code = String(options.code ?? '');
8
+ const inferred = codes[key] ?? codes[code]
9
+ ?? (/^[a-z0-9]$/i.test(key) ? key.toUpperCase().charCodeAt(0) : 0);
10
+ const keyCode = Number(options.windowsVirtualKeyCode ?? options.keyCode ?? inferred) || 0;
11
+ return { key, code, keyCode, which:keyCode, charCode:0, composed:true };
12
+ }
@@ -8,6 +8,8 @@
8
8
  * Protocol (structured-clone messages):
9
9
  * controller -> renderer { type: "call", id, method, params }
10
10
  * { type: "fetch-result", id, result | error }
11
+ * { type: "fetch-chunk", id, data } (video/audio stream)
12
+ * { type: "fetch-end", id } / { type: "fetch-error", id, error }
11
13
  * renderer -> controller { type: "ready", sandbox: { seccomp, ses } } (each { installed, reason })
12
14
  * { type: "result", id, result | error }
13
15
  * { type: "fetch", id, url, kind }
@@ -21,7 +23,9 @@
21
23
  * controller -> renderer { type: "ws-event", id, event, ... } (open, message, error, close)
22
24
  * renderer -> controller { type: "audio-open", id, rate, channels, name } / { type: "audio-data", id, data }
23
25
  * / { type: "audio-finish", id } / { type: "audio-close", id } (audio output)
24
- * controller -> renderer { type: "audio-event", id, event, ... } (request with bytes, ended, error)
26
+ * / { type: "audio-resync", id } (paced from now on, after the writer paused)
27
+ * controller -> renderer { type: "audio-event", id, event, ... } (ready, request with bytes, ended, error,
28
+ * closed with sentBytes: the PCM that reached the output before audio-close)
25
29
  *
26
30
  * With --compositor, a compositor Worker (compositor.js) answers screenshots
27
31
  * and scrolling on fd 3 while this thread is busy; frames go to it with
@@ -40,13 +44,15 @@ import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, MOBILE_FLAG, VERIFY_STYLE_FLAG } from
40
44
  import { installSeccomp } from "./seccomp.js";
41
45
  import { RENDERER_LOG_FLAG } from "../log.js";
42
46
  import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
47
+ import { createBytePipe } from "../media/byte-pipe.js";
48
+ import { notifyRejectionHandled, notifyUnhandledRejection } from "./rejections.js";
43
49
 
44
50
  const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
45
51
 
46
52
  /** Methods the controller may invoke; anything else is ignored. */
47
53
  const METHODS = new Set([
48
- "loadDocument", "showFragment", "runJavaScriptURL", "url", "scrollTo", "resize", "screenshot", "title",
49
- "scroll", "press", "type", "scrollOffset", "click", "evaluate", "setUserAgent", "consoleMessages", "whenLoaded",
54
+ "loadDocument", "showFragment", "wheel", "runJavaScriptURL", "url", "scrollTo", "resize", "screenshot", "title",
55
+ "scroll", "press", "type", "scrollOffset", "click", "move", "evaluate", "setUserAgent", "setInitScripts", "consoleMessages", "whenLoaded",
50
56
  "applyCompositorScroll",
51
57
  ]);
52
58
 
@@ -57,29 +63,48 @@ const logging = process.argv.includes(RENDERER_LOG_FLAG);
57
63
  const stderr = process.stderr;
58
64
  const report = (text) => stderr.write(`${text}\n`);
59
65
 
60
- // As in browsers, a page's unhandled rejection is logged, not fatal.
61
- process.on("unhandledRejection", (reason) => {
66
+ // As in browsers, a page's unhandled rejection is logged, not fatal, unless
67
+ // its unhandledrejection event is canceled.
68
+ process.on("unhandledRejection", (reason, promise) => {
69
+ if (notifyUnhandledRejection(reason, promise)) return;
62
70
  const text = reason?.stack || (reason?.name ? `${reason.name}: ${reason.message}` : String(reason));
63
71
  report(`buninu-browser: page error: unhandled rejection: ${logging ? text : text.split("\n")[0]}`);
64
72
  });
65
73
 
74
+ process.on("rejectionHandled", (promise) => notifyRejectionHandled(promise));
75
+
66
76
  const pendingFetches = new Map();
67
77
  let nextFetchId = 1;
68
78
 
69
79
  function fetchResource(url, kind) {
70
80
  return new Promise((resolve, reject) => {
71
81
  const id = nextFetchId++;
72
- pendingFetches.set(id, { resolve, reject });
82
+ // The pipe exists before the request is sent, so a chunk that arrives
83
+ // before the head is still part of the body the caller receives.
84
+ const pipe = kind === "video" || kind === "audio" ? createBytePipe() : null;
85
+ pendingFetches.set(id, { resolve, reject, pipe, head: false });
73
86
  process.send({ type: "fetch", id, url, kind });
74
87
  });
75
88
  }
76
89
 
77
90
  function pageFetch(request) {
78
- return new Promise((resolve, reject) => {
91
+ let fetchId;
92
+ let streamController;
93
+ const body = new ReadableStream({
94
+ start(controller) { streamController = controller; },
95
+ cancel() { promise.abort(); },
96
+ });
97
+ const promise = new Promise((resolve, reject) => {
79
98
  const id = nextFetchId++;
80
- pendingFetches.set(id, { resolve, reject });
99
+ fetchId = id;
100
+ pendingFetches.set(id, { resolve, reject, page: true, body, streamController, head: false });
81
101
  process.send({ type: "page-fetch", id, request });
82
102
  });
103
+ promise.abort = () => {
104
+ if (!pendingFetches.delete(fetchId)) return;
105
+ process.send({ type: "page-fetch-abort", id: fetchId });
106
+ };
107
+ return promise;
83
108
  }
84
109
 
85
110
  // Page WebSockets live in the controller (Bun's WebSocket); events come back as ws-event.
@@ -98,6 +123,8 @@ const webSockets = {
98
123
 
99
124
  // Audio output: PCM goes to the controller, which plays it through PulseAudio.
100
125
  const audioListeners = new Map();
126
+ // audio.close() promises, settled by the controller's "closed" event.
127
+ const audioClosing = new Map();
101
128
  let nextAudioId = 1;
102
129
  const audio = {
103
130
  open({ rate, channels, name }, onEvent) {
@@ -108,14 +135,19 @@ const audio = {
108
135
  },
109
136
  write: (id, data) => process.send({ type: "audio-data", id, data }),
110
137
  finish: (id) => process.send({ type: "audio-finish", id }),
138
+ resync: (id) => process.send({ type: "audio-resync", id }),
139
+ /** Closes a stream; resolves to the bytes of PCM that reached the output first. */
111
140
  close(id) {
112
141
  audioListeners.delete(id);
113
142
  process.send({ type: "audio-close", id });
143
+ return new Promise((resolve) => audioClosing.set(id, resolve));
114
144
  },
115
145
  };
116
146
 
117
147
  // Workers load their module from disk, so this happens before seccomp.
118
148
  const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
149
+ // Started before the sandbox closes: the worker loads its module from disk.
150
+ const rewriteScript = await startScriptRewriter();
119
151
 
120
152
  // Frames sent to the compositor and the latest one it confirmed.
121
153
  let framesSent = 0;
@@ -137,6 +169,7 @@ async function startCompositor() {
137
169
  });
138
170
  worker.addEventListener("message", ({ data }) => {
139
171
  if (data?.type === "scroll") renderer.applyCompositorScroll(data.x, data.y);
172
+ else if (data?.type === "video-report") renderer.videoReport(data);
140
173
  else if (data?.type === "frame-ack" && Number.isInteger(data.serial)) {
141
174
  framesAcknowledged = Math.max(framesAcknowledged, data.serial);
142
175
  frameWaiters = frameWaiters.filter((waiter) => {
@@ -149,9 +182,48 @@ async function startCompositor() {
149
182
  return worker;
150
183
  }
151
184
 
185
+ /**
186
+ * A worker thread that rewrites classic scripts for the page realm (see
187
+ * rewrite-worker.js); null when it cannot start, and scripts are then
188
+ * rewritten on the main thread as before.
189
+ */
190
+ async function startScriptRewriter() {
191
+ let worker;
192
+ try {
193
+ worker = new Worker(new URL("./rewrite-worker.js", import.meta.url).href);
194
+ await new Promise((resolve, reject) => {
195
+ worker.addEventListener("message", resolve, { once: true });
196
+ worker.addEventListener("error", reject, { once: true });
197
+ });
198
+ } catch {
199
+ worker?.terminate();
200
+ return null;
201
+ }
202
+ const pending = new Map();
203
+ let nextId = 0;
204
+ worker.addEventListener("message", ({ data }) => {
205
+ const request = pending.get(data?.id);
206
+ if (!request) return;
207
+ pending.delete(data.id);
208
+ if (data.error !== undefined) request.reject(new Error(data.error));
209
+ else request.resolve(data.result);
210
+ });
211
+ worker.addEventListener("error", (event) => {
212
+ for (const request of pending.values()) request.reject(new Error(String(event?.message ?? "script rewriter failed")));
213
+ pending.clear();
214
+ });
215
+ worker.unref?.();
216
+ return (source) => new Promise((resolve, reject) => {
217
+ const id = ++nextId;
218
+ pending.set(id, { resolve, reject });
219
+ worker.postMessage({ id, source });
220
+ });
221
+ }
222
+
152
223
  const renderer = await createPageRenderer({
153
224
  fetchResource,
154
- requestNavigation: (url, download = null, fragmentFrom = null) => process.send({ type: "navigate", url, download, fragmentFrom }),
225
+ rewriteScript,
226
+ requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
155
227
  requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
156
228
  pageFetch,
157
229
  webSockets,
@@ -161,7 +233,12 @@ const renderer = await createPageRenderer({
161
233
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
162
234
  trace: logging ? (text) => report(`timing: ${text}`) : null,
163
235
  onFrame: compositor ? (frame) => compositor.postMessage({ type: "frame", serial: ++framesSent, ...frame }) : null,
164
- onLifecycle: (name, generation) => process.send({ type: "lifecycle", name, generation }),
236
+ onVideo: compositor ? (message) => compositor.postMessage(message) : null,
237
+ // Whoever waits for load (a screenshot, say) sees the frame painted at load.
238
+ onLifecycle: (name, generation) => {
239
+ if (name !== "load") process.send({ type: "lifecycle", name, generation });
240
+ else compositorCaughtUp().then(() => process.send({ type: "lifecycle", name, generation }));
241
+ },
165
242
  mobile: process.argv.includes(MOBILE_FLAG),
166
243
  verifyStyle: process.argv.includes(VERIFY_STYLE_FLAG),
167
244
  fullStyle: process.argv.includes(FULL_STYLE_FLAG),
@@ -224,15 +301,88 @@ async function warmUp() {
224
301
  }
225
302
 
226
303
  process.on("message", async (message) => {
304
+ if (message?.type === "page-fetch-head") {
305
+ const pending = pendingFetches.get(message.id);
306
+ if (!pending?.page || pending.head) return;
307
+ pending.head = true;
308
+ pending.resolve({ ...(message.result ?? {}), body: pending.body });
309
+ return;
310
+ }
311
+ if (message?.type === "page-fetch-chunk") {
312
+ pendingFetches.get(message.id)?.streamController?.enqueue(message.data);
313
+ return;
314
+ }
315
+ if (message?.type === "page-fetch-end") {
316
+ const pending = pendingFetches.get(message.id);
317
+ if (!pending?.page) return;
318
+ pendingFetches.delete(message.id);
319
+ pending.streamController.close();
320
+ return;
321
+ }
322
+ if (message?.type === "page-fetch-error") {
323
+ const pending = pendingFetches.get(message.id);
324
+ if (!pending?.page) return;
325
+ pendingFetches.delete(message.id);
326
+ const error = new Error(message.error || "fetch failed");
327
+ if (pending.head) pending.streamController.error(error);
328
+ else pending.reject(error);
329
+ return;
330
+ }
331
+ if (message?.type === "fetch-chunk") {
332
+ const pending = pendingFetches.get(message.id);
333
+ if (message.data) pending?.pipe?.append(message.data);
334
+ return;
335
+ }
336
+ if (message?.type === "fetch-end") {
337
+ const pending = pendingFetches.get(message.id);
338
+ if (!pending) return;
339
+ pendingFetches.delete(message.id);
340
+ pending.pipe?.finish();
341
+ return;
342
+ }
343
+ if (message?.type === "fetch-error") {
344
+ const pending = pendingFetches.get(message.id);
345
+ if (!pending) return;
346
+ pendingFetches.delete(message.id);
347
+ const error = new Error(message.error || "media fetch failed");
348
+ pending.pipe?.fail(error);
349
+ if (!pending.head) pending.reject(error);
350
+ return;
351
+ }
227
352
  if (message?.type === "fetch-result") {
228
353
  const pending = pendingFetches.get(message.id);
229
354
  if (!pending) return;
355
+ if (message.error) {
356
+ pendingFetches.delete(message.id);
357
+ const error = new Error(message.error);
358
+ pending.pipe?.fail(error);
359
+ if (!pending.head) pending.reject(error);
360
+ return;
361
+ }
362
+ const result = message.result ?? {};
363
+ if (result.streaming && pending.pipe) {
364
+ pending.head = true;
365
+ pending.resolve({ ...result, body: pending.pipe });
366
+ return;
367
+ }
230
368
  pendingFetches.delete(message.id);
231
- if (message.error) pending.reject(new Error(message.error));
232
- else pending.resolve(message.result);
369
+ if (pending.pipe) {
370
+ if (result.body) pending.pipe.append(result.body);
371
+ pending.pipe.finish();
372
+ pending.head = true;
373
+ pending.resolve({ ...result, body: pending.pipe });
374
+ return;
375
+ }
376
+ pending.resolve(result);
233
377
  return;
234
378
  }
235
379
  if (message?.type === "audio-event") {
380
+ if (message.event === "closed") {
381
+ const resolve = audioClosing.get(message.id);
382
+ audioClosing.delete(message.id);
383
+ resolve?.(Number.isFinite(message.sentBytes) ? message.sentBytes : 0);
384
+ return;
385
+ }
236
386
  const listener = audioListeners.get(message.id);
237
387
  if (message.event === "ended" || message.event === "error") audioListeners.delete(message.id);
238
388
  listener?.(message);
@@ -0,0 +1,13 @@
1
+ // `play` means unpaused; `playing` means playback can actually advance.
2
+ // Keep the distinction across pauses and network starvation.
3
+ const phases = new WeakMap();
4
+ export function playbackEvent(state, phase, dispatch) {
5
+ const previous = phases.get(state);
6
+ if (previous === phase) return;
7
+ phases.set(state, phase);
8
+ if (phase === 'playing' && previous === 'waiting') {
9
+ dispatch(state, 'canplay');
10
+ if (phases.get(state) !== phase || !state.playing) return;
11
+ }
12
+ dispatch(state, phase);
13
+ }