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
@@ -1,3 +1,4 @@
1
+ import {writeClipboard} from '../clipboard.js';
1
2
  /**
2
3
  * Controller-side handle for a renderer process.
3
4
  *
@@ -9,10 +10,11 @@
9
10
  * Everything the renderer sends is treated as untrusted input.
10
11
  *
11
12
  * Besides IPC with the renderer's main thread, each renderer gets a second
12
- * channel (fd 3) to its compositor thread, which answers screenshots and
13
+ * authenticated loopback channel to its compositor thread, which answers screenshots and
13
14
  * scrolling even while the main thread is busy (see compositor.js).
14
15
  */
15
- import { closeSync } from "node:fs";
16
+ import {listenCompositor} from "./compositor-channel.js";
17
+ import {RawFrames} from "../paint/raw-frames.js";
16
18
  import { PulseClient } from "../audio/pulse-client.js";
17
19
  import { logEnabled, pipeToLog, RENDERER_LOG_FLAG, writeLog } from "../log.js";
18
20
  import { NetworkService } from "../network/network-service.js";
@@ -47,13 +49,18 @@ const MAX_WEB_SOCKETS = 64;
47
49
  const MAX_WEB_SOCKET_MESSAGE = 16 * 1024 * 1024;
48
50
  const COMPOSITOR_METHODS = new Set(["screenshot", "scroll", "scrollOffset"]);
49
51
  const COMPOSITOR_TIMEOUT = 10_000;
50
- const MAX_CHANNEL_LINE = 96 * 1024 * 1024;
51
52
  const LIFECYCLE_NAMES = new Set(["DOMContentLoaded", "load"]);
52
53
 
53
54
  // A file path, not a URL's pathname ("/C:/..." on Windows).
54
55
  const DEFAULT_ENTRY = Bun.fileURLToPath(new URL("./main.js", import.meta.url));
55
56
 
