DETLEF JavaScript SDK
detlef.mjsjavascript
import { instantiatePackage } from './packages.mjs';

/**
 * Create the embedding API for an initialized wasm-bindgen module.
 *
 * Browser modules must first finish their default initialization function. Node's
 * generated CommonJS module is initialized when required. Classes from different
 * createAPI calls must not be mixed, even when they use the same Wasm module.
 *
 * @param {Object} wasm Initialized generated Wasm bindings.
 * @returns {DetlefAPI} Runtime and value/access classes for these bindings.
 */
export function createAPI(wasm) {
  const synchronous = result => {
    if (result && typeof result.then === 'function') {
      throw new TypeError('Detlef callbacks must be synchronous');
    }

    return result;
  };

  /**
   * A handle to a DETLEF value or evaluation result.
   *
   * Instances are returned by execution and access operations; they must not be
   * constructed manually. Host-owned instances must be disposed. Callback-local
   * instances are disposed automatically; retain() creates a separately owned copy.
   *
   * @alias Value
   * @class
   * @hideconstructor
   */
  class Value {
    constructor(runtime, handle) {
      this.runtime = runtime;
      this.handle = handle;
      this.closed = false;
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    _handle(runtime) {
      if (this.closed) throw new Error('value is disposed');

      if (runtime !== this.runtime) throw new Error('value belongs to another runtime');

      return this.handle;
    }

    /**
     * Read a primitive value. Arrays and records are not copied into JavaScript objects.
     *
     * @memberof Value
     * @instance
     * @function
     * @returns {Primitive} BigInt for integer, Number for float, null for none, or undefined for no result.
     * @throws {Error} An object/descriptor cannot be read as a primitive, or the handle is disposed.
     */
    read() {
      return this.runtime._use(a => a.raw.read(this._handle(this.runtime)));
    }

    /**
     * Format the value as text. Arrays and records use JSON representation;
     * primitive strings yield their contents.
     *
     * @memberof Value
     * @instance
     * @function
     * @returns {string} Text representation.
     * @throws {Error} The value is disposed, belongs to an unavailable runtime, or cannot be formatted.
     */
    format() {
      return this.runtime._use(a => a.raw.format(this._handle(this.runtime)));
    }

    /**
     * Read the number of elements in an array.
     *
     * @memberof Value
     * @instance
     * @member
     * @type {number}
     * @readonly
     * @throws {Error} The value is not an array or its handle is disposed.
     */
    get length() {
      return this.runtime._use(a => a.raw.array_length(this._handle(this.runtime)));
    }

    /**
     * Read an array element or a named record field.
     *
     * The returned wrapper must be disposed outside a registered callback. Inside
     * a Function/Control callback it is temporary and disposed automatically.
     *
     * @memberof Value
     * @instance
     * @function
     * @param {(number|string)} key Zero-based array index or record field name.
     * @returns {Value} A new wrapper to the selected value.
     * @throws {Error} Invalid index/field, wrong object type, or a disposed handle.
     */
    get(key) {
      if (
        typeof key === 'number' &&
        (!Number.isSafeInteger(key) || key < 0 || key > 0xffffffff)
      ) {
        throw new RangeError('array index must be a nonnegative 32-bit integer');
      }

      return this.runtime._use(a =>
        a.wrap(
          typeof key === 'number'
            ? a.raw.array_get(this._handle(this.runtime), key)
            : a.raw.record_get(this._handle(this.runtime), key)
        )
      );
    }

    /**
     * Replace an array element or record field after checking its declared type.
     * The supplied Value is borrowed and is not consumed.
     *
     * @memberof Value
     * @instance
     * @function
     * @param {(number|string)} key Zero-based array index or record field name.
     * @param {HostValue} value Replacement value.
     * @returns {undefined} No value.
     * @throws {Error} Invalid index/field, type mismatch, or unavailable handles.
     */
    set(key, value) {
      if (
        typeof key === 'number' &&
        (!Number.isSafeInteger(key) || key < 0 || key > 0xffffffff)
      ) {
        throw new RangeError('array index must be a nonnegative 32-bit integer');
      }

      this.runtime._use(a =>
        a.withValue(value, v =>
          typeof key === 'number'
            ? a.raw.array_set(this._handle(this.runtime), key, v._handle(this.runtime))
            : a.raw.record_set(this._handle(this.runtime), key, v._handle(this.runtime))
        )
      );
    }

    /**
     * Create a separately owned wrapper to the same value. Objects remain shared.
     *
     * The returned wrapper must be disposed explicitly, including when created
     * inside a registered callback.
     *
     * @memberof Value
     * @instance
     * @function
     * @returns {Value} Retained wrapper.
     * @throws {Error} The source handle or runtime is unavailable.
     */
    retain() {
      return this.runtime._use(
        a => new Value(this.runtime, a.raw.retain(this._handle(this.runtime)))
      );
    }

    /**
     * Release this wrapper once. Repeated disposal has no effect.
     * Must be called before runtime disposal; a released wrapper must not be used again.
     *
     * @memberof Value
     * @instance
     * @function
     * @returns {undefined} No value.
     * @throws {Error} The runtime is already disposed or the handle cannot be released.
     */
    dispose() {
      if (this.closed) return;

      this.runtime._use(a => a.raw.release(this.handle));
      this.closed = true;
    }
  }

  /**
   * Borrowed value/object access for an eager Function callback.
   *
   * Must not be constructed, disposed, or saved for later use. Access expires when
   * the callback returns. Native Functions cannot execute code, mutate variables,
   * or register definitions, including through a captured Runtime.
   *
   * @alias FunctionAccess
   * @class
   * @hideconstructor
   */
  class FunctionAccess {
    constructor(runtime, raw, temporaries = null) {
      this.runtime = runtime;
      this.raw = raw;
      this.temporaries = temporaries;
      this.active = true;
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    check() {
      if (!this.active) throw new Error('access has expired');
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    wrap(handle) {
      this.check();

      const value = new Value(this.runtime, handle);

      this.temporaries?.add(value);

      return value;
    }

    /**
     * Wrap a JavaScript primitive as a DETLEF value, or borrow an existing Value.
     *
     * Numbers become floats; integers require BigInt within the signed 64-bit range.
     * A new wrapper is temporary inside a registered callback. Otherwise it must be
     * disposed explicitly. Existing wrappers keep their original ownership.
     *
     * @memberof FunctionAccess
     * @instance
     * @function
     * @param {HostValue} value Primitive or Value from this runtime.
     * @returns {Value} New primitive wrapper, or the supplied Value unchanged.
     * @throws {Error} Unsupported input, out-of-range integer, disposed handle, or expired access.
     */
    value(value) {
      this.check();

      if (value instanceof Value) {
        value._handle(this.runtime);

        return value;
      }

      let handle;

      if (value === undefined) handle = 0n;
      else if (value === null) handle = this.raw.none();
      else if (typeof value === 'bigint') {
        if (value < -(1n << 63n) || value >= 1n << 63n)
          throw new RangeError('integer is outside signed 64-bit range');

        handle = this.raw.integer(value);
      } else if (typeof value === 'number') handle = this.raw.float(value);
      else if (typeof value === 'boolean') handle = this.raw.boolean(value);
      else if (typeof value === 'string') handle = this.raw.string(value);
      else throw new TypeError('expected a primitive or Detlef Value');

      return this.wrap(handle);
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    withValue(input, fn) {
      const value = this.value(input);

      try {
        return fn(value);
      } finally {
        if (!(input instanceof Value)) value.dispose();
      }
    }

    /**
     * Create an array by copying the supplied values. Input wrappers are not consumed.
     * The result follows the same temporary/host-owned lifetime as value().
     *
     * @memberof FunctionAccess
     * @instance
     * @function
     * @param {TypeSpec} elementType Element type, not the enclosing array type.
     * @param {HostValue[]} [items=[]] Elements to copy.
     * @returns {Value} Array wrapper.
     * @throws {Error} Invalid type, non-array items, element type mismatch, or expired access.
     */
    array(elementType, items = []) {
      this.check();

      return this._objects(items, handles =>
        this.raw.create_array(elementType, handles)
      );
    }

    /**
     * Create an instance of an existing record type. Input wrappers are not consumed.
     * The result follows the same temporary/host-owned lifetime as value().
     *
     * @memberof FunctionAccess
     * @instance
     * @function
     * @param {string} type Name of a defined record type.
     * @param {HostValue[]} [fields=[]] Values in field declaration order.
     * @returns {Value} Record wrapper.
     * @throws {Error} Unknown record, field count/type mismatch, or expired access.
     */
    record(type, fields = []) {
      this.check();

      return this._objects(fields, handles => this.raw.create_record(type, handles));
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    _objects(items, create) {
      if (!Array.isArray(items)) throw new TypeError('expected an array of values');

      const values = [];

      try {
        for (const item of items) values.push(this.value(item));

        return this.wrap(create(values.map(value => value._handle(this.runtime))));
      } finally {
        for (let i = 0; i < values.length; i++)
          if (!(items[i] instanceof Value)) values[i].dispose();
      }
    }
  }

  /**
   * Borrowed context access for host setup and lazy Controls.
   *
   * Includes the value/object methods of FunctionAccess. Must not be constructed,
   * disposed, or used after its callback returns. An ancestor access must not be
   * used while a nested operation is executing.
   *
   * @alias Access
   * @class
   * @augments FunctionAccess
   * @hideconstructor
   */
  class Access extends FunctionAccess {
    /**
     * Evaluate source in the current context and return its last evaluation.
     * Earlier side effects remain if a later expression fails.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {string} source DETLEF source.
     * @returns {Value} Result wrapper; must be disposed in host setup, but is temporary in a Control callback.
     * @throws {Error} Parsing/evaluation failure or expired/busy access.
     */
    execute(source) {
      this.check();

      return this.wrap(this.raw.execute(source));
    }

    /**
     * Read an existing mutable variable. This operation does not look up a callable.
     * The result must be disposed in host setup, but is temporary in a Control callback.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {string} name Variable name.
     * @returns {Value} Current value wrapper.
     * @throws {Error} Unknown/uninitialized variable or expired access.
     */
    getVariable(name) {
      this.check();

      return this.wrap(this.raw.get_variable(name));
    }

    /**
     * Assign an existing mutable variable after checking its declared type.
     * The supplied Value is borrowed and not consumed.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {string} name Variable name.
     * @param {HostValue} value Replacement value.
     * @returns {undefined} No value.
     * @throws {Error} Unknown variable, type mismatch, or expired access.
     */
    setVariable(name, value) {
      this.check();
      this.withValue(value, v => this.raw.set_variable(name, v.handle));
    }

    /**
     * Install stdlib operations using synchronous I/O callbacks.
     * Output is passed through without an added newline. Input returns a line or EOF.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {IOOptions} [options] Synchronous input and output callbacks.
     * @returns {undefined} No value.
     * @throws {Error} Installation failure or invalid callback use.
     */
    installStdlib({ output = () => {}, input = () => null } = {}) {
      this.check();
      this.raw.install_stdlib(
        text => synchronous(output(text)),
        () => synchronous(input())
      );
    }

    /**
     * Register an eager Function with limited value/object access.
     * Arguments are evaluated before the host callback is invoked.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {string} name Callable name.
     * @param {TypeSpec[]} types Fixed parameter types in declaration order.
     * @param {FunctionCallback} callback Synchronous host implementation.
     * @param {Object} [options] Signature options.
     * @param {TypeSpec} [options.variadic] Type accepted by each additional argument.
     * @returns {undefined} No value.
     * @throws {Error} Invalid types, signature conflicts, expired access, or an unavailable runtime.
     */
    registerFunction(name, types, callback, { variadic } = {}) {
      this.check();
      this.raw.register_function(name, types, variadic, raw =>
        this.runtime._callback(
          raw,
          a => {
            const values = [];

            for (let i = 0; i < raw.argument_count(); i++) {
              const value = a.wrap(raw.argument(i));

              values.push(raw.kind(value.handle) === 6 ? value : value.read());
            }

            return callback(a, ...values);
          },
          true
        )
      );
    }

    /**
     * Define a record type. Records must be registered before signatures that use them.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {string} name Record type name.
     * @param {RecordField[]} fields Fields in declaration order; field types are named types.
     * @returns {undefined} No value.
     * @throws {Error} Invalid fields, unknown field types, or an already defined record.
     */
    registerRecord(name, fields) {
      this.check();
      this.raw.register_record(name, fields);
    }

    /**
     * Register a lazy Control. Its callback chooses which expressions to evaluate.
     *
     * @memberof Access
     * @instance
     * @function
     * @param {string} name Callable name.
     * @param {ControlCallback} callback Synchronous host implementation.
     * @returns {undefined} No value.
     * @throws {Error} Invalid registration, expired access, or an unavailable runtime.
     */
    registerControl(name, callback) {
      this.check();
      this.raw.register_control(name, raw =>
        this.runtime._callback(raw, a =>
          callback(a, {
            length: raw.argument_count(),
            evaluate: index => a.wrap(raw.argument(index)),
            identifier: index => {
              a.check();

              return raw.argument_identifier(index);
            },
          })
        )
      );
    }
  }

  /**
   * An interpreter instance and the host callbacks and packages installed in it.
   *
   * Create with new Runtime() after createAPI(). No stdlib is installed initially.
   * Must be disposed explicitly; JavaScript garbage collection does not replace
   * disposal or break every host-callback reference cycle.
   *
   * @alias Runtime
   * @class
   */
  class Runtime {
    constructor() {
      this.raw = new wasm.WasmRuntime();
      this.active = null;
      this.closed = false;
      this.packages = new Map();
      this.loading = false;
    }

    /**
     * Load a separately compiled Wasm artifact from a package archive.
     *
     * Downloads and file access belong to the host. A failed initializer may have
     * registered definitions; its instance is retained and cannot be loaded again
     * under the same package name. Loading must finish before another load or disposal.
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {(ArrayBuffer|Uint8Array)} bytes Complete .dtfpkg archive.
     * @returns {Promise<PackageInfo>} Package name and version after initialization.
     * @throws {Error} Malformed archive, duplicate package, incompatible protocol/imports, or initialization failure.
     */
    async loadPackage(bytes) {
      if (this.closed || this.active || this.loading)
        throw new Error('runtime is unavailable for package loading');

      const archive = wasm.unpack_package(new Uint8Array(bytes));

      if (this.packages.has(archive.name))
        throw new Error('package already loaded: ' + archive.name);

      this.loading = true;

      try {
        const packageInstance = await instantiatePackage(this, archive.binary);

        this.packages.set(archive.name, packageInstance);
        packageInstance.install();

        return { name: archive.name, version: archive.version };
      } finally {
        this.loading = false;
      }
    }

    /**
     * Invoke a synchronous setup callback with borrowed Access.
     *
     * Values created here are host-owned and must be disposed, even though access
     * expires on callback return. A Promise result is rejected. Reentry through this
     * method is rejected; inside a Control use the supplied Access instead.
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {SetupCallback} callback Synchronous setup operation.
     * @returns {*} The callback result.
     * @throws {Error} Callback failure, Promise result, or busy/disposed runtime.
     */
    withAccess(callback) {
      if (this.closed) throw new Error('runtime is closed');

      if (this.active) throw new Error('runtime is busy; use the current access');

      return this.raw.access(raw => this._frame(raw, a => synchronous(callback(a))));
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    _frame(raw, callback, temporaries = null, functionOnly = false) {
      const previous = this.active;
      const access = functionOnly
        ? new FunctionAccess(this, raw, temporaries)
        : new Access(this, raw, temporaries);

      this.active = access;

      try {
        return callback(access);
      } finally {
        try {
          if (temporaries) for (const value of temporaries) value.dispose();
        } finally {
          access.active = false;
          this.active = previous;
          raw.free();
        }
      }
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    _callback(raw, callback, functionOnly = false) {
      return this._frame(
        raw,
        a => {
          const result = synchronous(callback(a));

          // Transfer a clone so returning a retained host value preserves it.
          return a.raw.retain(a.value(result)._handle(this));
        },
        new Set(),
        functionOnly
      );
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    _use(callback) {
      if (this.closed) throw new Error('runtime is closed');

      return this.active ? callback(this.active) : this.withAccess(callback);
    }

    /**
     * Evaluate source and return the last evaluation. The result must be disposed.
     * Calling this through a captured runtime inside an eager Function is rejected.
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {string} source DETLEF source.
     * @returns {Value} Host-owned result wrapper; callback use follows the active callback scope.
     * @throws {Error} Parsing/evaluation failure or unavailable context.
     */
    execute(source) {
      return this._context(a => a.execute(source));
    }

    /**
     * Install stdlib operations with synchronous I/O.
     * Equivalent to Access.installStdlib() in a context access.
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {IOOptions} [options] Synchronous input and output callbacks.
     * @returns {undefined} No value.
     * @throws {Error} Installation failure or invalid callback use.
     */
    installStdlib(options) {
      this._context(a => a.installStdlib(options));
    }

    /**
     * Register an eager Function. Equivalent to Access.registerFunction().
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {string} name Callable name.
     * @param {TypeSpec[]} types Fixed parameter types in declaration order.
     * @param {FunctionCallback} callback Synchronous host implementation.
     * @param {Object} [options] Signature options.
     * @param {TypeSpec} [options.variadic] Type accepted by each additional argument.
     * @returns {undefined} No value.
     * @throws {Error} Invalid types, signature conflicts, expired access, or an unavailable runtime.
     */
    registerFunction(...args) {
      this._context(a => a.registerFunction(...args));
    }

    /**
     * Define a record type. Equivalent to Access.registerRecord().
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {string} name Record type name.
     * @param {RecordField[]} fields Fields in declaration order; field types are named types.
     * @returns {undefined} No value.
     * @throws {Error} Invalid fields, unknown field types, or an already defined record.
     */
    registerRecord(...args) {
      this._context(a => a.registerRecord(...args));
    }

    /**
     * Register a lazy Control. Equivalent to Access.registerControl().
     *
     * @memberof Runtime
     * @instance
     * @function
     * @param {string} name Callable name.
     * @param {ControlCallback} callback Synchronous host implementation.
     * @returns {undefined} No value.
     * @throws {Error} Invalid registration, expired access, or an unavailable runtime.
     */
    registerControl(...args) {
      this._context(a => a.registerControl(...args));
    }

    /**
     * Internal implementation helper.
     *
     * @private
     */
    _context(callback) {
      return this._use(a => {
        if (!(a instanceof Access))
          throw new Error('context operations are not available to native Functions');

        return callback(a);
      });
    }

    /**
     * Release package-retained values, runtime handles, callbacks, and package instances.
     *
     * Must be called explicitly. Repeated calls after successful disposal have no
     * effect. Disposal while a callback or package load is active is rejected.
     * All remaining Value and access wrappers become unusable.
     *
     * @memberof Runtime
     * @instance
     * @function
     * @returns {undefined} No value.
     * @throws {Error} Active runtime/package load or package cleanup failure.
     */
    dispose() {
      if (this.closed) return;

      if (this.active || this.loading)
        throw new Error('cannot dispose an active runtime');

      let cleanupError;

      for (const pkg of this.packages.values()) {
        try {
          pkg.release();
        } catch (error) {
          cleanupError ??= error;
        }
      }

      this.raw.close();
      this.raw.free();
      this.closed = true;

      for (const pkg of this.packages.values()) {
        try {
          pkg.close();
        } catch (error) {
          cleanupError ??= error;
        }
      }

      this.packages.clear();

      if (cleanupError) throw cleanupError;
    }
  }

  return { Runtime, Value, FunctionAccess, Access };
}

/**
 * The classes returned by createAPI(). Access classes and Value are instantiated by
 * the wrapper; only Runtime is constructed directly by an embedding application.
 * @typedef {Object} DetlefAPI
 * @property {Function} Runtime Runtime constructor.
 * @property {Function} Value Value class, for identity checks.
 * @property {Function} FunctionAccess Limited access class.
 * @property {Function} Access Full context access class.
 */

/**
 * Primitive bridge values: BigInt integers, Number floats, booleans, strings,
 * null for none, and undefined for no result.
 * @typedef {(bigint|number|boolean|string|null|undefined)} Primitive
 */

/**
 * A primitive or a Value belonging to the same runtime.
 * @typedef {(Primitive|Value)} HostValue
 */

/**
 * A named type such as 'integer', or an object with one recursive key:
 * { array: TypeSpec } or { optional: TypeSpec }.
 * @typedef {(string|Object)} TypeSpec
 */

/**
 * A record field. This bridge currently accepts named field types.
 * @typedef {Object} RecordField
 * @property {string} name Field name.
 * @property {string} type Existing named field type.
 */

/**
 * Synchronous stdlib I/O. Output receives UTF-8 text without an implicit newline.
 * Input returns a string (including an empty line), or null/undefined for EOF.
 * Line endings are stripped by stdlib. Promises are rejected. An output exception
 * is reported after the enclosing access returns; side effects may already exist.
 * @typedef {Object} IOOptions
 * @property {function(string): void} [output] Output callback; default discards text.
 * @property {function(): (string|null|undefined)} [input] Input callback; default EOF.
 */

/**
 * Metadata returned after successful package loading.
 * @typedef {Object} PackageInfo
 * @property {string} name Manifest package name.
 * @property {string} version Manifest version string.
 */

/**
 * Unevaluated Control arguments. Must not be saved beyond the callback.
 * @typedef {Object} ControlArguments
 * @property {number} length Argument count.
 * @property {function(number): Value} evaluate Evaluates one argument by zero-based
 * index. Repeated calls repeat side effects. The result is callback-local.
 * @property {function(number): string} identifier Reads an identifier argument
 * without evaluating it; other expression kinds fail.
 */

/**
 * Synchronous host setup. Access expires on return. Values created here must be
 * disposed explicitly. Returning a Promise is rejected.
 * @callback SetupCallback
 * @param {Access} access Borrowed context access.
 * @returns {*} Result passed back through withAccess().
 */

/**
 * Eager native Function implementation. Object arguments are temporary Value
 * wrappers; primitive arguments are JavaScript primitives. Callback-local wrappers
 * are disposed automatically. A returned retained Value remains separately owned.
 * @callback FunctionCallback
 * @param {FunctionAccess} access Borrowed limited access.
 * @param {...HostValue} values Evaluated arguments.
 * @returns {HostValue} Result; undefined supplies no value. Promises are rejected.
 */

/**
 * Lazy Control implementation. Argument evaluation is explicitly requested through
 * args. Callback-local wrappers are disposed automatically; retain before saving.
 * @callback ControlCallback
 * @param {Access} access Borrowed context access.
 * @param {ControlArguments} args Borrowed expression access.
 * @returns {HostValue} Result; undefined supplies no value. Promises are rejected.
 */