buninu-browser 0.2.3 → 0.3.0

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 (116) hide show
  1. package/README.md +86 -1
  2. package/lib/cdp-server.js +47 -14
  3. package/lib/clipboard.js +22 -0
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +15 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +40 -11
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +213 -91
  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 +17 -4
  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 +8 -28
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +77 -16
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/raw-frames.js +47 -0
  24. package/lib/paint/rounded-rect.js +82 -0
  25. package/lib/paint/shared-frame-pool.js +105 -0
  26. package/lib/render-tree/index.js +38 -6
  27. package/lib/render-tree/svg.js +15 -7
  28. package/lib/renderer/bindings.js +233 -80
  29. package/lib/renderer/broadcast-channel.js +76 -0
  30. package/lib/renderer/canvas-2d.js +257 -14
  31. package/lib/renderer/canvas-text.js +120 -0
  32. package/lib/renderer/compositor-channel.js +103 -0
  33. package/lib/renderer/compositor.js +34 -40
  34. package/lib/renderer/custom-element-reactions.js +25 -1
  35. package/lib/renderer/flags.js +1 -1
  36. package/lib/renderer/font-loading.js +227 -0
  37. package/lib/renderer/host.js +133 -98
  38. package/lib/renderer/image-resources.js +65 -0
  39. package/lib/renderer/interfaces.js +28 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +52 -5
  42. package/lib/renderer/media-source.js +126 -55
  43. package/lib/renderer/page-realm.js +287 -99
  44. package/lib/renderer/page-renderer.js +663 -239
  45. package/lib/renderer/prepared-script-cache.js +50 -0
  46. package/lib/renderer/rendered-text.js +108 -0
  47. package/lib/renderer/rewrite-worker.js +4 -3
  48. package/lib/renderer/script-rewrite.js +13 -2
  49. package/lib/renderer/scroll-into-view.js +28 -0
  50. package/lib/renderer/slot-assignment.js +60 -0
  51. package/lib/renderer/video-presentation.js +23 -4
  52. package/lib/renderer/workers.js +2 -2
  53. package/lib/style/computed-style.js +611 -104
  54. package/lib/style/selector-traits.js +49 -5
  55. package/package.json +1 -1
  56. package/test/broadcast-channel.test.js +26 -0
  57. package/test/canvas-2d.test.js +83 -2
  58. package/test/canvas-paint.test.js +9 -0
  59. package/test/canvas-text.test.js +41 -0
  60. package/test/cdp-stream.test.js +56 -0
  61. package/test/clipboard.test.js +35 -0
  62. package/test/computed-style-binding.test.js +151 -0
  63. package/test/computed-style.test.js +258 -1
  64. package/test/display-list.test.js +12 -0
  65. package/test/document-base.test.js +87 -0
  66. package/test/dom-platform.test.js +461 -0
  67. package/test/fetch-lifecycle.test.js +161 -0
  68. package/test/fixtures/browser-raw-demo.html +36 -0
  69. package/test/fixtures/canvas-paint.js +117 -0
  70. package/test/fixtures/canvas-text.js +42 -0
  71. package/test/fixtures/image-lifetime.js +90 -0
  72. package/test/fixtures/media-seek-snapshot.js +77 -0
  73. package/test/fixtures/mse-audio-chirp.webm +0 -0
  74. package/test/fixtures/mse-switch-large.mp4 +0 -0
  75. package/test/fixtures/mse-switch-small.mp4 +0 -0
  76. package/test/fixtures/raw-capture-benchmark.json +76 -0
  77. package/test/fixtures/renderer-memory.js +110 -0
  78. package/test/fixtures/summary-style.js +28 -0
  79. package/test/font-loading.test.js +97 -0
  80. package/test/grid.test.js +58 -0
  81. package/test/hit-test.test.js +21 -1
  82. package/test/image-lifetime.test.js +12 -0
  83. package/test/image-resources.test.js +94 -0
  84. package/test/local-media.test.js +84 -0
  85. package/test/media-seek-snapshot.test.js +11 -0
  86. package/test/media-source-config.test.js +98 -0
  87. package/test/media-source-webm-accounting.test.js +98 -0
  88. package/test/media-source-webm.test.js +108 -0
  89. package/test/navigation.test.js +1 -0
  90. package/test/network-service.test.js +388 -0
  91. package/test/page-scripts.test.js +1 -1
  92. package/test/paint-frame.test.js +103 -0
  93. package/test/paint-paragraph-cache.test.js +87 -0
  94. package/test/position-audio.test.js +24 -0
  95. package/test/positioning.test.js +22 -0
  96. package/test/prepared-script-cache.test.js +45 -0
  97. package/test/pulse-client.test.js +10 -3
  98. package/test/raster-readback.test.js +40 -0
  99. package/test/raw-capture.test.js +152 -0
  100. package/test/render-tree.test.js +28 -0
  101. package/test/renderer-memory.test.js +11 -0
  102. package/test/selector-traits.test.js +24 -0
  103. package/test/shared-frame-pool.test.js +63 -0
  104. package/test/style-sheets.test.js +51 -0
  105. package/test/svg.test.js +29 -0
  106. package/test/text-layout.test.js +106 -0
  107. package/test/video-keyframe.test.js +61 -0
  108. package/test/video-presentation.test.js +69 -0
  109. package/test/window-resize.test.js +36 -0
  110. package/tools/bench-raw-capture.js +32 -0
  111. package/tools/browser-lab.js +78 -0
  112. package/tools/browser-soak.js +75 -0
  113. package/tools/network-reference.js +49 -0
  114. package/tools/process-memory.js +38 -0
  115. package/tools/script-benchmark.js +25 -0
  116. package/ai-debug.log +0 -5537
