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.
- package/README.md +9 -3
- package/lib/audio/pcm.js +11 -0
- package/lib/audio/pulse-client.js +7 -2
- package/lib/cdp-server.js +31 -7
- package/lib/happy-dom/document-base.js +29 -1
- package/lib/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/mutation-observer.js +157 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +6 -1
- package/lib/headless-shell.js +49 -9
- package/lib/layout/repaint.js +89 -0
- package/lib/layout/text-layout.js +4 -2
- package/lib/media/mp4-keyframes.js +144 -0
- package/lib/media/video-keyframe.js +29 -1
- package/lib/media/webm-snapshots.js +282 -0
- package/lib/network/cookie-jar.js +18 -2
- package/lib/paint/display-list.js +4 -3
- package/lib/paint/frame.js +12 -5
- package/lib/render-tree/index.js +3 -0
- package/lib/renderer/animation-frames.js +76 -0
- package/lib/renderer/bindings.js +107 -3
- package/lib/renderer/compositor-channel.js +95 -10
- package/lib/renderer/compositor.js +54 -18
- package/lib/renderer/document-cookies.js +87 -0
- package/lib/renderer/font-loading.js +60 -16
- package/lib/renderer/host.js +48 -19
- package/lib/renderer/idle-callbacks.js +109 -0
- package/lib/renderer/main.js +5 -1
- package/lib/renderer/media-source.js +4 -0
- package/lib/renderer/page-realm.js +159 -49
- package/lib/renderer/page-renderer.js +577 -201
- package/lib/renderer/resource-elements.js +88 -0
- package/lib/renderer/script-rewrite.js +18 -1
- package/lib/renderer/seccomp.js +6 -4
- package/lib/renderer/timers.js +71 -0
- package/lib/renderer/video-channel.js +94 -0
- package/lib/renderer/video-mask.js +115 -0
- package/lib/renderer/video-presentation.js +304 -39
- package/lib/renderer/video-worker.js +46 -0
- package/lib/renderer/workers.js +7 -17
- package/lib/style/computed-style.js +257 -61
- package/package.json +1 -1
- package/test/animation-frames.test.js +67 -0
- package/test/canvas-style-snapshot.test.js +18 -0
- package/test/cdp-stream.test.js +96 -3
- package/test/compare-node-iterator.js +42 -0
- package/test/compositor-channel-ownership.test.js +194 -0
- package/test/compositor.test.js +27 -1
- package/test/computed-style-binding.test.js +49 -0
- package/test/computed-style-connectivity.test.js +13 -0
- package/test/computed-style.test.js +86 -0
- package/test/cookie-sync.test.js +82 -0
- package/test/document-base-cache.test.js +103 -0
- package/test/document-cookies.test.js +117 -0
- package/test/duplicate-style-keys.test.js +11 -0
- package/test/fixtures/animation-frames.js +56 -0
- package/test/fixtures/canvas-style-snapshot.js +81 -0
- package/test/fixtures/computed-style-connectivity.js +101 -0
- package/test/fixtures/computed-style-lazy.js +135 -0
- package/test/fixtures/duplicate-style-keys.js +97 -0
- package/test/fixtures/fake-renderer.js +12 -0
- package/test/fixtures/font-definition-invalidation.js +97 -0
- package/test/fixtures/font-metrics-invalidation.js +87 -0
- package/test/fixtures/fractional-text-scale.js +38 -0
- package/test/fixtures/full-style-identity.js +36 -0
- package/test/fixtures/hidden-style-engine.js +260 -0
- package/test/fixtures/hidden-style-reentry.js +232 -0
- package/test/fixtures/hidden-style-renderer.js +203 -0
- package/test/fixtures/layout-repaint.js +69 -0
- package/test/fixtures/media-presentation-continuation.js +141 -0
- package/test/fixtures/media-presentation-refill.js +113 -0
- package/test/fixtures/media-probe.js +148 -0
- package/test/fixtures/media-refill-cancel.js +79 -0
- package/test/fixtures/media-repaint.js +48 -0
- package/test/fixtures/media-resource-reselection.js +94 -0
- package/test/fixtures/media-source-attachment.js +136 -0
- package/test/fixtures/media-source-no-decoder.js +17 -0
- package/test/fixtures/media-track-refill.js +132 -0
- package/test/fixtures/media-webm-seek.js +51 -0
- package/test/fixtures/mutation-observer.js +201 -0
- package/test/fixtures/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -0
- package/test/fixtures/observer-invalidation.js +105 -0
- package/test/fixtures/prepaint-scheduling.js +73 -0
- package/test/fixtures/resource-discovery.js +32 -0
- package/test/fixtures/spare-renderer-scheduling.js +124 -0
- package/test/fixtures/style-propagation.js +26 -0
- package/test/fixtures/svg-node-reuse.js +83 -0
- package/test/fixtures/video-black-replay.js +140 -0
- package/test/fixtures/video-controls-invalidation.js +71 -0
- package/test/fixtures/viewport-bounds.js +148 -0
- package/test/font-loading.test.js +77 -0
- package/test/font-metrics-invalidation.test.js +60 -0
- package/test/fractional-text-scale.test.js +6 -0
- package/test/full-style-identity.test.js +191 -0
- package/test/hidden-style-lazy.test.js +31 -0
- package/test/hidden-style-reentry.test.js +20 -0
- package/test/idle-callback.test.js +89 -0
- package/test/idle-scheduler.test.js +148 -0
- package/test/inert-link.test.js +37 -0
- package/test/inline-style-cascade.test.js +63 -0
- package/test/layout-repaint-renderer.test.js +9 -0
- package/test/layout-repaint.test.js +222 -0
- package/test/media-presentation-continuation.test.js +14 -0
- package/test/media-presentation-refill.test.js +8 -0
- package/test/media-probe.test.js +16 -0
- package/test/media-repaint.test.js +8 -0
- package/test/media-source-attachment.test.js +34 -0
- package/test/media-track-refill.test.js +26 -0
- package/test/media-webm-seek.test.js +11 -0
- package/test/mp4-keyframes.test.js +36 -0
- package/test/mutation-observer.test.js +91 -0
- package/test/node-iterator.test.js +81 -0
- package/test/observer-invalidation.test.js +12 -0
- package/test/page-scripts.test.js +151 -1
- package/test/page-timers.test.js +67 -0
- package/test/paint-frame.test.js +18 -1
- package/test/pcm.test.js +63 -0
- package/test/prepaint-scheduling.test.js +13 -0
- package/test/pulse-client.test.js +165 -0
- package/test/raw-capture.test.js +34 -5
- package/test/renderer-host.test.js +51 -0
- package/test/resource-discovery.test.js +12 -0
- package/test/resource-elements.test.js +234 -0
- package/test/root-self-style.test.js +104 -0
- package/test/script-rewrite.test.js +35 -0
- package/test/seccomp.test.js +45 -0
- package/test/spare-renderer-scheduling.test.js +8 -0
- package/test/style-propagation.test.js +225 -0
- package/test/style-sheets.test.js +19 -0
- package/test/svg.test.js +45 -0
- package/test/text-layout.test.js +15 -0
- package/test/timers.test.js +58 -0
- package/test/video-black-replay.test.js +34 -0
- package/test/video-capture.test.js +170 -0
- package/test/video-channel.test.js +253 -0
- package/test/video-continuation.test.js +57 -0
- package/test/video-controls-invalidation.test.js +12 -0
- package/test/video-keyframe.test.js +104 -4
- package/test/video-mask.test.js +94 -0
- package/test/video-presentation.test.js +239 -1
- package/test/video-timing-presentation.test.js +115 -0
- package/test/viewport-bounds.test.js +13 -0
- package/test/webm-snapshots.test.js +372 -0
- package/test/window-resize.test.js +29 -0
package/lib/paint/frame.js
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
}
|
package/lib/render-tree/index.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/renderer/bindings.js
CHANGED
|
@@ -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
|
|
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)
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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" } (
|
|
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
|
|
29
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
+
}
|