@reactra/behaviours 0.1.0-alpha.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.
@@ -0,0 +1,735 @@
1
+ // @reactra/behaviours/replayable — session-recording runtime for `uses replayable`.
2
+ //
3
+ // Owner spec: reactra-replay-spec.md §3 (format) / §4 (emission) / §5 (runtime) /
4
+ // §7 (dev-vs-prod gating). `uses replayable` is a compiler-native behaviour: Pass 9
5
+ // emits an inner-body preamble (`useReplayChannel` + a mount/unmount effect), an
6
+ // `action` + post-body `state_snapshot` emit per action, and a status `useEffect`
7
+ // per resource (Compiler §4 Pass 9.1). This module is the runtime those emitted
8
+ // calls drive — NOT a higher-order wrapper.
9
+ //
10
+ // Recording is OFF by default (§7): the channel is a no-op until
11
+ // `configureReplay({ enabled: true })`. The instrumentation is always emitted
12
+ // (never conditional — no Rules-of-Hooks violation); only the *recording* is
13
+ // gated, here, at the channel level. Node-portable: pure JS + React hooks,
14
+ // `performance.now()`, `Date.now()`. No browser- or Bun-only APIs.
15
+ import { useEffect, useRef } from "react";
16
+ const DEFAULT_CONFIG = {
17
+ enabled: false,
18
+ redactKeys: [],
19
+ maxEvents: 10_000,
20
+ excludeActions: [],
21
+ excludeComponents: [],
22
+ includeStateKeys: [],
23
+ coalesceMs: 0,
24
+ sample: 1,
25
+ maxEventsPerSecond: 0,
26
+ keyframeEvery: 20,
27
+ };
28
+ let config = DEFAULT_CONFIG;
29
+ const routeGlobal = globalThis;
30
+ /** Synthetic instance id for the route snapshot — shared with devtools. */
31
+ const ROUTE_ID = "Route#1";
32
+ const ROUTE_NAME = "Route";
33
+ /**
34
+ * Receive a router transition and record it as a `Route#1` keyframe snapshot
35
+ * (§5, C1/C3). Flattens `params`/`query` to top-level so a user's existing
36
+ * `redactKeys` match by param name; runs the whole object through the same
37
+ * `filterState` + `warnUnredactedPII` path every snapshot uses; and — when a
38
+ * param/query value is redacted out — scrubs that raw value from the stored
39
+ * `path` string too (the raw URL must not leak a value `filterState` removed).
40
+ * Gated on `recordingSession() !== null`, so the drive's own `navigate()` (which
41
+ * runs in replay mode) never records a feedback route event.
42
+ */
43
+ const receiveRoute = (route) => {
44
+ // Suppress during replay mode / when paused / sampled out — reuse the shared
45
+ // gate, identical to `emit`. Without this the drive's navigate() would record
46
+ // a feedback route event (the devtools scrub-nav-leak race).
47
+ if (recordingSession() === null)
48
+ return;
49
+ // §5 C4: exclusion is enforced here (the receiver bypasses the channel that
50
+ // normally checks it) — `excludeComponents:["Route"]` turns the tap off.
51
+ if (config.excludeComponents.includes(ROUTE_NAME))
52
+ return;
53
+ const path = route.pathname + (route.search ? `?${route.search}` : "");
54
+ // Flatten params + query to top-level for redaction matching, keeping the
55
+ // structured `path`/`pathname`/`params`/`query` for fidelity + the drive side.
56
+ const flat = { path, pathname: route.pathname };
57
+ for (const [k, v] of Object.entries(route.params))
58
+ flat[k] = v;
59
+ for (const [k, v] of Object.entries(route.query))
60
+ flat[k] = v;
61
+ flat.params = route.params;
62
+ flat.query = route.query;
63
+ const filtered = filterState(flat);
64
+ // Any flattened param/query key that `filterState` dropped is redacted — scrub
65
+ // its raw value from EVERY surviving string surface (`path`, `pathname`) and
66
+ // from the structured `params`/`query` copies, so a value redaction removed
67
+ // never leaks through the raw URL or a sibling field.
68
+ const redactedValues = [];
69
+ const safeParams = { ...route.params };
70
+ const safeQuery = { ...route.query };
71
+ for (const key of Object.keys(route.params)) {
72
+ if (key in filtered)
73
+ continue;
74
+ redactedValues.push(route.params[key]);
75
+ delete safeParams[key];
76
+ }
77
+ for (const key of Object.keys(route.query)) {
78
+ if (key in filtered)
79
+ continue;
80
+ const v = route.query[key];
81
+ for (const part of Array.isArray(v) ? v : [v])
82
+ redactedValues.push(part);
83
+ delete safeQuery[key];
84
+ }
85
+ const scrub = (s) => redactedValues.reduce(scrubValueFromPath, s);
86
+ if (typeof filtered.path === "string")
87
+ filtered.path = scrub(filtered.path);
88
+ if (typeof filtered.pathname === "string")
89
+ filtered.pathname = scrub(filtered.pathname);
90
+ if ("params" in filtered)
91
+ filtered.params = safeParams;
92
+ if ("query" in filtered)
93
+ filtered.query = safeQuery;
94
+ emit({ type: "state_snapshot", componentId: ROUTE_ID, state: filtered, timestamp: Date.now(), keyframe: true }, ROUTE_NAME);
95
+ };
96
+ /** Replace every occurrence of a raw redacted value in the URL string with `[redacted]`. */
97
+ const scrubValueFromPath = (path, value) => {
98
+ if (value === "")
99
+ return path;
100
+ const candidates = new Set([value, encodeURIComponent(value)]);
101
+ let out = path;
102
+ for (const c of candidates) {
103
+ if (c === "")
104
+ continue;
105
+ out = out.split(c).join("[redacted]");
106
+ }
107
+ return out;
108
+ };
109
+ /**
110
+ * Enable/configure session recording (Replay spec §5). `enabled` defaults to
111
+ * `false` — keep it off in production unless behind a sampling flag or a
112
+ * "report a bug" action. v1.5 adds the recording controls: exclusion
113
+ * (`excludeActions`/`excludeComponents`/`includeStateKeys`), coalescing
114
+ * (`coalesceMs`), session sampling (`sample`), and the per-second rate guard
115
+ * (`maxEventsPerSecond` → `gap` events).
116
+ */
117
+ export const configureReplay = (opts) => {
118
+ // §5 v1.5: allowlist and blocklist snapshot modes are mutually exclusive —
119
+ // both NON-EMPTY is a config error (explicit empty arrays are fine).
120
+ if ((opts.redactKeys?.length ?? 0) > 0 && (opts.includeStateKeys?.length ?? 0) > 0) {
121
+ throw new Error(`[reactra:replay] configureReplay: \`redactKeys\` (blocklist) and \`includeStateKeys\` ` +
122
+ `(allowlist) are mutually exclusive — set one or the other. (Replay §5 v1.5.)`);
123
+ }
124
+ config = {
125
+ enabled: opts.enabled ?? DEFAULT_CONFIG.enabled,
126
+ redactKeys: opts.redactKeys ?? DEFAULT_CONFIG.redactKeys,
127
+ maxEvents: opts.maxEvents ?? DEFAULT_CONFIG.maxEvents,
128
+ sink: opts.sink,
129
+ excludeActions: opts.excludeActions ?? DEFAULT_CONFIG.excludeActions,
130
+ excludeComponents: opts.excludeComponents ?? DEFAULT_CONFIG.excludeComponents,
131
+ includeStateKeys: opts.includeStateKeys ?? DEFAULT_CONFIG.includeStateKeys,
132
+ coalesceMs: opts.coalesceMs ?? DEFAULT_CONFIG.coalesceMs,
133
+ sample: opts.sample ?? DEFAULT_CONFIG.sample,
134
+ maxEventsPerSecond: opts.maxEventsPerSecond ?? DEFAULT_CONFIG.maxEventsPerSecond,
135
+ keyframeEvery: opts.keyframeEvery ?? DEFAULT_CONFIG.keyframeEvery,
136
+ meta: opts.meta,
137
+ };
138
+ // Reconfiguring starts a fresh session — a bundle that straddled a config
139
+ // change (e.g. redaction toggled mid-stream) would be inconsistent. Any
140
+ // pending coalesced pairs belong to the old generation: DISCARD them
141
+ // (cancel timers) rather than flush into the fresh session.
142
+ discardAllPendings();
143
+ session = null;
144
+ // §5: the warn-once sets (PII + sink error) are per configuration generation,
145
+ // and replay mode resets too (configureReplay's reset-everything semantics).
146
+ warnedPIIKeys = new Set();
147
+ warnedSinkError = false;
148
+ warnedSetterDriftKeys = new Set();
149
+ replayMode = false;
150
+ // Install/uninstall the route tap (§5). Only own the global while enabled;
151
+ // never clobber a receiver another consumer installed.
152
+ if (config.enabled) {
153
+ routeGlobal.__REACTRA_REPLAY_ROUTE__ = receiveRoute;
154
+ }
155
+ else if (routeGlobal.__REACTRA_REPLAY_ROUTE__ === receiveRoute) {
156
+ delete routeGlobal.__REACTRA_REPLAY_ROUTE__;
157
+ }
158
+ };
159
+ const liveInstances = new Map();
160
+ let replayMode = false;
161
+ /**
162
+ * Suspend recording while a replay is being driven (§5). Without the gate,
163
+ * driving setters would pollute a session with feedback events — the driven
164
+ * state fires the per-resource status effects. Explicit pair: the mode
165
+ * outlives a single drive call while the user scrubs.
166
+ */
167
+ export const enterReplayMode = () => {
168
+ replayMode = true;
169
+ };
170
+ /** Resume recording after a replay drive (§5). */
171
+ export const exitReplayMode = () => {
172
+ replayMode = false;
173
+ };
174
+ /**
175
+ * Streamed-mode ring trimming (§5 v1.6 — Stage R2). The TRANSPORT calls this
176
+ * with a durability watermark when the server acks: events at or below the
177
+ * watermark are dropped from the local ring, EXCEPT the newest keyframe per
178
+ * instance ≤ the watermark is retained as the local fold-start. An instance
179
+ * with no keyframe at/below the watermark keeps its oldest in-ring keyframe
180
+ * (above it) untrimmed naturally — its tail-start (C2). `finalizeReplay()`
181
+ * afterwards returns the TAIL bundle; the server is the durable store. The
182
+ * runtime stays ack-agnostic: this is a timestamp, never protocol detail.
183
+ */
184
+ export const acknowledgeReplay = (uptoTimestamp) => {
185
+ if (session === null)
186
+ return;
187
+ const s = session;
188
+ const live = s.events.slice(s.head);
189
+ // Newest keyframe ≤ watermark, per instance.
190
+ const retain = new Map();
191
+ for (const e of live) {
192
+ if (e.timestamp > uptoTimestamp)
193
+ break;
194
+ if (e.type === "state_snapshot" && e.keyframe)
195
+ retain.set(e.componentId, e);
196
+ }
197
+ const kept = [...retain.values()];
198
+ for (const e of live) {
199
+ if (e.timestamp > uptoTimestamp)
200
+ kept.push(e);
201
+ }
202
+ s.events = kept;
203
+ s.head = 0;
204
+ };
205
+ /**
206
+ * Drive every currently mounted instance of `componentName` to `state` through
207
+ * its registered setters (§5). Keys without a registered setter are ignored
208
+ * (cross-version bundles degrade gracefully); React 19 auto-batches the set
209
+ * calls into one re-render. Returns the number of instances driven.
210
+ *
211
+ * Per-key setter-drift warn (B4 §5): when a MOUNTED instance has no setter for
212
+ * a bundle key, the key silently won't re-drive — the recorded session is from a
213
+ * different component shape (renamed/removed `state`). We surface that per (component,
214
+ * key) once per session, dev-only. The whole-component zero-drive case is already
215
+ * surfaced by the caller via the returned count (timeTravel.ts), so this catches the
216
+ * partial-drift case: the component drives but a SUBSET of its keys have no setter.
217
+ */
218
+ export const applyReplayState = (state, componentName) => {
219
+ let driven = 0;
220
+ for (const inst of liveInstances.values()) {
221
+ if (inst.componentName !== componentName)
222
+ continue;
223
+ for (const [key, value] of Object.entries(state)) {
224
+ const setter = inst.setters[key];
225
+ if (setter)
226
+ setter(value);
227
+ else
228
+ warnSetterDrift(componentName, key);
229
+ }
230
+ driven++;
231
+ }
232
+ return driven;
233
+ };
234
+ /**
235
+ * True only in a CONFIRMED production build. Bundlers (Vite/webpack) statically
236
+ * replace the `process.env.NODE_ENV` literal at build time, so we reference that
237
+ * literal directly — a browser bundle has no runtime `process`, and a
238
+ * `typeof process` guard (the prior form) stays a runtime check that resolves to
239
+ * "undefined" in the browser, which silently suppressed the warn in the exact
240
+ * environment replay re-drive runs in. The try/catch covers a bundler that
241
+ * leaves the literal un-replaced (no `process` global): treat unknown as
242
+ * non-production so the dev signal is emitted rather than swallowed.
243
+ */
244
+ const isProductionEnv = () => {
245
+ try {
246
+ return process.env.NODE_ENV === "production";
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ };
252
+ /**
253
+ * Dev-gated, warn-once-per-(component,key)-per-session signal that a re-drive key
254
+ * has no registered setter on a mounted instance (B4 §5). Suppressed only in a
255
+ * confirmed production build (see {@link isProductionEnv}) so it surfaces in dev
256
+ * AND in the browser dev bundle (where replay re-drive actually runs). Resets
257
+ * with the session in `configureReplay` (per-configuration generation). Plain
258
+ * `console.warn` — no error-code prefix (matches the DVT001/DVT005 warn style).
259
+ */
260
+ const warnSetterDrift = (componentName, key) => {
261
+ if (isProductionEnv())
262
+ return;
263
+ const id = `${componentName}.${key}`;
264
+ if (warnedSetterDriftKeys.has(id))
265
+ return;
266
+ warnedSetterDriftKeys.add(id);
267
+ console.warn(`[reactra:replay] applyReplayState: "${componentName}" has no setter for key "${key}" — ` +
268
+ `the recorded session is from a different component shape (renamed/removed state). ` +
269
+ `That key will not re-drive. (Replay spec §5.)`);
270
+ };
271
+ let warnedSetterDriftKeys = new Set();
272
+ let warnedSinkError = false;
273
+ /** Swallow a sink failure — a broken transport must not break the app or the recording (§5). */
274
+ const guardSink = (fn) => {
275
+ try {
276
+ fn();
277
+ }
278
+ catch (err) {
279
+ if (warnedSinkError)
280
+ return;
281
+ warnedSinkError = true;
282
+ console.warn(`[reactra:replay] the configured sink threw — events are still recorded locally, but the ` +
283
+ `transport is broken (warned once per configureReplay): ${String(err)}`);
284
+ }
285
+ };
286
+ // ---------------------------------------------------------------------------
287
+ // PII-convention warning (§5/§11 — runtime, warn-once). Segment-equality
288
+ // matching: `cardNumber` → ["card","number"] warns; `cardinality` does not.
289
+ // The list is shared with RLIM-01's planned Phase-2 auto-redact.
290
+ // ---------------------------------------------------------------------------
291
+ const PII_SEGMENTS = ["password", "token", "secret", "card", "ssn", "cvv"];
292
+ /** Split a field name on underscores + camelCase boundaries, lowercased (§5). */
293
+ const nameSegments = (name) => name
294
+ .split(/[_\s-]+|(?=[A-Z])/)
295
+ .map((s) => s.toLowerCase())
296
+ .filter((s) => s.length > 0);
297
+ const looksLikePII = (key) => nameSegments(key).some((seg) => PII_SEGMENTS.includes(seg));
298
+ let warnedPIIKeys = new Set();
299
+ /**
300
+ * Warn once per offending key when a recorded snapshot carries a field whose
301
+ * name segment-matches the PII convention list (§5). Runs on the post-redaction
302
+ * object, so a `redactKeys` field never reaches it.
303
+ */
304
+ const warnUnredactedPII = (state) => {
305
+ for (const key of Object.keys(state)) {
306
+ if (warnedPIIKeys.has(key) || !looksLikePII(key))
307
+ continue;
308
+ warnedPIIKeys.add(key);
309
+ console.warn(`[reactra:replay] BHV005: state field "${key}" looks like PII (segment match against ` +
310
+ `[${PII_SEGMENTS.join(", ")}]) but is not in redactKeys — it is being recorded. ` +
311
+ `Add it to configureReplay({ redactKeys: [...] }) to drop it from snapshots. (Replay §5/§11.)`);
312
+ }
313
+ };
314
+ let session = null;
315
+ /**
316
+ * Pending coalesced-pair flushers, one per channel holding a pair (§5 v1.5
317
+ * `coalesceMs`). `finalizeReplay` flushes them synchronously (C1a);
318
+ * `configureReplay` discards them (a fresh generation).
319
+ */
320
+ const pendingFlushes = new Map(); // flush -> discard
321
+ const flushAllPendings = () => {
322
+ for (const flush of [...pendingFlushes.keys()])
323
+ flush();
324
+ };
325
+ const discardAllPendings = () => {
326
+ for (const discard of [...pendingFlushes.values()])
327
+ discard();
328
+ pendingFlushes.clear();
329
+ };
330
+ /** Monotonic per-component-name instance counter — mints the `Name#N` ids (§4.3 rule 1). */
331
+ const instanceCounts = new Map();
332
+ const mintInstanceId = (componentName) => {
333
+ const next = (instanceCounts.get(componentName) ?? 0) + 1;
334
+ instanceCounts.set(componentName, next);
335
+ return `${componentName}#${next}`;
336
+ };
337
+ const mintSessionId = () => `ses_${Math.random().toString(36).slice(2, 11)}`;
338
+ /** Lazily start a session on the first emit while enabled (the first `mount()` in practice). */
339
+ const ensureSession = () => {
340
+ if (session === null) {
341
+ session = {
342
+ startTime: Date.now(),
343
+ sessionId: mintSessionId(),
344
+ events: [],
345
+ head: 0,
346
+ components: new Set(),
347
+ lastAction: new Map(),
348
+ // §5 v1.5: session-level sampling, decided exactly once per session.
349
+ sampled: config.sample >= 1 || Math.random() < config.sample,
350
+ rate: { epoch: -1, count: 0, dropped: 0, droppedBy: "" },
351
+ meta: config.meta,
352
+ };
353
+ }
354
+ return session;
355
+ };
356
+ /**
357
+ * The recording gate shared by the emit path and the coalescing accumulator
358
+ * (§5 v1.5 C1b: a sampled-out session must never accumulate pending pairs or
359
+ * timers). Returns the live session, or null when nothing should record.
360
+ */
361
+ const recordingSession = () => {
362
+ if (!config.enabled || replayMode)
363
+ return null;
364
+ const s = ensureSession();
365
+ return s.sampled ? s : null;
366
+ };
367
+ /** Ring push with O(1) drop-oldest (head index + periodic compaction). */
368
+ const pushRing = (s, event) => {
369
+ s.events.push(event);
370
+ if (s.events.length - s.head > config.maxEvents)
371
+ s.head++;
372
+ // Compact once the dead prefix dominates — amortized O(1) per push.
373
+ if (s.head > 1024 && s.head * 2 > s.events.length) {
374
+ s.events = s.events.slice(s.head);
375
+ s.head = 0;
376
+ }
377
+ };
378
+ /** Push an event when recording is enabled; ring-cap to `maxEvents` (drop oldest). No-op when disabled, replay-driving, or sampled out (§5). */
379
+ const emit = (event, componentName) => {
380
+ const s = recordingSession();
381
+ if (s === null)
382
+ return false;
383
+ // §5 v1.5 rate guard — fixed-epoch per-second budget (C5). `gap` markers
384
+ // themselves never consume budget.
385
+ if (config.maxEventsPerSecond > 0 && event.type !== "gap") {
386
+ const epoch = Math.floor(event.timestamp / 1000);
387
+ if (epoch !== s.rate.epoch) {
388
+ if (s.rate.dropped > 0) {
389
+ emit({ type: "gap", componentId: s.rate.droppedBy, dropped: s.rate.dropped, timestamp: event.timestamp }, componentName);
390
+ }
391
+ s.rate.epoch = epoch;
392
+ s.rate.count = 0;
393
+ s.rate.dropped = 0;
394
+ }
395
+ if (s.rate.count >= config.maxEventsPerSecond) {
396
+ s.rate.dropped++;
397
+ s.rate.droppedBy = "componentId" in event ? event.componentId : "";
398
+ return false;
399
+ }
400
+ s.rate.count++;
401
+ }
402
+ // Both snapshot paths (channel + useReplayCapture) funnel through here with
403
+ // an already-filtered object — surviving keys are by definition recorded.
404
+ if (event.type === "state_snapshot")
405
+ warnUnredactedPII(event.state);
406
+ s.components.add(componentName);
407
+ pushRing(s, event);
408
+ // §5 sink tap (v1.3): synchronous, post-redaction, after the ring push.
409
+ // Session identity is stable for the whole session — the transport's
410
+ // boundary signal is a CHANGED sessionId, not a callback. (v1.5: a
411
+ // COALESCED pair reaches here only at its flush — the documented deferred-
412
+ // delivery carve-out.)
413
+ if (config.sink) {
414
+ const sink = config.sink;
415
+ guardSink(() => sink.event(event, { sessionId: s.sessionId, startTime: s.startTime, meta: s.meta }));
416
+ }
417
+ return true;
418
+ };
419
+ /**
420
+ * Snapshot filtering (§3/§5): allowlist mode records ONLY `includeStateKeys`;
421
+ * otherwise blocklist mode drops `redactKeys`. The two are mutually exclusive
422
+ * by configureReplay's guard.
423
+ */
424
+ const filterState = (state) => {
425
+ if (config.includeStateKeys.length > 0) {
426
+ const out = {};
427
+ for (const k of config.includeStateKeys) {
428
+ if (k in state)
429
+ out[k] = state[k];
430
+ }
431
+ return out;
432
+ }
433
+ if (config.redactKeys.length === 0)
434
+ return state;
435
+ const out = {};
436
+ for (const [k, v] of Object.entries(state)) {
437
+ if (!config.redactKeys.includes(k))
438
+ out[k] = v;
439
+ }
440
+ return out;
441
+ };
442
+ const makeChannel = (componentId, componentName) => {
443
+ // §5 v1.5 recording controls — per-instance state. `excludedPending` marks
444
+ // "the next snapshot belongs to an excluded action, skip it"; `pending`
445
+ // holds at most one coalesced action+snapshot pair.
446
+ let excludedPending = false;
447
+ let pending = null;
448
+ // §5 v1.6 deltas — the per-instance diff base (the last RECORDED state) and
449
+ // the per-session snapshot count for keyframe minting. Reset on session
450
+ // change (a delta against a previous session's state would be wrong).
451
+ let deltaBase = null;
452
+ let snapshotCount = 0;
453
+ let deltaSessionId = null;
454
+ let txCount = 0; // per-instance command-transaction counter (txId minting)
455
+ // Exclusion is checked AT CALL TIME (config may change between renders).
456
+ const componentExcluded = () => config.excludeComponents.includes(componentName);
457
+ /**
458
+ * Record a committed FULL filtered snapshot as a keyframe or a delta (§3
459
+ * v1.6). The diff base advances ONLY when the event actually records —
460
+ * a rate-guard drop leaves the base untouched, so the dropped change is
461
+ * absorbed into the NEXT delta instead of lost.
462
+ */
463
+ const emitSnapshot = (full, timestamp) => {
464
+ const s = recordingSession();
465
+ if (s === null)
466
+ return;
467
+ if (s.sessionId !== deltaSessionId) {
468
+ deltaBase = null;
469
+ snapshotCount = 0;
470
+ deltaSessionId = s.sessionId;
471
+ }
472
+ const isKeyframe = deltaBase === null || snapshotCount % config.keyframeEvery === 0;
473
+ let event;
474
+ if (isKeyframe) {
475
+ event = { type: "state_snapshot", componentId, state: full, timestamp, keyframe: true };
476
+ }
477
+ else {
478
+ // Shallow !== — immutable updates always produce new references; errs
479
+ // toward recording MORE, never less (§3 v1.6).
480
+ const delta = {};
481
+ for (const [k, v] of Object.entries(full)) {
482
+ if (deltaBase[k] !== v)
483
+ delta[k] = v;
484
+ }
485
+ if (Object.keys(delta).length === 0)
486
+ return; // nothing changed — record nothing
487
+ event = { type: "state_snapshot", componentId, state: delta, timestamp };
488
+ }
489
+ if (emit(event, componentName)) {
490
+ deltaBase = full;
491
+ snapshotCount++;
492
+ }
493
+ };
494
+ const discardPending = () => {
495
+ if (pending === null)
496
+ return;
497
+ clearTimeout(pending.timer);
498
+ pending = null;
499
+ pendingFlushes.delete(flushPending);
500
+ };
501
+ /** Commit the held pair — in order, through the fully-gated emit path. */
502
+ const flushPending = () => {
503
+ if (pending === null)
504
+ return;
505
+ const { action, snapshot } = pending;
506
+ discardPending();
507
+ emit(action, componentName);
508
+ if (snapshot)
509
+ emitSnapshot(snapshot.state, snapshot.timestamp);
510
+ };
511
+ return {
512
+ mount(setters) {
513
+ // Live-drive registration (§5 v1.4) happens regardless of the recording
514
+ // gate AND of recording exclusion — exclusion governs recording, not
515
+ // playback (§5 v1.5).
516
+ if (setters)
517
+ liveInstances.set(componentId, { componentName, setters });
518
+ if (componentExcluded())
519
+ return;
520
+ flushPending(); // a real event boundary breaks any coalescing window
521
+ emit({ type: "mount", componentId, phase: "mount", timestamp: Date.now() }, componentName);
522
+ },
523
+ unmount() {
524
+ liveInstances.delete(componentId);
525
+ if (componentExcluded())
526
+ return;
527
+ flushPending();
528
+ emit({ type: "mount", componentId, phase: "unmount", timestamp: Date.now() }, componentName);
529
+ },
530
+ action(name, payload, writes) {
531
+ if (componentExcluded())
532
+ return;
533
+ // The exclusion mark clears at the start of every action — pure actions
534
+ // have no trailing snapshot to consume it (§5 v1.5).
535
+ excludedPending = false;
536
+ if (config.excludeActions.includes(name)) {
537
+ // C2a: an excluded action is still an event BOUNDARY — flush any
538
+ // pending coalesced pair first, then record nothing for itself.
539
+ flushPending();
540
+ excludedPending = true;
541
+ return;
542
+ }
543
+ // Payload redaction (§5): blocklist hit, OR — in allowlist mode — a
544
+ // write-set DISJOINT from the allowlist (every non-listed key is treated
545
+ // as redacted on BOTH surfaces, v1.5). Whole-payload replacement: params
546
+ // can't be soundly mapped to the fields they flow into.
547
+ const allowlist = config.includeStateKeys;
548
+ const redact = writes !== undefined &&
549
+ (writes.some((w) => config.redactKeys.includes(w)) ||
550
+ (allowlist.length > 0 && !writes.some((w) => allowlist.includes(w))));
551
+ const event = {
552
+ type: "action",
553
+ name,
554
+ payload: redact ? ["[redacted]"] : payload,
555
+ ...(redact ? { payloadRedacted: true } : {}),
556
+ componentId,
557
+ timestamp: Date.now(),
558
+ duration: 0,
559
+ };
560
+ if (config.coalesceMs > 0) {
561
+ // C1b: never accumulate for a disabled / replay-mode / sampled-out
562
+ // session — the same gate emit() applies.
563
+ if (recordingSession() === null)
564
+ return;
565
+ if (pending !== null &&
566
+ pending.action.name === name &&
567
+ event.timestamp - pending.action.timestamp <= config.coalesceMs) {
568
+ // Same-(instance, action) burst: keep the LAST occurrence.
569
+ clearTimeout(pending.timer);
570
+ pending.action = event;
571
+ pending.snapshot = undefined;
572
+ pending.timer = setTimeout(flushPending, config.coalesceMs);
573
+ return;
574
+ }
575
+ flushPending();
576
+ pending = { action: event, timer: setTimeout(flushPending, config.coalesceMs) };
577
+ pendingFlushes.set(flushPending, discardPending);
578
+ return;
579
+ }
580
+ emit(event, componentName);
581
+ // Remember it so the post-body `snapshot(_, t0)` can fill the real duration.
582
+ if (config.enabled && session !== null)
583
+ session.lastAction.set(componentId, event);
584
+ },
585
+ snapshot(state, t0) {
586
+ if (componentExcluded())
587
+ return;
588
+ if (excludedPending) {
589
+ // The paired snapshot of an excluded action — consume and skip.
590
+ excludedPending = false;
591
+ return;
592
+ }
593
+ const filtered = filterState(state);
594
+ if (pending !== null) {
595
+ // The held pair's snapshot — replaces any earlier one in the window.
596
+ // Stored as FULL filtered state; delta conversion happens at commit.
597
+ pending.action.duration = performance.now() - t0;
598
+ pending.snapshot = { state: filtered, timestamp: Date.now() };
599
+ return;
600
+ }
601
+ const last = session?.lastAction.get(componentId);
602
+ if (last)
603
+ last.duration = performance.now() - t0;
604
+ emitSnapshot(filtered, Date.now());
605
+ },
606
+ resource(name, status) {
607
+ if (componentExcluded())
608
+ return;
609
+ flushPending(); // event boundary
610
+ emit({ type: "resource", name, status, componentId, timestamp: Date.now() }, componentName);
611
+ },
612
+ begin(name, payload, _base) {
613
+ // Excluded component: a no-op transaction (recording off, drive still works).
614
+ if (componentExcluded()) {
615
+ return { mark() { }, commit() { }, rollback() { } };
616
+ }
617
+ flushPending(); // event boundary (like resource)
618
+ const txId = `${componentId}~c${++txCount}`;
619
+ // Record the command invocation. (Payload redaction for `command` is a
620
+ // future refinement — the settled STATE is PII-filtered below regardless.)
621
+ emit({ type: "action", name, payload, componentId, timestamp: Date.now(), duration: 0 }, componentName);
622
+ const txMark = (phase, state) => {
623
+ emit({ type: "tx_mark", componentId, txId, phase, state: filterState(state), timestamp: Date.now() }, componentName);
624
+ };
625
+ return {
626
+ // optimistic apply: a NON-FOLDING tx_mark only — never a state_snapshot,
627
+ // so statesAt can never return the optimistic transient.
628
+ mark(state) {
629
+ txMark("optimistic", state);
630
+ },
631
+ // settle success: the committed marker + the real (foldable) snapshot.
632
+ commit(state) {
633
+ txMark("committed", state);
634
+ emitSnapshot(filterState(state), Date.now());
635
+ },
636
+ // settle failure: the rolled-back marker + a snapshot at base. When base
637
+ // equals the last recorded state the delta is empty → emitSnapshot records
638
+ // nothing (correct: a failed transaction adds no committed stop).
639
+ rollback(state) {
640
+ txMark("rolledback", state);
641
+ emitSnapshot(filterState(state), Date.now());
642
+ },
643
+ };
644
+ },
645
+ };
646
+ };
647
+ /**
648
+ * Create a replay channel bound to a freshly-minted `Name#N` instance id — the
649
+ * non-hook primitive `useReplayChannel` wraps. Useful for imperative/non-React
650
+ * contexts and for testing the recording pieces without a renderer.
651
+ */
652
+ export const createReplayChannel = (componentName) => makeChannel(mintInstanceId(componentName), componentName);
653
+ /**
654
+ * Bind a per-instance replay channel (Replay spec §4.1). The compiler emits
655
+ * `const __replay = useReplayChannel("<ComponentName>")`; the runtime mints the
656
+ * stable `Name#N` instance id (the compiler never emits the instance suffix —
657
+ * §4.3 rule 1). The channel is created once per mounted instance (`useRef`), so
658
+ * the mount/unmount `useEffect` (emitted with `[]` deps) captures a stable handle.
659
+ */
660
+ export const useReplayChannel = (componentName) => {
661
+ const ref = useRef(null);
662
+ if (ref.current === null)
663
+ ref.current = createReplayChannel(componentName);
664
+ return ref.current;
665
+ };
666
+ /**
667
+ * Finalize the active session into a `SessionBundle` for upload/inspection
668
+ * (Replay spec §2/§5), then end it. Returns `null` when recording is disabled or
669
+ * no session was started — the documented "disabled = no bundle" contract
670
+ * (§8, RP-05). User-callable from `cleanup { … }` or a "report a bug" handler.
671
+ */
672
+ export const finalizeReplay = async () => {
673
+ // §5 v1.5 C1a: synchronously flush + cancel every pending coalesced pair
674
+ // BEFORE building the bundle, so a burst followed by an immediate finalize
675
+ // loses nothing.
676
+ flushAllPendings();
677
+ if (!config.enabled || session === null)
678
+ return null;
679
+ const s = session;
680
+ session = null;
681
+ // §5 v1.5 `sample`: a sampled-out session recorded nothing — the existing
682
+ // "disabled = no bundle" shape (RP-05).
683
+ if (!s.sampled)
684
+ return null;
685
+ const bundle = {
686
+ version: "2.1",
687
+ mode: "state-snapshot",
688
+ startTime: s.startTime,
689
+ duration: Date.now() - s.startTime,
690
+ sessionId: s.sessionId,
691
+ components: [...s.components],
692
+ events: s.head > 0 ? s.events.slice(s.head) : s.events,
693
+ };
694
+ // §5 sink tap (v1.3): the transport's end-of-session signal. Recording
695
+ // semantics are unchanged — the bundle is still returned to the caller.
696
+ if (config.sink?.finalize) {
697
+ const sink = config.sink;
698
+ guardSink(() => sink.finalize?.(bundle));
699
+ }
700
+ return bundle;
701
+ };
702
+ /**
703
+ * Register plain-React state (held outside the DSL) with the active session so
704
+ * it appears in snapshots (Replay spec §6). Ordinary user-called hook code built
705
+ * from `useEffect` — emits a `state_snapshot` of the non-redacted fields whenever
706
+ * a captured value changes (no-op while recording is disabled). Fields marked
707
+ * `redact` (or matching `redactKeys`) are excluded.
708
+ */
709
+ export const useReplayCapture = (fields) => {
710
+ const ref = useRef(null);
711
+ if (ref.current === null)
712
+ ref.current = mintInstanceId("PlainReact");
713
+ const componentId = ref.current;
714
+ useEffect(() => {
715
+ if (!config.enabled)
716
+ return;
717
+ const state = {};
718
+ for (const f of fields) {
719
+ if (f.redact || config.redactKeys.includes(f.name))
720
+ continue;
721
+ state[f.name] = f.value;
722
+ }
723
+ emit({ type: "state_snapshot", componentId, state: filterState(state), timestamp: Date.now(), keyframe: true }, "PlainReact");
724
+ },
725
+ // Re-emit when any captured value changes (the standard interop pattern).
726
+ fields.map((f) => f.value));
727
+ };
728
+ /**
729
+ * Identity HOF kept for type-level `uses` resolution. `uses replayable` is
730
+ * compiler-native (inner-body emission — Replay spec §4), so this wrapper is NOT
731
+ * applied to the component; the real runtime is `useReplayChannel` above.
732
+ */
733
+ export const replayable = (component) => component;
734
+ export default replayable;
735
+ //# sourceMappingURL=replayable.js.map