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,227 @@
|
|
|
1
|
+
// Web fonts belong to the document. Built-in typefaces are shared native
|
|
2
|
+
// references: registering their byte buffers again would copy tens of MB for
|
|
3
|
+
// every document and iframe.
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const MAX_FONT_BYTES = 32 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export function registerTypeface(CanvasKit, provider, typeface, family) {
|
|
8
|
+
const bytes = encoder.encode(`${family}\0`);
|
|
9
|
+
const memory = CanvasKit.Malloc(Uint8Array, bytes.length);
|
|
10
|
+
try {
|
|
11
|
+
memory.toTypedArray().set(bytes);
|
|
12
|
+
// This is the entry point the bundled registerFont(bytes, family) uses
|
|
13
|
+
// after creating a Typeface. Skia retains the typeface and copies the name.
|
|
14
|
+
provider._registerFont(typeface, memory.byteOffset);
|
|
15
|
+
} finally {
|
|
16
|
+
CanvasKit.Free(memory);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function styleRank(face, wanted) {
|
|
21
|
+
const order = wanted === "italic" ? ["italic", "oblique", "normal"]
|
|
22
|
+
: wanted === "oblique" ? ["oblique", "italic", "normal"] : ["normal", "oblique", "italic"];
|
|
23
|
+
return order.indexOf(face.style ?? "normal");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** CSS Fonts weight fallback order, including the special 400..500 interval. */
|
|
27
|
+
function weightRank(face, wanted) {
|
|
28
|
+
const [low, high] = face.weight ?? [400, 400];
|
|
29
|
+
if (low <= wanted && wanted <= high) return 0;
|
|
30
|
+
if (wanted >= 400 && wanted <= 500) {
|
|
31
|
+
if (low > wanted && low <= 500) return low - wanted;
|
|
32
|
+
if (high < wanted) return 1000 + wanted - high;
|
|
33
|
+
return 2000 + low - wanted;
|
|
34
|
+
}
|
|
35
|
+
if (wanted < 400) return high < wanted ? wanted - high : 1000 + low - wanted;
|
|
36
|
+
return low > wanted ? low - wanted : 1000 + wanted - high;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function covers(face, point) {
|
|
40
|
+
return (face.unicodeRanges ?? [[0, 0x10ffff]]).some(([from, to]) => from <= point && point <= to);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stretchRank(face, wanted) {
|
|
44
|
+
const [low, high] = face.stretch ?? [100, 100];
|
|
45
|
+
if (low <= wanted && wanted <= high) return 0;
|
|
46
|
+
if (wanted <= 100) return high < wanted ? wanted - high : 1000 + low - wanted;
|
|
47
|
+
return low > wanted ? low - wanted : 1000 + wanted - high;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ranked(faces, weight, style, stretch = 100) {
|
|
51
|
+
return faces.toReversed().sort((left, right) => stretchRank(left, stretch) - stretchRank(right, stretch)
|
|
52
|
+
|| styleRank(left, style) - styleRank(right, style)
|
|
53
|
+
|| weightRank(left, weight) - weightRank(right, weight));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Only request faces needed by text which actually participates in layout. */
|
|
57
|
+
export function neededFontFaces(faces, textRuns, availableFamilies = new Set()) {
|
|
58
|
+
const families = new Map();
|
|
59
|
+
for (const face of faces) {
|
|
60
|
+
const key = face.family.toLowerCase();
|
|
61
|
+
if (!families.has(key)) families.set(key, []);
|
|
62
|
+
families.get(key).push(face);
|
|
63
|
+
}
|
|
64
|
+
const needed = new Set();
|
|
65
|
+
const choices = new Map();
|
|
66
|
+
for (const { text, style } of textRuns) {
|
|
67
|
+
if (!text || !style) continue;
|
|
68
|
+
const points = new Set([...text].map(character => character.codePointAt(0)));
|
|
69
|
+
for (const family of style.fontFamily ?? []) {
|
|
70
|
+
const definitions = families.get(family.toLowerCase());
|
|
71
|
+
if (!definitions) {
|
|
72
|
+
if (availableFamilies.has(family.toLowerCase())) break;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const key = `${family.toLowerCase()}\n${style.fontWeight ?? 400}/${style.fontStyle ?? "normal"}/${style.fontStretch ?? 100}`;
|
|
76
|
+
let ordered = choices.get(key);
|
|
77
|
+
if (!ordered) choices.set(key, ordered = ranked(definitions, style.fontWeight ?? 400, style.fontStyle ?? "normal", style.fontStretch ?? 100));
|
|
78
|
+
for (const point of points) {
|
|
79
|
+
const face = ordered.find(face => covers(face, point));
|
|
80
|
+
if (face) { needed.add(face); points.delete(point); }
|
|
81
|
+
}
|
|
82
|
+
if (!points.size) break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return needed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class DocumentFonts {
|
|
89
|
+
#CanvasKit;
|
|
90
|
+
#builtins;
|
|
91
|
+
#defaultCollection;
|
|
92
|
+
#fetch;
|
|
93
|
+
#changed;
|
|
94
|
+
#error;
|
|
95
|
+
#provider = null;
|
|
96
|
+
#collection = null;
|
|
97
|
+
#faces = [];
|
|
98
|
+
#records = new Map();
|
|
99
|
+
#sources = new Map();
|
|
100
|
+
#pending = new Set();
|
|
101
|
+
#requests = new Set();
|
|
102
|
+
#closed = false;
|
|
103
|
+
#nextAlias = 1;
|
|
104
|
+
#availableFamilies;
|
|
105
|
+
#hasUnicodeRanges = false;
|
|
106
|
+
|
|
107
|
+
constructor({ CanvasKit, builtins, defaultCollection, fetch, availableFamilies = new Set(), changed = () => {}, error = () => {} }) {
|
|
108
|
+
this.#CanvasKit = CanvasKit;
|
|
109
|
+
this.#builtins = builtins;
|
|
110
|
+
this.#defaultCollection = defaultCollection;
|
|
111
|
+
this.#fetch = fetch;
|
|
112
|
+
this.#changed = changed;
|
|
113
|
+
this.#error = error;
|
|
114
|
+
this.#availableFamilies = availableFamilies;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get collection() { return this.#collection ?? this.#defaultCollection; }
|
|
118
|
+
|
|
119
|
+
get hasUnicodeRanges() { return this.#hasUnicodeRanges; }
|
|
120
|
+
|
|
121
|
+
setFaces(faces) {
|
|
122
|
+
this.#faces = faces;
|
|
123
|
+
this.#hasUnicodeRanges = faces.some(face => face.unicodeRanges?.length
|
|
124
|
+
&& !(face.unicodeRanges.length === 1 && face.unicodeRanges[0][0] === 0 && face.unicodeRanges[0][1] === 0x10ffff));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
update(faces, textRuns) {
|
|
128
|
+
if (this.#closed) return;
|
|
129
|
+
this.setFaces(faces);
|
|
130
|
+
for (const face of neededFontFaces(faces, textRuns, this.#availableFamilies)) {
|
|
131
|
+
const key = JSON.stringify(face);
|
|
132
|
+
if (this.#records.has(key)) continue;
|
|
133
|
+
const record = { face, alias: null };
|
|
134
|
+
this.#records.set(key, record);
|
|
135
|
+
const pending = this.#load(record).catch(error => {
|
|
136
|
+
if (!this.#closed) this.#error(error);
|
|
137
|
+
}).finally(() => this.#pending.delete(pending));
|
|
138
|
+
this.#pending.add(pending);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Available faces in CSS fallback order; unicode-subset files share a family. */
|
|
143
|
+
resolve(family, weight = 400, style = "normal", point = null, stretch = 100) {
|
|
144
|
+
const definitions = this.#faces.filter(face => face.family.toLowerCase() === family.toLowerCase()
|
|
145
|
+
&& (point === null || covers(face, point)));
|
|
146
|
+
return ranked(definitions, weight, style, stretch).map(face => this.#records.get(JSON.stringify(face))?.alias).filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async ready() {
|
|
150
|
+
while (!this.#closed && this.#pending.size) await Promise.all([...this.#pending]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#ensureProvider() {
|
|
154
|
+
if (this.#provider) return;
|
|
155
|
+
const kit = this.#CanvasKit;
|
|
156
|
+
this.#provider = kit.TypefaceFontProvider.Make();
|
|
157
|
+
for (const { family, typeface } of this.#builtins) registerTypeface(kit, this.#provider, typeface, family);
|
|
158
|
+
this.#collection = kit.FontCollection.Make();
|
|
159
|
+
this.#collection.setDefaultFontManager(this.#provider);
|
|
160
|
+
this.#collection.enableFontFallback();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async #load(record) {
|
|
164
|
+
let failure;
|
|
165
|
+
for (const source of record.face.sources) {
|
|
166
|
+
if (this.#closed) return;
|
|
167
|
+
try {
|
|
168
|
+
let typeface;
|
|
169
|
+
if (source.local) {
|
|
170
|
+
typeface = this.#builtins.find(entry => entry.family.toLowerCase() === source.local.toLowerCase())?.typeface;
|
|
171
|
+
if (!typeface) continue;
|
|
172
|
+
} else {
|
|
173
|
+
if (!source.url || (source.format && !/^(?:woff2?|truetype|opentype)(?:-variations)?$/i.test(source.format))) continue;
|
|
174
|
+
let pending = this.#sources.get(source.url);
|
|
175
|
+
if (!pending) {
|
|
176
|
+
pending = this.#loadSource(source.url);
|
|
177
|
+
this.#sources.set(source.url, pending);
|
|
178
|
+
}
|
|
179
|
+
typeface = await pending;
|
|
180
|
+
if (this.#closed) return;
|
|
181
|
+
}
|
|
182
|
+
this.#ensureProvider();
|
|
183
|
+
const alias = `Buninu Web Font ${this.#nextAlias++}`;
|
|
184
|
+
registerTypeface(this.#CanvasKit, this.#provider, typeface, alias);
|
|
185
|
+
record.alias = alias;
|
|
186
|
+
this.#changed();
|
|
187
|
+
return;
|
|
188
|
+
} catch (error) { failure = error; }
|
|
189
|
+
}
|
|
190
|
+
if (failure) throw failure;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async #loadSource(url) {
|
|
194
|
+
// Fonts use CORS even though images and classic stylesheets normally do
|
|
195
|
+
// not. This goes through the controller's existing page-fetch policy.
|
|
196
|
+
const request = this.#fetch({ url, method: "GET", headers: [], body: null,
|
|
197
|
+
mode: "cors", credentials: "same-origin", redirect: "follow" });
|
|
198
|
+
this.#requests.add(request);
|
|
199
|
+
try {
|
|
200
|
+
const response = await request;
|
|
201
|
+
if (this.#closed) return null;
|
|
202
|
+
if (response.status < 200 || response.status >= 300) throw new Error(`Font HTTP ${response.status}`);
|
|
203
|
+
const bytes = response.body instanceof Uint8Array ? response.body : await Bun.readableStreamToBytes(response.body);
|
|
204
|
+
if (this.#closed) return null;
|
|
205
|
+
if (!bytes.length || bytes.length > MAX_FONT_BYTES) throw new Error("Font size exceeds the renderer limit");
|
|
206
|
+
const typeface = this.#CanvasKit.Typeface.MakeTypefaceFromData(bytes);
|
|
207
|
+
if (!typeface) throw new Error("Font decoding failed");
|
|
208
|
+
return typeface;
|
|
209
|
+
} finally {
|
|
210
|
+
this.#requests.delete(request);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
dispose() {
|
|
215
|
+
if (this.#closed) return;
|
|
216
|
+
this.#closed = true;
|
|
217
|
+
for (const request of this.#requests) request.abort?.();
|
|
218
|
+
this.#requests.clear();
|
|
219
|
+
this.#collection?.delete();
|
|
220
|
+
this.#provider?.delete();
|
|
221
|
+
this.#collection = this.#provider = null;
|
|
222
|
+
for (const source of this.#sources.values()) source.then(typeface => typeface?.delete(), () => {});
|
|
223
|
+
this.#sources.clear();
|
|
224
|
+
this.#records.clear();
|
|
225
|
+
this.#faces = [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Methods borrowed from another window's History.prototype must operate on
|
|
2
|
+
// their receiver, not the window in which the function was created.
|
|
3
|
+
const histories = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function historyBinding(location, setHref) {
|
|
6
|
+
const state = { location, setHref, value: null, length: 1 };
|
|
7
|
+
const receiver = (object) => {
|
|
8
|
+
const record = histories.get(object);
|
|
9
|
+
if (!record) throw new TypeError("Illegal invocation");
|
|
10
|
+
return record;
|
|
11
|
+
};
|
|
12
|
+
const change = (object, data, url, push) => {
|
|
13
|
+
const record = receiver(object);
|
|
14
|
+
if (url !== undefined && url !== null) {
|
|
15
|
+
const target = new URL(String(url), record.location.href);
|
|
16
|
+
if (target.origin !== new URL(record.location.href).origin) {
|
|
17
|
+
throw new DOMException("History state URL must have the document's origin.", "SecurityError");
|
|
18
|
+
}
|
|
19
|
+
record.setHref(target.href);
|
|
20
|
+
}
|
|
21
|
+
record.value = data ?? null;
|
|
22
|
+
if (push) record.length++;
|
|
23
|
+
};
|
|
24
|
+
function History() { throw new TypeError("Illegal constructor"); }
|
|
25
|
+
History.prototype = {
|
|
26
|
+
constructor: History,
|
|
27
|
+
get length() { return receiver(this).length; },
|
|
28
|
+
get state() { return receiver(this).value; },
|
|
29
|
+
scrollRestoration: "auto",
|
|
30
|
+
back() {},
|
|
31
|
+
forward() {},
|
|
32
|
+
go() {},
|
|
33
|
+
pushState(data, _title, url = undefined) { change(this, data, url, true); },
|
|
34
|
+
replaceState(data, _title, url = undefined) { change(this, data, url, false); },
|
|
35
|
+
};
|
|
36
|
+
const history = Object.create(History.prototype);
|
|
37
|
+
histories.set(history, state);
|
|
38
|
+
return { History, history, pushEntry(replace) {
|
|
39
|
+
state.value = null;
|
|
40
|
+
if (!replace) state.length++;
|
|
41
|
+
} };
|
|
42
|
+
}
|
package/lib/renderer/host.js
CHANGED
|
@@ -73,6 +73,7 @@ export class RendererHost {
|
|
|
73
73
|
#audio = new Map();
|
|
74
74
|
// In-flight page fetches, so AbortController reaches the actual HTTP request.
|
|
75
75
|
#pageFetches = new Map();
|
|
76
|
+
#resourceFetches = new Map();
|
|
76
77
|
#nextId = 1;
|
|
77
78
|
#queue = Promise.resolve();
|
|
78
79
|
#restarting = false;
|
|
@@ -189,6 +190,7 @@ export class RendererHost {
|
|
|
189
190
|
this.call("applyCompositorScroll", x, y).catch(() => {});
|
|
190
191
|
}
|
|
191
192
|
if ((method === "loadDocument" || method === "showFragment") && params[0]) {
|
|
193
|
+
if (method === "loadDocument") this.#closeResourceFetches(this.#child);
|
|
192
194
|
this.#documentUrl = String(method === "loadDocument" ? params[0].url : params[0]);
|
|
193
195
|
}
|
|
194
196
|
const run = () => this.#call(method, params);
|
|
@@ -489,7 +491,14 @@ export class RendererHost {
|
|
|
489
491
|
/** A renderer's audio element starts playing: a PulseAudio stream relays its PCM. */
|
|
490
492
|
#openAudio(child, { id, rate, channels, name }) {
|
|
491
493
|
const send = (payload) => {
|
|
492
|
-
if (this.#child === child)
|
|
494
|
+
if (this.#child === child) {
|
|
495
|
+
child.send({ type: "audio-event", id, ...payload });
|
|
496
|
+
// The compositor's A/V clock must not wait behind page JavaScript.
|
|
497
|
+
if (payload.event === "timing" && this.#channel && !this.#channel.closed) {
|
|
498
|
+
this.#channel.writer.write(`${JSON.stringify({ type: "audio-timing", audioId: id, seconds: payload.seconds })}\n`);
|
|
499
|
+
this.#channel.writer.flush();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
493
502
|
};
|
|
494
503
|
const owned = [...this.#audio.values()].filter((entry) => entry.child === child).length;
|
|
495
504
|
if (this.#audio.has(id) || owned >= MAX_AUDIO_STREAMS
|
|
@@ -527,6 +536,7 @@ export class RendererHost {
|
|
|
527
536
|
}
|
|
528
537
|
|
|
529
538
|
#closePageFetches(child) {
|
|
539
|
+
this.#closeResourceFetches(child);
|
|
530
540
|
for (const [id, entry] of this.#pageFetches) {
|
|
531
541
|
if (entry.child !== child) continue;
|
|
532
542
|
this.#pageFetches.delete(id);
|
|
@@ -534,6 +544,14 @@ export class RendererHost {
|
|
|
534
544
|
}
|
|
535
545
|
}
|
|
536
546
|
|
|
547
|
+
#closeResourceFetches(child) {
|
|
548
|
+
for (const [id, entry] of this.#resourceFetches) {
|
|
549
|
+
if (entry.child !== child) continue;
|
|
550
|
+
this.#resourceFetches.delete(id);
|
|
551
|
+
entry.controller.abort();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
537
555
|
#closeSockets(child) {
|
|
538
556
|
for (const [id, entry] of this.#sockets) {
|
|
539
557
|
if (entry.child !== child) continue;
|
|
@@ -545,6 +563,16 @@ export class RendererHost {
|
|
|
545
563
|
}
|
|
546
564
|
|
|
547
565
|
async #fetchForRenderer(child, { id, url, kind }) {
|
|
566
|
+
const entry = { child, controller: new AbortController() };
|
|
567
|
+
this.#resourceFetches.set(id, entry);
|
|
568
|
+
try {
|
|
569
|
+
await this.#fetchResource(child, { id, url, kind }, entry.controller.signal);
|
|
570
|
+
} finally {
|
|
571
|
+
if (this.#resourceFetches.get(id) === entry) this.#resourceFetches.delete(id);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async #fetchResource(child, { id, url, kind }, signal) {
|
|
548
576
|
const reply = (payload) => {
|
|
549
577
|
if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
|
|
550
578
|
};
|
|
@@ -554,7 +582,10 @@ export class RendererHost {
|
|
|
554
582
|
} catch {
|
|
555
583
|
return reply({ error: "invalid URL" });
|
|
556
584
|
}
|
|
557
|
-
|
|
585
|
+
// Local documents may render local resources. Authorize this from the
|
|
586
|
+
// controller's document URL, never from a renderer-supplied referrer.
|
|
587
|
+
const localFile = target.protocol === "file:" && new URL(this.#documentUrl).protocol === "file:";
|
|
588
|
+
if ((!ALLOWED_SCHEMES.has(target.protocol) && !localFile) || !RESOURCE_KINDS.has(kind)) {
|
|
558
589
|
return reply({ error: `blocked ${kind} request for ${target.protocol}` });
|
|
559
590
|
}
|
|
560
591
|
// A renderer only ever holds documents of its own origin (site isolation
|
|
@@ -563,7 +594,7 @@ export class RendererHost {
|
|
|
563
594
|
// being framed.
|
|
564
595
|
const frame = kind === "iframe";
|
|
565
596
|
if (frame && !sameOrigin(target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
566
|
-
// Video and audio over http(s) stream. data: stays one response: it is
|
|
597
|
+
// Video and audio over http(s)/file stream. data: stays one response: it is
|
|
567
598
|
// already in hand, and the renderer wraps it in a finished pipe.
|
|
568
599
|
if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
|
|
569
600
|
const send = (payload) => {
|
|
@@ -592,7 +623,7 @@ export class RendererHost {
|
|
|
592
623
|
} else if (event.type === "error") {
|
|
593
624
|
send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
|
|
594
625
|
}
|
|
595
|
-
});
|
|
626
|
+
}, { signal });
|
|
596
627
|
} catch (error) {
|
|
597
628
|
send({ type: "fetch-error", id, error: String(error?.message ?? error) });
|
|
598
629
|
}
|
|
@@ -601,7 +632,7 @@ export class RendererHost {
|
|
|
601
632
|
try {
|
|
602
633
|
const response = target.protocol === "data:"
|
|
603
634
|
? await dataResponse(target)
|
|
604
|
-
: await this.#network.subresource(target.href, this.#documentUrl, kind);
|
|
635
|
+
: await this.#network.subresource(target.href, this.#documentUrl, kind, { signal });
|
|
605
636
|
if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
|
|
606
637
|
if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
607
638
|
if (frame && framingDenied(response.rawHeaders ?? [])) return reply({ error: "framing denied" });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Image bytes are decoded by our renderer, not Happy DOM's disabled loader.
|
|
2
|
+
// Share their state with HTMLImageElement's page-facing IDL attributes.
|
|
3
|
+
const states = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function beginImageLoad(element, src) {
|
|
6
|
+
states.set(element, { src, complete: false, width: 0, height: 0 });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function finishImageLoad(element, src, resource, dispatch = () => {}) {
|
|
10
|
+
if (element.getAttribute('src') !== src) return;
|
|
11
|
+
states.set(element, { src, complete: true, width: resource?.width ?? 0, height: resource?.height ?? 0 });
|
|
12
|
+
dispatch(resource ? 'load' : 'error');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function installImageLoadingAttributes(prototype, nodeOf) {
|
|
16
|
+
for (const [property, field] of [['naturalWidth', 'width'], ['naturalHeight', 'height'], ['complete', 'complete']]) {
|
|
17
|
+
Object.defineProperty(prototype, property, {
|
|
18
|
+
configurable: true, enumerable: true,
|
|
19
|
+
get() {
|
|
20
|
+
const node = nodeOf(this), src = node.getAttribute('src');
|
|
21
|
+
const state = states.get(node);
|
|
22
|
+
if (state && state.src === src) return state[field];
|
|
23
|
+
return field === 'complete' ? !src : 0;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce concurrent requests for the same image within one document. Each
|
|
3
|
+
* consumer gets its own CanvasKit handle, sharing the underlying pixels, so
|
|
4
|
+
* removing one element cannot delete another element's image. Completed
|
|
5
|
+
* requests are dropped; HTTP freshness and persistent caching belong to the
|
|
6
|
+
* controller's network service.
|
|
7
|
+
*/
|
|
8
|
+
export function imageCrossOrigin(element) {
|
|
9
|
+
const value = element.getAttribute("crossorigin");
|
|
10
|
+
return value === null ? null : value.toLowerCase() === "use-credentials" ? "include" : "same-origin";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function imageRequestKey(src, credentials) {
|
|
14
|
+
return JSON.stringify([src, credentials]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createImageResourceLoader(CanvasKit, fetchResource, fetchCors) {
|
|
18
|
+
const documents = new WeakMap();
|
|
19
|
+
const corsRequests = new WeakMap();
|
|
20
|
+
const loadImage = function (document, url, credentials = null) {
|
|
21
|
+
let requests = documents.get(document);
|
|
22
|
+
if (!requests) documents.set(document, requests = new Map());
|
|
23
|
+
const key = imageRequestKey(url, credentials);
|
|
24
|
+
let entry = requests.get(key);
|
|
25
|
+
if (!entry) {
|
|
26
|
+
entry = { consumers: 0, ready: (async () => {
|
|
27
|
+
let response;
|
|
28
|
+
if (credentials !== null) {
|
|
29
|
+
const request = fetchCors({ url, method: "GET", headers: [], body: null,
|
|
30
|
+
mode: "cors", credentials, redirect: "follow" });
|
|
31
|
+
let pending = corsRequests.get(document);
|
|
32
|
+
if (!pending) corsRequests.set(document, pending = new Set());
|
|
33
|
+
pending.add(request);
|
|
34
|
+
try {
|
|
35
|
+
const result = await request;
|
|
36
|
+
const body = result.body instanceof Uint8Array ? result.body : await Bun.readableStreamToBytes(result.body);
|
|
37
|
+
response = { ...result, body };
|
|
38
|
+
} finally { pending.delete(request); }
|
|
39
|
+
} else response = await fetchResource(url, "image");
|
|
40
|
+
if (!(response.ok ?? (response.status >= 200 && response.status < 300))) throw new Error(`HTTP ${response.status}`);
|
|
41
|
+
// Keep Bun's header validation/decompression-size guard before Skia
|
|
42
|
+
// takes ownership of these untrusted encoded bytes.
|
|
43
|
+
const { width, height } = await new Bun.Image(response.body).metadata();
|
|
44
|
+
const image = CanvasKit.MakeImageFromEncoded(response.body);
|
|
45
|
+
if (!image) throw new Error("Image decoding failed");
|
|
46
|
+
return { width, height, image, url: response.url ?? url, requestedURL: url, crossOrigin: credentials,
|
|
47
|
+
originClean: credentials !== null ? true : undefined };
|
|
48
|
+
})() };
|
|
49
|
+
requests.set(key, entry);
|
|
50
|
+
}
|
|
51
|
+
entry.consumers++;
|
|
52
|
+
return entry.ready.then(resource => Object.freeze({ ...resource, image: resource.image.clone() })).finally(() => {
|
|
53
|
+
if (--entry.consumers) return;
|
|
54
|
+
requests.delete(key);
|
|
55
|
+
// All successful consumers already own their clone. Failures have no
|
|
56
|
+
// image to release, and must allow a later request to try again.
|
|
57
|
+
entry.ready.then(resource => resource.image.delete(), () => {});
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
loadImage.cancelDocument = (document) => {
|
|
61
|
+
for (const request of corsRequests.get(document) ?? []) request.abort?.();
|
|
62
|
+
corsRequests.delete(document);
|
|
63
|
+
};
|
|
64
|
+
return loadImage;
|
|
65
|
+
}
|
|
@@ -74,6 +74,34 @@ export function platformObject(name, template, { parent = Object.prototype, base
|
|
|
74
74
|
|
|
75
75
|
const accessor = (object, key) => Object.getOwnPropertyDescriptor(object, key);
|
|
76
76
|
|
|
77
|
+
/** A page-local Error constructor around SES's tamed intrinsic, retaining native error branding. */
|
|
78
|
+
export function errorInterface(BaseError, isError) {
|
|
79
|
+
const Error = function Error(message, options) {
|
|
80
|
+
return Reflect.construct(BaseError, arguments, new.target ?? Error);
|
|
81
|
+
};
|
|
82
|
+
const prototype = Object.create(BaseError.prototype);
|
|
83
|
+
for (const key of Reflect.ownKeys(BaseError.prototype)) {
|
|
84
|
+
if (key === "constructor") continue;
|
|
85
|
+
const descriptor = Object.getOwnPropertyDescriptor(BaseError.prototype, key);
|
|
86
|
+
Object.defineProperty(prototype, key, { ...descriptor, configurable: true, ...("value" in descriptor ? { writable: true } : {}) });
|
|
87
|
+
}
|
|
88
|
+
Object.defineProperty(prototype, "constructor", { value: Error, writable: true, configurable: true });
|
|
89
|
+
Error.prototype = prototype;
|
|
90
|
+
Object.setPrototypeOf(Error, BaseError);
|
|
91
|
+
for (const key of Reflect.ownKeys(BaseError)) {
|
|
92
|
+
if (["length", "name", "prototype"].includes(key)) continue;
|
|
93
|
+
const descriptor = Object.getOwnPropertyDescriptor(BaseError, key);
|
|
94
|
+
Object.defineProperty(Error, key, { ...descriptor, configurable: true, ...("value" in descriptor ? { writable: true } : {}) });
|
|
95
|
+
}
|
|
96
|
+
Object.defineProperty(Error, "length", { value: 1, configurable: true });
|
|
97
|
+
Object.defineProperty(Error, Symbol.hasInstance, {
|
|
98
|
+
value(value) { return Function.prototype[Symbol.hasInstance].call(this === Error ? BaseError : this, value); },
|
|
99
|
+
configurable: true,
|
|
100
|
+
});
|
|
101
|
+
if (isError) Object.defineProperty(Error, "isError", { value: isError, writable: true, configurable: true });
|
|
102
|
+
return Error;
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
/**
|
|
78
106
|
* window.localStorage and sessionStorage (HTML §12.2.1): the methods on
|
|
79
107
|
* Storage.prototype, and the stored items as each storage's own properties
|
|
@@ -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
|
+
}
|