Source: lib/core/trace/resource.js

/**
 * @file resource.js
 * @description Recording and replay of `<resource>` requests.
 *
 * Resources are Avenx's declarative async boundary, which makes them the one
 * place the framework can see network work happening. Recording their
 * settlements — and feeding those settlements back during replay — is what
 * lets a recorded session be reproduced without a server.
 *
 * This deliberately does not intercept `fetch` or `XMLHttpRequest` globally. A
 * trace that silently swallowed every request in the page would be recording
 * work Avenx has no model of, and would report success for reproductions that
 * only appeared to work. Requests made outside a `<resource>` are not
 * reproduced, and a trace whose state depends on them diverges visibly during
 * replay rather than passing by accident.
 * @module lib/core/trace/resource
 */

import { tracer } from './tracer.js';
import { TraceNodeType, NonDeterminismReason } from './schema.js';

/**
 * Recorded settlements queued per resource name, oldest first.
 *
 * Empty outside replay, so {@link takeRecordedResponse} is a `Map.size` check
 * on the recording path.
 * @type {Map<string, object[]>}
 */
const recordedResponses = new Map();

/**
 * Called when replay asks for a response that was never recorded.
 * @type {function(string): void|null}
 */
let onMissingResponse = null;

/**
 * Loads a trace's resource settlements so replay can serve them.
 * @param {object} trace - The trace to replay.
 * @param {function(string): void} [onMissing] - Called when a resource asks for a response the trace does not have.
 */
export function installResourceResponses(trace, onMissing) {
  recordedResponses.clear();
  onMissingResponse = onMissing || null;

  for (const node of trace.nodes || []) {
    if (node.type !== TraceNodeType.RESOURCE || node.phase !== 'settled') {
      continue;
    }
    let queue = recordedResponses.get(node.name);
    if (!queue) {
      queue = [];
      recordedResponses.set(node.name, queue);
    }
    queue.push({ status: node.status, value: node.value, error: node.error });
  }
}

/**
 * Discards any loaded responses, restoring live resource behaviour.
 */
export function clearResourceResponses() {
  recordedResponses.clear();
  onMissingResponse = null;
}

/**
 * Whether replay has responses loaded.
 * @returns {boolean}
 */
export function hasRecordedResponses() {
  return recordedResponses.size > 0;
}

/**
 * Takes the next recorded settlement for a resource.
 *
 * A resource that asks for more settlements than were recorded is reported
 * rather than served a repeat: replay has gone further than the recording did,
 * which is divergence, and inventing a response would hide it.
 * @param {string} name - The resource name.
 * @returns {object|null} `{status, value, error}`, or null when nothing is queued.
 */
export function takeRecordedResponse(name) {
  if (recordedResponses.size === 0) {
    return null;
  }
  const queue = recordedResponses.get(name);
  if (!queue || queue.length === 0) {
    if (onMissingResponse) {
      onMissingResponse(name);
    }
    return null;
  }
  return queue.shift();
}

/**
 * Opens a node for a resource that has started work.
 * @param {string} name - The resource name.
 * @param {object|null} component - The owning component instance.
 * @param {number} pollInterval - The configured poll interval, if any.
 * @returns {object|null} The opened node, for later settlement.
 */
export function traceResourceStart(name, component, pollInterval) {
  const recorder = tracer.sink;
  if (!recorder) {
    return null;
  }

  if (pollInterval > 0) {
    // How many times a polling resource settled depends on wall-clock time, so
    // the sequence cannot be reproduced by replaying inputs.
    recorder.markNonDeterministic(
      NonDeterminismReason.POLLING_RESOURCE,
      `<resource name="${name}"> polls every ${pollInterval}ms`,
    );
  }

  return tracer.record(TraceNodeType.RESOURCE, {
    name,
    phase: 'pending',
    component: component && component.constructor && component.constructor.name,
  });
}

/**
 * Records how a resource settled.
 *
 * Settlements are recorded as their own node rather than by annotating the
 * pending one: a resource settles asynchronously, and a trace that mutated an
 * older node in place would lose the ordering that replay depends on.
 * @param {object|null} pending - The node returned by {@link traceResourceStart}.
 * @param {string} name - The resource name.
 * @param {'resolved'|'rejected'} status - How it settled.
 * @param {any} payload - The value, or the error.
 */
export function traceResourceSettle(pending, name, status, payload) {
  const recorder = tracer.sink;
  if (!recorder) {
    return;
  }

  const node = {
    name,
    phase: 'settled',
    status,
    // Parented to the request rather than to whatever happened to be running
    // when the promise settled, which is a microtask with no causal relation.
    request: pending ? pending.id : undefined,
  };

  if (status === 'rejected') {
    node.error = {
      name: (payload && payload.name) || 'Error',
      message: String((payload && payload.message) || payload),
    };
  } else {
    node.value = recorder.capture(payload, `resource.${name}`);
  }

  tracer.record(TraceNodeType.RESOURCE, node);
}