buninu-browser 0.5.1 → 0.7.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 (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,282 @@
1
+ import { ebmlHeader } from './webm-stream.js';
2
+
3
+ const CLUSTER = 0x1f43b675;
4
+ const MAX_RECORDS = 250_000;
5
+ const OPUS_RATE = 48_000;
6
+ const join = parts => parts.length === 1 ? parts[0] : new Uint8Array(Bun.concatArrayBuffers(parts));
7
+
8
+ // Decode-time snapshots for a single Opus track. A PCM-only decoder cannot
9
+ // represent holes in a WebM timeline: concatenating 0..2s and 6..8s produces
10
+ // four seconds of samples, not eight. Keep each contiguous run independently
11
+ // decodable, without changing any packet or inventing samples for the hole.
12
+ export function createWebMAudioSnapshots() {
13
+ const headers = [], records = [];
14
+ let scale = 1_000_000, track = null, valid = true, runs = null, serial = 0, ordered = true;
15
+ let preroll = 1, cached = null, initHeaders = null;
16
+ const uint = (bytes, from, size) => {
17
+ if (size < 1 || size > 6 || from + size > bytes.length) throw Error('invalid integer');
18
+ let value = 0;
19
+ for (let i = 0; i < size; i++) value = value * 256 + bytes[from + i];
20
+ return value;
21
+ };
22
+ const children = (bytes, from, end) => {
23
+ const result = [];
24
+ for (let at = from; at < end;) {
25
+ if (result.length >= 65_536) throw Error('too many elements');
26
+ const head = ebmlHeader(bytes, at);
27
+ if (!head || !Number.isFinite(head.size) || head.size > end - at - head.length) throw Error('incomplete element');
28
+ const data = at + head.length;
29
+ result.push({ id: head.id, start: at, data, size: head.size, end: data + head.size });
30
+ at = data + head.size;
31
+ }
32
+ return result;
33
+ };
34
+ const payload = bytes => {
35
+ const head = ebmlHeader(bytes);
36
+ if (!head || head.size !== bytes.length - head.length) throw Error('incomplete element');
37
+ return children(bytes, head.length, bytes.length);
38
+ };
39
+ const opusSamples = (bytes, at, end) => {
40
+ if (at >= end) throw Error('empty packet');
41
+ const toc = bytes[at], config = toc >> 3;
42
+ const frame = config >= 16 ? 2.5 * 2 ** (config & 3)
43
+ : config >= 12 ? 10 * 2 ** (config & 1) : [10, 20, 40, 60][config & 3];
44
+ const code = toc & 3;
45
+ if (code === 3 && at + 1 >= end) throw Error('incomplete packet');
46
+ const count = code === 0 ? 1 : code === 3 ? bytes[at + 1] & 63 : 2;
47
+ const samples = frame * count * 48;
48
+ if (!count || samples > 5760) throw Error('invalid Opus duration');
49
+ return samples;
50
+ };
51
+ const blockRange = (bytes, block, origin) => {
52
+ const from = block.data;
53
+ if (!bytes[from]) throw Error('invalid track');
54
+ let length = 1, marker = 0x80;
55
+ while (!(bytes[from] & marker)) { length++; marker >>= 1; }
56
+ if (length > 6 || from + length + 3 > block.end) throw Error('invalid block');
57
+ let number = bytes[from] & (marker - 1);
58
+ for (let i = 1; i < length; i++) number = number * 256 + bytes[from + i];
59
+ if (number !== track) throw Error('another track');
60
+ const at = from + length;
61
+ // Lacing requires a separate packet table; leave that format on the
62
+ // ordinary decoder path until its timing can be proved as well.
63
+ if (bytes[at + 2] & 6) throw Error('laced block');
64
+ const relative = new DataView(bytes.buffer, bytes.byteOffset + at, 2).getInt16(0);
65
+ const start = (origin + relative) * scale / 1e9;
66
+ if (!Number.isFinite(start) || start < 0) throw Error('invalid timestamp');
67
+ const samples = opusSamples(bytes, at + 3, block.end);
68
+ return { start, end: start + samples / OPUS_RATE, samples };
69
+ };
70
+ const record = (element) => {
71
+ const { bytes, cluster } = element;
72
+ const items = cluster ? children(bytes, 0, bytes.length) : payload(bytes);
73
+ if (!cluster) {
74
+ // A finite Cluster can contain many seconds of Opus. Index its complete
75
+ // packets just like a live Cluster, so reopening keeps the requested
76
+ // preroll instead of decoding everything from the Cluster's beginning.
77
+ // Only the size field changes; timestamps and encoded Blocks stay intact.
78
+ const head = ebmlHeader(bytes), header = bytes.slice(0, head.length);
79
+ const width = head.length - 4; // Cluster ID is always four bytes.
80
+ header[4] = (1 << (9 - width)) - 1;
81
+ header.fill(0xff, 5);
82
+ const prefix = [header], blocks = [];
83
+ let time = null;
84
+ for (const item of items) {
85
+ if (item.id === 0xa3 || item.id === 0xa0) {
86
+ blocks.push(bytes.subarray(item.start, item.end));
87
+ } else if (!blocks.length && (item.id === 0xec || (item.id === 0xe7 && time == null))) {
88
+ if (item.id === 0xe7) time = uint(bytes, item.data, item.size);
89
+ prefix.push(bytes.subarray(item.start, item.end));
90
+ } else {
91
+ // CRC/Position/PrevSize describe the original complete Cluster;
92
+ // encrypted or unknown metadata cannot safely survive this cut.
93
+ throw Error('unsupported finite cluster metadata');
94
+ }
95
+ }
96
+ if (time == null) throw Error('missing cluster time');
97
+ // This prefix cannot grow, unlike a live Cluster's mutable metadata.
98
+ // Share it across packet records instead of copying metadata N times.
99
+ Object.freeze(prefix);
100
+ const token = {};
101
+ for (const block of blocks) record({ bytes: block, cluster: { token, time, prefix, sharedPrefix: true } });
102
+ return;
103
+ }
104
+ let origin = cluster?.time;
105
+ if (origin == null) {
106
+ const time = items.find(item => item.id === 0xe7);
107
+ if (!time) throw Error('missing cluster time');
108
+ origin = uint(bytes, time.data, time.size);
109
+ }
110
+ let start = null, end = null, samples = 0, padded = false;
111
+ for (const item of items) {
112
+ let block = item, padding = 0;
113
+ if (item.id === 0xa0) {
114
+ const group = children(bytes, item.data, item.end);
115
+ block = group.find(child => child.id === 0xa1);
116
+ const discard = group.find(child => child.id === 0x75a2);
117
+ if (discard) {
118
+ if (bytes[discard.data] & 0x80) throw Error('negative discard padding');
119
+ padding = uint(bytes, discard.data, discard.size) / 1e9;
120
+ }
121
+ } else if (item.id !== 0xa3) continue;
122
+ if (!block) throw Error('missing block');
123
+ const range = blockRange(bytes, block, origin);
124
+ range.end -= padding;
125
+ if (range.end <= range.start) throw Error('invalid padding');
126
+ if (end != null && Math.abs(range.start - end) > scale / 1e9 + 1e-6) throw Error('discontinuous cluster');
127
+ start ??= range.start;
128
+ end = range.end;
129
+ samples += range.samples;
130
+ padded ||= padding > 0;
131
+ }
132
+ if (start == null) return;
133
+ if (records.length >= MAX_RECORDS) throw Error('too many blocks');
134
+ if ((cluster?.prefix.length ?? 0) > 65_536) throw Error('too much cluster metadata');
135
+ initHeaders ??= headers.slice();
136
+ const inOrder = !records.length || start >= records.at(-1).start;
137
+ if (!inOrder) ordered = false;
138
+ const entry = { id: ++serial, start, end, samples, padded, bytes, token: cluster?.token,
139
+ prefix: cluster && (cluster.sharedPrefix ? cluster.prefix : cluster.prefix.slice()) };
140
+ records.push(entry);
141
+ // Ordered appends update only the final run, even for live Clusters which
142
+ // emit one record per packet. An out-of-order append rebuilds once on use.
143
+ if (runs && inOrder && start >= runs.at(-1).end - scale / 1e9 - 1e-6) addEntry(runs, entry);
144
+ else runs = null;
145
+ cached = null;
146
+ };
147
+ const addEntry = (result, entry) => {
148
+ let run = result.at(-1);
149
+ const tolerance = scale / 1e9 + 1e-6;
150
+ if (run && entry.start < run.end - tolerance) throw Error('overlapping clusters');
151
+ if (!run || entry.start > run.end + tolerance) {
152
+ run = { id: entry.id, startTime: entry.start, end: entry.end,
153
+ startSample: Math.round(entry.start * OPUS_RATE), headers: initHeaders, entries: [], samples: 0, exact: true };
154
+ result.push(run);
155
+ }
156
+ // Packet sample counts are exact; millisecond Block timestamps are not.
157
+ // Reopening reapplies Opus pre-skip, cancelling the initial decoder's
158
+ // pre-skip, so the new origin advances by encoded samples, not rounded PTS.
159
+ const timestamp = run.startTime + run.samples / OPUS_RATE;
160
+ // Round the initial cursor once, as openAudioDecoder does, then add integer
161
+ // samples. Rounding (fractional origin + elapsed time) can move a sample.
162
+ entry.origin = (run.startSample + run.samples) / OPUS_RATE;
163
+ entry.seekable = run.exact && Math.abs(entry.start - timestamp) <= tolerance;
164
+ run.samples += entry.samples;
165
+ run.exact &&= entry.seekable && !entry.padded && Number.isSafeInteger(run.samples);
166
+ run.entries.push(entry);
167
+ run.end = entry.end;
168
+ };
169
+ const buildRuns = () => {
170
+ const sorted = ordered ? records : records.slice().sort((a, b) => a.start - b.start);
171
+ const result = [];
172
+ for (const entry of sorted) addEntry(result, entry);
173
+ return result;
174
+ };
175
+ return {
176
+ // Called only after SourceBuffer's existing retry/deduplication checks.
177
+ append(element) {
178
+ if (!valid) return;
179
+ try {
180
+ const { id, bytes } = element;
181
+ if (id === CLUSTER) {
182
+ if (track == null) throw Error('missing supported track');
183
+ record(element);
184
+ return;
185
+ }
186
+ if (records.length) {
187
+ // Metadata at EOF does not belong in a run; a new initialization
188
+ // needs a separate codec epoch, which this small index declines.
189
+ if ([0x1a45dfa3, 0x18538067, 0x1549a966, 0x1654ae6b].includes(id)) throw Error('new initialization');
190
+ return;
191
+ }
192
+ if (id === 0x1549a966) {
193
+ const time = payload(bytes).find(item => item.id === 0x2ad7b1);
194
+ if (time) scale = uint(bytes, time.data, time.size);
195
+ if (!(scale > 0)) throw Error('invalid timestamp scale');
196
+ } else if (id === 0x1654ae6b) {
197
+ const tracks = payload(bytes).filter(item => item.id === 0xae);
198
+ if (tracks.length !== 1 || track != null) throw Error('multiple tracks');
199
+ const fields = children(bytes, tracks[0].data, tracks[0].end);
200
+ const field = id => fields.find(item => item.id === id);
201
+ if (field(0x6d80)) throw Error('encoded or encrypted track');
202
+ const number = field(0xd7), type = field(0x83), codec = field(0x86), priv = field(0x63a2), delay = field(0x56aa);
203
+ if (!number || !type || !codec || !priv || !delay || uint(bytes, type.data, type.size) !== 2
204
+ || new TextDecoder().decode(bytes.subarray(codec.data, codec.end)) !== 'A_OPUS'
205
+ || priv.size < 19 || new TextDecoder().decode(bytes.subarray(priv.data, priv.data + 8)) !== 'OpusHead') throw Error('unsupported audio track');
206
+ const skip = bytes[priv.data + 10] | (bytes[priv.data + 11] << 8);
207
+ // Opening a new run reapplies Opus pre-skip. CodecDelay must match
208
+ // that skip, making the first decoded sample's origin its original
209
+ // Block timestamp; later cuts advance by exact packet sample counts.
210
+ if (Math.abs(uint(bytes, delay.data, delay.size) - skip * 1e9 / 48000) > 1) throw Error('inconsistent codec delay');
211
+ const seek = field(0x56bb);
212
+ // Matroska's Opus SeekPreRoll recommends 80ms. Keep at least one
213
+ // second to restore decoder state before returning audible PCM.
214
+ if (seek) preroll = Math.max(preroll, uint(bytes, seek.data, seek.size) / 1e9);
215
+ track = uint(bytes, number.data, number.size);
216
+ if (!(track > 0)) throw Error('invalid track');
217
+ }
218
+ if (headers.length >= 65_536) throw Error('too many headers');
219
+ headers.push(bytes);
220
+ } catch {
221
+ valid = false;
222
+ headers.length = records.length = 0;
223
+ runs = cached = initHeaders = null;
224
+ }
225
+ },
226
+ at(seconds, fallback) {
227
+ const ordinary = () => ({ id: 0, startTime: 0, endTime: Infinity, bytes: fallback() });
228
+ if (!valid || !records.length) return ordinary();
229
+ try {
230
+ runs ??= buildRuns();
231
+ let low = 0, high = runs.length;
232
+ while (low + 1 < high) {
233
+ const mid = (low + high) >>> 1;
234
+ if (runs[mid].startTime <= seconds + 1e-6) low = mid;
235
+ else high = mid;
236
+ }
237
+ const selected = runs[low], endTime = runs[low + 1]?.startTime ?? Infinity;
238
+ const entries = selected.entries, count = entries.length;
239
+ let first = 0;
240
+ high = count;
241
+ const latest = seconds - preroll;
242
+ while (first + 1 < high) {
243
+ const mid = (first + high) >>> 1;
244
+ if (entries[mid].origin <= latest) first = mid;
245
+ else high = mid;
246
+ }
247
+ if (!entries[first].seekable) first = 0;
248
+ if (!first && runs.length === 1 && ordered) {
249
+ // Keep the initial full-file path, pre-skip and trailing metadata.
250
+ return { id: selected.id, startTime: selected.startTime, endTime, bytes: fallback() };
251
+ }
252
+ if (cached?.run === selected && cached.first === first && cached.count === count && cached.endTime === endTime) return cached.snapshot;
253
+ let bytes;
254
+ const snapshot = { id: selected.id, startTime: first ? entries[first].origin : selected.startTime, endTime,
255
+ get bytes() {
256
+ if (!bytes) {
257
+ const parts = selected.headers.slice();
258
+ let token = null;
259
+ for (let i = first; i < count; i++) {
260
+ const entry = entries[i];
261
+ if (entry.token && entry.token !== token) for (const part of entry.prefix) parts.push(part);
262
+ parts.push(entry.bytes);
263
+ token = entry.token;
264
+ }
265
+ bytes = join(parts);
266
+ }
267
+ return bytes;
268
+ } };
269
+ // Metadata notifications only read id/endTime; do not copy the audio
270
+ // there. The getter's fixed record count keeps its snapshot immutable
271
+ // if more packets append before decoding. Cache only the latest view.
272
+ cached = { run: selected, first, count, endTime, snapshot };
273
+ return snapshot;
274
+ } catch {
275
+ valid = false;
276
+ headers.length = records.length = 0;
277
+ runs = cached = initHeaders = null;
278
+ return ordinary();
279
+ }
280
+ },
281
+ };
282
+ }
@@ -16,10 +16,10 @@ export class CookieJar {
16
16
  }
17
17
 
18
18
  /** Stores Set-Cookie header values received for `url`. `fromScript` rejects HttpOnly (document.cookie). */
19
- store(url, setCookies, { fromScript = false } = {}) {
19
+ store(url, setCookies, { fromScript = false, receivedAt = this.#now() } = {}) {
20
20
  const target = new URL(url);
21
21
  for (const header of setCookies) {
22
- const cookie = parseSetCookie(header, target, this.#now());
22
+ const cookie = parseSetCookie(header, target, receivedAt);
23
23
  if (!cookie) continue;
24
24
  if (fromScript && cookie.httpOnly) continue;
25
25
  if (cookie.secure && target.protocol !== "https:" && !isLocalhost(target.hostname)) continue;
@@ -69,6 +69,22 @@ export class CookieJar {
69
69
  return this.header(url, { includeHttpOnly: false });
70
70
  }
71
71
 
72
+ /** Non-HttpOnly records a renderer of this origin may mirror, including all paths. */
73
+ scriptSnapshot(url) {
74
+ const target = new URL(url), now = this.#now();
75
+ return this.#cookies.filter(cookie => !cookie.httpOnly && cookie.expires > now
76
+ && domainMatches(cookie, target.hostname)
77
+ && (!cookie.secure || target.protocol === "https:" || isLocalhost(target.hostname)))
78
+ .map(cookie => ({ ...cookie }));
79
+ }
80
+
81
+ /** Replace a renderer's script-only mirror with a trusted controller snapshot. */
82
+ replaceScriptSnapshot(cookies) {
83
+ const now = this.#now();
84
+ this.#cookies = cookies.filter(cookie => !cookie.httpOnly && cookie.expires > now)
85
+ .map(cookie => ({ ...cookie, httpOnly: false }));
86
+ }
87
+
72
88
  get size() {
73
89
  return this.#cookies.length;
74
90
  }
@@ -405,7 +405,8 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
405
405
  if (!style) continue;
406
406
  if (style.position === "fixed") fixed = true;
407
407
  opacity *= style.opacity ?? 1;
408
- clips.push([node, shapeClip(node)]);
408
+ const shape = shapeClip(node);
409
+ if (shape) clips.push([node, shape]);
409
410
  const ownContent = kind === "text" || node !== owner;
410
411
  if (ownContent && escaping !== "fixed") {
411
412
  const positioned = style.position && style.position !== "static";
@@ -415,7 +416,7 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
415
416
  if (radii) {
416
417
  radii = insetRadii(radii, style.borderWidths ?? [0, 0, 0, 0]);
417
418
  }
418
- clips.push([node, clip, radii]);
419
+ if (clip) clips.push([node, clip, radii]);
419
420
  if (isScroller(style) && boxes.has(node.id)) {
420
421
  if (!scrolledBy.size) outOfFlow = positionedOut;
421
422
  scrolledBy.add(node);
@@ -435,7 +436,7 @@ function paintEffects(layout, tree, scrollOffsetOf = null) {
435
436
  const node = chain[index];
436
437
  const own = ownTransform(node);
437
438
  if (own) transform = transform ? composeTransforms(transform, own) : own;
438
- transforms.set(node, transform);
439
+ if (transform) transforms.set(node, transform);
439
440
  const offset = scrolledBy.has(node) ? scrollOffsetOf?.(node.domNode) : null;
440
441
  if (offset && (offset.x || offset.y)) {
441
442
  const scrolled = { a: 1, d: 1, e: 0 - offset.x, f: 0 - offset.y };
@@ -117,11 +117,18 @@ export function backgroundBytes(color) {
117
117
  }
118
118
 
119
119
  /** Raw screenshot payload, owned independently of a recyclable raster tile. */
120
- export function rawFrame(pixels, width, height, format) {
121
- if (!['rgba', 'bgra'].includes(format) || width * height > 16777216) throw Error('Invalid raw screenshot');
122
- const data = new Uint8Array(pixels);
123
- if (format === 'bgra') {
124
- for (let i=0; i<data.length; i+=4) { const red=data[i]; data[i]=data[i+2]; data[i+2]=red; }
120
+ export function rawFrame(pixels, width, height, format, { takeOwnership = false, inputFormat = "rgba" } = {}) {
121
+ if (!['rgba', 'bgra'].includes(format) || !['rgba', 'bgra'].includes(inputFormat) || width * height > 16777216) throw Error('Invalid raw screenshot');
122
+ // Only private composition/crop snapshots may be consumed in place. The
123
+ // default keeps raw captures independent of immutable/recyclable tiles.
124
+ const data = takeOwnership && pixels.buffer instanceof ArrayBuffer && pixels.byteOffset % 4 === 0 ? pixels : new Uint8Array(pixels);
125
+ if (format !== inputFormat) {
126
+ // Swap R/B as packed pixels, preserving G and alpha (Bun targets are little-endian).
127
+ const words = new Uint32Array(data.buffer, data.byteOffset, Math.floor(data.byteLength / 4));
128
+ for (let i = 0; i < words.length; i++) {
129
+ const p = words[i];
130
+ words[i] = (p & 0xff00ff00) | ((p & 0xff) << 16) | ((p >>> 16) & 0xff);
131
+ }
125
132
  }
126
133
  return {format, width, height, pixels:data};
127
134
  }
@@ -315,6 +315,9 @@ function sameNodeProperties(previous, type, properties) {
315
315
  for (const [key, value] of Object.entries(properties)) {
316
316
  const old = previous[key];
317
317
  if (old === value) continue;
318
+ // Extraction makes fresh immutable SVG values even when their paint is
319
+ // unchanged. Retain the node so its ancestors can reuse layout results.
320
+ if (key === "svg" && Bun.deepEquals(old, value, true)) continue;
318
321
  if (key !== "children" || old.length !== value.length) return false;
319
322
  for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
320
323
  }
@@ -0,0 +1,76 @@
1
+ import { drainMicrotasks } from "bun:jsc";
2
+ import { createTimers } from "./timers.js";
3
+
4
+ // Older Bun checkpoints also dispatched queued event-loop tasks. Although
5
+ // 1.4.3 stable fixed this, Bun.version hides the suffix of unsafe 1.4.3
6
+ // canaries. Require the later 1.4.4 branch and keep unknown channels on timers.
7
+ export function supportsFrameCheckpoint(version = Bun.version) {
8
+ const match = typeof version === "string"
9
+ && /^((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:-canary(?:\.(?:0|[1-9]\d*))?)?$/.exec(version);
10
+ return !!match && Bun.semver.order(match[1], "1.4.4") >= 0;
11
+ }
12
+
13
+ /** One callback collection per document, with a checkpoint between callbacks. */
14
+ export function createAnimationFrames({ invoke, now, checkpoint = drainMicrotasks,
15
+ frameTimers = null }) {
16
+ let nextWakeId = 1;
17
+ // A private timer collection keeps frame handles separate from Window timers.
18
+ // timerTask=false also prevents a frame inheriting a timer's nesting clamp.
19
+ const timers = frameTimers ?? createTimers({ invoke: callback => callback(), nextId: () => nextWakeId++ });
20
+ const callbacks = new Map();
21
+ let nextHandle = 0, wake = null, running = false, disposed = false;
22
+
23
+ function arm(delay) {
24
+ if (!disposed && wake === null && !running && callbacks.size) {
25
+ wake = timers.schedule(deliver, delay, false, false);
26
+ }
27
+ }
28
+ function deliver() {
29
+ wake = null;
30
+ if (disposed) return;
31
+ running = true;
32
+ const timestamp = now(), handles = [...callbacks.keys()];
33
+ try {
34
+ for (const handle of handles) {
35
+ if (disposed) break;
36
+ const callback = callbacks.get(handle);
37
+ if (callback === undefined) continue;
38
+ callbacks.delete(handle);
39
+ // invoke reports page exceptions. Drain the complete promise/microtask
40
+ // queue here, without yielding unrelated timers or I/O between callbacks.
41
+ try { invoke(callback, timestamp); }
42
+ finally { checkpoint(); }
43
+ }
44
+ } finally {
45
+ running = false;
46
+ // Callbacks registered during delivery, including its microtasks, belong
47
+ // to the next frame. Long callbacks do not add another fixed 16 ms delay.
48
+ arm(Math.max(0, Math.ceil(16 - (now() - timestamp))));
49
+ }
50
+ }
51
+ return {
52
+ request(callback) {
53
+ if (typeof callback !== "function") throw new TypeError("requestAnimationFrame callback must be a function");
54
+ do { nextHandle = (nextHandle + 1) >>> 0; } while (callbacks.has(nextHandle));
55
+ const handle = nextHandle;
56
+ if (!disposed) {
57
+ callbacks.set(handle, callback);
58
+ arm(16);
59
+ }
60
+ return handle;
61
+ },
62
+ cancel(handle) {
63
+ callbacks.delete(+handle >>> 0);
64
+ if (!callbacks.size && wake !== null) {
65
+ timers.cancel(wake);
66
+ wake = null;
67
+ }
68
+ },
69
+ dispose() {
70
+ disposed = true;
71
+ callbacks.clear();
72
+ timers.dispose();
73
+ wake = null;
74
+ },
75
+ };
76
+ }
@@ -777,6 +777,7 @@ class ElementBinding extends ParentBinding {
777
777
  load() {
778
778
  if (!isMedia(this)) return;
779
779
  realmOf(this).hooks.pauseMedia?.(nodeOf(this));
780
+ realmOf(this).hooks.reselectMedia?.(nodeOf(this));
780
781
  void realmOf(this).hooks.seekMedia?.(nodeOf(this), 0);
781
782
  }
782
783
 
@@ -918,6 +919,7 @@ class ElementBinding extends ParentBinding {
918
919
  if (resetsCanvas) resetCanvas(node);
919
920
  touch(this);
920
921
  }
922
+ if (attribute.toLowerCase() === "src" && isMedia(this)) realmOf(this).hooks.attachMediaSrc?.(node, node.src);
921
923
  }
922
924
 
923
925
  removeAttribute(name) {
@@ -12,7 +12,7 @@ export function encodePacket(message, pixels = null) {
12
12
  const json = Buffer.from(JSON.stringify(message));
13
13
  const size = pixels?.byteLength ?? 0;
14
14
  if (json.length > MAX_JSON || size > MAX_PIXELS || json.length + size > MAX_QUEUE) throw Error('Compositor packet too large');
15
- const packet = Buffer.allocUnsafe(8 + json.length + size);
15
+ const packet = Buffer.allocUnsafeSlow(8 + json.length + size);
16
16
  packet.writeUInt32LE(json.length, 0); packet.writeUInt32LE(size, 4);
17
17
  packet.set(json, 8); if (size) packet.set(pixels, 8 + json.length);
18
18
  return packet;
@@ -34,7 +34,7 @@ export class PacketReader {
34
34
  const binaryLength = this.header.readUInt32LE(4);
35
35
  const total = this.jsonLength + binaryLength;
36
36
  if (!this.jsonLength || this.jsonLength > MAX_JSON || binaryLength > MAX_PIXELS || total > this.limit) throw Error('Invalid compositor packet size');
37
- this.packet = Buffer.allocUnsafe(total);
37
+ this.packet = Buffer.allocUnsafeSlow(total);
38
38
  } else {
39
39
  const packet = this.packet; this.packet = null;
40
40
  const message = JSON.parse(packet.subarray(0, this.jsonLength).toString());
@@ -45,21 +45,106 @@ export class PacketReader {
45
45
  }
46
46
  }
47
47
 
48
+ // Serialize complete packets, including their two writes, so control messages
49
+ // cannot land between an owned frame's header and pixels. Do not cork these
50
+ // writes: Bun's node:net _writev joins chunks with Buffer.concat.
51
+ export class PacketWriter {
52
+ constructor(socket, limit = MAX_QUEUE) {
53
+ this.socket = socket; this.limit = limit; this.queue = []; this.bytes = 0;
54
+ this.current = null; this.waiting = false; this.pumping = false; this.closed = false;
55
+ socket.on('error', () => this.close());
56
+ socket.once('close', () => {
57
+ this.close();
58
+ // A destroyed socket can still have a pending native write until its
59
+ // callback/close event. Only now is its last borrowed buffer disposable.
60
+ this.release(this.current); this.current = null;
61
+ });
62
+ }
63
+ release(entry) {
64
+ if (!entry || entry.released) return;
65
+ entry.released = true;
66
+ if (entry.queued) this.bytes -= entry.size;
67
+ for (const buffer of entry.buffers) buffer.transfer(0);
68
+ }
69
+ close() {
70
+ if (this.closed) return;
71
+ this.closed = true;
72
+ for (const entry of this.queue) this.release(entry);
73
+ this.queue.length = 0;
74
+ this.socket.destroy();
75
+ }
76
+ pump() {
77
+ if (this.pumping || this.waiting || this.closed) return;
78
+ this.pumping = true;
79
+ try {
80
+ while (!this.waiting && !this.closed) {
81
+ this.current ??= this.queue.shift();
82
+ const entry = this.current;
83
+ if (!entry) break;
84
+ if (entry.index === entry.parts.length) {
85
+ this.release(entry); this.current = null; continue;
86
+ }
87
+ this.waiting = true;
88
+ try {
89
+ this.socket.write(entry.parts[entry.index++], error => {
90
+ if (entry !== this.current || entry.released) return;
91
+ this.waiting = false;
92
+ if (error || this.closed || this.socket.destroyed) {
93
+ this.close(); this.release(entry); this.current = null;
94
+ } else this.pump();
95
+ });
96
+ } catch (error) {
97
+ // Some header bytes may already be on the wire. Closing is required
98
+ // even when write throws synchronously, or the next packet is corrupt.
99
+ this.close();
100
+ return error;
101
+ }
102
+ }
103
+ } finally { this.pumping = false; }
104
+ }
105
+ send(message, pixels, {takeOwnership = false} = {}) {
106
+ const entry = {parts:[], buffers:[], index:0, size:0, queued:false, released:false};
107
+ try {
108
+ if (takeOwnership) {
109
+ if (!(pixels instanceof Uint8Array) || !(pixels.buffer instanceof ArrayBuffer))
110
+ throw TypeError('Owned compositor pixels must have a private ArrayBuffer');
111
+ // The caller gives up this entire buffer, including on a rejected send.
112
+ entry.buffers.push(pixels.buffer);
113
+ }
114
+ if (this.closed || this.socket.destroyed) throw Error('Compositor channel closed');
115
+ const packet = encodePacket(message, takeOwnership ? null : pixels);
116
+ entry.buffers.push(packet.buffer);
117
+ entry.parts.push(packet);
118
+ if (takeOwnership) {
119
+ const size = pixels.byteLength;
120
+ if (size > MAX_PIXELS || packet.length - 8 + size > MAX_QUEUE) throw Error('Compositor packet too large');
121
+ packet.writeUInt32LE(size, 4);
122
+ if (size) entry.parts.push(pixels);
123
+ }
124
+ entry.size = entry.parts.reduce((size, part) => size + part.byteLength, 0);
125
+ if (this.bytes + entry.size > this.limit) {
126
+ this.close(); throw Error('Compositor channel backpressure limit');
127
+ }
128
+ entry.queued = true; this.bytes += entry.size; this.queue.push(entry);
129
+ const error = this.pump();
130
+ if (error) throw error;
131
+ } catch (error) {
132
+ if (!entry.queued) this.release(entry);
133
+ throw error;
134
+ }
135
+ }
136
+ }
137
+
48
138
  function attach(socket, receive, closed, limit) {
49
139
  socket.setNoDelay(true);
50
140
  const reader = new PacketReader(receive, limit);
141
+ const writer = new PacketWriter(socket);
51
142
  socket.on('data', chunk => { try { reader.push(chunk); } catch { socket.destroy(); } });
52
- socket.on('error', () => {});
53
143
  socket.once('close', closed);
54
144
  return {
55
145
  reader,
56
- send(message, pixels) {
57
- if (socket.destroyed) throw Error('Compositor channel closed');
58
- const packet = encodePacket(message, pixels);
59
- if (socket.writableLength + packet.length > MAX_QUEUE) { socket.destroy(); throw Error('Compositor channel backpressure limit'); }
60
- socket.write(packet);
61
- },
62
- close() { socket.destroy(); },
146
+ send(message, pixels, options) { writer.send(message, pixels, options); },
147
+ close() { writer.close(); },
63
148
  };
64
149
  }
65
150