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
@@ -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
+ }
@@ -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);