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
@@ -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
+ }
@@ -10,6 +10,8 @@
10
10
  *
11
11
  * Coverage is the commonly used subset of DOM, not the full WebIDL surface.
12
12
  */
13
+ import { replaceNode, replaceWithNodes } from '../happy-dom/node-replacement.js';
14
+ import { observeIteratorRemoval } from '../happy-dom/node-iterator-removal.js';
13
15
  import { renderedText, setRenderedText } from "./rendered-text.js";
14
16
  import { assignedSlot, slotAssignedNodes, assignSlotNodes } from "./slot-assignment.js";
15
17
  import { scrollIntoViewOptions, scrollAlignmentDelta } from "./scroll-into-view.js";
@@ -503,7 +505,7 @@ class NodeBinding {
503
505
  replaceChild(child, old) {
504
506
  const node = requireNode(child);
505
507
  const list = insertionList([node]);
506
- this.#node.replaceChild(node, requireNode(old));
508
+ replaceNode(this.#node, node, requireNode(old));
507
509
  inserted(this, list);
508
510
  return old;
509
511
  }
@@ -775,6 +777,7 @@ class ElementBinding extends ParentBinding {
775
777
  load() {
776
778
  if (!isMedia(this)) return;
777
779
  realmOf(this).hooks.pauseMedia?.(nodeOf(this));
780
+ realmOf(this).hooks.reselectMedia?.(nodeOf(this));
778
781
  void realmOf(this).hooks.seekMedia?.(nodeOf(this), 0);
779
782
  }
780
783
 
@@ -916,6 +919,7 @@ class ElementBinding extends ParentBinding {
916
919
  if (resetsCanvas) resetCanvas(node);
917
920
  touch(this);
918
921
  }
922
+ if (attribute.toLowerCase() === "src" && isMedia(this)) realmOf(this).hooks.attachMediaSrc?.(node, node.src);
919
923
  }
920
924
 
921
925
  removeAttribute(name) {
@@ -1053,7 +1057,7 @@ class ElementBinding extends ParentBinding {
1053
1057
  replaceWith(...values) {
1054
1058
  const nodes = values.map((value) => toNode(this, value));
1055
1059
  const list = insertionList(nodes);
1056
- nodeOf(this).replaceWith(...nodes);
1060
+ replaceWithNodes(nodeOf(this), nodes);
1057
1061
  inserted(this, list);
1058
1062
  }
1059
1063
 
@@ -1690,6 +1694,10 @@ class DocumentBinding extends ParentBinding {
1690
1694
  return realmOf(this).wrap(nodeOf(this).createComment(String(text)));
1691
1695
  }
1692
1696
 
1697
+ createNodeIterator(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
1698
+ return new NodeIteratorBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
1699
+ }
1700
+
1693
1701
  createTreeWalker(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
1694
1702
  return new TreeWalkerBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
1695
1703
  }
@@ -2033,6 +2041,101 @@ export const NodeFilter = Object.freeze({
2033
2041
  });
2034
2042
  const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter;
2035
2043
 
2044
+ /** DOM §6.1: a cursor between nodes, including the root; REJECT skips
2045
+ * only that node, unlike TreeWalker where it also prunes descendants. */
2046
+ class NodeIteratorBinding {
2047
+ #realm; #root; #reference; #before = true; #whatToShow; #filter; #active = false;
2048
+ #candidate = null;
2049
+ constructor(key, realm, root, whatToShow, filter) {
2050
+ if (key !== CONSTRUCT) throw new TypeError("Illegal constructor");
2051
+ this.#realm = realm; this.#root = this.#reference = root;
2052
+ this.#whatToShow = +whatToShow >>> 0; this.#filter = filter ?? null;
2053
+ // WebIDL callback interfaces accept objects/functions or null, but no
2054
+ // primitives; validate even when whatToShow would skip every node.
2055
+ if (this.#filter !== null && typeof filter !== "object" && typeof filter !== "function") {
2056
+ throw new TypeError("NodeIterator filter must be an object or null");
2057
+ }
2058
+ // Keep the internal removal callback off the page-visible interface.
2059
+ observeIteratorRemoval(root, this.#removal);
2060
+ }
2061
+ #removal = { preRemove: node => this.#preRemove(node) };
2062
+ get root() { return this.#realm.wrap(this.#root); }
2063
+ get referenceNode() { return this.#realm.wrap(this.#reference); }
2064
+ get pointerBeforeReferenceNode() { return this.#before; }
2065
+ get whatToShow() { return this.#whatToShow; }
2066
+ get filter() { return this.#filter; }
2067
+ detach() {} // Legacy no-op, as required by DOM.
2068
+ nextNode() { return this.#traverse(true); }
2069
+ previousNode() { return this.#traverse(false); }
2070
+ #following(node, children = true) {
2071
+ if (children && node.firstChild) return node.firstChild;
2072
+ while (node && node !== this.#root) {
2073
+ if (node.nextSibling) return node.nextSibling;
2074
+ node = node.parentNode;
2075
+ }
2076
+ return null;
2077
+ }
2078
+ #preceding(node) {
2079
+ if (node === this.#root) return null;
2080
+ if (!node.previousSibling) return node.parentNode;
2081
+ node = node.previousSibling;
2082
+ while (node.lastChild) node = node.lastChild;
2083
+ return node;
2084
+ }
2085
+ #accept(node) {
2086
+ if (this.#active) throw new DOMException("The iterator filter is already active", "InvalidStateError");
2087
+ if (!(this.#whatToShow & (1 << (node.nodeType - 1)))) return FILTER_SKIP;
2088
+ if (this.#filter === null) return FILTER_ACCEPT;
2089
+ this.#active = true;
2090
+ try {
2091
+ const filter = this.#filter, wrapper = this.#realm.wrap(node);
2092
+ return +(typeof filter === "function" ? Reflect.apply(filter, undefined, [wrapper])
2093
+ : Reflect.apply(filter.acceptNode, filter, [wrapper])) & 0xffff;
2094
+ } finally { this.#active = false; }
2095
+ }
2096
+ #traverse(forward) {
2097
+ if (this.#active) throw new DOMException("The iterator filter is already active", "InvalidStateError");
2098
+ // A filter can remove its candidate or an ancestor synchronously. Track
2099
+ // the traversal cursor separately so pre-removal adjusts both positions.
2100
+ const cursor = this.#candidate = { node: this.#reference, before: this.#before };
2101
+ try {
2102
+ for (;;) {
2103
+ if (forward) {
2104
+ if (cursor.before) cursor.before = false;
2105
+ else cursor.node = this.#following(cursor.node);
2106
+ } else {
2107
+ if (!cursor.before) cursor.before = true;
2108
+ else cursor.node = this.#preceding(cursor.node);
2109
+ }
2110
+ const node = cursor.node;
2111
+ if (!node) return null;
2112
+ if (this.#accept(node) === FILTER_ACCEPT) {
2113
+ this.#reference = cursor.node; this.#before = cursor.before;
2114
+ // Return the accepted node even if the filter removed it; the next
2115
+ // traversal resumes at the repaired position in the remaining tree.
2116
+ return this.#realm.wrap(node);
2117
+ }
2118
+ }
2119
+ } finally { this.#candidate = null; }
2120
+ }
2121
+ #preRemove(node) {
2122
+ if (node === this.#root) return;
2123
+ const adjust = cursor => {
2124
+ if (!cursor?.node || !node.contains(cursor.node)) return;
2125
+ if (cursor.before) {
2126
+ const next = this.#following(node, false);
2127
+ if (next) { cursor.node = next; return; }
2128
+ cursor.before = false;
2129
+ }
2130
+ cursor.node = this.#preceding(node);
2131
+ };
2132
+ const reference = { node: this.#reference, before: this.#before };
2133
+ adjust(reference);
2134
+ this.#reference = reference.node; this.#before = reference.before;
2135
+ adjust(this.#candidate);
2136
+ }
2137
+ }
2138
+
2036
2139
  /**
2037
2140
  * DOM §6.2 TreeWalker over the real nodes. The page's filter (a function or
2038
2141
  * an object with acceptNode) sees node wrappers, and its exceptions reach the
@@ -2833,7 +2936,7 @@ function cssEscape(value) {
2833
2936
  * Must run after SES lockdown() (which provides `harden`).
2834
2937
  */
2835
2938
  const BINDINGS = [NodeBinding, CharacterDataBinding, TextBinding, ParentBinding, FragmentBinding, ShadowRootBinding, ElementBinding,
2836
- DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding, RangeBinding];
2939
+ DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding, NodeIteratorBinding, RangeBinding];
2837
2940
 
2838
2941
  // Members defined with Object.defineProperty({ get() {...} }) are named
2839
2942
  // "get"; Web IDL names an attribute's accessors "get x" / "set x" and an
@@ -2869,5 +2972,6 @@ export const INTERFACES = Object.freeze({
2869
2972
  DocumentFragment: FragmentBinding,
2870
2973
  ShadowRoot: ShadowRootBinding,
2871
2974
  TreeWalker: TreeWalkerBinding,
2975
+ NodeIterator: NodeIteratorBinding,
2872
2976
  DOMTokenList: ClassListBinding,
2873
2977
  });
@@ -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
 
@@ -11,7 +11,7 @@
11
11
  * controller -> compositor { id, method: "screenshot" | "scroll" | "scrollOffset", params }
12
12
  * (screenshot params: [{ format?: "png" | "jpeg" | "webp", quality? }])
13
13
  * compositor -> controller { id, result } | { id, error }
14
- * { type: "frame" } (what the viewport shows changed)
14
+ * { type: "frame", scrollOffset: { x, y } } (the viewport changed)
15
15
  *
16
16
  * Messages with the main thread (postMessage):
17
17
  * main -> compositor { type: "frame", serial, tile, viewport, content, scroll, scrollEpoch }
@@ -25,8 +25,8 @@ import { ScrollViewport } from "../layout/scroll-viewport.js";
25
25
  import { rawFrame, captureViewport, encodeImage, encodeImageBase64 } from "../paint/frame.js";
26
26
  import { FrameCaptureCache } from "../paint/frame-cache.js";
27
27
  import { FrameLeaseTracker } from "../paint/shared-frame-pool.js";
28
- import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
29
- import { VideoPresentation, compositeVideo } from "./video-presentation.js";
28
+ import { startVideoChannel } from "./video-channel.js";
29
+ import { compositeVideo, captureVideoViewport } from "./video-presentation.js";
30
30
 
31
31
  import {connectCompositor} from './compositor-channel.js';
32
32
 
@@ -36,9 +36,14 @@ let scrollEpoch = -1;
36
36
  let frameSerial = 0;
37
37
  const captures = new FrameCaptureCache();
38
38
  const frameLeases = new FrameLeaseTracker(serial => postMessage({ type: "frame-release", serial }));
39
- const video = new VideoPresentation(wasmpeg, () => {
39
+ // Register before awaiting decoder startup: the parent's connect message may
40
+ // arrive while the media worker is loading WASM.
41
+ const endpointReady = new Promise(resolve => {
42
+ self.onmessage = ({data}) => { if (data?.type === 'connect') resolve(data.endpoint); };
43
+ });
44
+ const video = await startVideoChannel(() => {
40
45
  frameSerial++;
41
- send({ type: "frame" });
46
+ notifyFrame();
42
47
  }, report => postMessage({ type: "video-report", ...report }));
43
48
 
44
49
  let channel;
@@ -46,16 +51,23 @@ function send(message) {
46
51
  const result = message.result;
47
52
  if (result?.pixels instanceof Uint8Array) {
48
53
  const {pixels,...metadata} = result;
49
- channel.send({...message,result:metadata},pixels);
54
+ // This raw result is exclusively owned here. The transport borrows it
55
+ // directly, then releases it after the write completes (or the socket
56
+ // closes); do not detach bytes while a native socket may still use them.
57
+ channel.send({...message,result:metadata},pixels,{takeOwnership:true});
50
58
  } else channel.send(message);
51
59
  }
52
- const endpoint = await new Promise(resolve => {
53
- self.onmessage = ({data}) => { if (data?.type === 'connect') resolve(data.endpoint); };
54
- });
60
+ function notifyFrame() {
61
+ // Snapshot the compositor-owned position with its invalidation. A zero-area
62
+ // screencast needs no pixels and can use this without another socket request.
63
+ send({ type: "frame", scrollOffset: { x: viewport.x, y: viewport.y } });
64
+ }
65
+ const endpoint = await endpointReady;
55
66
  channel = await connectCompositor(endpoint, request => { void handleRequest(request); }, () => process.exit(1));
56
67
 
57
68
  self.onmessage = ({ data }) => {
58
69
  if (data?.type === "video-open") { void video.open(data); return; }
70
+ if (data?.type === "video-extend") { video.extend(data); return; }
59
71
  if (data?.type === "video-stop") { video.stop(data.id); frameSerial++; return; }
60
72
  if (data?.type === "video-bind") { video.bind(data.id, data.audioId, data.audioStart, data.time, data.endTime); return; }
61
73
  if (data?.type !== "frame") return;
@@ -75,33 +87,58 @@ self.onmessage = ({ data }) => {
75
87
  postMessage({ type: "scroll", x: viewport.x, y: viewport.y });
76
88
  }
77
89
  }
78
- send({ type: "frame" });
90
+ notifyFrame();
79
91
  // Lets the main thread answer the call that produced this frame only once
80
92
  // screenshots taken after it will show it.
81
93
  postMessage({ type: "frame-ack", serial: data.serial });
82
94
  };
83
95
 
96
+ // Only private composition/crop buffers are disposable. A full-width crop
97
+ // may borrow the immutable raster tile, which remains protected by its lease.
98
+ function releaseCapture(frame, composited, captured, retained = null) {
99
+ if (captured && captured.pixels.buffer !== retained && captured.pixels.buffer !== composited.pixels.buffer)
100
+ captured.pixels.buffer.transfer(0);
101
+ if (composited !== frame && composited.pixels.buffer !== retained) composited.pixels.buffer.transfer(0);
102
+ }
103
+
84
104
  const methods = {
85
105
  async screenshot({ format = "png", quality = 80, optimizeForSpeed = false, clip } = {}) {
86
106
  if (!tile) return "";
87
107
  if (format === 'rgba' || format === 'bgra') {
88
108
  return frameLeases.capture(frame => {
89
- const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame) : frame;
90
- const {pixels,width,height} = captureViewport(composited,viewport.x,viewport.y,viewport.width,viewport.height,clip);
91
- return rawFrame(pixels,width,height,format);
109
+ const visible = frame.videoLayer?.id === video.config?.id
110
+ ? captureVideoViewport(frame, video.frame, viewport.x, viewport.y, viewport.width, viewport.height, clip, format) : null;
111
+ if (visible) {
112
+ // This crop owns its bytes. The normal transport releases them
113
+ // after the socket write completes; the raster tile's lease stays intact.
114
+ try { return rawFrame(visible.pixels, visible.width, visible.height, format,
115
+ {takeOwnership: true, inputFormat: visible.pixelFormat}); }
116
+ catch (error) { visible.pixels.buffer.transfer(0); throw error; }
117
+ }
118
+ const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame, format) : frame;
119
+ let captured, result;
120
+ try {
121
+ captured = captureViewport(composited,viewport.x,viewport.y,viewport.width,viewport.height,clip);
122
+ const takeOwnership = captured.pixels.buffer !== frame.pixels.buffer;
123
+ result = rawFrame(captured.pixels,captured.width,captured.height,format,{takeOwnership,inputFormat:composited.pixelFormat ?? "rgba"});
124
+ return result;
125
+ } finally { releaseCapture(frame, composited, captured, result?.pixels.buffer); }
92
126
  });
93
127
  }
94
128
  const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}:${optimizeForSpeed}:${JSON.stringify(clip)}`;
95
- return captures.get(key, () => frameLeases.capture(frame => {
129
+ return captures.get(key, () => frameLeases.capture(async frame => {
96
130
  const composited = frame.videoLayer?.id === video.config?.id ? compositeVideo(frame, video.frame) : frame;
97
- const { pixels, width, height } = captureViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height,clip);
98
- return encodeImageBase64(pixels, width, height, { format, quality, optimizeForSpeed });
131
+ let captured;
132
+ try {
133
+ captured = captureViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height,clip);
134
+ return await encodeImageBase64(captured.pixels, captured.width, captured.height, { format, quality, optimizeForSpeed });
135
+ } finally { releaseCapture(frame, composited, captured); }
99
136
  }));
100
137
  },
101
138
  scroll(deltaX, deltaY) {
102
139
  if (viewport.scrollBy(deltaX, deltaY)) {
103
140
  postMessage({ type: "scroll", x: viewport.x, y: viewport.y });
104
- send({ type: "frame" });
141
+ notifyFrame();
105
142
  }
106
143
  return { x: viewport.x, y: viewport.y };
107
144
  },
@@ -128,5 +165,4 @@ async function handleRequest(request) {
128
165
  // renderer installs seccomp, which forbids opening files.
129
166
  await encodeImage(new Uint8Array(4), 1, 1);
130
167
  await encodeImage(new Uint8Array(4), 1, 1, { format: "jpeg" });
131
- await wasmpeg.load();
132
168
  postMessage({ type: "ready" });
@@ -0,0 +1,87 @@
1
+ import { CookieJar } from "../network/cookie-jar.js";
2
+
3
+ /** Shared by the document and its same-origin frames; the controller remains authoritative. */
4
+ export class DocumentCookies {
5
+ #jar;
6
+ #now;
7
+ #send;
8
+ #nextSequence = 1;
9
+ #revision = -1;
10
+ #acknowledged = 0;
11
+ #pending = new Map();
12
+ #legacyCookie = "";
13
+
14
+ constructor(send = () => {}, now = () => Date.now()) {
15
+ this.#send = send;
16
+ this.#now = now;
17
+ this.#jar = new CookieJar({ now });
18
+ }
19
+
20
+ get(url) {
21
+ return this.#revision < 0 ? this.#legacyCookie : this.#jar.documentCookie(url);
22
+ }
23
+
24
+ set(url, value) {
25
+ const assignment = String(value);
26
+ // The controller imposes the same IPC limit; rejected writes cannot
27
+ // temporarily create a cookie which the controller will never accept.
28
+ if (assignment.length > 4096) return;
29
+ const sequence = this.#nextSequence++;
30
+ // Legacy embedders need not acknowledge setters. Only retain mutations
31
+ // after the controller has established the versioned snapshot protocol.
32
+ if (this.#revision < 0) {
33
+ this.#legacyCookie = mergeLegacyCookie(this.#legacyCookie, assignment, this.#now());
34
+ } else {
35
+ const mutation = { sequence, url, value: assignment, receivedAt: this.#now() };
36
+ this.#pending.set(sequence, mutation);
37
+ this.#jar.store(url, [assignment], { fromScript: true, receivedAt: mutation.receivedAt });
38
+ }
39
+ this.#send(assignment, url, sequence);
40
+ }
41
+
42
+ accept(state) {
43
+ if (!state || !Number.isSafeInteger(state.revision) || state.revision <= this.#revision
44
+ || !Number.isSafeInteger(state.acknowledged) || state.acknowledged < this.#acknowledged
45
+ || !Array.isArray(state.cookies)) return false;
46
+ this.#revision = state.revision;
47
+ this.#acknowledged = state.acknowledged;
48
+ this.#legacyCookie = "";
49
+ for (const sequence of this.#pending.keys()) {
50
+ if (sequence <= state.acknowledged) this.#pending.delete(sequence);
51
+ }
52
+ this.#jar.replaceScriptSnapshot(state.cookies);
53
+ // A response may have been queued before a newer synchronous setter ran.
54
+ // Replay only those setters, retaining unrelated server cookies and the
55
+ // original Max-Age start time rather than extending it on each snapshot.
56
+ for (const mutation of this.#pending.values()) {
57
+ this.#jar.store(mutation.url, [mutation.value], { fromScript: true, receivedAt: mutation.receivedAt });
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /** Compatibility for embedders providing only the old documentCookie string. */
63
+ seed(url, value, replace = false) {
64
+ if (this.#revision >= 0 || typeof value !== "string") return;
65
+ // A header has no Path/Domain metadata, so preserve it verbatim instead of
66
+ // inventing records (which would collapse legitimate duplicate names).
67
+ // An embedded about:blank initial load must not erase its parent's value.
68
+ if (replace || value) this.#legacyCookie = value;
69
+ }
70
+ }
71
+
72
+ /** Preserve the string-only embedder API until a controller snapshot arrives. */
73
+ function mergeLegacyCookie(current, assignment, now) {
74
+ const [pair, ...attributes] = assignment.split(";");
75
+ const equals = pair.indexOf("=");
76
+ if (equals < 1) return current;
77
+ const name = pair.slice(0, equals).trim();
78
+ const value = pair.slice(equals + 1).trim();
79
+ const expired = attributes.some(attribute => {
80
+ const [key, argument = ""] = attribute.split("=").map(part => part.trim().toLowerCase());
81
+ return (key === "max-age" && Number(argument) <= 0)
82
+ || (key === "expires" && Date.parse(argument) <= now);
83
+ });
84
+ const entries = current ? current.split("; ").filter(entry => !entry.startsWith(`${name}=`)) : [];
85
+ if (!expired) entries.push(`${name}=${value}`);
86
+ return entries.join("; ");
87
+ }