Source: lib/bundler/emit.js

/**
 * @file emit.js
 * @description Renders a linked module graph into one classic script.
 *
 * ## The output shape, and why
 *
 * One outer IIFE. Inside it, each module is an inner IIFE assigned to a `var`,
 * emitted in dependency order:
 *
 * ```js
 * (function () {
 *   'use strict';
 *   var __avx3 = (function () { var __x = {}; ...body...; return __x; })();
 *   var __avx7 = (function () { var { thing } = __avx3; ...body...; })();
 * })();
 * ```
 *
 * `var` rather than `const` is load-bearing: `var` hoists to the top of the
 * outer function, so a module emitted *before* its dependency — which happens
 * exactly once per cycle — can still name it. The binding is `undefined` at
 * that moment and holds the exports object by the time anything dereferences
 * it, which is the same shape the language itself has across a cycle.
 *
 * ## Three problems this file has to get right
 *
 * **Live bindings.** `reactive/watcher.js` has `export let activeWatcher`, it
 * reassigns it, and `runtime/AvenxComponent.js` reads it across the module
 * boundary to decide whether it is inside its own render. Copying the value at
 * import time would capture `null` forever and break that check silently. So a
 * mutable exported binding is *hoisted out of its module* into bundle scope
 * under its own name: the exporter's assignments and every importer's reads
 * then resolve to one variable through the ordinary scope chain, with no
 * identifier rewriting anywhere. Exports are exposed as getters for the same
 * reason, so a namespace or a re-export barrel sees the current value.
 *
 * **Cycles.** Across the one edge that closes a cycle, the dependency's
 * exports object does not exist yet. ES modules make exactly one guarantee
 * there — function declarations hoist — so this emitter reproduces exactly
 * that: a cyclic import of a function becomes a forwarder that dereferences on
 * call, and a cyclic import of anything else is a build error, because in a
 * browser it would be a temporal dead zone.
 *
 * **Line fidelity.** Every rewritten declaration is emitted on a single line
 * and padded back to the line count it replaced, so a module's body keeps a
 * 1:1 line correspondence with its source. That is what makes the source map
 * exact and a production stack trace point at a line the developer wrote.
 * @module lib/bundler/emit
 */

import path from 'path';

/**
 * Raised when a graph cannot be rendered into a correct bundle.
 */
export class EmitError extends Error {
  /**
   * @param {string} message - What cannot be emitted, and why.
   * @param {string} [file] - The module responsible.
   * @param {'cycle'|'live-binding-alias'|'live-binding-collision'} [kind] - Which
   *   condition was hit, so a caller can map it onto its own diagnostics
   *   without matching on the message text.
   */
  constructor(message, file = '', kind = 'cycle') {
    super(message);
    this.name = 'EmitError';
    /** @type {string} */
    this.file = file;
    /** @type {string} */
    this.kind = kind;
    /** @type {Array<string[]>} */
    this.cycles = [];
  }
}

/**
 * Counts the lines a chunk of text occupies.
 * @param {string} text - The text.
 * @returns {number} Line count, minimum one.
 */
function lineCount(text) {
  let lines = 1;
  for (let i = 0; i < text.length; i += 1) {
    if (text[i] === '\n') lines += 1;
  }
  return lines;
}

/**
 * Pads generated text so it occupies the same number of lines it replaced.
 * @param {string} generated - Replacement text, expected to be one line.
 * @param {string} original - The text being replaced.
 * @returns {string} The replacement, newline-padded.
 */
function padToLines(generated, original) {
  const want = lineCount(original);
  const have = lineCount(generated);
  return have >= want ? generated : generated + '\n'.repeat(want - have);
}

/**
 * A valid JavaScript identifier for a module's exports object.
 * @param {number} index - The module's index in emission order.
 * @returns {string} The binding name.
 */
function moduleBinding(index) {
  return `__avx${index}`;
}

