buninu-browser 0.5.0 → 0.6.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 (146) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/inert-link.js +26 -0
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/node-iterator-removal.js +36 -0
  9. package/lib/happy-dom/node-replacement.js +60 -0
  10. package/lib/happy-dom/parser.js +6 -1
  11. package/lib/headless-shell.js +49 -9
  12. package/lib/layout/repaint.js +89 -0
  13. package/lib/layout/text-layout.js +4 -2
  14. package/lib/media/mp4-keyframes.js +144 -0
  15. package/lib/media/video-keyframe.js +29 -1
  16. package/lib/media/webm-snapshots.js +282 -0
  17. package/lib/network/cookie-jar.js +18 -2
  18. package/lib/paint/display-list.js +4 -3
  19. package/lib/paint/frame.js +12 -5
  20. package/lib/render-tree/index.js +3 -0
  21. package/lib/renderer/animation-frames.js +76 -0
  22. package/lib/renderer/bindings.js +107 -3
  23. package/lib/renderer/compositor-channel.js +95 -10
  24. package/lib/renderer/compositor.js +54 -18
  25. package/lib/renderer/document-cookies.js +87 -0
  26. package/lib/renderer/font-loading.js +60 -16
  27. package/lib/renderer/host.js +48 -19
  28. package/lib/renderer/idle-callbacks.js +109 -0
  29. package/lib/renderer/main.js +5 -1
  30. package/lib/renderer/media-source.js +4 -0
  31. package/lib/renderer/page-realm.js +159 -49
  32. package/lib/renderer/page-renderer.js +577 -201
  33. package/lib/renderer/resource-elements.js +88 -0
  34. package/lib/renderer/script-rewrite.js +18 -1
  35. package/lib/renderer/seccomp.js +6 -4
  36. package/lib/renderer/timers.js +71 -0
  37. package/lib/renderer/video-channel.js +94 -0
  38. package/lib/renderer/video-mask.js +115 -0
  39. package/lib/renderer/video-presentation.js +304 -39
  40. package/lib/renderer/video-worker.js +46 -0
  41. package/lib/renderer/workers.js +7 -17
  42. package/lib/style/computed-style.js +257 -61
  43. package/package.json +1 -1
  44. package/test/animation-frames.test.js +67 -0
  45. package/test/canvas-style-snapshot.test.js +18 -0
  46. package/test/cdp-stream.test.js +96 -3
  47. package/test/compare-node-iterator.js +42 -0
  48. package/test/compositor-channel-ownership.test.js +194 -0
  49. package/test/compositor.test.js +27 -1
  50. package/test/computed-style-binding.test.js +49 -0
  51. package/test/computed-style-connectivity.test.js +13 -0
  52. package/test/computed-style.test.js +86 -0
  53. package/test/cookie-sync.test.js +82 -0
  54. package/test/document-base-cache.test.js +103 -0
  55. package/test/document-cookies.test.js +117 -0
  56. package/test/duplicate-style-keys.test.js +11 -0
  57. package/test/fixtures/animation-frames.js +56 -0
  58. package/test/fixtures/canvas-style-snapshot.js +81 -0
  59. package/test/fixtures/computed-style-connectivity.js +101 -0
  60. package/test/fixtures/computed-style-lazy.js +135 -0
  61. package/test/fixtures/duplicate-style-keys.js +97 -0
  62. package/test/fixtures/fake-renderer.js +12 -0
  63. package/test/fixtures/font-definition-invalidation.js +97 -0
  64. package/test/fixtures/font-metrics-invalidation.js +87 -0
  65. package/test/fixtures/fractional-text-scale.js +38 -0
  66. package/test/fixtures/full-style-identity.js +36 -0
  67. package/test/fixtures/hidden-style-engine.js +260 -0
  68. package/test/fixtures/hidden-style-reentry.js +232 -0
  69. package/test/fixtures/hidden-style-renderer.js +203 -0
  70. package/test/fixtures/layout-repaint.js +69 -0
  71. package/test/fixtures/media-presentation-continuation.js +141 -0
  72. package/test/fixtures/media-presentation-refill.js +113 -0
  73. package/test/fixtures/media-probe.js +148 -0
  74. package/test/fixtures/media-refill-cancel.js +79 -0
  75. package/test/fixtures/media-repaint.js +48 -0
  76. package/test/fixtures/media-resource-reselection.js +94 -0
  77. package/test/fixtures/media-source-attachment.js +136 -0
  78. package/test/fixtures/media-source-no-decoder.js +17 -0
  79. package/test/fixtures/media-track-refill.js +132 -0
  80. package/test/fixtures/media-webm-seek.js +51 -0
  81. package/test/fixtures/mutation-observer.js +201 -0
  82. package/test/fixtures/node-iterator-cases.js +59 -0
  83. package/test/fixtures/node-iterator-chromium.json +315 -0
  84. package/test/fixtures/observer-invalidation.js +105 -0
  85. package/test/fixtures/prepaint-scheduling.js +73 -0
  86. package/test/fixtures/resource-discovery.js +32 -0
  87. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  88. package/test/fixtures/style-propagation.js +26 -0
  89. package/test/fixtures/svg-node-reuse.js +83 -0
  90. package/test/fixtures/video-black-replay.js +140 -0
  91. package/test/fixtures/video-controls-invalidation.js +71 -0
  92. package/test/fixtures/viewport-bounds.js +148 -0
  93. package/test/font-loading.test.js +77 -0
  94. package/test/font-metrics-invalidation.test.js +60 -0
  95. package/test/fractional-text-scale.test.js +6 -0
  96. package/test/full-style-identity.test.js +191 -0
  97. package/test/hidden-style-lazy.test.js +31 -0
  98. package/test/hidden-style-reentry.test.js +20 -0
  99. package/test/idle-callback.test.js +89 -0
  100. package/test/idle-scheduler.test.js +148 -0
  101. package/test/inert-link.test.js +37 -0
  102. package/test/inline-style-cascade.test.js +63 -0
  103. package/test/layout-repaint-renderer.test.js +9 -0
  104. package/test/layout-repaint.test.js +222 -0
  105. package/test/media-presentation-continuation.test.js +14 -0
  106. package/test/media-presentation-refill.test.js +8 -0
  107. package/test/media-probe.test.js +16 -0
  108. package/test/media-repaint.test.js +8 -0
  109. package/test/media-source-attachment.test.js +34 -0
  110. package/test/media-track-refill.test.js +26 -0
  111. package/test/media-webm-seek.test.js +11 -0
  112. package/test/mp4-keyframes.test.js +36 -0
  113. package/test/mutation-observer.test.js +91 -0
  114. package/test/node-iterator.test.js +81 -0
  115. package/test/observer-invalidation.test.js +12 -0
  116. package/test/page-scripts.test.js +151 -1
  117. package/test/page-timers.test.js +67 -0
  118. package/test/paint-frame.test.js +18 -1
  119. package/test/pcm.test.js +63 -0
  120. package/test/prepaint-scheduling.test.js +13 -0
  121. package/test/pulse-client.test.js +165 -0
  122. package/test/raw-capture.test.js +34 -5
  123. package/test/renderer-host.test.js +51 -0
  124. package/test/resource-discovery.test.js +12 -0
  125. package/test/resource-elements.test.js +234 -0
  126. package/test/root-self-style.test.js +104 -0
  127. package/test/script-rewrite.test.js +35 -0
  128. package/test/seccomp.test.js +45 -0
  129. package/test/spare-renderer-scheduling.test.js +8 -0
  130. package/test/style-propagation.test.js +225 -0
  131. package/test/style-sheets.test.js +19 -0
  132. package/test/svg.test.js +45 -0
  133. package/test/text-layout.test.js +15 -0
  134. package/test/timers.test.js +58 -0
  135. package/test/video-black-replay.test.js +34 -0
  136. package/test/video-capture.test.js +170 -0
  137. package/test/video-channel.test.js +253 -0
  138. package/test/video-continuation.test.js +57 -0
  139. package/test/video-controls-invalidation.test.js +12 -0
  140. package/test/video-keyframe.test.js +104 -4
  141. package/test/video-mask.test.js +94 -0
  142. package/test/video-presentation.test.js +239 -1
  143. package/test/video-timing-presentation.test.js +115 -0
  144. package/test/viewport-bounds.test.js +13 -0
  145. package/test/webm-snapshots.test.js +372 -0
  146. package/test/window-resize.test.js +29 -0
