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.
Files changed (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. 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
+ }
@@ -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) child.send({ type: "audio-event", id, ...payload });
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
- if (!ALLOWED_SCHEMES.has(target.protocol) || !RESOURCE_KINDS.has(kind)) {
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
+ }