/**
 * Finds the exported bindings a module can reassign.
 *
 * Only `let` and `var` declarations qualify. A `const`, a `function` and a
 * `class` binding cannot be reassigned, so copying their value at import time
 * is indistinguishable from a live binding and costs nothing.
 * @param {object} module - A graph module.
 * @returns {Set<string>} Names that must live at bundle scope.
 */
export function mutableExports(module) {
  const names = new Set();
  if (module.format !== 'esm') {
    return names;
  }
  for (const statement of module.record.statements) {
    if (statement.kind !== 'export-declaration') continue;
    const text = module.source.slice(statement.bodyStart, statement.end).trimStart();
    if (/^(let|var)\b/.test(text)) {
      for (const name of statement.declares) {
        names.add(name);
      }
    }
  }
  return names;
}

/**
 * The local name under which a module declares an exported function.
 *
 * This is the question that decides whether a cyclic import is legal, so it is
 * answered from the declaration rather than guessed from usage: only a function
 * declaration is initialised before any module body runs, which is the one
 * guarantee ES modules make across a cycle.
 * @param {object} module - The exporting graph module.
 * @param {string} exported - The exported name.
 * @returns {string|null} The local declaration name, or null when the export is
 *   not a function declaration.
 */
function functionExportLocal(module, exported) {
  if (!module || module.format !== 'esm') {
    return null;
  }
  for (const statement of module.record.statements) {
    const isDefault = statement.kind === 'export-default';
    if (statement.kind !== 'export-declaration' && !isDefault) continue;
    if (isDefault ? exported !== 'default' : !statement.declares.includes(exported)) continue;
    const text = module.source.slice(statement.bodyStart, statement.end).trimStart();
    if (/^(async\s+)?function\b/.test(text)) {
      return isDefault ? statement.declares[0] || null : exported;
    }
  }
  return null;
}

/**
 * Renders one module's body with its declarations rewritten.
 * @param {object} context - Emission context.
 * @param {object} context.module - The graph module.
 * @param {object} context.graph - The module graph.
 * @param {Map<string, number>} context.indexOf - Module id to emission index.
 * @param {Set<string>} context.hoisted - Names living at bundle scope.
 * @param {Set<string>} context.included - Module ids that reached the bundle.
 * @param {function(string, string): string} context.slotFor - Allocates a
 *   bundle-scope slot for a function crossing a cycle.
 * @returns {string} The transformed module body.
 * @throws {EmitError} When a construct cannot be rendered correctly.
 */