@@ -3,6 +3,7 @@
3
3
  // every document and iframe.
4
4
  const encoder = new TextEncoder();
5
5
  const MAX_FONT_BYTES = 32 * 1024 * 1024;
6
+ const MAX_RANKED_FONT_CHOICES = 128;
6
7
 
7
8
  export function registerTypeface(CanvasKit, provider, typeface, family) {
8
9
  const bytes = encoder.encode(`${family}\0`);
@@ -37,7 +38,13 @@ function weightRank(face, wanted) {
37
38
  }
38
39
 
39
40
  function covers(face, point) {
40
- return (face.unicodeRanges ?? [[0, 0x10ffff]]).some(([from, to]) => from <= point && point <= to);
41
+ const ranges = face.unicodeRanges;
42
+ if (!ranges) return 0 <= point && point <= 0x10ffff;
43
+ for (let index = 0; index < ranges.length; index++) {
44
+ const range = ranges[index];
45
+ if (range[0] <= point && point <= range[1]) return true;
46
+ }
47
+ return false;
41
48
  }
42
49
 
43
50
  function stretchRank(face, wanted) {
@@ -61,20 +68,37 @@ export function neededFontFaces(faces, textRuns, availableFamilies = new Set())
61
68
  if (!families.has(key)) families.set(key, []);
62
69
  families.get(key).push(face);
63
70
  }
71
+ return neededFacesFromIndex(families, new Map(), textRuns, availableFamilies);
72
+ }
73
+
74
+ function orderedFontFaces(families, choices, name, weight, style, stretch) {
75
+ const definitions = families.get(name);
76
+ if (!definitions) return null;
77
+ const key = JSON.stringify([name, weight, style, stretch]);
78
+ let ordered = choices.get(key);
79
+ if (!ordered) {
80
+ // Ranking is independent of Unicode coverage; stable sorting preserves
81
+ // the CSS face order even when a character filters out other subsets.
82
+ ordered = ranked(definitions, weight, style, stretch);
83
+ if (choices.size >= MAX_RANKED_FONT_CHOICES) choices.clear();
84
+ choices.set(key, ordered);
85
+ }
86
+ return ordered;
87
+ }
88
+
89
+ function neededFacesFromIndex(families, choices, textRuns, availableFamilies) {
64
90
  const needed = new Set();
65
- const choices = new Map();
66
91
  for (const { text, style } of textRuns) {
67
92
  if (!text || !style) continue;
68
93
  const points = new Set([...text].map(character => character.codePointAt(0)));
69
94
  for (const family of style.fontFamily ?? []) {
70
- const definitions = families.get(family.toLowerCase());
71
- if (!definitions) {
72
- if (availableFamilies.has(family.toLowerCase())) break;
95
+ const name = family.toLowerCase();
96
+ const ordered = orderedFontFaces(families, choices, name,
97
+ style.fontWeight ?? 400, style.fontStyle ?? "normal", style.fontStretch ?? 100);
98
+ if (!ordered) {
99
+ if (availableFamilies.has(name)) break;
73
100
  continue;
74
101
  }
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
102
  for (const point of points) {
79
103
  const face = ordered.find(face => covers(face, point));
80
104
  if (face) { needed.add(face); points.delete(point); }
@@ -94,7 +118,9 @@ export class DocumentFonts {
94
118
  #error;
95
119
  #provider = null;
96
120
  #collection = null;
97
- #faces = [];
121
+ #families = new Map();
122
+ #faceKeys = new WeakMap();
123
+ #rankedFaces = new Map();
98
124
  #records = new Map();
99
125
  #sources = new Map();
100
126
  #pending = new Set();
@@ -119,7 +145,16 @@ export class DocumentFonts {
119
145
  get hasUnicodeRanges() { return this.#hasUnicodeRanges; }
120
146
 
121
147
  setFaces(faces) {
122
- this.#faces = faces;
148
+ this.#families.clear();
149
+ this.#faceKeys = new WeakMap();
150
+ this.#rankedFaces.clear();
151
+ for (const face of faces) {
152
+ const family = face.family.toLowerCase();
153
+ let definitions = this.#families.get(family);
154
+ if (!definitions) this.#families.set(family, definitions = []);
155
+ definitions.push(face);
156
+ this.#faceKeys.set(face, JSON.stringify(face));
157
+ }
123
158
  this.#hasUnicodeRanges = faces.some(face => face.unicodeRanges?.length
124
159
  && !(face.unicodeRanges.length === 1 && face.unicodeRanges[0][0] === 0 && face.unicodeRanges[0][1] === 0x10ffff));
125
160
  }
@@ -127,8 +162,8 @@ export class DocumentFonts {
127
162
  update(faces, textRuns) {
128
163
  if (this.#closed) return;
129
164
  this.setFaces(faces);
130
- for (const face of neededFontFaces(faces, textRuns, this.#availableFamilies)) {
131
- const key = JSON.stringify(face);
165
+ for (const face of neededFacesFromIndex(this.#families, this.#rankedFaces, textRuns, this.#availableFamilies)) {
166
+ const key = this.#faceKeys.get(face);
132
167
  if (this.#records.has(key)) continue;
133
168
  const record = { face, alias: null };
134
169
  this.#records.set(key, record);
@@ -141,9 +176,16 @@ export class DocumentFonts {
141
176
 
142
177
  /** Available faces in CSS fallback order; unicode-subset files share a family. */
143
178
  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);
179
+ const ordered = orderedFontFaces(this.#families, this.#rankedFaces, family.toLowerCase(), weight, style, stretch);
180
+ if (!ordered) return [];
181
+ const aliases = [];
182
+ for (const face of ordered) {
183
+ if (point !== null && !covers(face, point)) continue;
184
+ // Read the live record: a face may finish loading after it was ranked.
185
+ const alias = this.#records.get(this.#faceKeys.get(face))?.alias;
186
+ if (alias) aliases.push(alias);
187
+ }
188
+ return aliases;
147
189
  }
148
190
 
149
191
  async ready() {
@@ -222,6 +264,8 @@ export class DocumentFonts {
222
264
  for (const source of this.#sources.values()) source.then(typeface => typeface?.delete(), () => {});
223
265
  this.#sources.clear();
224
266
  this.#records.clear();
225
- this.#faces = [];
267
+ this.#families.clear();
268
+ this.#faceKeys = new WeakMap();
269
+ this.#rankedFaces.clear();
226
270
  }
227
271
  }
@@ -70,6 +70,8 @@ export class RendererHost {
70
70
  // The document this host asked its renderer to load: the trusted origin
71
71
  // for everything the renderer requests.
72
72
  #documentUrl = "about:blank";
73
+ #cookieSequence = 0;
74
+ #cookieRevision = 0;
73
75
  #child = null;
74
76
  #ready = null;
75
77
  #pending = new Map();
@@ -100,7 +102,7 @@ export class RendererHost {
100
102
  * onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null,
101
103
  * post: { method: "POST", body: Uint8Array, contentType: string, referrer: string } | null) => void,
102
104
  * onDownload?: (payload: { url: string, name: string, data: Uint8Array }) => void,
103
- * onFrame?: () => void,
105
+ * onFrame?: (frame?: { scrollOffset: { x: number, y: number } }) => void,
104
106
  * onLifecycle?: (name: "DOMContentLoaded" | "load", generation: number) => void,
105
107
  * compositor?: boolean,
106
108
  * fetch?: typeof fetch,
@@ -108,7 +110,7 @@ export class RendererHost {
108
110
  * }} options
109
111
  */
110
112
  constructor({
111
- entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, onDownload = null,
113
+ entry = DEFAULT_ENTRY, args = [], timeout = 0, onRestart = null, onNavigate = null, onDownload = null,
112
114
  fetch: fetchImpl = fetch,
113
115
  network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
114
116
  // A dedicated loopback socket works independently of the main thread on all platforms.
@@ -172,13 +174,11 @@ export class RendererHost {
172
174
  if (message?.type === "ready") {
173
175
  this.#sandbox = sanitizeSandboxReport(message.sandbox);
174
176
  this.#rejectStartup=null;
175
- clearTimeout(startupTimer);
176
177
  resolveReady();
177
178
  }
178
179
  else this.#handleMessage(child, message);
179
180
  },
180
181
  onExit: (_process, exitCode, signalCode) => {
181
- clearTimeout(startupTimer);
182
182
  this.#closeChannel(channel);
183
183
  this.#closeSockets(child);
184
184
  this.#closeAudio(child);
@@ -194,9 +194,10 @@ export class RendererHost {
194
194
  },
195
195
  });
196
196
  this.#child = child;
197
- const startupTimer = setTimeout(() => {
198
- this.#closeChannel(channel); child.kill(9); rejectReady(Error('Renderer startup timed out'));
199
- }, this.#timeout);
197
+ this.#cookieSequence = 0;
198
+ // Startup has no deadline: compiling WASM on a slow machine can take
199
+ // arbitrarily long. Exit and explicit close still reject the wait.
200
+ // Interactive method watchdogs start only after the renderer is ready.
200
201
  if (logging) {
201
202
  pipeToLog(child.stdout, `renderer ${child.pid}`, (chunk) => process.stdout.write(chunk));
202
203
  pipeToLog(child.stderr, `renderer ${child.pid}`, (chunk) => process.stderr.write(chunk));
@@ -270,16 +271,22 @@ export class RendererHost {
270
271
  catch(error) { channel.pending.get(id).reject(error); channel.pending.delete(id); }
271
272
  });
272
273
  if (channel.closed || this.#child !== child) throw Error("Renderer closed during capture");
273
- const sanitized = this.#captureResult(method, result, params);
274
+ const sanitized = this.#captureResult(method, result, params, true);
274
275
  if (method === "scroll") this.#pendingScroll = sanitized;
275
276
  return sanitized;
276
277
  }
277
278
 
278
- #captureResult(method,result,params) {
279
+ #captureResult(method,result,params,ownsPacket=false) {
279
280
  if (method === 'screenshot' && ['rgba','bgra'].includes(params[0]?.format)) {
280
- if (result?.format !== params[0].format) throw Error('Raw screenshot format mismatch');
281
- this.#rawFrames ??= new RawFrames();
282
- return this.#rawFrames.write(result.pixels,result.width,result.height,result.format);
281
+ try {
282
+ if (result?.format !== params[0].format) throw Error('Raw screenshot format mismatch');
283
+ this.#rawFrames ??= new RawFrames();
284
+ return this.#rawFrames.write(result.pixels,result.width,result.height,result.format);
285
+ } finally {
286
+ // PacketReader owns a standalone allocation; write() above is sync.
287
+ // Main-thread IPC fallback buffers have a different ownership contract.
288
+ if (ownsPacket && result?.pixels instanceof Uint8Array) result.pixels.buffer.transfer(0);
289
+ }
283
290
  }
284
291
  return sanitizeCompositorResult(method,result);
285
292
  }
@@ -287,7 +294,12 @@ export class RendererHost {
287
294
  #handleCompositorMessage(channel, message, pixels) {
288
295
  if (channel.closed || typeof message !== "object" || message === null) return;
289
296
  if (message.type === "frame") {
290
- if (this.#channel === channel) this.#onFrame?.();
297
+ if (this.#channel === channel) {
298
+ const offset = message.scrollOffset;
299
+ const frame = Number.isFinite(offset?.x) && Number.isFinite(offset?.y)
300
+ ? { scrollOffset: { x: offset.x, y: offset.y } } : undefined;
301
+ this.#onFrame?.(frame);
302
+ }
291
303
  } else if (Number.isInteger(message.id) && channel.pending.has(message.id)) {
292
304
  const pending = channel.pending.get(message.id);
293
305
  channel.pending.delete(message.id);
@@ -329,9 +341,17 @@ export class RendererHost {
329
341
  const child = this.#child;
330
342
  if (method === 'click' || method === 'press') this.#clipboardUntil = Date.now()+5000;
331
343
  if (method === 'loadDocument') this.#clipboardUntil = 0;
344
+ if (method === 'loadDocument' && params[0]) {
345
+ params = [{ ...params[0], cookieState: this.#cookieState() }, ...params.slice(1)];
346
+ }
332
347
  const id = this.#nextId++;
333
348
  return new Promise((resolve, reject) => {
334
- const timer = setTimeout(() => {
349
+ // Navigation, including its final fragment positioning, can include
350
+ // expensive layout/script work. Close/exit cancels it, not a time limit.
351
+ const navigating = method === 'loadDocument' || method === 'showFragment';
352
+ // A watchdog is opt-in: elapsed time alone cannot distinguish a slow
353
+ // renderer from a hung one, including title queries during page loading.
354
+ const timer = navigating || !(timeout > 0) ? null : setTimeout(() => {
335
355
  this.#pending.delete(id);
336
356
  // Watchdog: a renderer that misses its deadline is killed outright.
337
357
  this.#child = null;
@@ -457,7 +477,12 @@ export class RendererHost {
457
477
  } else if (message.type === "lifecycle" && LIFECYCLE_NAMES.has(message.name) && Number.isInteger(message.generation)) {
458
478
  this.#onLifecycle?.(message.name, message.generation);
459
479
  } else if (message.type === "cookie" && typeof message.value === "string" && message.value.length <= 4096) {
460
- this.#network.setDocumentCookie(this.#documentUrl, message.value);
480
+ const url = typeof message.url === "string" ? message.url : this.#documentUrl;
481
+ if (sameOrigin(url, this.#documentUrl)) this.#network.setDocumentCookie(url, message.value);
482
+ if (Number.isSafeInteger(message.sequence) && message.sequence > this.#cookieSequence) {
483
+ this.#cookieSequence = message.sequence;
484
+ }
485
+ child.send({ type: "cookie-state", state: this.#cookieState() });
461
486
  } else if (message.type === "navigate" && typeof message.url === "string") {
462
487
  // A page asked to navigate (location.href = ...): validate before obeying.
463
488
  let target;
@@ -652,7 +677,7 @@ export class RendererHost {
652
677
  url: event.url || target.href,
653
678
  contentType: event.contentType ?? "",
654
679
  contentLength: event.contentLength ?? null,
655
- documentCookie: this.#network.documentCookie(this.#documentUrl),
680
+ documentCookie: this.#network.documentCookie(this.#documentUrl), cookieState: this.#cookieState(),
656
681
  },
657
682
  });
658
683
  } else if (event.type === "chunk" && event.data instanceof Uint8Array) {
@@ -683,7 +708,7 @@ export class RendererHost {
683
708
  url: response.url || target.href,
684
709
  contentType: response.contentType,
685
710
  body: response.body,
686
- documentCookie: this.#network.documentCookie(this.#documentUrl),
711
+ documentCookie: this.#network.documentCookie(this.#documentUrl), cookieState: this.#cookieState(),
687
712
  },
688
713
  });
689
714
  } catch (error) {
@@ -711,7 +736,7 @@ export class RendererHost {
711
736
  if (event.type === "head") {
712
737
  streamHead = true;
713
738
  child.send({ type: "page-fetch-head", id, result: {
714
- ...event.result, documentCookie: this.#network.documentCookie(this.#documentUrl),
739
+ ...event.result, documentCookie: this.#network.documentCookie(this.#documentUrl), cookieState: this.#cookieState(),
715
740
  } });
716
741
  }
717
742
  else if (event.type === "chunk") child.send({ type: "page-fetch-chunk", id, data: event.data });
@@ -724,7 +749,7 @@ export class RendererHost {
724
749
  }
725
750
  const { rawHeaders, ...result } = response;
726
751
  logFetch(sanitized, `${response.status} ${response.body?.byteLength ?? 0}B`, started);
727
- reply({ result: { ...result, documentCookie: this.#network.documentCookie(this.#documentUrl) } });
752
+ reply({ result: { ...result, documentCookie: this.#network.documentCookie(this.#documentUrl), cookieState: this.#cookieState() } });
728
753
  } catch (error) {
729
754
  logFetch(sanitized ?? request, `failed: ${error?.message ?? error}`, started);
730
755
  if (streamHead && this.#child === child) {
@@ -734,6 +759,10 @@ export class RendererHost {
734
759
  if (this.#pageFetches.get(id) === entry) this.#pageFetches.delete(id);
735
760
  }
736
761
  }
762
+ #cookieState() {
763
+ return { revision: ++this.#cookieRevision, acknowledged: this.#cookieSequence,
764
+ cookies: this.#network.cookies.scriptSnapshot(this.#documentUrl) };
765
+ }
737
766
  }
738
767
 
739
768
  /** With BUNINU_LOG, one line per page fetch()/XMLHttpRequest: method, URL (shortened), outcome, time. */
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Cooperative idle work shares a short period, rather than granting every
3
+ * queued callback a fresh budget. Dispatch one callback per host task so
4
+ * ordinary timers and input can run between callbacks. A callback registered
5
+ * during a period joins the next one; positive timeouts have their own task.
6
+ * Clock/timer injection keeps the fairness and lifetime tests deterministic.
7
+ */
8
+ export function createIdleCallbacks({ now, setTimer, clearTimer, invoke }) {
9
+ const callbacks = new Map();
10
+ let runnable = [], next = 0, deadline = 0, timer = null;
11
+ let running = false, disposed = false;
12
+
13
+ function queue() {
14
+ if (disposed) return;
15
+ if (!callbacks.size) {
16
+ if (timer !== null) clearTimer(timer);
17
+ timer = null;
18
+ runnable = []; next = 0;
19
+ return;
20
+ }
21
+ if (running || timer !== null) return;
22
+ // A nested request must not start a new period before this one ends.
23
+ const delay = next >= runnable.length ? Math.max(1, deadline - now()) : 1;
24
+ timer = setTimer(dispatch, delay);
25
+ }
26
+
27
+ function remove(record) {
28
+ if (callbacks.get(record.id) !== record) return false;
29
+ callbacks.delete(record.id);
30
+ if (record.timer !== null) clearTimer(record.timer);
31
+ record.timer = null;
32
+ return true;
33
+ }
34
+
35
+ function deliver(record, didTimeout, until) {
36
+ if (!remove(record)) return;
37
+ invoke(record.callback, {
38
+ get didTimeout() { return didTimeout; },
39
+ timeRemaining: () => Math.max(0, until - now()),
40
+ });
41
+ }
42
+
43
+ function queueTimeout(record) {
44
+ // Web IDL permits unsigned-long timeouts, while host timers only accept a
45
+ // signed 32-bit delay. Chunk longer waits instead of overflowing to 1ms.
46
+ record.timer = setTimer(() => {
47
+ record.timer = null;
48
+ if (disposed || callbacks.get(record.id) !== record) return;
49
+ if (now() < record.expires) { queueTimeout(record); return; }
50
+ running = true;
51
+ try { deliver(record, true, now()); }
52
+ finally {
53
+ running = false;
54
+ queue();
55
+ }
56
+ }, Math.min(2147483647, Math.max(1, record.expires - now())));
57
+ }
58
+
59
+ function dispatch() {
60
+ timer = null;
61
+ if (disposed || !callbacks.size) return;
62
+ running = true;
63
+ try {
64
+ const at = now();
65
+ if (at >= deadline) {
66
+ runnable = [...callbacks.values()];
67
+ next = 0;
68
+ deadline = at + 10;
69
+ }
70
+ while (next < runnable.length && callbacks.get(runnable[next].id) !== runnable[next]) next++;
71
+ if (next < runnable.length) {
72
+ const record = runnable[next++], at = now();
73
+ const didTimeout = at >= record.expires;
74
+ deliver(record, didTimeout, didTimeout ? at : deadline);
75
+ }
76
+ } finally {
77
+ running = false;
78
+ queue();
79
+ }
80
+ }
81
+
82
+ return {
83
+ request(id, callback, timeout) {
84
+ if (disposed) return id;
85
+ const record = { id, callback, expires: timeout > 0 ? now() + timeout : Infinity, timer: null };
86
+ callbacks.set(id, record);
87
+ if (timeout > 0) queueTimeout(record);
88
+ queue();
89
+ return id;
90
+ },
91
+ cancel(id) {
92
+ const record = callbacks.get(id);
93
+ if (record) remove(record);
94
+ if (!callbacks.size) {
95
+ if (timer !== null) clearTimer(timer);
96
+ timer = null;
97
+ runnable = []; next = 0;
98
+ }
99
+ },
100
+ dispose() {
101
+ disposed = true;
102
+ if (timer !== null) clearTimer(timer);
103
+ timer = null;
104
+ for (const record of callbacks.values()) if (record.timer !== null) clearTimer(record.timer);
105
+ callbacks.clear();
106
+ runnable = []; next = 0;
107
+ },
108
+ };
109
+ }
@@ -265,7 +265,7 @@ const renderer = await createPageRenderer({
265
265
  clipboardWriteInput:text=>clipboardWrite(text,'clipboard-write-input'),
266
266
  webSockets,
267
267
  audio,
268
- setCookie: (value) => process.send({ type: "cookie", value }),
268
+ setCookie: (value, url, sequence) => process.send({ type: "cookie", value, url, sequence }),
269
269
  reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
270
270
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
271
271
  trace: logging ? (text) => report(`timing: ${text}`) : null,
@@ -352,6 +352,10 @@ async function warmUp() {
352
352
  }
353
353
 
354
354
  process.on("message", async (message) => {
355
+ if (message?.type === "cookie-state") {
356
+ renderer.updateCookies(message.state);
357
+ return;
358
+ }
355
359
  if(message?.type==='clipboard-result') {
356
360
  const pending=clipboardWrites.get(message.id);if(!pending)return;
357
361
  clipboardWrites.delete(message.id);clearTimeout(pending.timer);
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createBytePipe } from "../media/byte-pipe.js";
16
16
  import { createSourceSnapshots } from "../media/source-snapshots.js";
17
+ import { createWebMAudioSnapshots } from "../media/webm-snapshots.js";
17
18
  import { createWebMStream, ebmlHeader } from "../media/webm-stream.js";
18
19
 
19
20
  const BRAND = Symbol("MediaSource");
@@ -444,6 +445,8 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
444
445
  this._webm = {};
445
446
  this._webmCovered = new Map();
446
447
  this._webmStream = /^(video|audio)\/webm\b/i.test(this._mime) ? createWebMStream() : null;
448
+ this._webmSnapshots = /^audio\/webm\b/i.test(this._mime) ? createWebMAudioSnapshots() : null;
449
+ if (this._webmSnapshots) this._pipe.snapshotAt = time => this._webmSnapshots.at(time, () => this._pipe.bytes());
447
450
  this._hasSegmentTimeline = false;
448
451
  this._hasMediaSegment = false;
449
452
  this._appendWindowStart = 0;
@@ -589,6 +592,7 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
589
592
  addCovered(this._covered, start, end);
590
593
  }
591
594
  }
595
+ this._webmSnapshots?.append({ id, bytes, cluster });
592
596
  if (cluster && this._webmCluster !== cluster.token) {
593
597
  for (const prefix of cluster.prefix) this._pipe.append(prefix);
594
598
  this._webmCluster = cluster.token;