Source: lib/core/runtime/bridge.js

/**
 * @file bridge.js
 * @description The Avenx-JS Bridge factory.
 *
 * A Bridge is a module-scoped, reactive unit of shared state and behaviour.
 * Components reach a bridge by importing it, which is what makes the
 * connection statically visible to the compiler:
 *
 *   // src/bridges/auth.bridge.js
 *   import { bridge } from 'avenx-core/runtime';
 *
 *   export default bridge({
 *     state: { user: null },
 *     get isLoggedIn() { return this.user !== null; },
 *     login(user) {
 *       this.user = user;
 *       this.emit('login', user);
 *     },
 *   });
 *
 *   // any component
 *   import auth from '../bridges/auth.bridge.js';
 *   <p>{{ auth.user?.name }}</p>
 *
 * Two facades are built over one reactive state object:
 *
 *   - `this` inside actions, getters and setup() can read *and* write state,
 *     and can emit events.
 *   - the exported instance can read state, call actions and subscribe with
 *     `on()`, but cannot assign state and cannot emit.
 *
 * The split is lexical rather than temporal, so it survives `await` inside an
 * async action, and it gives every mutation a single traceable origin.
 */

import { StateFactory } from '../reactive/createState.js';
import { pushWatcher, popWatcher } from '../reactive/watcher.js';
import { onScopeDispose, runInScope } from '../reactive/scope.js';
import { AvenxError, AvenxErrorCodes, formatMessage } from './AvenxError.js';
import { logger } from './AvenxLogger.js';
import { tracer } from '../trace/tracer.js';
import { TraceNodeType } from '../trace/schema.js';
import { traceDerived } from '../trace/derived.js';
import { journal } from '../reactive/journal.js';
import { atomicOptions } from './atomic.js';

/**
 * Marks a value as an Avenx bridge instance.
 * @type {symbol}
 */
export const IS_BRIDGE = Symbol.for('avenx.bridge');

/**
 * Internal channel used by the compiler to label a bridge. A symbol keeps it
 * out of the member namespace that templates and `ownKeys` see.
 * @type {symbol}
 */
const SET_NAME = Symbol.for('avenx.bridge.setName');

/**
 * Definition keys that the Bridge API owns and a definition may not redeclare.
 * @type {string[]}
 */
const RESERVED_KEYS = ['on', 'emit', '$dispose', '$name'];

/**
 * Keys of the consumer-facing instance that are not bridge state.
 * @type {Set<string>}
 */
const INSTANCE_API_KEYS = new Set(['on', '$dispose', '$name']);

const stateFactory = new StateFactory();

/**
 * Deep-copies plain objects and arrays so a bridge never mutates the literal
 * that was passed to `bridge()`. Everything else (class instances, Dates,
 * functions, Maps) is intentionally shared by reference: those values are not
 * made reactive either, so copying them would only be surprising.
 * @param {any} value - The value to copy.
 * @returns {any} A copy for plain containers, or the original value.
 */
function cloneInitial(value) {
  if (Array.isArray(value)) {
    return value.map(cloneInitial);
  }
  if (value !== null && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
    const copy = {};
    for (const key of Object.keys(value)) {
      copy[key] = cloneInitial(value[key]);
    }
    return copy;
  }
  return value;
}

/**
 * Runs a function outside of any reactive watcher, so reads performed inside it
 * are not attributed to whichever render happened to trigger it.
 * @template T
 * @param {function(): T} fn - The function to run untracked.
 * @returns {T} Whatever `fn` returned.
 */
function untracked(fn) {
  pushWatcher(null);
  try {
    return fn();
  } finally {
    popWatcher();
  }
}

/**
 * Creates a Bridge: a reactive unit of shared state and behaviour that
 * components consume by importing it.
 * @param {object} definition - The bridge definition.
 * @param {object} [definition.state] - Initial shared state. Reactive, read-only for consumers.
 * @param {Function} [definition.setup] - Lazy initializer run on first use. May return a cleanup function.
 * @returns {object} The bridge instance to export from the module.
 */