@@ -6,8 +6,8 @@
6
6
  * screenshots and scrolling on its own channel, so the page stays visible and
7
7
  * scrollable while the main thread works.
8
8
  *
9
- * Channel to the controller: fd 3, a socket shared with the controller,
10
- * newline-delimited JSON (it does not go through the main thread's IPC):
9
+ * Channel to the controller: authenticated loopback socket, with length-prefixed
10
+ * JSON and optional binary pixels (does not go through main-thread IPC):
11
11
  * controller -> compositor { id, method: "screenshot" | "scroll" | "scrollOffset", params }
12
12
  * (screenshot params: [{ format?: "png" | "jpeg" | "webp", quality? }])
13
13
  * compositor -> controller { id, result } | { id, error }
@@ -22,36 +22,45 @@
22
22
  * a fragment link, a key press or a new document.
23
23
  */
24
24
  import { ScrollViewport } from "../layout/scroll-viewport.js";
25
- import { cropViewport, encodeImage } from "../paint/frame.js";
25
+ import { rawFrame, captureViewport, encodeImage, encodeImageBase64 } from "../paint/frame.js";
26
+ import { FrameCaptureCache } from "../paint/frame-cache.js";
27
+ import { FrameLeaseTracker } from "../paint/shared-frame-pool.js";
26
28
  import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
27
29
  import { VideoPresentation, compositeVideo } from "./video-presentation.js";
28
30
 
29
- const CHANNEL_FD = 3;
30
- const MAX_LINE_BYTES = 1024 * 1024;
31
+ import {connectCompositor} from './compositor-channel.js';
31
32
 
32
33
  const viewport = new ScrollViewport(800, 600);
33
34
  let tile = null;
34
35
  let scrollEpoch = -1;
35
36
  let frameSerial = 0;
36
- let cached = { key: "", data: "" };
37
+ const captures = new FrameCaptureCache();
38
+ const frameLeases = new FrameLeaseTracker(serial => postMessage({ type: "frame-release", serial }));
37
39
  const video = new VideoPresentation(wasmpeg, () => {
38
40
  frameSerial++;
39
41
  send({ type: "frame" });
40
42
  }, report => postMessage({ type: "video-report", ...report }));
41
43
 
42
- const channel = Bun.file(CHANNEL_FD).writer();
43
-
44
+ let channel;
44
45
  function send(message) {
45
- channel.write(`${JSON.stringify(message)}\n`);
46
- channel.flush();
46
+ const result = message.result;
47
+ if (result?.pixels instanceof Uint8Array) {
48
+ const {pixels,...metadata} = result;
49
+ channel.send({...message,result:metadata},pixels);
50
+ } else channel.send(message);
47
51
  }
52
+ const endpoint = await new Promise(resolve => {
53
+ self.onmessage = ({data}) => { if (data?.type === 'connect') resolve(data.endpoint); };
54
+ });
55
+ channel = await connectCompositor(endpoint, request => { void handleRequest(request); }, () => process.exit(1));
48
56
 
49
57
  self.onmessage = ({ data }) => {
50
58
  if (data?.type === "video-open") { void video.open(data); return; }
51
59
  if (data?.type === "video-stop") { video.stop(data.id); frameSerial++; return; }
52
- if (data?.type === "video-bind") { video.bind(data.id, data.audioId, data.audioStart, data.time); return; }
60
+ if (data?.type === "video-bind") { video.bind(data.id, data.audioId, data.audioStart, data.time, data.endTime); return; }
53
61
  if (data?.type !== "frame") return;
54
62
  tile = data.tile;
63
+ frameLeases.replace(data.serial, tile);
55
64
  frameSerial++;
56
65
  viewport.resize(data.viewport.width, data.viewport.height);
57
66
  viewport.setContentSize(data.content.width, data.content.height);
@@ -73,16 +82,21 @@ self.onmessage = ({ data }) => {
73
82
  };
74
83
 
75
84
  const methods = {
76
- async screenshot({ format = "png", quality = 80 } = {}) {
85
+ async screenshot({ format = "png", quality = 80, optimizeForSpeed = false, clip } = {}) {
77
86
  if (!tile) return "";
78
- const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}`;
79
- if (cached.key !== key) {
80
- const composited = tile.videoLayer?.id === video.config?.id ? compositeVideo(tile, video.frame) : tile;
81
- const { pixels, width, height } = cropViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height);
82
- const bytes = await encodeImage(pixels, width, height, { format, quality });
83
- cached = { key, data: Buffer.from(bytes).toString("base64") };
87
+ if (format === 'rgba' || format === 'bgra') {
88
+ return frameLeases.capture(frame => {
89
+ const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame) : frame;
90
+ const {pixels,width,height} = captureViewport(composited,viewport.x,viewport.y,viewport.width,viewport.height,clip);
91
+ return rawFrame(pixels,width,height,format);
92
+ });
84
93
  }
85
- return cached.data;
94
+ const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}:${optimizeForSpeed}:${JSON.stringify(clip)}`;
95
+ return captures.get(key, () => frameLeases.capture(frame => {
96
+ const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame) : frame;
97
+ const { pixels, width, height } = captureViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height,clip);
98
+ return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
99
+ }));
86
100
  },
87
101
  scroll(deltaX, deltaY) {
88
102
  if (viewport.scrollBy(deltaX, deltaY)) {
@@ -96,13 +110,7 @@ const methods = {
96
110
  },
97
111
  };
98
112
 
99
- async function handleLine(line) {
100
- let request;
101
- try {
102
- request = JSON.parse(line);
103
- } catch {
104
- return;
105
- }
113
+ async function handleRequest(request) {
106
114
  if (request?.type === "audio-timing" && Number.isInteger(request.audioId)) {
107
115
  video.timing(request.audioId, request.seconds);
108
116
  return;
@@ -116,20 +124,6 @@ async function handleLine(line) {
116
124
  }
117
125
  }
118
126
 
119
- (async () => {
120
- const decoder = new TextDecoder();
121
- let pending = "";
122
- for await (const chunk of Bun.file(CHANNEL_FD).stream()) {
123
- pending += decoder.decode(chunk, { stream: true });
124
- let newline;
125
- while ((newline = pending.indexOf("\n")) >= 0) {
126
- handleLine(pending.slice(0, newline));
127
- pending = pending.slice(newline + 1);
128
- }
129
- if (pending.length > MAX_LINE_BYTES) pending = "";
130
- }
131
- })();
132
-
133
127
  // Bun.Image initialises its codecs on first use; do that before the
134
128
  // renderer installs seccomp, which forbids opening files.
135
129
  await encodeImage(new Uint8Array(4), 1, 1);
@@ -5,14 +5,38 @@ export function installAttributeReactions(Adapter, constructor, realm, callPage)
5
5
  const callback = constructor.prototype.attributeChangedCallback;
6
6
  if (typeof callback !== "function") return;
7
7
  const observed = constructor.observedAttributes;
8
+ const pending = new WeakMap();
8
9
  Object.defineProperty(Adapter, "observedAttributes", {
9
10
  value: observed === undefined ? [] : Array.from(observed, String),
10
11
  });
11
12
  Object.defineProperty(Adapter.prototype, "attributeChangedCallback", {
12
13
  value(name, oldValue, newValue, namespace = null) {
13
- callPage(callback, realm.wrap(this), [name, oldValue, newValue, namespace]);
14
+ const args = [name, oldValue, newValue, namespace];
15
+ const reactions = pending.get(this);
16
+ if (reactions) reactions.push(args);
17
+ else callPage(callback, realm.wrap(this), args);
14
18
  },
15
19
  configurable: true,
16
20
  writable: true,
17
21
  });
22
+ const observedNames = new Set(Adapter.observedAttributes);
23
+ // The host DOM upgrades existing elements in place, but does not enqueue
24
+ // their initial attribute callbacks. Capture values before construction:
25
+ // those reactions run after the constructor and before connectedCallback.
26
+ return (element) => {
27
+ const reactions = Array.from(element.attributes)
28
+ .filter(attribute => observedNames.has(attribute.localName))
29
+ .map(attribute => [attribute.localName, null, attribute.value, attribute.namespaceURI]);
30
+ pending.set(element, reactions);
31
+ return {
32
+ finish() {
33
+ try {
34
+ // Attribute writes by the constructor or earlier callbacks append
35
+ // to this element's queue, after the captured initial reactions.
36
+ for (let index = 0; index < reactions.length; index++) callPage(callback, realm.wrap(element), reactions[index]);
37
+ } finally { pending.delete(element); }
38
+ },
39
+ cancel() { pending.delete(element); },
40
+ };
41
+ };
18
42
  }
@@ -1,7 +1,7 @@
1
1
  /** Command-line flags the controller passes to renderer processes. */
2
2
  import { CHROMIUM_VERSION } from "../user-agent.js";
3
3
 
4
- /** fd 3 is the controller's compositor channel; start the compositor Worker. */
4
+ /** Start the compositor Worker with its pre-sandbox loopback channel. */
5
5
  export const COMPOSITOR_FLAG = "--compositor";
6
6
 
7
7
  /** BUNINU_VERIFY_STYLE: check every incremental restyle against a full one (development). */
@@ -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
+ }