browserscale-ts 1.5.0 → 1.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.
- package/README.md +30 -0
- package/dist/browser.d.ts +4 -1
- package/dist/browser.js +5 -0
- package/dist/browserscale.browser.js +1581 -46
- package/dist/client.d.ts +316 -7
- package/dist/client.js +588 -7
- package/dist/dom-mirror.d.ts +271 -0
- package/dist/dom-mirror.js +613 -0
- package/dist/gen/wrc_pb.d.ts +1251 -87
- package/dist/gen/wrc_pb.js +214 -44
- package/dist/index.d.ts +31 -1
- package/dist/index.js +61 -0
- package/dist/internal/convert.d.ts +8 -2
- package/dist/internal/convert.js +39 -0
- package/dist/network-capture.d.ts +84 -0
- package/dist/network-capture.js +107 -0
- package/dist/scripts.d.ts +205 -0
- package/dist/scripts.js +234 -0
- package/dist/types.d.ts +157 -0
- package/dist/ws-transport.d.ts +15 -1
- package/dist/ws-transport.js +155 -10
- package/package.json +1 -1
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/** Node ids are renderer-local, so the frame is part of the address. */
|
|
2
|
+
const keyOf = (frameId, backendNodeId) => `${frameId}#${backendNodeId}`;
|
|
3
|
+
/**
|
|
4
|
+
* How hard to try to rebuild the page after the browser voids it. A void
|
|
5
|
+
* usually means the page is navigating, and the window where the old document
|
|
6
|
+
* is gone and the new one cannot be serialized yet is short.
|
|
7
|
+
*/
|
|
8
|
+
const RESYNC_ATTEMPTS = 4;
|
|
9
|
+
const RESYNC_RETRY_MS = 150;
|
|
10
|
+
/**
|
|
11
|
+
* A live copy of a page's DOM, across every frame in it.
|
|
12
|
+
*
|
|
13
|
+
* Returned by {@link CloudBrowser.mirrorDom}. The browser sends the top of the
|
|
14
|
+
* tree once and from then on only what changed in the part you expanded, so a
|
|
15
|
+
* page that churns inside a collapsed subtree costs one number per batch
|
|
16
|
+
* instead of a re-serialized document.
|
|
17
|
+
*
|
|
18
|
+
* It is one tree. An `<iframe>` is an element whose one child is the document
|
|
19
|
+
* it hosts; expanding it fetches that document and starts mirroring the frame,
|
|
20
|
+
* collapsing it stops again, and a frame navigating arrives as its owner's
|
|
21
|
+
* child being replaced. Underneath there is still one mirror per document,
|
|
22
|
+
* because a mutation observer is bound to a single Document and an
|
|
23
|
+
* out-of-process iframe is a different Document in a different process — but
|
|
24
|
+
* that is engine bookkeeping, not something a caller models.
|
|
25
|
+
*
|
|
26
|
+
* Node ids restart per frame, so a node's address is the pair
|
|
27
|
+
* ({@link DomNode.frameId}, `backendNodeId`) and never the id alone.
|
|
28
|
+
*
|
|
29
|
+
* The tree is treated as immutable: applying a change replaces the nodes from
|
|
30
|
+
* the root down to the one that moved and leaves every other object identical.
|
|
31
|
+
* A UI can therefore re-render from `root` and let `React.memo` (or any
|
|
32
|
+
* identity check) skip the parts that did not move.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const mirror = await browser.mirrorDom({ pierce: true }, () => render(mirror.root));
|
|
36
|
+
* await mirror.expand(bodyNode); // start reporting changes inside <body>
|
|
37
|
+
* await mirror.collapse(bodyNode); // stop again
|
|
38
|
+
* await mirror.stop();
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class DomMirror {
|
|
42
|
+
/** @internal Constructed by CloudBrowser; not part of the public API. */
|
|
43
|
+
constructor(init) {
|
|
44
|
+
this.nodes = new Map();
|
|
45
|
+
this.slots = new Map();
|
|
46
|
+
this.expandedKeys = new Set();
|
|
47
|
+
/**
|
|
48
|
+
* Per node: the page sequence its current state was defined at, by a read
|
|
49
|
+
* payload or by an edit.
|
|
50
|
+
*
|
|
51
|
+
* Reads and events reach a client over two different channels — a unary call
|
|
52
|
+
* and a stream — so an event can turn up that the read reply already folded
|
|
53
|
+
* in. Applying it twice would duplicate an insertion, which is the one entry
|
|
54
|
+
* type that is not idempotent. Comparing against the node's own watermark
|
|
55
|
+
* rather than a single page-wide one keeps that from silently discarding a
|
|
56
|
+
* change to an unrelated part of the tree.
|
|
57
|
+
*/
|
|
58
|
+
this.asOf = new Map();
|
|
59
|
+
this.seqValue = 0;
|
|
60
|
+
this.rootNode = null;
|
|
61
|
+
this.mainFrame = "";
|
|
62
|
+
/**
|
|
63
|
+
* Events are held until a snapshot exists to apply them to, and dropped if
|
|
64
|
+
* they predate it. Without this a batch that lands between subscribing and
|
|
65
|
+
* the snapshot arriving would either be applied to nothing or applied twice.
|
|
66
|
+
*/
|
|
67
|
+
this.ready = false;
|
|
68
|
+
this.pendingEvents = [];
|
|
69
|
+
this.stopped = false;
|
|
70
|
+
this.failure = null;
|
|
71
|
+
this.transport = init.transport;
|
|
72
|
+
this.options = init.options;
|
|
73
|
+
this.onChange = init.onChange;
|
|
74
|
+
this.onResync = init.onResync;
|
|
75
|
+
this.abort = init.abort;
|
|
76
|
+
this.finished = this.pump(init.stream);
|
|
77
|
+
}
|
|
78
|
+
// ── public surface ────────────────────────────────────────────────────
|
|
79
|
+
/** The main frame's document, or null before the first snapshot arrived. */
|
|
80
|
+
get root() {
|
|
81
|
+
return this.rootNode;
|
|
82
|
+
}
|
|
83
|
+
/** The page's main frame. */
|
|
84
|
+
get mainFrameId() {
|
|
85
|
+
return this.mainFrame;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Every frame with a document in the tree, main frame first. A frame whose
|
|
89
|
+
* `<iframe>` has not been expanded is not mirrored and not listed.
|
|
90
|
+
*/
|
|
91
|
+
get frameIds() {
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
if (this.mainFrame)
|
|
94
|
+
seen.add(this.mainFrame);
|
|
95
|
+
for (const node of this.nodes.values()) {
|
|
96
|
+
if (node.nodeType === 9)
|
|
97
|
+
seen.add(node.frameId);
|
|
98
|
+
}
|
|
99
|
+
return [...seen];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The page sequence of the last change applied. One clock for the whole
|
|
103
|
+
* page: a change in an out-of-process iframe and one in the main document
|
|
104
|
+
* are ordered against each other.
|
|
105
|
+
*/
|
|
106
|
+
get seq() {
|
|
107
|
+
return this.seqValue;
|
|
108
|
+
}
|
|
109
|
+
/** Looks up a node by its address. */
|
|
110
|
+
getNode(frameId, backendNodeId) {
|
|
111
|
+
return this.nodes.get(keyOf(frameId, backendNodeId));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether this node's children are known. Changes inside a node that is not
|
|
115
|
+
* expanded arrive only as an updated `childNodeCount`.
|
|
116
|
+
*/
|
|
117
|
+
isExpanded(node) {
|
|
118
|
+
return this.expandedKeys.has(keyOf(node.frameId, node.backendNodeId));
|
|
119
|
+
}
|
|
120
|
+
/** Why the mirror ended: null while running and after a clean stop. */
|
|
121
|
+
get error() {
|
|
122
|
+
return this.failure;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Fetches a node's children and starts reporting changes inside them. This
|
|
126
|
+
* is what a tree view calls when the user opens a node.
|
|
127
|
+
*
|
|
128
|
+
* On an `<iframe>` the one child is the document it hosts, and this call is
|
|
129
|
+
* what starts mirroring that frame. Nothing about the result says a process
|
|
130
|
+
* boundary was crossed; it is a child list like any other.
|
|
131
|
+
*
|
|
132
|
+
* @param depth levels below the node, default 1
|
|
133
|
+
*/
|
|
134
|
+
async expand(node, depth) {
|
|
135
|
+
if (this.stopped)
|
|
136
|
+
return;
|
|
137
|
+
const frame = node.frameId;
|
|
138
|
+
const key = keyOf(frame, node.backendNodeId);
|
|
139
|
+
const reply = await this.transport.children(node.backendNodeId, frame, depth);
|
|
140
|
+
// The node can be gone by the time the reply lands — a resync or a removal
|
|
141
|
+
// in between rebuilt the part of the tree this describes.
|
|
142
|
+
if (!this.nodes.has(key))
|
|
143
|
+
return;
|
|
144
|
+
const kids = JSON.parse(reply.children || "[]");
|
|
145
|
+
const fresh = this.touch(key);
|
|
146
|
+
this.dropChildren(fresh);
|
|
147
|
+
fresh.children = kids.map((k) => this.adopt(k, { parent: key, kind: "children" }, frame, reply.seq));
|
|
148
|
+
fresh.childNodeCount = fresh.children.length;
|
|
149
|
+
this.expandedKeys.add(key);
|
|
150
|
+
this.mark(key, reply.seq);
|
|
151
|
+
this.onChange(this);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Stops reporting changes inside a node, called when the user closes it. The
|
|
155
|
+
* node itself stays in the tree and keeps reporting its child count. A child
|
|
156
|
+
* frame below it stops being mirrored too.
|
|
157
|
+
*
|
|
158
|
+
* Skipping this is not an error, it is a slow leak: the browser's revealed
|
|
159
|
+
* set only grows, and eventually it is no longer filtering anything.
|
|
160
|
+
*/
|
|
161
|
+
async collapse(node) {
|
|
162
|
+
if (this.stopped)
|
|
163
|
+
return;
|
|
164
|
+
const key = keyOf(node.frameId, node.backendNodeId);
|
|
165
|
+
await this.transport.release(node.backendNodeId, node.frameId);
|
|
166
|
+
if (!this.nodes.has(key))
|
|
167
|
+
return;
|
|
168
|
+
const fresh = this.touch(key);
|
|
169
|
+
this.dropChildren(fresh);
|
|
170
|
+
delete fresh.children;
|
|
171
|
+
this.expandedKeys.delete(key);
|
|
172
|
+
this.onChange(this);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Brings a node into the tree together with its ancestors and their
|
|
176
|
+
* siblings, and starts reporting changes along that path.
|
|
177
|
+
*
|
|
178
|
+
* Use it to focus a node you do not hold — an `inspectAtPosition` hit, say.
|
|
179
|
+
* You cannot walk up to it yourself: it is not in your tree, so there is
|
|
180
|
+
* nothing to walk from.
|
|
181
|
+
*
|
|
182
|
+
* The node may be in a frame nobody opened, and that works: the chain comes
|
|
183
|
+
* back crossing the frame boundaries it has to, and those frames start being
|
|
184
|
+
* mirrored, exactly as if you had expanded your way there by hand.
|
|
185
|
+
*
|
|
186
|
+
* @returns the ancestor chain, the main document first, or an empty array if
|
|
187
|
+
* the node is not on the page
|
|
188
|
+
*/
|
|
189
|
+
async reveal(backendNodeId, frameId) {
|
|
190
|
+
if (this.stopped)
|
|
191
|
+
return [];
|
|
192
|
+
const frame = frameId || this.mainFrame;
|
|
193
|
+
const reply = await this.transport.reveal(backendNodeId, frame);
|
|
194
|
+
const path = JSON.parse(reply.path || "[]");
|
|
195
|
+
if (path.length === 0)
|
|
196
|
+
return [];
|
|
197
|
+
// The chain starts at the main document, which the mirror already holds,
|
|
198
|
+
// so it is merged level by level rather than re-rooted. Re-rooting would
|
|
199
|
+
// throw away the rest of the page, including every other frame opened
|
|
200
|
+
// into it.
|
|
201
|
+
const keys = [];
|
|
202
|
+
let space = this.mainFrame;
|
|
203
|
+
let above = null;
|
|
204
|
+
for (const step of path) {
|
|
205
|
+
// A document names its own frame, and everything after it counts in
|
|
206
|
+
// that frame's ids. This is the boundary, and it is the only place the
|
|
207
|
+
// address space changes.
|
|
208
|
+
if (step.nodeType === 9 && step.frameId)
|
|
209
|
+
space = step.frameId;
|
|
210
|
+
const key = keyOf(space, step.backendNodeId);
|
|
211
|
+
if (!this.nodes.has(key)) {
|
|
212
|
+
// The only step that can be missing is a hosted document: every other
|
|
213
|
+
// one arrived in the child list of the step above it. Hanging it off
|
|
214
|
+
// its owner is what crossing into the frame means here.
|
|
215
|
+
if (above === null || !this.nodes.has(above))
|
|
216
|
+
break;
|
|
217
|
+
const owner = this.touch(above);
|
|
218
|
+
this.dropChildren(owner);
|
|
219
|
+
owner.children = [
|
|
220
|
+
this.adopt(step, { parent: above, kind: "children" }, space, reply.seq),
|
|
221
|
+
];
|
|
222
|
+
owner.childNodeCount = 1;
|
|
223
|
+
this.expandedKeys.add(above);
|
|
224
|
+
this.mark(above, reply.seq);
|
|
225
|
+
}
|
|
226
|
+
this.mergeChildren(key, step, space, reply.seq);
|
|
227
|
+
keys.push(key);
|
|
228
|
+
above = key;
|
|
229
|
+
}
|
|
230
|
+
this.onChange(this);
|
|
231
|
+
return keys
|
|
232
|
+
.map((k) => this.nodes.get(k))
|
|
233
|
+
.filter((n) => n !== undefined);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Throws away the local copy of the whole page and fetches a fresh one.
|
|
237
|
+
* Happens automatically whenever the browser says the copy is void, so you
|
|
238
|
+
* rarely need to call it.
|
|
239
|
+
*/
|
|
240
|
+
async resync(reason = "manual") {
|
|
241
|
+
if (this.stopped)
|
|
242
|
+
return;
|
|
243
|
+
this.ready = false;
|
|
244
|
+
// A resync that fails leaves nothing usable behind: the local copy is
|
|
245
|
+
// already declared void and no more events will make sense against it. The
|
|
246
|
+
// common cause is transient — the page was voided because it is navigating,
|
|
247
|
+
// and the new document is not there yet to be serialized — so this retries
|
|
248
|
+
// before treating it as fatal.
|
|
249
|
+
let snapshot = null;
|
|
250
|
+
let last;
|
|
251
|
+
for (let attempt = 0; attempt < RESYNC_ATTEMPTS; attempt++) {
|
|
252
|
+
if (this.stopped)
|
|
253
|
+
return;
|
|
254
|
+
if (attempt > 0)
|
|
255
|
+
await new Promise((r) => setTimeout(r, RESYNC_RETRY_MS));
|
|
256
|
+
try {
|
|
257
|
+
snapshot = await this.transport.start(this.options);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
last = err;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (!snapshot) {
|
|
265
|
+
// Out of options. Ending the mirror is the point: a caller watching
|
|
266
|
+
// wait() can say the tree is frozen, where staying quietly broken looks
|
|
267
|
+
// exactly like a page on which nothing is happening.
|
|
268
|
+
this.failure = last instanceof Error ? last : new Error(String(last));
|
|
269
|
+
void this.stop();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.install(snapshot);
|
|
273
|
+
this.onResync?.(reason);
|
|
274
|
+
this.onChange(this);
|
|
275
|
+
}
|
|
276
|
+
/** Resolves once the mirror ends — stop(), a dead session, a transport failure. */
|
|
277
|
+
async wait() {
|
|
278
|
+
await this.finished;
|
|
279
|
+
if (this.failure)
|
|
280
|
+
throw this.failure;
|
|
281
|
+
}
|
|
282
|
+
/** Stops mirroring and detaches the reader. Idempotent, safe in a `finally`. */
|
|
283
|
+
async stop() {
|
|
284
|
+
if (this.stopped)
|
|
285
|
+
return;
|
|
286
|
+
this.stopped = true;
|
|
287
|
+
try {
|
|
288
|
+
await this.transport.stop();
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// The local reader goes down either way, and a page that is already gone
|
|
292
|
+
// is exactly when this throws.
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
this.abort.abort();
|
|
296
|
+
await this.finished;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// ── snapshot installation ─────────────────────────────────────────────
|
|
300
|
+
/** @internal Called by CloudBrowser with the opening snapshot. */
|
|
301
|
+
install(snapshot) {
|
|
302
|
+
this.reset();
|
|
303
|
+
this.mainFrame = snapshot.frameId || this.mainFrame;
|
|
304
|
+
this.seqValue = snapshot.seq;
|
|
305
|
+
const parsed = snapshot.root
|
|
306
|
+
? JSON.parse(snapshot.root)
|
|
307
|
+
: null;
|
|
308
|
+
this.rootNode = parsed
|
|
309
|
+
? this.adopt(parsed, null, this.mainFrame, snapshot.seq)
|
|
310
|
+
: null;
|
|
311
|
+
this.ready = true;
|
|
312
|
+
// Anything buffered while the snapshot was in flight is either already in
|
|
313
|
+
// it (seq <= snapshot) or genuinely newer. Either way the watermark check
|
|
314
|
+
// in handle() decides, so replaying the buffer here is safe.
|
|
315
|
+
const buffered = this.pendingEvents;
|
|
316
|
+
this.pendingEvents = [];
|
|
317
|
+
for (const event of buffered)
|
|
318
|
+
this.handle(event);
|
|
319
|
+
}
|
|
320
|
+
reset() {
|
|
321
|
+
this.nodes = new Map();
|
|
322
|
+
this.slots = new Map();
|
|
323
|
+
this.expandedKeys = new Set();
|
|
324
|
+
this.asOf = new Map();
|
|
325
|
+
this.rootNode = null;
|
|
326
|
+
}
|
|
327
|
+
// ── event stream ──────────────────────────────────────────────────────
|
|
328
|
+
async pump(stream) {
|
|
329
|
+
try {
|
|
330
|
+
for await (const event of stream) {
|
|
331
|
+
if (event.event.case === "resync") {
|
|
332
|
+
// Awaited, so the events behind it are processed against the tree it
|
|
333
|
+
// produces rather than against the one it just invalidated.
|
|
334
|
+
await this.resync(event.event.value.reason);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
this.handle(event);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
// Cancelling is how stop() ends the stream, so that error is expected.
|
|
342
|
+
if (!this.stopped) {
|
|
343
|
+
this.failure = err instanceof Error ? err : new Error(String(err));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
handle(event) {
|
|
348
|
+
if (!this.ready) {
|
|
349
|
+
if (event.event.case !== undefined)
|
|
350
|
+
this.pendingEvents.push(event);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (event.event.case !== "update")
|
|
354
|
+
return;
|
|
355
|
+
const batch = event.event.value;
|
|
356
|
+
const frame = batch.frameId || this.mainFrame;
|
|
357
|
+
const seq = Number(batch.seq);
|
|
358
|
+
let entries;
|
|
359
|
+
try {
|
|
360
|
+
entries = JSON.parse(batch.edits || "[]");
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
void this.resync("overflow");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Which entries to keep is decided against the watermarks as they stand
|
|
367
|
+
// BEFORE the batch, and the watermarks are moved only afterwards.
|
|
368
|
+
// Entries within a batch share its sequence, and a frame swap is exactly
|
|
369
|
+
// a removal and an insertion on the same owner in one batch — judging the
|
|
370
|
+
// second against a watermark the first just raised would drop it.
|
|
371
|
+
const keep = [];
|
|
372
|
+
const touched = [];
|
|
373
|
+
for (const entry of entries) {
|
|
374
|
+
const key = keyOf(frame, targetOf(entry));
|
|
375
|
+
if ((this.asOf.get(key) ?? -1) >= seq)
|
|
376
|
+
continue;
|
|
377
|
+
keep.push(entry);
|
|
378
|
+
touched.push(key);
|
|
379
|
+
}
|
|
380
|
+
for (const entry of keep)
|
|
381
|
+
this.apply(entry, frame, seq);
|
|
382
|
+
for (const key of touched)
|
|
383
|
+
this.mark(key, seq);
|
|
384
|
+
if (seq > this.seqValue)
|
|
385
|
+
this.seqValue = seq;
|
|
386
|
+
this.onChange(this);
|
|
387
|
+
}
|
|
388
|
+
// ── applying one entry ────────────────────────────────────────────────
|
|
389
|
+
apply(entry, frame, seq) {
|
|
390
|
+
switch (entry.type) {
|
|
391
|
+
case "childNodeInserted": {
|
|
392
|
+
const parentKey = keyOf(frame, entry.parentId);
|
|
393
|
+
if (!this.nodes.has(parentKey))
|
|
394
|
+
return;
|
|
395
|
+
const fresh = this.touch(parentKey);
|
|
396
|
+
// The browser only reports insertions for nodes it expanded. If we do
|
|
397
|
+
// not have a child list for one, it had none when we last saw it, so
|
|
398
|
+
// an empty list is the right thing to grow from.
|
|
399
|
+
const kids = fresh.children ?? [];
|
|
400
|
+
const node = this.adopt(entry.node, { parent: parentKey, kind: "children" }, frame, seq);
|
|
401
|
+
const at = entry.previousNodeId === 0
|
|
402
|
+
? 0
|
|
403
|
+
: kids.findIndex((c) => c.backendNodeId === entry.previousNodeId) + 1;
|
|
404
|
+
kids.splice(at <= 0 ? 0 : at, 0, node);
|
|
405
|
+
fresh.children = kids;
|
|
406
|
+
fresh.childNodeCount = kids.length;
|
|
407
|
+
this.expandedKeys.add(parentKey);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
case "childNodeRemoved": {
|
|
411
|
+
const parentKey = keyOf(frame, entry.parentId);
|
|
412
|
+
const parent = this.nodes.get(parentKey);
|
|
413
|
+
if (!parent?.children)
|
|
414
|
+
return;
|
|
415
|
+
const fresh = this.touch(parentKey);
|
|
416
|
+
const kids = fresh.children ?? [];
|
|
417
|
+
// Matched on the id alone, not the pair. When the child is a hosted
|
|
418
|
+
// document the id is in ITS frame, not the parent's — but a frame
|
|
419
|
+
// owner has exactly one child, so there is nothing to confuse it with.
|
|
420
|
+
const at = kids.findIndex((c) => c.backendNodeId === entry.nodeId);
|
|
421
|
+
if (at < 0)
|
|
422
|
+
return;
|
|
423
|
+
this.forget(kids[at]);
|
|
424
|
+
kids.splice(at, 1);
|
|
425
|
+
fresh.children = kids;
|
|
426
|
+
fresh.childNodeCount = kids.length;
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
case "childNodeCountUpdated": {
|
|
430
|
+
const key = keyOf(frame, entry.nodeId);
|
|
431
|
+
if (!this.nodes.has(key))
|
|
432
|
+
return;
|
|
433
|
+
this.touch(key).childNodeCount = entry.count;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
case "childListReordered": {
|
|
437
|
+
const key = keyOf(frame, entry.nodeId);
|
|
438
|
+
const parent = this.nodes.get(key);
|
|
439
|
+
if (!parent?.children)
|
|
440
|
+
return;
|
|
441
|
+
const fresh = this.touch(key);
|
|
442
|
+
const have = new Map((fresh.children ?? []).map((c) => [c.backendNodeId, c]));
|
|
443
|
+
const next = [];
|
|
444
|
+
for (const id of entry.order) {
|
|
445
|
+
const child = have.get(id);
|
|
446
|
+
if (child)
|
|
447
|
+
next.push(child);
|
|
448
|
+
}
|
|
449
|
+
// The browser sends the complete order, so a mismatch means our copy
|
|
450
|
+
// drifted. Keeping the strays would hide that; dropping the batch and
|
|
451
|
+
// rebuilding is the only honest option.
|
|
452
|
+
if (next.length !== have.size) {
|
|
453
|
+
void this.resync("overflow");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
fresh.children = next;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
case "attributesUpdated": {
|
|
460
|
+
const key = keyOf(frame, entry.nodeId);
|
|
461
|
+
if (!this.nodes.has(key))
|
|
462
|
+
return;
|
|
463
|
+
this.touch(key).attributes = entry.attributes.slice();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
case "characterDataModified": {
|
|
467
|
+
const key = keyOf(frame, entry.nodeId);
|
|
468
|
+
if (!this.nodes.has(key))
|
|
469
|
+
return;
|
|
470
|
+
this.touch(key).nodeValue = entry.value;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// ── tree bookkeeping ──────────────────────────────────────────────────
|
|
476
|
+
/** Records that a node's state is current as of `seq`. Never moves back. */
|
|
477
|
+
mark(key, seq) {
|
|
478
|
+
if (seq > (this.asOf.get(key) ?? -1))
|
|
479
|
+
this.asOf.set(key, seq);
|
|
480
|
+
if (seq > this.seqValue)
|
|
481
|
+
this.seqValue = seq;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Replaces `key` and every ancestor with copies, so the path from the root
|
|
485
|
+
* to the changed node has new identities and nothing else does. Returns the
|
|
486
|
+
* fresh copy of `key`, which the caller then edits in place.
|
|
487
|
+
*
|
|
488
|
+
* The walk crosses frame boundaries without noticing them: a document is a
|
|
489
|
+
* child of the `<iframe>` hosting it like any other, so a change deep inside
|
|
490
|
+
* an out-of-process frame still produces a new `root`.
|
|
491
|
+
*/
|
|
492
|
+
touch(key) {
|
|
493
|
+
const original = this.nodes.get(key);
|
|
494
|
+
if (!original)
|
|
495
|
+
throw new Error(`dom mirror: node ${key} is not in the tree`);
|
|
496
|
+
const clone = { ...original };
|
|
497
|
+
if (original.children)
|
|
498
|
+
clone.children = original.children.slice();
|
|
499
|
+
if (original.shadowRoots)
|
|
500
|
+
clone.shadowRoots = original.shadowRoots.slice();
|
|
501
|
+
this.nodes.set(key, clone);
|
|
502
|
+
let childKey = key;
|
|
503
|
+
let childNode = clone;
|
|
504
|
+
for (;;) {
|
|
505
|
+
const slot = this.slots.get(childKey);
|
|
506
|
+
if (!slot) {
|
|
507
|
+
this.rootNode = childNode;
|
|
508
|
+
return clone;
|
|
509
|
+
}
|
|
510
|
+
const parent = this.nodes.get(slot.parent);
|
|
511
|
+
if (!parent)
|
|
512
|
+
return clone;
|
|
513
|
+
const parentClone = { ...parent };
|
|
514
|
+
const list = (parent[slot.kind] ?? []).slice();
|
|
515
|
+
const at = list.findIndex((n) => n.backendNodeId === childNode.backendNodeId &&
|
|
516
|
+
n.frameId === childNode.frameId);
|
|
517
|
+
if (at >= 0)
|
|
518
|
+
list[at] = childNode;
|
|
519
|
+
parentClone[slot.kind] = list;
|
|
520
|
+
this.nodes.set(slot.parent, parentClone);
|
|
521
|
+
childKey = slot.parent;
|
|
522
|
+
childNode = parentClone;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Registers a payload subtree and returns the copy that lives in the tree.
|
|
527
|
+
*
|
|
528
|
+
* `frame` is the id space the payload's ids belong to, and it changes here
|
|
529
|
+
* and nowhere else: a document node names its own frame, and everything
|
|
530
|
+
* below it counts in that frame. That is the whole of what crossing into an
|
|
531
|
+
* iframe means to a client.
|
|
532
|
+
*/
|
|
533
|
+
adopt(node, slot, frame, seq) {
|
|
534
|
+
// The browser puts the frame's own id on a document and the HOSTED
|
|
535
|
+
// frame's id on a frame owner element. Same field, two relationships —
|
|
536
|
+
// which is which is decided by the node type, and only one of them can be
|
|
537
|
+
// called frameId without making "which frame is this node in" ambiguous.
|
|
538
|
+
const isDocument = node.nodeType === 9;
|
|
539
|
+
const own = isDocument && node.frameId ? node.frameId : frame;
|
|
540
|
+
const key = keyOf(own, node.backendNodeId);
|
|
541
|
+
const copy = { ...node, frameId: own };
|
|
542
|
+
if (!isDocument && node.frameId && node.frameId !== own) {
|
|
543
|
+
copy.contentFrameId = node.frameId;
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
delete copy.contentFrameId;
|
|
547
|
+
}
|
|
548
|
+
this.nodes.set(key, copy);
|
|
549
|
+
this.mark(key, seq);
|
|
550
|
+
if (slot)
|
|
551
|
+
this.slots.set(key, slot);
|
|
552
|
+
else
|
|
553
|
+
this.slots.delete(key);
|
|
554
|
+
if (node.children) {
|
|
555
|
+
copy.children = node.children.map((c) => this.adopt(c, { parent: key, kind: "children" }, own, seq));
|
|
556
|
+
this.expandedKeys.add(key);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
delete copy.children;
|
|
560
|
+
this.expandedKeys.delete(key);
|
|
561
|
+
}
|
|
562
|
+
if (node.shadowRoots) {
|
|
563
|
+
copy.shadowRoots = node.shadowRoots.map((r) => this.adopt(r, { parent: key, kind: "shadowRoots" }, own, seq));
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
delete copy.shadowRoots;
|
|
567
|
+
}
|
|
568
|
+
return copy;
|
|
569
|
+
}
|
|
570
|
+
/** Replaces a node's child list from a fresh payload for the same node. */
|
|
571
|
+
mergeChildren(key, payload, frame, seq) {
|
|
572
|
+
if (!payload.children)
|
|
573
|
+
return;
|
|
574
|
+
const fresh = this.touch(key);
|
|
575
|
+
this.dropChildren(fresh);
|
|
576
|
+
fresh.children = payload.children.map((c) => this.adopt(c, { parent: key, kind: "children" }, frame, seq));
|
|
577
|
+
fresh.childNodeCount = fresh.children.length;
|
|
578
|
+
this.expandedKeys.add(key);
|
|
579
|
+
this.mark(key, seq);
|
|
580
|
+
}
|
|
581
|
+
dropChildren(node) {
|
|
582
|
+
for (const child of node.children ?? [])
|
|
583
|
+
this.forget(child);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Removes a subtree from the index. It descends through hosted documents
|
|
587
|
+
* like through anything else — they are children, and a frame stops being
|
|
588
|
+
* mirrored exactly when the element hosting it stops being expanded.
|
|
589
|
+
*/
|
|
590
|
+
forget(node) {
|
|
591
|
+
const key = keyOf(node.frameId, node.backendNodeId);
|
|
592
|
+
this.nodes.delete(key);
|
|
593
|
+
this.slots.delete(key);
|
|
594
|
+
this.expandedKeys.delete(key);
|
|
595
|
+
this.asOf.delete(key);
|
|
596
|
+
for (const child of node.children ?? [])
|
|
597
|
+
this.forget(child);
|
|
598
|
+
for (const root of node.shadowRoots ?? [])
|
|
599
|
+
this.forget(root);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// The node an entry is about, which for the two child-list entries is the
|
|
603
|
+
// PARENT: that is the node whose state they change, and whose watermark
|
|
604
|
+
// therefore decides whether they have already been folded in.
|
|
605
|
+
function targetOf(entry) {
|
|
606
|
+
switch (entry.type) {
|
|
607
|
+
case "childNodeInserted":
|
|
608
|
+
case "childNodeRemoved":
|
|
609
|
+
return entry.parentId;
|
|
610
|
+
default:
|
|
611
|
+
return entry.nodeId;
|
|
612
|
+
}
|
|
613
|
+
}
|