Source: lib/core/trace/devtools.js

/**
 * @file devtools.js
 * @description The browser side of `avenx serve --trace`.
 *
 * Recording is off unless a developer asks for it. The dev server injects one
 * call to {@link installTraceRecorder} when `--trace` is passed, and nothing in
 * this module runs otherwise — no listeners, no timers, no globals.
 *
 * What it adds is the smallest thing that closes the loop: start recording,
 * let the developer reproduce the bug, then send the trace to the dev server so
 * `avenx trace list` can see it. Everything after that happens in the terminal,
 * where the rest of Avenx's tooling already lives.
 * @module lib/core/trace/devtools
 */

import { startRecording, stopRecording, activeRecorder } from './recorder.js';
import { installRecordingGlobals, clearGlobalOverrides } from './globals.js';
import { tracer } from './tracer.js';

/**
 * Where a recording is posted when the developer saves it.
 * @type {string}
 */
export const TRACE_ENDPOINT = '/__avenx/trace';

/**
 * The control surface published on `window.avenxTrace` while recording.
 * @type {object|null}
 */
let controller = null;

/**
 * Starts recording in a browser and publishes a small control surface.
 *
 * The recorder is armed after the current task rather than immediately, so the
 * application's initial mount is treated as setup rather than as a session of
 * unexplained state writes.
 * @param {object} [options] - Recorder options.
 * @param {string} [options.endpoint] - Where `save()` posts the trace.
 * @param {string[]} [options.redact] - Property paths to withhold.
 * @param {number} [options.maxNodes] - Ring-buffer capacity.
 * @param {boolean} [options.autoSave] - Save automatically on page hide.
 * @returns {object} The control surface, also published as `window.avenxTrace`.
 */
export function installTraceRecorder(options = {}) {
  if (controller) {
    return controller;
  }

  const endpoint = options.endpoint || TRACE_ENDPOINT;
  const recorder = startRecording({
    redact: options.redact || [],
    maxNodes: options.maxNodes,
    meta: {
      url: typeof location !== 'undefined' ? location.href : undefined,
      userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
    },
  });

  installRecordingGlobals(recorder);

  // Mount happens synchronously after this call, so arming is deferred to the
  // next macrotask. Writes made while a component builds itself are the app
  // starting up, not a user interaction the trace can attribute.
  setTimeout(() => {
    if (activeRecorder() === recorder) {
      recorder.arm();
    }
  }, 0);

  /**
   * Posts the current trace to the dev server.
   * @returns {Promise<object>} `{ok, id}` or `{ok: false, error}`.
   */
  const save = async () => {
    const current = activeRecorder();
    if (!current) {
      return { ok: false, error: 'Recording has already stopped.' };
    }
    const trace = current.toJSON();
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(trace),
      });
      if (!response.ok) {
        return { ok: false, error: `Dev server responded ${response.status}` };
      }
      console.info(
        `[Avenx] Saved ${trace.id} (${trace.nodes.length} nodes, ${trace.determinism.status}).\n` +
          `        avenx trace view ${trace.id}\n` +
          `        avenx trace export ${trace.id}`,
      );
      return { ok: true, id: trace.id };
    } catch (error) {
      return { ok: false, error: String((error && error.message) || error) };
    }
  };

  controller = {
    /** The id this recording will be saved under. */
    get id() {
      return recorder.id;
    },
    /** How many nodes have been recorded so far. */
    get size() {
      return recorder.nodes.length;
    },
    /** Whether the recording is still believed replayable. */
    get deterministic() {
      return recorder.isDeterministic;
    },
    save,
    /** The trace as it stands, without saving it. */
    snapshot: () => recorder.toJSON(),
    /** Stops recording and restores the real globals. */
    stop: () => {
      clearGlobalOverrides();
      controller = null;
      return stopRecording();
    },
  };

  if (typeof window !== 'undefined') {
    window.avenxTrace = controller;

    if (options.autoSave !== false && typeof document !== 'undefined') {
      // `pagehide` rather than `beforeunload`: it fires for back/forward cache
      // navigations too, which is exactly when a developer has finished
      // reproducing something and clicked away.
      window.addEventListener('pagehide', () => {
        if (activeRecorder() === recorder && recorder.nodes.length > 0) {
          // A keepalive beacon, because a normal fetch is cancelled on unload.
          try {
            const body = JSON.stringify(recorder.toJSON());
            if (navigator.sendBeacon) {
              navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
            }
          } catch {
            // Losing a trace on unload is not worth breaking navigation over.
          }
        }
      });
    }

    console.info(
      `[Avenx] Recording trace ${recorder.id}. Reproduce the behaviour, then run:\n` +
        '        await avenxTrace.save()\n' +
        '        (or just navigate away — the trace is sent automatically)',
    );
  }

  return controller;
}

/**
 * Stops any recording this module started.
 * @returns {object|null} The finished trace.
 */
export function uninstallTraceRecorder() {
  if (!controller) {
    return null;
  }
  return controller.stop();
}

/**
 * Whether a browser recording is currently running.
 * @returns {boolean}
 */
export function isRecording() {
  return controller !== null && tracer.on;
}