56
57
  export class RendererHost {
58
+ #clipboardWrite = writeClipboard;
59
+ #clipboardUntil = 0;
60
+ #clipboardPending = false;
61
+ #rawFrames = null;
62
+ #startEpoch = 0;
63
+ #rejectStartup = null;
57
64
  #entry;
58
65
  #args;
59
66
  #timeout;
@@ -73,6 +80,7 @@ export class RendererHost {
73
80
  #audio = new Map();
74
81
  // In-flight page fetches, so AbortController reaches the actual HTTP request.
75
82
  #pageFetches = new Map();
83
+ #resourceFetches = new Map();
76
84
  #nextId = 1;
77
85
  #queue = Promise.resolve();
78
86
  #restarting = false;
@@ -104,11 +112,11 @@ export class RendererHost {
104
112
  entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, onDownload = null,
105
113
  fetch: fetchImpl = fetch,
106
114
  network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
107
- // The compositor's channel is an extra stdio pipe used as fd 3, which
108
- // Windows does not have (Bun hands out libuv pipes there, not file
109
- // descriptors): there the main thread answers screenshots and scrolling.
110
- compositor = entry === DEFAULT_ENTRY && process.platform !== "win32",
115
+ // A dedicated loopback socket works independently of the main thread on all platforms.
116
+ compositor = entry === DEFAULT_ENTRY,
117
+ clipboardWrite = writeClipboard,
111
118
  } = {}) {
119
+ this.#clipboardWrite = clipboardWrite;
112
120
  this.#onFrame = onFrame;
113
121
  this.#onLifecycle = onLifecycle;
114
122
  this.#compositor = compositor;
@@ -130,47 +138,61 @@ export class RendererHost {
130
138
  resolveReady = resolve;
131
139
  rejectReady = reject;
132
140
  });
141
+ this.#rejectStartup = rejectReady;
133
142
  const logging = logEnabled();
134
143
  const compositor = this.#compositor;
135
- const child = Bun.spawn({
136
- cmd: [process.execPath, this.#entry, ...this.#args,
137
- ...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG] : []),
138
- ...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
139
- // No inherited environment: nothing from the controller leaks in.
140
- // Windows programs need SystemRoot to load system libraries.
141
- env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
142
- // With BUNINU_LOG, output goes through the controller into the log file.
143
- // fd 3 is a socket to the renderer's compositor thread.
144
- stdio: ["ignore", logging ? "pipe" : "inherit", logging ? "pipe" : "inherit", ...(compositor ? ["pipe"] : [])],
145
- serialization: "advanced",
146
- ipc: (message) => {
147
- if (message?.type === "ready") {
148
- this.#sandbox = sanitizeSandboxReport(message.sandbox);
149
- resolveReady();
150
- }
151
- else this.#handleMessage(child, message);
152
- },
153
- onExit: (_process, exitCode, signalCode) => {
154
- this.#closeChannel(channel);
155
- this.#closeSockets(child);
156
- this.#closeAudio(child);
157
- this.#closePageFetches(child);
158
- if (this.#child !== child) return;
159
- console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
160
- this.#child = null;
161
- this.#ready = null;
162
- rejectReady(new Error("renderer exited during startup"));
163
- for (const { reject } of this.#pending.values()) reject(new Error("renderer exited"));
164
- this.#pending.clear();
165
- },
166
- });
167
- this.#child = child;
168
- const channel = compositor && Number.isInteger(child.stdio[3]) ? this.#openChannel(child.stdio[3]) : null;
169
- this.#channel = channel;
170
- if (logging) {
171
- pipeToLog(child.stdout, `renderer ${child.pid}`, (chunk) => process.stdout.write(chunk));
172
- pipeToLog(child.stderr, `renderer ${child.pid}`, (chunk) => process.stderr.write(chunk));
173
- }
144
+ const epoch = ++this.#startEpoch;
145
+ void (async () => {
146
+ const channel = compositor ? {pending:new Map(),nextId:1,closed:false,transport:null} : null;
147
+ if (channel) channel.transport = await listenCompositor(
148
+ (message,pixels)=>this.#handleCompositorMessage(channel,message,pixels),
149
+ ()=>this.#closeChannel(channel));
150
+ if (epoch !== this.#startEpoch) { channel?.transport.close(); throw Error('Renderer startup cancelled'); }
151
+ this.#channel = channel;
152
+ const child = Bun.spawn({
153
+ cmd: [process.execPath, this.#entry, ...this.#args,
154
+ ...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG, `--compositor-endpoint=${JSON.stringify(channel.transport.endpoint)}`] : []),
155
+ ...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
156
+ // No inherited environment: nothing from the controller leaks in.
157
+ // Windows programs need SystemRoot to load system libraries.
158
+ env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
159
+ // With BUNINU_LOG, output goes through the controller into the log file.
160
+ stdio: ["ignore", logging ? "pipe" : "inherit", logging ? "pipe" : "inherit"],
161
+ serialization: "advanced",
162
+ ipc: (message) => {
163
+ if (message?.type === "ready") {
164
+ this.#sandbox = sanitizeSandboxReport(message.sandbox);
165
+ this.#rejectStartup=null;
166
+ clearTimeout(startupTimer);
167
+ resolveReady();
168
+ }
169
+ else this.#handleMessage(child, message);
170
+ },
171
+ onExit: (_process, exitCode, signalCode) => {
172
+ clearTimeout(startupTimer);
173
+ this.#closeChannel(channel);
174
+ this.#closeSockets(child);
175
+ this.#closeAudio(child);
176
+ this.#closePageFetches(child);
177
+ rejectReady(new Error("renderer exited during startup"));
178
+ if (this.#child !== child) return;
179
+ console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
180
+ this.#child = null;
181
+ this.#ready = null;
182
+ this.#rawFrames?.close(); this.#rawFrames=null;
183
+ for (const { reject } of this.#pending.values()) reject(new Error("renderer exited"));
184
+ this.#pending.clear();
185
+ },
186
+ });
187
+ this.#child = child;
188
+ const startupTimer = setTimeout(() => {
189
+ this.#closeChannel(channel); child.kill(9); rejectReady(Error('Renderer startup timed out'));
190
+ }, this.#timeout);
191
+ if (logging) {
192
+ pipeToLog(child.stdout, `renderer ${child.pid}`, (chunk) => process.stdout.write(chunk));
193
+ pipeToLog(child.stderr, `renderer ${child.pid}`, (chunk) => process.stderr.write(chunk));
194
+ }
195
+ })().catch(error=>{ if(epoch===this.#startEpoch){this.#closeChannel(this.#channel);this.#ready=null;} rejectReady(error); });
174
196
  return this.#ready;
175
197
  }
176
198
 
@@ -189,6 +211,7 @@ export class RendererHost {
189
211
  this.call("applyCompositorScroll", x, y).catch(() => {});
190
212
  }
191
213
  if ((method === "loadDocument" || method === "showFragment") && params[0]) {
214
+ if (method === "loadDocument") this.#closeResourceFetches(this.#child);
192
215
  this.#documentUrl = String(method === "loadDocument" ? params[0].url : params[0]);
193
216
  }
194
217
  const run = () => this.#call(method, params);
@@ -211,12 +234,13 @@ export class RendererHost {
211
234
  * waiting for the renderer's main thread or its call queue.
212
235
  */
213
236
  async composite(method, ...params) {
214
- if (!this.#compositor || !COMPOSITOR_METHODS.has(method)) return this.call(method, ...params);
237
+ if (!this.#compositor || !COMPOSITOR_METHODS.has(method)) return this.#captureResult(method, await this.call(method, ...params), params);
215
238
  // A dead renderer is restarted (and its page restored) through the call path.
216
239
  if (!this.#child) await this.call("url");
217
240
  await this.start();
218
241
  const channel = this.#channel;
219
- if (!channel) return this.call(method, ...params);
242
+ if (!channel) return this.#captureResult(method, await this.call(method, ...params), params);
243
+ const child = this.#child;
220
244
  const result = await new Promise((resolve, reject) => {
221
245
  const id = channel.nextId++;
222
246
  const timer = setTimeout(() => {
@@ -233,56 +257,25 @@ export class RendererHost {
233
257
  reject(error);
234
258
  },
235
259
  });
236
- channel.writer.write(`${JSON.stringify({ id, method, params })}\n`);
237
- channel.writer.flush();
260
+ try { channel.transport.send({id,method,params}); }
261
+ catch(error) { channel.pending.get(id).reject(error); channel.pending.delete(id); }
238
262
  });
239
- const sanitized = sanitizeCompositorResult(method, result);
263
+ if (channel.closed || this.#child !== child) throw Error("Renderer closed during capture");
264
+ const sanitized = this.#captureResult(method, result, params);
240
265
  if (method === "scroll") this.#pendingScroll = sanitized;
241
266
  return sanitized;
242
267
  }
243
268
 
244
- #openChannel(fd) {
245
- const channel = { writer: Bun.file(fd).writer(), pending: new Map(), nextId: 1, closed: false };
246
- (async () => {
247
- const decoder = new TextDecoder();
248
- let buffer = "";
249
- try {
250
- for await (const chunk of Bun.file(fd).stream()) {
251
- buffer += decoder.decode(chunk, { stream: true });
252
- let newline;
253
- while ((newline = buffer.indexOf("\n")) >= 0) {
254
- this.#handleCompositorLine(channel, buffer.slice(0, newline));
255
- buffer = buffer.slice(newline + 1);
256
- }
257
- if (buffer.length > MAX_CHANNEL_LINE) buffer = "";
258
- }
259
- } catch {
260
- // The renderer went away.
261
- }
262
- this.#closeChannel(channel);
263
- // Close everything that refers to the socket once, after end of
264
- // stream, so no descriptor number can have been reused meanwhile.
265
- try {
266
- channel.writer.end();
267
- } catch {
268
- // Already closed.
269
- }
270
- try {
271
- closeSync(fd);
272
- } catch {
273
- // Already closed.
274
- }
275
- })();
276
- return channel;
269
+ #captureResult(method,result,params) {
270
+ if (method === 'screenshot' && ['rgba','bgra'].includes(params[0]?.format)) {
271
+ if (result?.format !== params[0].format) throw Error('Raw screenshot format mismatch');
272
+ this.#rawFrames ??= new RawFrames();
273
+ return this.#rawFrames.write(result.pixels,result.width,result.height,result.format);
274
+ }
275
+ return sanitizeCompositorResult(method,result);
277
276
  }
278
277
 
279
- #handleCompositorLine(channel, line) {
280
- let message;
281
- try {
282
- message = JSON.parse(line);
283
- } catch {
284
- return;
285
- }
278
+ #handleCompositorMessage(channel, message, pixels) {
286
279
  if (channel.closed || typeof message !== "object" || message === null) return;
287
280
  if (message.type === "frame") {
288
281
  if (this.#channel === channel) this.#onFrame?.();
@@ -290,19 +283,23 @@ export class RendererHost {
290
283
  const pending = channel.pending.get(message.id);
291
284
  channel.pending.delete(message.id);
292
285
  if (Object.hasOwn(message, "error")) pending.reject(new Error(`compositor: ${String(message.error).slice(0, 200)}`));
293
- else pending.resolve(message.result);
286
+ else pending.resolve(pixels.length ? {...message.result,pixels} : message.result);
294
287
  }
295
288
  }
296
289
 
297
290
  #closeChannel(channel) {
298
291
  if (!channel || channel.closed) return;
299
292
  channel.closed = true;
293
+ channel.transport?.close();
300
294
  for (const { reject } of channel.pending.values()) reject(new Error("compositor closed"));
301
295
  channel.pending.clear();
302
296
  if (this.#channel === channel) this.#channel = null;
303
297
  }
304
298
 
305
299
  close() {
300
+ this.#startEpoch++;
301
+ this.#rejectStartup?.(Error("Renderer closed during startup")); this.#rejectStartup=null;
302
+ this.#rawFrames?.close(); this.#rawFrames=null;
306
303
  this.#closeChannel(this.#channel);
307
304
  const child = this.#child;
308
305
  this.#closeSockets(child);
@@ -310,6 +307,8 @@ export class RendererHost {
310
307
  this.#closePageFetches(child);
311
308
  this.#child = null;
312
309
  this.#ready = null;
310
+ for (const {reject} of this.#pending.values()) reject(Error("Renderer closed"));
311
+ this.#pending.clear();
313
312
  child?.kill();
314
313
  }
315
314
 
@@ -317,6 +316,8 @@ export class RendererHost {
317
316
  if (!this.#child) await this.#restart();
318
317
  await this.start();
319
318
  const child = this.#child;
319
+ if (method === 'click' || method === 'press') this.#clipboardUntil = Date.now()+5000;
320
+ if (method === 'loadDocument') this.#clipboardUntil = 0;
320
321
  const id = this.#nextId++;
321
322
  return new Promise((resolve, reject) => {
322
323
  const timer = setTimeout(() => {
@@ -325,6 +326,7 @@ export class RendererHost {
325
326
  this.#child = null;
326
327
  this.#ready = null;
327
328
  console.error(`buninu-browser: renderer ${child.pid} missed the ${timeout}ms deadline for ${method}; killing it`);
329
+ this.#rawFrames?.close(); this.#rawFrames=null;
328
330
  child.kill(9);
329
331
  reject(new Error(`renderer ${method} timed out after ${timeout}ms`));
330
332
  }, timeout);
@@ -363,6 +365,18 @@ export class RendererHost {
363
365
  pending.reject(new Error(`renderer: ${String(message.error).split("\n")[0] || "unknown error"}`));
364
366
  }
365
367
  else pending.resolve(message.result);
368
+ } else if (message.type === 'clipboard-write' && Number.isInteger(message.id)) {
369
+ const reply = error => { if(this.#child===child)child.send({type:'clipboard-result',id:message.id,...(error?{error}: {})}); };
370
+ let secure=false;
371
+ try { const url=new URL(this.#documentUrl); secure=url.protocol==='https:' || (url.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname)); } catch {}
372
+ if (!secure || Date.now()>this.#clipboardUntil || this.#clipboardPending
373
+ || typeof message.text!=='string' || Buffer.byteLength(message.text)>1024*1024) {
374
+ reply('Clipboard write requires a recent user action in a secure document'); return;
375
+ }
376
+ this.#clipboardUntil=0; this.#clipboardPending=true;
377
+ Promise.resolve().then(()=>this.#clipboardWrite(message.text))
378
+ .then(()=>reply(),()=>reply('Unable to write system clipboard'))
379
+ .finally(()=>{this.#clipboardPending=false;});
366
380
  } else if (message.type === "fetch" && Number.isInteger(message.id)) {
367
381
  this.#fetchForRenderer(child, message);
368
382
  } else if (message.type === "page-fetch" && Number.isInteger(message.id)) {
@@ -493,8 +507,7 @@ export class RendererHost {
493
507
  child.send({ type: "audio-event", id, ...payload });
494
508
  // The compositor's A/V clock must not wait behind page JavaScript.
495
509
  if (payload.event === "timing" && this.#channel && !this.#channel.closed) {
496
- this.#channel.writer.write(`${JSON.stringify({ type: "audio-timing", audioId: id, seconds: payload.seconds })}\n`);
497
- this.#channel.writer.flush();
510
+ this.#channel.transport.send({type:"audio-timing",audioId:id,seconds:payload.seconds});
498
511
  }
499
512
  }
500
513
  };
@@ -534,6 +547,7 @@ export class RendererHost {
534
547
  }
535
548
 
536
549
  #closePageFetches(child) {
550
+ this.#closeResourceFetches(child);
537
551
  for (const [id, entry] of this.#pageFetches) {
538
552
  if (entry.child !== child) continue;
539
553
  this.#pageFetches.delete(id);
@@ -541,6 +555,14 @@ export class RendererHost {
541
555
  }
542
556
  }
543
557
 
558
+ #closeResourceFetches(child) {
559
+ for (const [id, entry] of this.#resourceFetches) {
560
+ if (entry.child !== child) continue;
561
+ this.#resourceFetches.delete(id);
562
+ entry.controller.abort();
563
+ }
564
+ }
565
+
544
566
  #closeSockets(child) {
545
567
  for (const [id, entry] of this.#sockets) {
546
568
  if (entry.child !== child) continue;
@@ -552,6 +574,16 @@ export class RendererHost {
552
574
  }
553
575
 
554
576
  async #fetchForRenderer(child, { id, url, kind }) {
577
+ const entry = { child, controller: new AbortController() };
578
+ this.#resourceFetches.set(id, entry);
579
+ try {
580
+ await this.#fetchResource(child, { id, url, kind }, entry.controller.signal);
581
+ } finally {
582
+ if (this.#resourceFetches.get(id) === entry) this.#resourceFetches.delete(id);
583
+ }
584
+ }
585
+
586
+ async #fetchResource(child, { id, url, kind }, signal) {
555
587
  const reply = (payload) => {
556
588
  if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
557
589
  };
@@ -561,7 +593,10 @@ export class RendererHost {
561
593
  } catch {
562
594
  return reply({ error: "invalid URL" });
563
595
  }
564
- if (!ALLOWED_SCHEMES.has(target.protocol) || !RESOURCE_KINDS.has(kind)) {
596
+ // Local documents may render local resources. Authorize this from the
597
+ // controller's document URL, never from a renderer-supplied referrer.
598
+ const localFile = target.protocol === "file:" && new URL(this.#documentUrl).protocol === "file:";
599
+ if ((!ALLOWED_SCHEMES.has(target.protocol) && !localFile) || !RESOURCE_KINDS.has(kind)) {
565
600
  return reply({ error: `blocked ${kind} request for ${target.protocol}` });
566
601
  }
567
602
  // A renderer only ever holds documents of its own origin (site isolation
@@ -570,7 +605,7 @@ export class RendererHost {
570
605
  // being framed.
571
606
  const frame = kind === "iframe";
572
607
  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
608
+ // Video and audio over http(s)/file stream. data: stays one response: it is
574
609
  // already in hand, and the renderer wraps it in a finished pipe.
575
610
  if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
576
611
  const send = (payload) => {
@@ -599,7 +634,7 @@ export class RendererHost {
599
634
  } else if (event.type === "error") {
600
635
  send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
601
636
  }
602
- });
637
+ }, { signal });
603
638
  } catch (error) {
604
639
  send({ type: "fetch-error", id, error: String(error?.message ?? error) });
605
640
  }
@@ -608,7 +643,7 @@ export class RendererHost {
608
643
  try {
609
644
  const response = target.protocol === "data:"
610
645
  ? await dataResponse(target)
611
- : await this.#network.subresource(target.href, this.#documentUrl, kind);
646
+ : await this.#network.subresource(target.href, this.#documentUrl, kind, { signal });
612
647
  if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
613
648
  if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
614
649
  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