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.
Files changed (39) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/CITATION.cff +2 -2
  3. package/README.md +21 -6
  4. package/dist/cyborg-hunter-replay.js +3 -3
  5. package/dist/cyborg-hunter.esm.js +3 -2
  6. package/dist/cyborg-hunter.min.js +3 -3
  7. package/dist/extension-cyborg-hunter.js +1 -1
  8. package/dist/extension-guard-friction.js +5 -5
  9. package/package.json +10 -2
  10. package/src/cli/ingest.js +311 -57
  11. package/src/cli/renderers/html-index-core.js +66 -12
  12. package/src/cli/renderers/html-index.js +7 -5
  13. package/src/cli/renderers/replay-assets.js +61 -7
  14. package/src/cli/renderers/replay-client-source.js +102 -0
  15. package/src/cli/renderers/replay-viewer.client.js +1750 -595
  16. package/src/cli/report.js +6 -0
  17. package/src/core/monitor.js +12 -13
  18. package/src/jspsych/extension-cyborg-hunter-replay.js +64 -3
  19. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  20. package/src/jspsych/extension-guard-friction.js +59 -1
  21. package/src/replay/capture-dom.js +470 -458
  22. package/src/replay/capture-trace.js +688 -271
  23. package/src/replay/delivery.js +82 -0
  24. package/src/replay/dom-instantiate.js +779 -0
  25. package/src/replay/index.js +88 -5
  26. package/src/replay/initial-state.js +295 -0
  27. package/src/replay/mutations.js +668 -0
  28. package/src/replay/node-registry.js +116 -0
  29. package/src/replay/persistence.js +19 -6
  30. package/src/replay/recorder.js +342 -73
  31. package/src/replay/redaction.js +165 -0
  32. package/src/replay/serializer.js +148 -43
  33. package/src/replay/snapshot.js +409 -0
  34. package/src/replay/span.js +55 -0
  35. package/src/replay/viewer-model.js +293 -102
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/inline-safe.js +80 -0
  38. package/src/shared/schema-v2-validator.js +595 -0
  39. 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
+ }