function renderEsmBody({ module, graph, indexOf, hoisted, included, slotFor }) {
  const source = module.source;
  const pieces = [];
  const trailing = [];
  let cursor = 0;

  /**
   * The bundle-scope binding for a specifier this module imports.
   * @param {string} specifier - The specifier as written.
   * @returns {{name: string, target: object, cyclic: boolean}} Binding details.
   */
  const targetOf = (specifier) => {
    const id = module.resolved.get(specifier);
    const target = graph.modules.get(id);
    return {
      name: moduleBinding(indexOf.get(id)),
      target,
      id,
      cyclic: graph.isCyclicEdge(module.id, id),
      included: included.has(id),
    };
  };

  /**
   * Declares one export on the module's exports object, live.
   * @param {string} exported - The exported name.
   * @param {string} expression - An expression yielding the current value.
   */
  const declareExport = (exported, expression) => {
    trailing.push(`__avx_def(__x, ${JSON.stringify(exported)}, function () { return ${expression}; });`);
  };

  // Statement rewrites and dynamic-import rewrites are one ordered list. A
  // dynamic import lives inside an ordinary statement, which is copied
  // verbatim, so the two never overlap -- but they have to be applied in
  // source order for the cursor to stay monotonic.
  const edits = [
    ...module.record.statements
      .filter((statement) => statement.kind !== 'statement')
      .map((statement) => ({ start: statement.start, end: statement.end, statement })),
    ...module.record.dynamicImports.map((entry) => ({ start: entry.start, end: entry.end, dynamic: entry })),
  ].sort((a, b) => a.start - b.start);

  for (const edit of edits) {
    pieces.push(source.slice(cursor, edit.start));
    const original = source.slice(edit.start, edit.end);

    if (edit.dynamic) {
      const { name, target, included: present } = targetOf(edit.dynamic.specifier);
      // Everything is in one chunk, so the module is already evaluated by the
      // time anything can await it. `import()` therefore resolves immediately
      // with the namespace, which is the correct observable behaviour for an
      // unsplit build -- what is missing is a separate chunk, not the semantics.
      const namespace = target && target.format === 'cjs' ? `{ default: ${name} }` : name;
      pieces.push(padToLines(present ? `Promise.resolve(${namespace})` : 'Promise.resolve({})', original));
      cursor = edit.end;
      continue;
    }

    const statement = edit.statement;
    let replacement;

    if (statement.kind === 'import') {
      const entry = module.record.imports.find((item) => item.start === statement.start);
      const { name, target, cyclic, included: present } = targetOf(entry.specifier);

      if (!present) {
        // The dependency was shaken out because nothing this module keeps uses
        // it. Its bindings are unreachable by construction, so there is
        // nothing to declare.
        replacement = '';
      } else if (entry.sideEffectOnly) {
        // Ordering already guarantees the module ran; naming it keeps the
        // dependency visible in the output.
        replacement = `/* side-effect import: ${entry.specifier} */ void ${name};`;
      } else {
        const parts = [];
        const plain = [];

        if (entry.namespace) {
          parts.push(`var ${entry.namespace} = ${name};`);
        }
        if (entry.defaultLocal) {
          if (cyclic) {
            parts.push(cyclicBinding(entry.defaultLocal, 'default', target, module, slotFor));
          } else {
            // A CommonJS module's default export is its `module.exports`
            // object itself, which is what `__avxN` already holds.
            const value = target && target.format === 'cjs' ? name : `${name}.default`;
            parts.push(`var ${entry.defaultLocal} = ${value};`);
          }
        }

        for (const binding of entry.bindings) {
          if (hoisted.has(binding.imported)) {
            if (binding.imported === binding.local) {
              // A live binding lives at bundle scope under this exact name, so
              // the reference resolves through the scope chain and stays live.
              // Declaring anything here would shadow it with a stale copy.
              continue;
            }
            throw new EmitError(
              `"${binding.imported}" is a live binding and cannot be imported under the alias "${binding.local}". ` +
                'Import it under its own name so it keeps resolving to the one variable that holds it.',
              module.id,
              'live-binding-alias',
            );
          }
          if (cyclic) {
            parts.push(cyclicBinding(binding.local, binding.imported, target, module, slotFor));
          } else {
            plain.push(binding);
          }
        }

        if (plain.length > 0) {
          const pattern = plain
            .map((binding) => (binding.imported === binding.local ? binding.local : `${binding.imported}: ${binding.local}`))
            .join(', ');
          parts.push(`var { ${pattern} } = ${name};`);
        }
        replacement = parts.join(' ');
      }
    } else if (statement.kind === 'export-declaration') {
      const body = source.slice(statement.bodyStart, statement.end);
      const isMutable = /^\s*(let|var)\b/.test(body);
      if (isMutable) {
        // Hoisted to bundle scope: drop the declaration keyword so the
        // assignment targets the outer variable rather than a module-local
        // shadow of it.
        replacement = body.replace(/^(\s*)(let|var)\s+/, '$1');
        if (!/=/.test(replacement)) {
          replacement = '';
        }
      } else {
        replacement = body;
      }
      for (const name of statement.declares) {
        declareExport(name, name);
      }
    } else if (statement.kind === 'export-default') {
      const body = source.slice(statement.bodyStart, statement.end);
      if (statement.declares.length > 0) {
        replacement = body;
        declareExport('default', statement.declares[0]);
      } else {
        const expression = body.replace(/;\s*$/, '');
        replacement = `var __avx_default = ${expression};`;
        declareExport('default', '__avx_default');
      }
    } else if (statement.kind === 'export-list') {
      for (const entry of statement.entries || []) {
        declareExport(entry.exported, entry.local);
      }
      replacement = '';
    } else if (statement.kind === 'reexport') {
      for (const entry of statement.reExportEntries || []) {
        const { name, included: present } = targetOf(entry.specifier);
        if (!present) continue;
        // `export * as ns from 'm'` re-exports the namespace object itself.
        const value = entry.imported === '*' ? name : `${name}[${JSON.stringify(entry.imported)}]`;
        declareExport(entry.exported, value);
      }
      replacement = '';
    } else if (statement.kind === 'star-reexport') {
      const { name, target, included: present } = targetOf(statement.starSpecifier);
      if (present && target) {
        // Expanded to explicit getters rather than a runtime copy loop: the
        // names are known here, and a copy would freeze a live binding.
        for (const exported of target.exportNames) {
          if (exported === 'default' || exported === '*') continue;
          declareExport(exported, `${name}[${JSON.stringify(exported)}]`);
        }
      }
      replacement = '';
    } else {
      replacement = original;
    }

    pieces.push(padToLines(replacement, original));
    cursor = statement.end;
  }

  pieces.push(source.slice(cursor));
  if (trailing.length > 0) {
    pieces.push(`\n${trailing.join('\n')}\n`);
  }
  return pieces.join('');
}

