buninu-browser 0.2.3 → 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/cdp-server.js +47 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +9 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +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 +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +13 -11
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +28 -4
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +30 -4
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +270 -98
- package/lib/renderer/page-renderer.js +647 -235
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/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/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- package/ai-debug.log +0 -5537
|
@@ -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
|
+
}
|
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);
|
|
@@ -534,6 +536,7 @@ export class RendererHost {
|
|
|
534
536
|
}
|
|
535
537
|
|
|
536
538
|
#closePageFetches(child) {
|
|
539
|
+
this.#closeResourceFetches(child);
|
|
537
540
|
for (const [id, entry] of this.#pageFetches) {
|
|
538
541
|
if (entry.child !== child) continue;
|
|
539
542
|
this.#pageFetches.delete(id);
|
|
@@ -541,6 +544,14 @@ export class RendererHost {
|
|
|
541
544
|
}
|
|
542
545
|
}
|
|
543
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
|
+
|
|
544
555
|
#closeSockets(child) {
|
|
545
556
|
for (const [id, entry] of this.#sockets) {
|
|
546
557
|
if (entry.child !== child) continue;
|
|
@@ -552,6 +563,16 @@ export class RendererHost {
|
|
|
552
563
|
}
|
|
553
564
|
|
|
554
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) {
|
|
555
576
|
const reply = (payload) => {
|
|
556
577
|
if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
|
|
557
578
|
};
|
|
@@ -561,7 +582,10 @@ export class RendererHost {
|
|
|
561
582
|
} catch {
|
|
562
583
|
return reply({ error: "invalid URL" });
|
|
563
584
|
}
|
|
564
|
-
|
|
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)) {
|
|
565
589
|
return reply({ error: `blocked ${kind} request for ${target.protocol}` });
|
|
566
590
|
}
|
|
567
591
|
// A renderer only ever holds documents of its own origin (site isolation
|
|
@@ -570,7 +594,7 @@ export class RendererHost {
|
|
|
570
594
|
// being framed.
|
|
571
595
|
const frame = kind === "iframe";
|
|
572
596
|
if (frame && !sameOrigin(target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
573
|
-
// 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
|
|
574
598
|
// already in hand, and the renderer wraps it in a finished pipe.
|
|
575
599
|
if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
|
|
576
600
|
const send = (payload) => {
|
|
@@ -599,7 +623,7 @@ export class RendererHost {
|
|
|
599
623
|
} else if (event.type === "error") {
|
|
600
624
|
send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
|
|
601
625
|
}
|
|
602
|
-
});
|
|
626
|
+
}, { signal });
|
|
603
627
|
} catch (error) {
|
|
604
628
|
send({ type: "fetch-error", id, error: String(error?.message ?? error) });
|
|
605
629
|
}
|
|
@@ -608,7 +632,7 @@ export class RendererHost {
|
|
|
608
632
|
try {
|
|
609
633
|
const response = target.protocol === "data:"
|
|
610
634
|
? await dataResponse(target)
|
|
611
|
-
: await this.#network.subresource(target.href, this.#documentUrl, kind);
|
|
635
|
+
: await this.#network.subresource(target.href, this.#documentUrl, kind, { signal });
|
|
612
636
|
if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
|
|
613
637
|
if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
614
638
|
if (frame && framingDenied(response.rawHeaders ?? [])) return reply({ error: "framing denied" });
|
|
@@ -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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-document layout observers. A layout/scroll notification schedules one
|
|
3
|
+
* measurement pass for all observers; page callbacks run only after every
|
|
4
|
+
* target has been measured, avoiding a layout flush for each lazy image.
|
|
5
|
+
* Geometry comes from the renderer, never from Happy DOM's zero-size boxes.
|
|
6
|
+
*/
|
|
7
|
+
export function createLayoutObservers({ nodeOf, boundsOf, computedStyle, viewport, schedule, cancel, call, now }) {
|
|
8
|
+
const intersections = new Set();
|
|
9
|
+
const resizes = new Set();
|
|
10
|
+
let pending = null;
|
|
11
|
+
let measuring = false;
|
|
12
|
+
let disposed = false;
|
|
13
|
+
let lastGeometry = null;
|
|
14
|
+
|
|
15
|
+
const queue = () => {
|
|
16
|
+
if (disposed || measuring || pending !== null || (!intersections.size && !resizes.size)) return;
|
|
17
|
+
pending = schedule(flush);
|
|
18
|
+
};
|
|
19
|
+
function flush() {
|
|
20
|
+
pending = null;
|
|
21
|
+
if (disposed) return;
|
|
22
|
+
measuring = true;
|
|
23
|
+
const deliveries = [];
|
|
24
|
+
// Multiple observers commonly watch the same ancestor or target.
|
|
25
|
+
const bounds = new Map();
|
|
26
|
+
const styles = new Map();
|
|
27
|
+
const rectOf = (node) => {
|
|
28
|
+
if (!bounds.has(node)) bounds.set(node, node?.isConnected ? boundsOf(node) : null);
|
|
29
|
+
return bounds.get(node);
|
|
30
|
+
};
|
|
31
|
+
const styleOf = (node) => {
|
|
32
|
+
if (!styles.has(node)) styles.set(node, computedStyle(node));
|
|
33
|
+
return styles.get(node);
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
for (const observer of resizes) {
|
|
37
|
+
const state = resizeStates.get(observer);
|
|
38
|
+
const entries = [];
|
|
39
|
+
for (const [node, target] of state.targets) {
|
|
40
|
+
const rect = rectOf(node);
|
|
41
|
+
const style = rect ? styleOf(node) : null;
|
|
42
|
+
const border = style?.borderWidths ?? ZERO_EDGES;
|
|
43
|
+
const padding = style?.padding ?? ZERO_EDGES;
|
|
44
|
+
const width = Math.max(0, (rect?.width ?? 0) - sum(border, 1, 3) - sum(padding, 1, 3));
|
|
45
|
+
const height = Math.max(0, (rect?.height ?? 0) - sum(border, 0, 2) - sum(padding, 0, 2));
|
|
46
|
+
const scale = viewport().devicePixelRatio || 1;
|
|
47
|
+
const entry = {
|
|
48
|
+
target: target.wrapper,
|
|
49
|
+
contentRect: rectangle({ left: number(padding[3]), top: number(padding[0]), width, height }),
|
|
50
|
+
borderBoxSize: [size(rect?.width ?? 0, rect?.height ?? 0)],
|
|
51
|
+
contentBoxSize: [size(width, height)],
|
|
52
|
+
devicePixelContentBoxSize: [size(Math.round(width * scale), Math.round(height * scale))],
|
|
53
|
+
};
|
|
54
|
+
const selected = target.box === "border-box" ? entry.borderBoxSize[0]
|
|
55
|
+
: target.box === "device-pixel-content-box" ? entry.devicePixelContentBoxSize[0] : entry.contentBoxSize[0];
|
|
56
|
+
if (!target.last || selected.inlineSize !== target.last.inlineSize || selected.blockSize !== target.last.blockSize) {
|
|
57
|
+
target.last = selected;
|
|
58
|
+
entries.push(entry);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (entries.length) deliveries.push([state.callback, observer, entries]);
|
|
62
|
+
}
|
|
63
|
+
for (const observer of intersections) {
|
|
64
|
+
const state = intersectionStates.get(observer);
|
|
65
|
+
const rootNode = nodeOf(state.root);
|
|
66
|
+
const screen = viewport();
|
|
67
|
+
const rootRect = rootNode?.nodeType === 1 ? rectOf(rootNode) : { left: 0, top: 0, width: screen.width, height: screen.height };
|
|
68
|
+
const rootBounds = rootRect ? marginRect(rectangle(rootRect), state.margin) : rectangle(ZERO_RECT);
|
|
69
|
+
for (const [node, target] of state.targets) {
|
|
70
|
+
const raw = rectOf(node);
|
|
71
|
+
const boundingClientRect = rectangle(raw ?? ZERO_RECT);
|
|
72
|
+
let intersection = raw && rootRect && (rootNode?.nodeType !== 1 || rootNode.contains(node))
|
|
73
|
+
? boundingClientRect : null;
|
|
74
|
+
// Clip through scrolling/overflow ancestors before the root.
|
|
75
|
+
for (let ancestor = node.parentElement; intersection && ancestor && ancestor !== rootNode; ancestor = ancestor.parentElement) {
|
|
76
|
+
const style = styleOf(ancestor);
|
|
77
|
+
if (style?.overflowX === "clip" || style?.overflowY === "clip") {
|
|
78
|
+
const clip = rectOf(ancestor);
|
|
79
|
+
intersection = clip ? intersect(intersection, rectangle(clip), style.overflowX === "clip", style.overflowY === "clip") : null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (intersection) intersection = intersect(intersection, rootBounds);
|
|
83
|
+
const isIntersecting = intersection !== null;
|
|
84
|
+
const area = boundingClientRect.width * boundingClientRect.height;
|
|
85
|
+
const ratio = area ? (intersection ? intersection.width * intersection.height / area : 0) : (isIntersecting ? 1 : 0);
|
|
86
|
+
const thresholdIndex = state.thresholds.findIndex((threshold) => threshold > ratio);
|
|
87
|
+
const index = thresholdIndex === -1 ? state.thresholds.length : thresholdIndex;
|
|
88
|
+
if (target.index !== index || target.intersecting !== isIntersecting) {
|
|
89
|
+
target.index = index;
|
|
90
|
+
target.intersecting = isIntersecting;
|
|
91
|
+
state.records.push({ target: target.wrapper, time: now(), isIntersecting, intersectionRatio: ratio,
|
|
92
|
+
boundingClientRect, intersectionRect: intersection ?? rectangle(ZERO_RECT), rootBounds });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (state.records.length) deliveries.push([state.callback, observer, state.records.splice(0)]);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
measuring = false;
|
|
99
|
+
}
|
|
100
|
+
for (const [callback, observer, entries] of deliveries) call(callback, observer, [entries, observer]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const element = (target) => {
|
|
104
|
+
const node = nodeOf(target);
|
|
105
|
+
if (node?.nodeType !== 1) throw new TypeError("The target must be an Element.");
|
|
106
|
+
return node;
|
|
107
|
+
};
|
|
108
|
+
const intersectionStates = new WeakMap();
|
|
109
|
+
class IntersectionObserver {
|
|
110
|
+
constructor(callback, options = {}) {
|
|
111
|
+
if (typeof callback !== "function") throw new TypeError("IntersectionObserver callback must be a function.");
|
|
112
|
+
const root = options?.root ?? null;
|
|
113
|
+
const rootNode = root === null ? null : nodeOf(root);
|
|
114
|
+
if (root !== null && rootNode?.nodeType !== 1 && rootNode?.nodeType !== 9) {
|
|
115
|
+
throw new TypeError("IntersectionObserver root must be an Element or Document.");
|
|
116
|
+
}
|
|
117
|
+
const margin = parseMargin(options?.rootMargin ?? "0px");
|
|
118
|
+
const raw = options?.threshold ?? 0;
|
|
119
|
+
const thresholds = (typeof raw === "object" && raw !== null ? Array.from(raw, Number) : [Number(raw)]).sort((a, b) => a - b);
|
|
120
|
+
if (thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
|
|
121
|
+
throw new RangeError("IntersectionObserver thresholds must be between 0 and 1.");
|
|
122
|
+
}
|
|
123
|
+
if (!thresholds.length) thresholds.push(0);
|
|
124
|
+
intersectionStates.set(this, { callback, root, margin, thresholds: Object.freeze(thresholds), targets: new Map(), records: [] });
|
|
125
|
+
}
|
|
126
|
+
get root() { return intersectionState(this).root; }
|
|
127
|
+
get rootMargin() { return intersectionState(this).margin.map(({ value, unit }) => `${value}${unit}`).join(" "); }
|
|
128
|
+
get thresholds() { return intersectionState(this).thresholds; }
|
|
129
|
+
observe(target) {
|
|
130
|
+
const state = intersectionState(this);
|
|
131
|
+
const node = element(target);
|
|
132
|
+
if (state.targets.has(node)) return;
|
|
133
|
+
state.targets.set(node, { wrapper: target, index: -1, intersecting: false });
|
|
134
|
+
intersections.add(this);
|
|
135
|
+
queue();
|
|
136
|
+
}
|
|
137
|
+
unobserve(target) {
|
|
138
|
+
const state = intersectionState(this);
|
|
139
|
+
state.targets.delete(element(target));
|
|
140
|
+
if (!state.targets.size) intersections.delete(this);
|
|
141
|
+
}
|
|
142
|
+
disconnect() {
|
|
143
|
+
intersectionState(this).targets.clear();
|
|
144
|
+
intersections.delete(this);
|
|
145
|
+
}
|
|
146
|
+
takeRecords() { return intersectionState(this).records.splice(0); }
|
|
147
|
+
}
|
|
148
|
+
const intersectionState = (observer) => {
|
|
149
|
+
const state = intersectionStates.get(observer);
|
|
150
|
+
if (!state) throw new TypeError("Illegal invocation");
|
|
151
|
+
return state;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const resizeStates = new WeakMap();
|
|
155
|
+
class ResizeObserver {
|
|
156
|
+
constructor(callback) {
|
|
157
|
+
if (typeof callback !== "function") throw new TypeError("ResizeObserver callback must be a function.");
|
|
158
|
+
resizeStates.set(this, { callback, targets: new Map() });
|
|
159
|
+
}
|
|
160
|
+
observe(target, options = {}) {
|
|
161
|
+
const state = resizeState(this);
|
|
162
|
+
const node = element(target);
|
|
163
|
+
const box = String(options?.box ?? "content-box");
|
|
164
|
+
if (!["content-box", "border-box", "device-pixel-content-box"].includes(box)) throw new TypeError("Invalid ResizeObserver box option.");
|
|
165
|
+
if (state.targets.get(node)?.box === box) return;
|
|
166
|
+
state.targets.set(node, { wrapper: target, box, last: size(0, 0) });
|
|
167
|
+
resizes.add(this);
|
|
168
|
+
queue();
|
|
169
|
+
}
|
|
170
|
+
unobserve(target) {
|
|
171
|
+
const state = resizeState(this);
|
|
172
|
+
state.targets.delete(element(target));
|
|
173
|
+
if (!state.targets.size) resizes.delete(this);
|
|
174
|
+
}
|
|
175
|
+
disconnect() {
|
|
176
|
+
resizeState(this).targets.clear();
|
|
177
|
+
resizes.delete(this);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const resizeState = (observer) => {
|
|
181
|
+
const state = resizeStates.get(observer);
|
|
182
|
+
if (!state) throw new TypeError("Illegal invocation");
|
|
183
|
+
return state;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
IntersectionObserver, ResizeObserver,
|
|
188
|
+
update(...geometry) {
|
|
189
|
+
// Video frames and screenshots can repaint without changing layout.
|
|
190
|
+
// They must not cause an O(targets × ancestors) observer pass each time.
|
|
191
|
+
if (geometry.length && lastGeometry && geometry.every((value, index) => value === lastGeometry[index])) return;
|
|
192
|
+
lastGeometry = geometry;
|
|
193
|
+
queue();
|
|
194
|
+
},
|
|
195
|
+
dispose() {
|
|
196
|
+
disposed = true;
|
|
197
|
+
if (pending !== null) cancel(pending);
|
|
198
|
+
pending = null;
|
|
199
|
+
intersections.clear();
|
|
200
|
+
resizes.clear();
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const ZERO_RECT = Object.freeze({ left: 0, top: 0, width: 0, height: 0 });
|
|
206
|
+
const ZERO_EDGES = Object.freeze([0, 0, 0, 0]);
|
|
207
|
+
const number = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
208
|
+
const sum = (edges, a, b) => number(edges[a]) + number(edges[b]);
|
|
209
|
+
const size = (inlineSize, blockSize) => ({ inlineSize, blockSize });
|
|
210
|
+
function rectangle({ left, top, width, height }) {
|
|
211
|
+
return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height,
|
|
212
|
+
toJSON() { return { x: this.x, y: this.y, width: this.width, height: this.height,
|
|
213
|
+
top: this.top, right: this.right, bottom: this.bottom, left: this.left }; } };
|
|
214
|
+
}
|
|
215
|
+
function parseMargin(value) {
|
|
216
|
+
const tokens = String(value).trim().split(/\s+/);
|
|
217
|
+
if (!tokens.length || tokens.length > 4) throw new DOMException("Invalid rootMargin.", "SyntaxError");
|
|
218
|
+
const parts = tokens.map((token) => {
|
|
219
|
+
const match = /^([+-]?(?:\d+\.?\d*|\.\d+))(px|%)$/.exec(token);
|
|
220
|
+
if (!match) throw new DOMException("rootMargin values must be pixels or percent.", "SyntaxError");
|
|
221
|
+
return { value: Number(match[1]), unit: match[2] };
|
|
222
|
+
});
|
|
223
|
+
const [top, right = top, bottom = top, left = right] = parts;
|
|
224
|
+
return [top, right, bottom, left];
|
|
225
|
+
}
|
|
226
|
+
function marginRect(rect, margin) {
|
|
227
|
+
const [top, right, bottom, left] = margin.map(({ value, unit }) => unit === "%" ? value * rect.width / 100 : value);
|
|
228
|
+
return rectangle({ left: rect.left - left, top: rect.top - top, width: rect.width + left + right, height: rect.height + top + bottom });
|
|
229
|
+
}
|
|
230
|
+
function intersect(a, b, x = true, y = true) {
|
|
231
|
+
const left = x ? Math.max(a.left, b.left) : a.left;
|
|
232
|
+
const right = x ? Math.min(a.right, b.right) : a.right;
|
|
233
|
+
const top = y ? Math.max(a.top, b.top) : a.top;
|
|
234
|
+
const bottom = y ? Math.min(a.bottom, b.bottom) : a.bottom;
|
|
235
|
+
return left <= right && top <= bottom ? rectangle({ left, top, width: right - left, height: bottom - top }) : null;
|
|
236
|
+
}
|