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.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/ai-debug.log +5537 -0
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +14 -0
- package/test/page-scripts.test.js +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -0
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- 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
|
+
}
|
package/lib/renderer/main.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
232
|
-
|
|
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
|
+
}
|