/**
 * Builds a binding for an import that crosses a cycle.
 *
 * The dependency's *exports object* does not exist yet at this point — the
 * importing module is running inside the call that will produce it — so the
 * forwarder cannot go through it. It goes through a bundle-scope slot instead,
 * which the exporting module fills from its hoisted function declaration before
 * its own body runs. That is exactly the order ES modules use, so a cyclic call
 * that works in a browser works here and one that does not, does not.
 * @param {string} local - The local name to declare.
 * @param {string} imported - The name being imported.
 * @param {object} target - The exporting graph module.
 * @param {object} importer - The importing graph module.
 * @param {function(string, string): string} slotFor - Allocates the shared slot.
 * @returns {string} The declaration.
 * @throws {EmitError} When the binding cannot legally cross a cycle.
 */
function cyclicBinding(local, imported, target, importer, slotFor) {
  const declared = functionExportLocal(target, imported);
  if (!declared) {
    throw new EmitError(
      `"${imported}" is imported across a module cycle but is not a function declaration.\n` +
        'Only function declarations are initialised before a module body runs, so reading anything ' +
        'else across a cycle is a temporal dead zone in a browser too. Break the cycle, or move the ' +
        'value behind a function.',
      importer.id,
    );
  }
  const slot = slotFor(target.id, imported);
  return `var ${local} = function () { return ${slot}.apply(this, arguments); };`;
}

/**
 * Wraps a CommonJS module so the bundle can evaluate it.
 * @param {object} context - Emission context.
 * @param {object} context.module - The graph module.
 * @param {Map<string, number>} context.indexOf - Module id to emission index.
 * @param {Set<string>} context.included - Module ids in the bundle.
 * @returns {string} The wrapped body.
 */
function renderCjsBody({ module, indexOf, included }) {
  const cases = [];
  for (const [specifier, id] of module.resolved) {
    if (!included.has(id)) continue;
    cases.push(`if (id === ${JSON.stringify(specifier)}) return ${moduleBinding(indexOf.get(id))};`);
  }

  return [
    'var module = { exports: {} }, exports = module.exports;',
    `function require(id) { ${cases.join(' ')} throw new Error('Cannot find module ' + id); }`,
    'void require;',
    module.source,
    'return module.exports;',
  ].join('\n');
}

/**
 * The prelude every bundle carries.
 *
 * One helper, five lines. A module system that needs more machinery than this
 * at runtime has moved work out of the build that belonged in it.
 * @type {string}
 */
const PRELUDE = `var __avx_def = function (target, name, get) {
  Object.defineProperty(target, name, { enumerable: true, configurable: true, get: get });
};`;

