cyborg-hunter 0.7.4 → 0.8.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/CHANGELOG.md +63 -0
- package/CITATION.cff +2 -2
- package/README.md +21 -6
- package/dist/cyborg-hunter-replay.js +3 -3
- package/dist/cyborg-hunter.esm.js +3 -2
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/package.json +10 -2
- package/src/cli/ingest.js +311 -57
- package/src/cli/renderers/html-index-core.js +66 -12
- package/src/cli/renderers/html-index.js +7 -5
- package/src/cli/renderers/replay-assets.js +61 -7
- package/src/cli/renderers/replay-client-source.js +102 -0
- package/src/cli/renderers/replay-viewer.client.js +1750 -595
- package/src/cli/report.js +6 -0
- package/src/core/monitor.js +12 -13
- package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
- package/src/jspsych/extension-cyborg-hunter.js +1 -1
- package/src/jspsych/extension-guard-friction.js +59 -1
- package/src/replay/capture-dom.js +470 -458
- package/src/replay/capture-trace.js +688 -271
- package/src/replay/delivery.js +82 -0
- package/src/replay/dom-instantiate.js +779 -0
- package/src/replay/index.js +88 -5
- package/src/replay/initial-state.js +295 -0
- package/src/replay/mutations.js +668 -0
- package/src/replay/node-registry.js +116 -0
- package/src/replay/persistence.js +19 -6
- package/src/replay/recorder.js +342 -73
- package/src/replay/redaction.js +165 -0
- package/src/replay/serializer.js +148 -43
- package/src/replay/snapshot.js +409 -0
- package/src/replay/span.js +55 -0
- package/src/replay/viewer-model.js +293 -102
- package/src/shared/constants.js +1 -1
- package/src/shared/inline-safe.js +80 -0
- package/src/shared/schema-v2-validator.js +595 -0
- package/tools/convert/jspsych-v1-to-v2.mjs +432 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// src/replay/snapshot.js
|
|
2
|
+
// DomNode snapshot serializer for the v2 recording format (spec §4).
|
|
3
|
+
//
|
|
4
|
+
// Replaced the v1 HTML-string walker (capture-dom.js `serializeDom`) as the
|
|
5
|
+
// keyframe producer: a keyframe is a tree of `{id, kind, ...}` objects, and
|
|
6
|
+
// every node carries the integer id that later `dom.*` patches address it by.
|
|
7
|
+
// The string walker is gone; capture-dom.js calls this at every trial start.
|
|
8
|
+
//
|
|
9
|
+
// Three families of node never reach the file, and all three are deliberate:
|
|
10
|
+
// - script/noscript: skipped outright, no node and no placeholder. The
|
|
11
|
+
// player must never see executable content, and an empty <script> element
|
|
12
|
+
// in the tree would be a lie about the page's shape without being useful.
|
|
13
|
+
// - excluded subtrees (spec §4): reduced to a placeholder `{id, kind, tag}`,
|
|
14
|
+
// which keeps sibling positions and event targets coherent.
|
|
15
|
+
// - iframe children (spec §13): frames are recorded as the element only.
|
|
16
|
+
// In every case the registry still NUMBERS what is not emitted (assignTree is
|
|
17
|
+
// unconditional), so ids stay stable and a later lookup of a hidden node
|
|
18
|
+
// resolves — see node-registry.js on "numbered but never emitted".
|
|
19
|
+
//
|
|
20
|
+
// Which is why the emission rules are not private to the walk: `emitNode`
|
|
21
|
+
// branches on `isEmittableNode` / `carriesChildren`, and both — plus
|
|
22
|
+
// `nearestEmittedAncestor`, which composes them — are exported for the
|
|
23
|
+
// mutation mapper and, later, event-target resolution. One predicate, three
|
|
24
|
+
// callers; a second opinion about what reaches the file is how a `dom.remove`
|
|
25
|
+
// ends up naming a node the player never received.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
isRedacted, isInRedactedSubtree, markRedacted, isRedactionTainted, isInTaintedSubtree,
|
|
29
|
+
} from './redaction.js';
|
|
30
|
+
|
|
31
|
+
var ELEMENT_NODE = 1;
|
|
32
|
+
var TEXT_NODE = 3;
|
|
33
|
+
var COMMENT_NODE = 8;
|
|
34
|
+
|
|
35
|
+
// Executable or replay-irrelevant: never serialized, in any form.
|
|
36
|
+
var SKIP_TAGS = { SCRIPT: true, NOSCRIPT: true };
|
|
37
|
+
|
|
38
|
+
// HTMLMediaElement tags — the elements spec §5.4's media.* events address and
|
|
39
|
+
// the ones §4's `media_src` annotation describes.
|
|
40
|
+
var MEDIA_TAGS = { VIDEO: true, AUDIO: true };
|
|
41
|
+
|
|
42
|
+
// Spec §4's exclusion attribute. Its presence is enough; the value is free.
|
|
43
|
+
var EXCLUDE_ATTR = 'data-record-exclude';
|
|
44
|
+
|
|
45
|
+
// CH-v1's guard-bait markers, still stamped by the honeypot extension
|
|
46
|
+
// (extension-guard-honeypot.js). They keep working as exclusion markers, but
|
|
47
|
+
// under v2 semantics: a placeholder, not the v1 full drop.
|
|
48
|
+
var LEGACY_EXCLUDE_ATTRS = { 'data-ch-role': true, 'data-ch-decoy': true };
|
|
49
|
+
var LEGACY_EXCLUDE_ID = 'ch-decoy';
|
|
50
|
+
|
|
51
|
+
// Shadow hosts keep their LIGHT-DOM children, which are ordinary capturable
|
|
52
|
+
// nodes; what the format cannot carry is the shadow ROOT's content (spec §13).
|
|
53
|
+
// The flag marks that gap so a player can label it rather than present a
|
|
54
|
+
// partial reconstruction as complete. Caveat a player should know: children
|
|
55
|
+
// that the shadow tree slots elsewhere are recorded in their light-DOM
|
|
56
|
+
// position, because the slot assignment lives in the tree we cannot see.
|
|
57
|
+
var SHADOW_FLAG_ATTR = 'data-ch-shadow';
|
|
58
|
+
|
|
59
|
+
// Valid attribute-name token; also refuses event-handler attributes (on*).
|
|
60
|
+
// The player is required not to execute recording content (spec §12), but the
|
|
61
|
+
// artifact should not carry handlers at all — a recording gets opened by other
|
|
62
|
+
// tools too.
|
|
63
|
+
var ATTR_NAME_RE = /^[a-zA-Z_:][-a-zA-Z0-9_:.]*$/;
|
|
64
|
+
function isSerializableAttr(name) {
|
|
65
|
+
return ATTR_NAME_RE.test(name) && !/^on/i.test(name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function attrValue(node, name) {
|
|
69
|
+
var attrs = node.attributes || [];
|
|
70
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
71
|
+
if (attrs[i].name === name) return attrs[i].value;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when this element's contents must not enter the recording.
|
|
78
|
+
*
|
|
79
|
+
* `data-record-exclude` (spec §4) is the primary, researcher-facing control
|
|
80
|
+
* and has no opt-out: it guards consent text and sensitive UI, so a debugging
|
|
81
|
+
* flag must not be able to switch it off. The legacy CH markers are guard bait,
|
|
82
|
+
* and `keepBait` — the v1 analysis override (capture-dom.js `isBait`) — keeps
|
|
83
|
+
* turning exactly those off, unchanged.
|
|
84
|
+
*
|
|
85
|
+
* Exported because the mutation mapper (Task 3) must reach the same verdict:
|
|
86
|
+
* a patch that leaks the contents of a subtree the snapshot placeheld would
|
|
87
|
+
* defeat the exclusion one mutation later.
|
|
88
|
+
*/
|
|
89
|
+
export function isExcluded(node, opts) {
|
|
90
|
+
if (!node || node.nodeType !== ELEMENT_NODE) return false;
|
|
91
|
+
var attrs = node.attributes || [];
|
|
92
|
+
var legacy = false;
|
|
93
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
94
|
+
var name = attrs[i].name;
|
|
95
|
+
if (name === EXCLUDE_ATTR) return true;
|
|
96
|
+
if (LEGACY_EXCLUDE_ATTRS[name]) legacy = true;
|
|
97
|
+
}
|
|
98
|
+
if (opts && opts.keepBait) return false;
|
|
99
|
+
return legacy || node.id === LEGACY_EXCLUDE_ID;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The SUBTREE reading of exclusion — "this node or any ancestor is excluded" —
|
|
103
|
+
// used to live here as `isInExcludedSubtree`, mirroring `isInRedactedSubtree`
|
|
104
|
+
// (redaction.js) so a caller could not check one floor and forget the other.
|
|
105
|
+
// It has no callers left: capture-trace fused it into `floorsFor`, which walks
|
|
106
|
+
// the chain once and answers all three floors from that walk, and the snapshot
|
|
107
|
+
// walk never needed it (it descends the tree itself and stops at the
|
|
108
|
+
// placeholder, so `isExcluded` per node is its question). Removed rather than
|
|
109
|
+
// kept as a second, drifting statement of the same rule — the reason both
|
|
110
|
+
// floors were written to read identically in the first place (T3 final review,
|
|
111
|
+
// F-4).
|
|
112
|
+
|
|
113
|
+
// Canvas bitmap size (spec §4). The width/height IDL properties are the
|
|
114
|
+
// browser's authoritative numbers and are always present there — a bare
|
|
115
|
+
// <canvas> reports the spec default 300x150, which is its real bitmap size, so
|
|
116
|
+
// every canvas in a real capture carries the annotation. The attribute
|
|
117
|
+
// fallback and the null return exist for duck-typed fixture nodes, where
|
|
118
|
+
// guessing 300x150 would claim a size nobody measured.
|
|
119
|
+
function canvasSize(node) {
|
|
120
|
+
var w = intOr(node.width, attrValue(node, 'width'));
|
|
121
|
+
var h = intOr(node.height, attrValue(node, 'height'));
|
|
122
|
+
if (w === null || h === null) return null;
|
|
123
|
+
return { w: w, h: h };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function intOr(prop, attr) {
|
|
127
|
+
if (typeof prop === 'number' && isFinite(prop)) return prop;
|
|
128
|
+
var parsed = parseInt(attr, 10);
|
|
129
|
+
return isFinite(parsed) ? parsed : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Resolved media URL (spec §4). `currentSrc` is what the element actually
|
|
133
|
+
// loaded — it resolves <source> children and relative paths, which is what a
|
|
134
|
+
// player needs; `src` is the fallback when nothing has loaded yet.
|
|
135
|
+
function mediaSrc(node) {
|
|
136
|
+
var src = node.currentSrc || node.src || attrValue(node, 'src');
|
|
137
|
+
return typeof src === 'string' && src ? src : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Does a snapshot of this element carry this attribute at all? Split out of
|
|
141
|
+
// buildAttrs so the mutation mapper (Task 3) reaches the same verdict for a
|
|
142
|
+
// `dom.attr` patch: a patch carrying what the keyframe refuses would leak the
|
|
143
|
+
// same content one mutation later.
|
|
144
|
+
function isEmittedAttr(tagName, name, redacted) {
|
|
145
|
+
if (!isSerializableAttr(name)) return false;
|
|
146
|
+
// `srcdoc` is a whole HTML document inlined in an attribute, scripts
|
|
147
|
+
// included. Frame content never replays (spec §13), so it buys nothing,
|
|
148
|
+
// and carrying it would make the recording a transport for markup v1 never
|
|
149
|
+
// shipped (v1 swapped the iframe for a bare styled span). Same principle
|
|
150
|
+
// as the on* strip above: a recording gets opened by other tools too.
|
|
151
|
+
if (name === 'srcdoc' && tagName === 'IFRAME') return false;
|
|
152
|
+
// `value` is the one attribute that routinely carries participant-ENTERED
|
|
153
|
+
// content, which is what spec §8 redacts; the rest are author-written page
|
|
154
|
+
// content the recording exists to reconstruct, and stripping them all
|
|
155
|
+
// would leave an unrenderable box where the field was. Known residual
|
|
156
|
+
// (test-pinned in snapshot.test.js and mutations.test.js): a page that
|
|
157
|
+
// mirrors typed content into some other attribute — a data-* attribute,
|
|
158
|
+
// title, aria-label — leaks it here. Widening the strip is a spec question,
|
|
159
|
+
// not a silent fix.
|
|
160
|
+
if (name === 'value' && redacted) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The value a snapshot carries for an attribute it does emit. Images prefer
|
|
165
|
+
// the resolved `src` PROPERTY: reconstruction happens in a document with a
|
|
166
|
+
// different base URL, where the relative stimulus path a page author wrote
|
|
167
|
+
// resolves to nothing.
|
|
168
|
+
function emittedAttrValue(node, tagName, name, raw) {
|
|
169
|
+
if (name === 'src' && tagName === 'IMG' &&
|
|
170
|
+
typeof node.src === 'string' && node.src) return node.src;
|
|
171
|
+
return String(raw);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildAttrs(node, tagName, redacted) {
|
|
175
|
+
var out = {};
|
|
176
|
+
var wroteSrc = false;
|
|
177
|
+
|
|
178
|
+
var attrs = node.attributes || [];
|
|
179
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
180
|
+
var name = attrs[i].name;
|
|
181
|
+
if (!isEmittedAttr(tagName, name, redacted)) continue;
|
|
182
|
+
out[name] = emittedAttrValue(node, tagName, name, attrs[i].value);
|
|
183
|
+
if (name === 'src') wroteSrc = true;
|
|
184
|
+
}
|
|
185
|
+
if (!wroteSrc && tagName === 'IMG' && typeof node.src === 'string' && node.src) {
|
|
186
|
+
out.src = node.src;
|
|
187
|
+
}
|
|
188
|
+
if (node.shadowRoot) out[SHADOW_FLAG_ATTR] = '';
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sentinel: this attribute never reaches the file, so there is no patch to
|
|
194
|
+
* emit for it — distinct from a `null` VALUE, which is spec §5.1's encoding
|
|
195
|
+
* for "the attribute was removed".
|
|
196
|
+
*/
|
|
197
|
+
export var ATTR_WITHHELD = { withheld: true };
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The `value` a `dom.attr` patch should carry for this attribute right now,
|
|
201
|
+
* or ATTR_WITHHELD when a snapshot would not carry the attribute at all.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} node the mutated element
|
|
204
|
+
* @param {string} name the mutated attribute
|
|
205
|
+
* @param {boolean} redacted whether the element sits in a redacted subtree
|
|
206
|
+
*/
|
|
207
|
+
export function attrPatchValue(node, name, redacted) {
|
|
208
|
+
var tagName = node.tagName || '';
|
|
209
|
+
if (!isEmittedAttr(tagName, name, redacted)) return ATTR_WITHHELD;
|
|
210
|
+
var raw = attrValue(node, name);
|
|
211
|
+
if (raw === null) return null; // removed (spec §5.1)
|
|
212
|
+
return emittedAttrValue(node, tagName, name, raw);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every attribute a snapshot of this element would carry, with its values —
|
|
217
|
+
* the set an exclusion transition has to clear or restore wholesale.
|
|
218
|
+
*/
|
|
219
|
+
export function emittedAttrs(node, redacted) {
|
|
220
|
+
return buildAttrs(node, node.tagName || '', redacted);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Can this node appear in the file at all, wherever it sits? Elements the
|
|
225
|
+
* serializer skips outright (script/noscript) and node kinds spec §4 gives no
|
|
226
|
+
* `kind` to (doctype, processing instruction, fragment) cannot.
|
|
227
|
+
*
|
|
228
|
+
* Half of the emission rule; `carriesChildren` is the other half. Both are
|
|
229
|
+
* exported and both are what `emitNode` itself branches on, so "would this
|
|
230
|
+
* node be in the file" has exactly one answer in this codebase.
|
|
231
|
+
*/
|
|
232
|
+
export function isEmittableNode(node) {
|
|
233
|
+
if (!node) return false;
|
|
234
|
+
var type = node.nodeType;
|
|
235
|
+
if (type === TEXT_NODE || type === COMMENT_NODE) return true;
|
|
236
|
+
if (type !== ELEMENT_NODE) return false;
|
|
237
|
+
return !SKIP_TAGS[node.tagName || ''];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Does an emitted element carry its children into the file? Exclusion
|
|
242
|
+
* placeholders and iframes do not.
|
|
243
|
+
*
|
|
244
|
+
* `excluded` is a PARAMETER rather than a lookup because the mutation mapper
|
|
245
|
+
* has to ask about the state one mutation ago: when `data-record-exclude`
|
|
246
|
+
* lands on a live element, the DOM already reads "placeholder" while the file
|
|
247
|
+
* still holds the children the mapper must now remove.
|
|
248
|
+
*/
|
|
249
|
+
export function carriesChildren(el, excluded) {
|
|
250
|
+
if (!el || el.nodeType !== ELEMENT_NODE) return false;
|
|
251
|
+
if (excluded) return false;
|
|
252
|
+
// Frames are the element only (spec §13): the child document is a separate
|
|
253
|
+
// browsing context this recorder never observes, and any parser fallback
|
|
254
|
+
// content inside the element was never rendered.
|
|
255
|
+
return (el.tagName || '') !== 'IFRAME';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The nearest ancestor-or-self of `node` that the file actually contains, or
|
|
260
|
+
* null when nothing on the path to `root` does (including `node` being outside
|
|
261
|
+
* the observed root entirely).
|
|
262
|
+
*
|
|
263
|
+
* The caller is the mutation mapper (mutations.js), which emits a patch only
|
|
264
|
+
* when the answer IS the node: removing or re-describing something the player
|
|
265
|
+
* never received is at best a no-op and at worst (resolved to a placeholder)
|
|
266
|
+
* touches the wrong node.
|
|
267
|
+
*
|
|
268
|
+
* Event capture asks the same question a different way. capture-trace.js needs
|
|
269
|
+
* "the nearest node the FILE holds" for a target or an anchor, and reads it off
|
|
270
|
+
* the span's delivery model (delivery.js) rather than re-deriving it here: the
|
|
271
|
+
* live DOM can have moved since the walk that produced the file, and the file
|
|
272
|
+
* is what the player holds. This function stays the answer for callers that are
|
|
273
|
+
* looking at the tree as it is, and the two must agree about exclusion
|
|
274
|
+
* placeholders — which they do, because `emitNode` delivers the placeholder and
|
|
275
|
+
* not its children.
|
|
276
|
+
*
|
|
277
|
+
* `registry.peekId` answers "was it numbered", which is not the same question:
|
|
278
|
+
* assignTree numbers unconditionally, so scripts and hidden subtrees peek
|
|
279
|
+
* non-null. This function is the missing half, and it is built from the same
|
|
280
|
+
* predicates `emitNode` branches on rather than re-deriving them.
|
|
281
|
+
*
|
|
282
|
+
* @param {object} node
|
|
283
|
+
* @param {object} root the observed root
|
|
284
|
+
* @param {object} [opts] {keepBait} — exclusion depends on it
|
|
285
|
+
* @param {object} [detachedParent] the parent a just-REMOVED node had, since
|
|
286
|
+
* its own `parentNode` is already null
|
|
287
|
+
*/
|
|
288
|
+
export function nearestEmittedAncestor(node, root, opts, detachedParent) {
|
|
289
|
+
if (!node || !root) return null;
|
|
290
|
+
opts = opts || {};
|
|
291
|
+
var chain = [];
|
|
292
|
+
var cur = node;
|
|
293
|
+
while (cur && cur !== root) {
|
|
294
|
+
chain.push(cur);
|
|
295
|
+
var parent = cur.parentNode;
|
|
296
|
+
if (!parent && chain.length === 1 && detachedParent) parent = detachedParent;
|
|
297
|
+
cur = parent;
|
|
298
|
+
}
|
|
299
|
+
if (cur !== root || !isEmittableNode(root)) return null;
|
|
300
|
+
|
|
301
|
+
var emitted = root;
|
|
302
|
+
for (var i = chain.length - 1; i >= 0; i--) {
|
|
303
|
+
if (!carriesChildren(emitted, isExcluded(emitted, opts))) return emitted;
|
|
304
|
+
if (!isEmittableNode(chain[i])) return emitted;
|
|
305
|
+
emitted = chain[i];
|
|
306
|
+
}
|
|
307
|
+
return emitted;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function emitNode(node, span, opts, inheritedRedaction, parent) {
|
|
311
|
+
if (!isEmittableNode(node)) return null;
|
|
312
|
+
var type = node.nodeType;
|
|
313
|
+
// Everything this function emits enters the file, and the span's delivery
|
|
314
|
+
// model is where the file's contents are tracked (mutations.js reads it to
|
|
315
|
+
// decide what the player already holds). Recording it HERE means the
|
|
316
|
+
// keyframe walk and a mid-span dom.add subtree are accounted for by the
|
|
317
|
+
// same line.
|
|
318
|
+
span.delivery.deliver(node, parent || null);
|
|
319
|
+
// Redaction travels WITH the node, not with its current position: a node
|
|
320
|
+
// this recording once stripped stays stripped even after it is moved out of
|
|
321
|
+
// the redacted subtree (redaction.js on the taint set). Every node emitted
|
|
322
|
+
// under redaction joins the set here, which is the one place that knows.
|
|
323
|
+
if (!inheritedRedaction && isRedactionTainted(node, opts.taint)) inheritedRedaction = true;
|
|
324
|
+
|
|
325
|
+
if (type === TEXT_NODE || type === COMMENT_NODE) {
|
|
326
|
+
// Structure survives redaction, content does not: the node keeps its id
|
|
327
|
+
// and position with an empty string, so patches addressing it still land
|
|
328
|
+
// and the reconstruction keeps the same shape. `text` stays present —
|
|
329
|
+
// spec §4 types it as a required string.
|
|
330
|
+
if (inheritedRedaction) markRedacted(node, opts.taint);
|
|
331
|
+
return {
|
|
332
|
+
id: span.registry.idFor(node),
|
|
333
|
+
kind: type === TEXT_NODE ? 'text' : 'comment',
|
|
334
|
+
text: inheritedRedaction ? '' : String(node.textContent || ''),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
var tagName = node.tagName || '';
|
|
338
|
+
var id = span.registry.idFor(node);
|
|
339
|
+
if (isExcluded(node, opts)) return { id: id, kind: 'element', tag: tagName.toLowerCase() };
|
|
340
|
+
|
|
341
|
+
var redacted = inheritedRedaction || isRedacted(node, opts.redactSelector);
|
|
342
|
+
if (redacted) markRedacted(node, opts.taint);
|
|
343
|
+
var out = {
|
|
344
|
+
id: id,
|
|
345
|
+
kind: 'element',
|
|
346
|
+
tag: tagName.toLowerCase(),
|
|
347
|
+
attrs: buildAttrs(node, tagName, redacted),
|
|
348
|
+
children: [],
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
var size = tagName === 'CANVAS' ? canvasSize(node) : null;
|
|
352
|
+
if (size) out.canvas_size = size;
|
|
353
|
+
// Withheld inside a redacted subtree: `currentSrc` is a RESOLVED url that no
|
|
354
|
+
// attribute need contain — a blob/object URL for participant-supplied media
|
|
355
|
+
// is the case that matters — so this annotation can expose what buildAttrs
|
|
356
|
+
// does not. (The plain `src` attribute is still emitted; see there.)
|
|
357
|
+
var src = MEDIA_TAGS[tagName] && !redacted ? mediaSrc(node) : null;
|
|
358
|
+
if (src) out.media_src = src;
|
|
359
|
+
|
|
360
|
+
// Not excluded here (that returned above), so this is the iframe rule.
|
|
361
|
+
if (!carriesChildren(node, false)) return out;
|
|
362
|
+
|
|
363
|
+
var kids = node.childNodes || [];
|
|
364
|
+
for (var i = 0; i < kids.length; i++) {
|
|
365
|
+
var child = emitNode(kids[i], span, opts, redacted, node);
|
|
366
|
+
if (child) out.children.push(child);
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Serialize a subtree into the DomNode tree of a keyframe (spec §4).
|
|
373
|
+
*
|
|
374
|
+
* @param {object} root the observed root (or, mid-span, an inserted subtree)
|
|
375
|
+
* @param {object} span a capture span (span.js): supplies the ids and
|
|
376
|
+
* receives the record of what this walk put in the file
|
|
377
|
+
* @param {object} [opts] {keepBait, redactSelector, taint}
|
|
378
|
+
* @returns {object|null} the root DomNode; null if the root itself is unserializable
|
|
379
|
+
*
|
|
380
|
+
* Registry contract: the whole tree is numbered FIRST, in one unconditional
|
|
381
|
+
* pre-order pass, and only then walked for emission. That ordering is what
|
|
382
|
+
* makes ids independent of serialization decisions — a node hidden behind an
|
|
383
|
+
* exclusion placeholder or skipped as a script still holds its number, so
|
|
384
|
+
* removing an exclusion attribute later does not renumber its siblings.
|
|
385
|
+
*
|
|
386
|
+
* `span.reset()` is the CALLER's call (keyframe cadence, Task 8): this
|
|
387
|
+
* function only ever adds to the span it is given. Calling it at a keyframe
|
|
388
|
+
* means resetting first, so that this walk is the span's first allocation and
|
|
389
|
+
* the tree numbers 1..N (the precondition node-registry.js documents). A walk
|
|
390
|
+
* whose output is NOT going into the file — sizing a snapshot, inspecting a
|
|
391
|
+
* tree — takes a fresh span of its own, which is what keeps it from telling a
|
|
392
|
+
* live recording that the player holds what it has never been sent.
|
|
393
|
+
*/
|
|
394
|
+
export function serializeTree(root, span, opts) {
|
|
395
|
+
opts = opts || {};
|
|
396
|
+
if (!root) return null;
|
|
397
|
+
span.registry.assignTree(root);
|
|
398
|
+
// Ancestors above the root count, for BOTH halves of the question: a
|
|
399
|
+
// redaction selector matching a wrapper outside the observed root redacts
|
|
400
|
+
// everything inside it, and so does a tainted ancestor — which is what makes
|
|
401
|
+
// a subtree serialized MID-SPAN (a `dom.add` payload, a reveal) agree with
|
|
402
|
+
// the keyframe that will describe the same nodes later. `emitNode` already
|
|
403
|
+
// inherits taint downward once the walk is inside; this is the seed that
|
|
404
|
+
// carries the answer in from above the root (T3 final review, F-1).
|
|
405
|
+
return emitNode(root, span, opts,
|
|
406
|
+
isInRedactedSubtree(root, opts.redactSelector)
|
|
407
|
+
|| isInTaintedSubtree(root, opts.taint),
|
|
408
|
+
root.parentNode || null);
|
|
409
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/replay/span.js
|
|
2
|
+
// The KEYFRAME SPAN: one recording's node ids and one recording's picture of
|
|
3
|
+
// what the file already contains, bound together because they are the same
|
|
4
|
+
// lifetime and the same mistake.
|
|
5
|
+
//
|
|
6
|
+
// A span is what capture threads through everything. `serializeTree` writes
|
|
7
|
+
// into it (numbering nodes, recording what it emitted) and `mapMutations`
|
|
8
|
+
// reads it (which node the player holds, and where). Two properties follow
|
|
9
|
+
// from binding them, and neither survived them being separate:
|
|
10
|
+
//
|
|
11
|
+
// - **Resetting is one call.** Ids restart at a keyframe and the delivered
|
|
12
|
+
// picture is rebuilt by the same walk, so they must reset together.
|
|
13
|
+
// Missing one is not stale bookkeeping: a keyframe taken with a stale
|
|
14
|
+
// delivery model believes the player already holds what it is about to
|
|
15
|
+
// stop describing, and the next patch for that node is suppressed. The
|
|
16
|
+
// node then never appears in the recording.
|
|
17
|
+
// - **A serialization taken to measure cannot corrupt a live recording.**
|
|
18
|
+
// Sizing a snapshot (Task 8's cadence trigger, a byte-cap probe) means
|
|
19
|
+
// serializing a tree the recorder is also serializing. Give that call its
|
|
20
|
+
// OWN span and it writes into its own ids and its own delivered set; there
|
|
21
|
+
// is no shared default to fall back into.
|
|
22
|
+
//
|
|
23
|
+
// NOT in the span, deliberately: the redaction taint set (redaction.js). It
|
|
24
|
+
// tracks content this recording has ever withheld, which must outlive any
|
|
25
|
+
// keyframe — a leak scan reads the whole file — and a stray serialization can
|
|
26
|
+
// only ever add to it, which is the safe direction.
|
|
27
|
+
|
|
28
|
+
import { createRegistry } from './node-registry.js';
|
|
29
|
+
import { createDelivery } from './delivery.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a capture span.
|
|
33
|
+
*
|
|
34
|
+
* @returns {{registry: object, delivery: object, reset: () => void}}
|
|
35
|
+
* `registry` — node ids (node-registry.js)
|
|
36
|
+
* `delivery` — what the file contains (delivery.js)
|
|
37
|
+
* `reset()` — start a new keyframe span: ids restart at 1 and the delivered
|
|
38
|
+
* picture empties, in one call so neither can be forgotten.
|
|
39
|
+
*
|
|
40
|
+
* One per recording (the capture wiring creates it); reset at each keyframe
|
|
41
|
+
* (the cadence logic calls it); a fresh one for any serialization whose output
|
|
42
|
+
* is not going into the file.
|
|
43
|
+
*/
|
|
44
|
+
export function createSpan() {
|
|
45
|
+
var registry = createRegistry();
|
|
46
|
+
var delivery = createDelivery();
|
|
47
|
+
return {
|
|
48
|
+
registry: registry,
|
|
49
|
+
delivery: delivery,
|
|
50
|
+
reset: function () {
|
|
51
|
+
registry.resetSpan();
|
|
52
|
+
delivery.reset();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|