export function bridge(definition) {
  if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) {
    throw new AvenxError(
      AvenxErrorCodes.BRIDGE_INVALID_DEFINITION,
      definition === null ? 'null' : Array.isArray(definition) ? 'an array' : typeof definition,
    );
  }

  const descriptors = Object.getOwnPropertyDescriptors(definition);

  /**
   * Getter name to getter implementation.
   * @type {Map<string, Function>}
   */
  const getters = new Map();
  /**
   * Action name to raw implementation.
   * @type {Map<string, Function>}
   */
  const rawActions = new Map();
  /** @type {Function|null} */
  let setupFn = null;

  for (const [key, descriptor] of Object.entries(descriptors)) {
    if (RESERVED_KEYS.includes(key)) {
      throw new AvenxError(AvenxErrorCodes.BRIDGE_RESERVED_KEY, key, RESERVED_KEYS.join(', '));
    }
    if (key === 'state') {
      if (descriptor.get) {
        throw new AvenxError(AvenxErrorCodes.BRIDGE_RESERVED_KEY, 'state', 'state must be a plain object, not a getter');
      }
      continue;
    }
    if (descriptor.get) {
      getters.set(key, descriptor.get);
      continue;
    }
    if (key === 'setup') {
      if (typeof descriptor.value !== 'function') {
        throw new AvenxError(AvenxErrorCodes.BRIDGE_INVALID_MEMBER, 'setup', typeof descriptor.value, 'setup');
      }
      setupFn = descriptor.value;
      continue;
    }
    if (typeof descriptor.value === 'function') {
      rawActions.set(key, descriptor.value);
      continue;
    }
    throw new AvenxError(
      AvenxErrorCodes.BRIDGE_INVALID_MEMBER,
      key,
      descriptor.value === null ? 'null' : typeof descriptor.value,
      key,
    );
  }

  const initialState = definition.state;
  if (initialState !== undefined && (initialState === null || typeof initialState !== 'object' || Array.isArray(initialState))) {
    throw new AvenxError(
      AvenxErrorCodes.BRIDGE_INVALID_MEMBER,
      'state',
      initialState === null ? 'null' : Array.isArray(initialState) ? 'array' : typeof initialState,
      'state',
    );
  }

  const stateKeys = initialState ? Object.keys(initialState) : [];
  for (const key of stateKeys) {
    if (getters.has(key) || rawActions.has(key)) {
      throw new AvenxError(AvenxErrorCodes.BRIDGE_RESERVED_KEY, key, 'state keys must not collide with actions or getters');
    }
  }

  /**
   * Debug label; set by the compiler through defineBridgeName().
   * @type {string}
   */
  let name = 'bridge';
  /** @type {Map<string, Set<Function>>} */
  const listeners = new Map();
  /**
   * Cleanup returned by setup().
   * @type {Function|null}
   */
  let cleanupFn = null;
  /** @type {boolean} */
  let initialized = false;
  /** @type {boolean} */
  let initializing = false;
  /**
   * The reactive state proxy.
   * @type {object}
   */
  const state = stateFactory.create(cloneInitial(initialState) || {});

  /**
   * Broadcasts an event to every listener. Available on `this` inside the
   * bridge only: consumers observe events, they do not fabricate them.
   * @param {string} event - The event name.
   * @param {any} [payload] - The value handed to each listener.
   */
  const emit = (event, payload) => {
    if (typeof event !== 'string' || event.length === 0) {
      throw new AvenxError(AvenxErrorCodes.BRIDGE_INVALID_EVENT, `emitted by "${name}"`, typeof event, typeof payload);
    }
    const handlers = listeners.get(event);

    // Recorded before the no-listener shortcut below: an emit that nobody was
    // subscribed to is exactly the kind of thing a developer opens a trace to
    // discover, so it is worth a node even though it does nothing.
    //
    // An emit opens a scope rather than recording a leaf, so whatever the
    // listeners go on to write belongs under the event that notified them
    // rather than under whichever action happened to be running.
    const emitToken = tracer.on
      ? tracer.enter(TraceNodeType.BRIDGE_EMIT, {
        bridge: name,
        event,
        listeners: handlers ? handlers.size : 0,
        payload: tracer.sink.capture(payload, `${name}.${event}`),
      })
      : -1;

    try {
      if (!handlers || handlers.size === 0) {
        return;
      }
      // Copy: a listener may unsubscribe itself or others while running.
      for (const handler of [...handlers]) {
        try {
          handler(payload);
        } catch (error) {
          // One faulty listener must not stop the others.
          logger.error(formatMessage(AvenxErrorCodes.BRIDGE_LISTENER_ERROR, name, event, error));
        }
      }
    } finally {
      if (emitToken >= 0) {
        tracer.leave(emitToken);
      }
    }
  };

  /**
   * Stable bound action identities.
   * @type {Map<string, Function>}
   */
  const boundActions = new Map();

  /**
   * Resolves a member on the write-capable facade.
   * @param {string|symbol} key - The member name.
   * @returns {any} The resolved value.
   */
  const readSelf = (key) => {
    if (key === 'emit') return emit;
    if (typeof key !== 'string') return undefined;
    if (boundActions.has(key)) return boundActions.get(key);
    const getter = getters.get(key);
    if (getter) {
      const value = getter.call(self);
      if (tracer.on) {
        // A bridge getter has no cache of its own, so this runs on every read.
        // traceDerived records it only when the answer changed, which is the
        // only form in which a derived value explains anything.
        traceDerived(self, { name: key, kind: 'getter', owner: name }, value);
      }
      return value;
    }
    return state[key];
  };

  /**
   * The write-capable facade bound to `this` inside actions, getters and setup.
   * @type {object}
   */
  const self = new Proxy(Object.create(null), {
    get: (_target, key) => readSelf(key),
    set: (_target, key, value) => {
      if (typeof key === 'string' && (getters.has(key) || boundActions.has(key) || key === 'emit')) {
        throw new AvenxError(AvenxErrorCodes.BRIDGE_RESERVED_KEY, key, 'actions and getters are not assignable');
      }
      state[key] = value;
      return true;
    },
    has: (_target, key) => key === 'emit' || getters.has(key) || boundActions.has(key) || key in state,
    deleteProperty: (_target, key) => {
      delete state[key];
      return true;
    },
    ownKeys: () => [...new Set([...Object.keys(state), ...getters.keys()])],
    getOwnPropertyDescriptor: (_target, key) => {
      if (typeof key !== 'string' || !(getters.has(key) || key in state)) {
        return undefined;
      }
      return { enumerable: true, configurable: true, writable: true, value: readSelf(key) };
    },
  });

  for (const [key, fn] of rawActions) {
    // Declared with atomic(), so every write this action makes is journaled
    // and undone if it fails. Resolved once, here, so an action that is not
    // atomic does not pay a closure for the branch on every call.
    const transaction = atomicOptions(fn);
    const spec = transaction ? { owner: name, name: key, onConflict: transaction.onConflict } : null;

    /**
     * Invokes the action, inside a transaction when it was declared atomic.
     * @param {any[]} args - The call arguments.
     * @returns {any} Whatever the action returned.
     */
    const runner = spec ? (args) => journal.run(spec, () => fn.apply(self, args)) : (args) => fn.apply(self, args);

    boundActions.set(key, (...args) => {
      if (!tracer.on) {
        return runner(args);
      }
      // A bridge is the only place shared state may be written, so a bridge
      // action is the single traceable origin of every mutation it makes.
      // Opening a node here is what lets a trace read
      // "cart.addQty(id, 1) -> write cart.items.2.qty".
      const token = tracer.enter(TraceNodeType.BRIDGE_ACTION, {
        bridge: name,
        name: key,
        args: args.length > 0 ? tracer.sink.capture(args, `${name}.${key}.args`) : undefined,
      });
      try {
        return runner(args);
      } finally {
        tracer.leave(token);
      }
    });
  }

  /**
   * Runs setup() on first use. Detached from the caller's reactive watcher and
   * disposal scope: a bridge belongs to the module, not to whichever component
   * happened to touch it first.
   */
  const ensureInitialized = () => {
    if (initialized || initializing) {
      return;
    }
    initializing = true;
    try {
      if (setupFn) {
        const cleanup = runInScope(null, () => untracked(() => setupFn.call(self)));
        cleanupFn = typeof cleanup === 'function' ? cleanup : null;
      }
      initialized = true;
    } catch (error) {
      initialized = true;
      throw new AvenxError(AvenxErrorCodes.BRIDGE_SETUP_FAILED, name, error && error.message ? error.message : error);
    } finally {
      initializing = false;
    }
  };

  /**
   * Subscribes to a bridge event.
   *
   * When called while a component's disposal scope is active — that is, from a
   * lifecycle hook or an event handler — the subscription is released
   * automatically on unmount. The returned function unsubscribes early.
   * @param {string} event - The event name to listen for.
   * @param {Function} handler - Invoked with the emitted payload.
   * @returns {Function} Unsubscribe function. Safe to call more than once.
   */
  const on = (event, handler) => {
    if (typeof event !== 'string' || event.length === 0 || typeof handler !== 'function') {
      throw new AvenxError(
        AvenxErrorCodes.BRIDGE_INVALID_EVENT,
        `subscription on "${name}"`,
        typeof event,
        typeof handler,
      );
    }
    ensureInitialized();

    let handlers = listeners.get(event);
    if (!handlers) {
      handlers = new Set();
      listeners.set(event, handlers);
    }
    handlers.add(handler);

    return onScopeDispose(() => {
      const current = listeners.get(event);
      if (current) {
        current.delete(handler);
        if (current.size === 0) {
          listeners.delete(event);
        }
      }
    });
  };

  /**
   * Releases everything the bridge holds: the cleanup returned by setup(), all
   * event listeners, and any state changes. The bridge stays usable — the next
   * access re-runs setup() with the original state. This makes bridges safe to
   * reuse across tests and across hot reloads.
   */
  const $dispose = () => {
    if (cleanupFn) {
      const cleanup = cleanupFn;
      cleanupFn = null;
      try {
        untracked(() => cleanup());
      } catch (error) {
        logger.error(formatMessage(AvenxErrorCodes.BRIDGE_SETUP_FAILED, name, error && error.message ? error.message : error));
      }
    }
    listeners.clear();
    initialized = false;

    // Reset in place rather than building a fresh reactive object. A new object
    // would strand every watcher that already tracked the old one — consumers
    // would silently stop updating — and it would hide the reset from anyone
    // currently rendering. Writing through the existing proxy keeps identity
    // stable and notifies dependents that the state reverted.
    const defaults = cloneInitial(initialState) || {};
    for (const key of Object.keys(state)) {
      if (!Object.prototype.hasOwnProperty.call(defaults, key)) {
        delete state[key];
      }
    }
    for (const [key, value] of Object.entries(defaults)) {
      state[key] = value;
    }
  };

  /**
   * Resolves a member on the consumer-facing instance.
   * @param {string|symbol} key - The member name.
   * @returns {any} The resolved value.
   */
  const readInstance = (key) => {
    if (key === IS_BRIDGE) return true;
    if (key === '$name') return name;
    if (key === SET_NAME) {
      return (bridgeName) => {
        if (typeof bridgeName === 'string' && bridgeName.length > 0) {
          name = bridgeName;
        }
      };
    }
    if (typeof key !== 'string') return undefined;
    if (key === 'on') return on;
    if (key === '$dispose') return $dispose;
    // Emission is the bridge's own capability: consumers observe events, they
    // never fabricate them. Keeping `emit` off the instance is what makes the
    // direction of a bridge unambiguous.
    if (key === 'emit') return undefined;
    ensureInitialized();
    return readSelf(key);
  };

  /**
   * The consumer-facing bridge instance.
   * @type {object}
   */
  const instance = new Proxy(Object.create(null), {
    get: (_target, key) => readInstance(key),
    set: (_target, key) => {
      throw new AvenxError(
        AvenxErrorCodes.BRIDGE_READONLY_STATE,
        name,
        String(key),
        `${name}.someAction(value)`,
      );
    },
    has: (_target, key) =>
      key === IS_BRIDGE ||
      INSTANCE_API_KEYS.has(key) ||
      getters.has(key) ||
      boundActions.has(key) ||
      key in state,
    deleteProperty: (_target, key) => {
      throw new AvenxError(
        AvenxErrorCodes.BRIDGE_READONLY_STATE,
        name,
        String(key),
        `${name}.someAction(value)`,
      );
    },
    // Spreading or serialising a bridge yields a snapshot of its data, not its
    // API surface: {...auth} and JSON.stringify(auth) both give state.
    ownKeys: () => {
      ensureInitialized();
      return [...new Set([...Object.keys(state), ...getters.keys()])];
    },
    getOwnPropertyDescriptor: (_target, key) => {
      if (typeof key !== 'string' || !(getters.has(key) || key in state)) {
        return undefined;
      }
      return { enumerable: true, configurable: true, writable: false, value: readInstance(key) };
    },
  });

  return instance;
}

/**
 * Reports whether a value is a bridge instance created by {@link bridge}.
 * @param {any} value - The value to test.
 * @returns {boolean} True when the value is a bridge instance.
 */
export function isBridge(value) {
  return !!(value && (typeof value === 'object' || typeof value === 'function') && value[IS_BRIDGE] === true);
}

/**
 * Assigns a bridge its diagnostic name. Emitted by the compiler alongside each
 * bridge definition so error messages and devtools can identify it.
 * @param {string} name - The bridge name, derived from its file name.
 * @param {object} instance - The bridge instance.
 * @returns {object} The same instance, for convenient chaining.
 */
export function defineBridgeName(name, instance) {
  if (isBridge(instance)) {
    const setName = instance[SET_NAME];
    if (typeof setName === 'function') {
      setName(name);
    }
  }
  return instance;
}