/**
 * Renders a linked graph into a single classic script.
 * @param {object} options - Emission options.
 * @param {object} options.graph - The module graph.
 * @param {string[]} options.order - Module ids in emission order.
 * @param {Set<string>} [options.included] - Modules that survived tree shaking.
 * @param {string} [options.banner] - Text placed before the outer IIFE.
 * @param {string} [options.footer] - Text placed inside the outer IIFE, after
 *   every module, where it can read `__avx_entry`.
 * @param {string} [options.rootDir] - Root for source-map paths.
 * @param {boolean} [options.sourceMap] - Whether to build a source map.
 * @param {string} [options.file] - Output file name, for the map.
 * @returns {{code: string, map: object|null, modules: number}} The bundle.
 * @throws {EmitError} When the graph cannot be rendered correctly.
 */
export function emitBundle({
  graph,
  order,
  included = null,
  banner = '',
  footer = '',
  rootDir = process.cwd(),
  sourceMap = false,
  file = 'bundle.js',
}) {
  const present = included || new Set(order);
  const emitted = order.filter((id) => present.has(id));
  const indexOf = new Map(emitted.map((id, index) => [id, index]));

  // Every mutable exported binding in the bundle lives at bundle scope, so the
  // exporter's writes and the importers' reads are the same variable.
  const hoisted = new Set();
  const hoistedOwner = new Map();
  for (const id of emitted) {
    const module = graph.modules.get(id);
    for (const name of mutableExports(module)) {
      if (hoistedOwner.has(name) && hoistedOwner.get(name) !== id) {
        throw new EmitError(
          `two modules export a mutable binding named "${name}":\n  ${hoistedOwner.get(name)}\n  ${id}\n` +
            'A live binding is hoisted to bundle scope under its own name, so the two would collide. ' +
            'Rename one of them.',
          id,
          'live-binding-collision',
        );
      }
      hoisted.add(name);
      hoistedOwner.set(name, id);
    }
  }

  // Functions reached across a cycle are published into a bundle-scope slot by
  // the module that declares them, before its body runs. `slots` is keyed by
  // module id and export name so one function is published once however many
  // cyclic importers it has.
  /** @type {Map<string, string>} */
  const slots = new Map();
  /** @type {Map<string, Array<{slot: string, local: string}>>} */
  const slotsByModule = new Map();
  const slotFor = (moduleId, exported) => {
    const key = `${moduleId}\u0000${exported}`;
    const existing = slots.get(key);
    if (existing) return existing;
    const slot = `__avx_fn${slots.size}`;
    slots.set(key, slot);
    const declared = functionExportLocal(graph.modules.get(moduleId), exported);
    if (!slotsByModule.has(moduleId)) slotsByModule.set(moduleId, []);
    slotsByModule.get(moduleId).push({ slot, local: declared });
    return slot;
  };

  const lines = [];
  /** @type {Array<{module: string, outputLine: number, lines: number}>} */
  const segments = [];

  const push = (text) => {
    lines.push(text);
  };

  if (banner) push(banner);
  push('(function () {');
  push("'use strict';");
  push(PRELUDE);
  if (hoisted.size > 0) {
    push(`var ${[...hoisted].join(', ')};`);
  }

  const currentLine = () => lines.join('\n').split('\n').length;

  let bodies;
  try {
    bodies = emitted.map((id) => {
      const module = graph.modules.get(id);
      return {
        id,
        module,
        body:
          module.format === 'cjs'
            ? renderCjsBody({ module, indexOf, included: present })
            : renderEsmBody({ module, graph, indexOf, hoisted, included: present, slotFor }),
      };
    });
  } catch (error) {
    // The cycles travel with the error so a caller can describe the failure in
    // its own vocabulary -- Avenx reports a cycle between two bridges as a
    // bridge problem, which is what the developer was actually writing.
    if (error instanceof EmitError) {
      error.cycles = graph.cycles;
    }
    throw error;
  }

  if (slots.size > 0) {
    push(`var ${[...slots.values()].join(', ')};`);
  }

  for (const { id, module, body } of bodies) {
    const binding = moduleBinding(indexOf.get(id));
    const label = module.virtual ? id : path.relative(rootDir, id);

    push(`// ${label}`);
    push(`var ${binding} = (function () {`);
    push('var __x = {};');

    // Published before the body, mirroring the point at which ES modules
    // initialise a hoisted function declaration.
    const published = slotsByModule.get(id);
    if (published && published.length > 0) {
      push(published.map((entry) => `${entry.slot} = ${entry.local};`).join(' '));
    }

    const bodyStartLine = currentLine() + 1;
    segments.push({ module: id, outputLine: bodyStartLine, lines: lineCount(body) });

    push(body);
    push('return __x;');
    push('})();');
  }

  // The last entry's exports are published as `__avx_entry` inside the bundle
  // scope. A footer -- the one that installs `globalThis.Avenx`, for instance
  // -- needs a name for what the entry produced, and inventing one per caller
  // would make the contract implicit.
  const liveEntries = graph.entries.filter((entry) => present.has(entry));
  for (const entry of liveEntries) {
    push(`void ${moduleBinding(indexOf.get(entry))};`);
  }
  if (liveEntries.length > 0) {
    push(`var __avx_entry = ${moduleBinding(indexOf.get(liveEntries[liveEntries.length - 1]))};`);
    push('void __avx_entry;');
  }

  if (footer) push(footer);
  push('})();');

  const code = lines.join('\n') + '\n';
  const map = sourceMap ? buildSourceMap({ graph, segments, rootDir, file }) : null;

  return { code, map, modules: emitted.length };
}

/**
 * Builds a line-level source map for an emitted bundle.
 *
 * Line-level rather than column-level, and exact rather than approximate: every
 * rewritten declaration was padded back to the line count it replaced, so
 * output line N of a module's body is source line N of that module. A stack
 * trace from a production bundle therefore names a file and a line the
 * developer wrote.
 * @param {object} options - Map options.
 * @param {object} options.graph - The module graph.
 * @param {Array<object>} options.segments - Where each module's body landed.
 * @param {string} options.rootDir - Root for relative source paths.
 * @param {string} options.file - The generated file name.
 * @returns {object} A source map, version 3.
 */
function buildSourceMap({ graph, segments, rootDir, file }) {
  const sources = [];
  const sourcesContent = [];
  const indexOfSource = new Map();

  const sourceIndex = (id) => {
    if (indexOfSource.has(id)) return indexOfSource.get(id);
    const module = graph.modules.get(id);
    const name = module.virtual ? id.replace(/^\s*/, '') : path.relative(rootDir, id).split(path.sep).join('/');
    const index = sources.length;
    sources.push(name);
    sourcesContent.push(module.source);
    indexOfSource.set(id, index);
    return index;
  };

  const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
  const encode = (value) => {
    let vlq = value < 0 ? ((-value) << 1) | 1 : value << 1;
    let out = '';
    do {
      let digit = vlq & 31;
      vlq >>>= 5;
      if (vlq > 0) digit |= 32;
      out += BASE64[digit];
    } while (vlq > 0);
    return out;
  };

  /** @type {Array<string>} */
  const mappingLines = [];
  let previousSource = 0;
  let previousSourceLine = 0;

  for (const segment of segments) {
    const index = sourceIndex(segment.module);
    for (let line = 0; line < segment.lines; line += 1) {
      const outputLine = segment.outputLine + line - 1;
      while (mappingLines.length <= outputLine) {
        mappingLines.push('');
      }
      mappingLines[outputLine] =
        encode(0) + encode(index - previousSource) + encode(line - previousSourceLine) + encode(0);
      previousSource = index;
      previousSourceLine = line;
    }
  }

  return {
    version: 3,
    file,
    sources,
    sourcesContent,
    names: [],
    mappings: mappingLines.join(';'),
  };
}