Merge branch 'sse-and-backoff' into HEAD

This commit is contained in:
2026-08-12 03:24:35 +00:00
22 changed files with 3833 additions and 1026 deletions
+40
View File
@@ -0,0 +1,40 @@
import { bigIntToVmNumber, utf8ToBin } from '@bitauth/libauth';
import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueTypeError } from './errors.ts';
/**
* Converts a value into bytes representation.
*
* @param {unknown} value - Value to encode, should be one of: Uint8Array, bigint, boolean, string, or a safe integer number.
* @param {string} valueIdentifier - Identifier used in error messages.
* @returns {Uint8Array} Bytes representation of the value.
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number is not a safe integer.
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the value type cannot be resolved.
*/
export const convertValueToBytes = (value: unknown, valueIdentifier: string): Uint8Array => {
if (value instanceof Uint8Array) {
return value;
}
if (typeof value === 'bigint') {
return bigIntToVmNumber(value);
}
if (typeof value === 'boolean') {
// The BCH VM treats an empty byte array as false and any nonempty byte array as true.
return new Uint8Array(value ? [ 1 ] : []);
}
if (typeof value === 'string') {
return utf8ToBin(value);
}
if (typeof value === 'number') {
if (Number.isSafeInteger(value) === true) {
return bigIntToVmNumber(BigInt(value));
}
throw new CashAssemblyNumberNotSafeIntegerError(valueIdentifier, value);
}
throw new CashAssemblyUnsupportedValueTypeError(valueIdentifier, typeof value);
};
+61
View File
@@ -0,0 +1,61 @@
/**
* Detects whether a string is a pure CashAssembly expression.
*
* CashAssembly expressions look like `$(<variable>)` or `$(<a> <b>)`. This pattern checks
* that the entire string is one such expression and nothing else. It will not match if
* there is other text surrounding the expression.
*
* For example:
* `$(<fee>)` matches (a full expression)
* `OP_DUP $(<fee>)` does not match (extra text before it)
* `$()` does not match (empty expression)
*/
export const CASHASSEMBLY_EXPRESSION_PATTERN = /^\$\([^)]+\)$/;
/**
* Finds all CashAssembly evaluations embedded in a larger string.
*
* An evaluation looks like `$(...)`, for example `$(<fee>)` or `$(<a> <b>)`. This pattern
* locates every occurrence in the input and returns them all (global flag `g`).
* Empty evaluations `$()` are intentionally excluded because they reference no variables.
*
* For example, scanning `"OP_DUP <$(<pubkeyHash>)> OP_HASH160 $(<fee>)"` would return
* `['$(<pubkeyHash>)', '$(<fee>)']`.
*/
export const CASHASSEMBLY_EVALUATION_PATTERN = /\$\([^)]+\)/g;
/**
* Extracts variable names from angle-bracket references inside a CashAssembly evaluation.
*
* Inside an evaluation like `$(<pubkeyHash> <fee>)`, variables are referenced as `<name>`.
* This pattern captures the name between the brackets. The global flag `g` allows iterating
* over every variable reference in a single evaluation string.
*
* For example, running this against `$(<pubkeyHash> <fee>)` would return the variable names
* `["pubkeyHash", "fee"]`.
*/
export const CASHASSEMBLY_VARIABLE_PATTERN = /<([^>]+)>/g;
/**
* Identifies CashAssembly literal tokens that appear inside angle-bracket push statements.
*
* Inside an evaluation, not everything between `<` and `>` is a variable name. Literals are
* also valid push contents: numeric literals (e.g. `<0>`, `<32>`), hex literals (`<0x02>`),
* binary literals (`<0b1010>`), and string literals (`<"minting">`, `<'hello'>`). This pattern
* matches any captured token that starts with a digit or a quote character.
*/
export const CASHASSEMBLY_LITERAL_TOKEN_PATTERN = /^[0-9"']/;
/**
* Matches a single dot variable method reference inside an angle-bracket identifier.
*
* Used to detect primitive method references such as `expiry.toIso8601`.
*
* For example:
* `expiry.toIso8601` matches (base `expiry`, method `toIso8601`)
* `requestedSatoshis` does not match (no method)
* `key.schnorr_signature.all_outputs` does not match (more than one dot)
* `key.public_key` matches the pattern shape but it is only resolved
* when `hint` maps to a primitive
*/
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
+85
View File
@@ -0,0 +1,85 @@
/* eslint-disable max-classes-per-file */
/**
* Error thrown when a required variable is missing.
*/
export class CashAssemblyRequiredVariableMissingError extends Error {
constructor(variableNames?: string[]) {
const defaultMessage = 'Missing required variable';
if (variableNames !== undefined && variableNames.length > 0) {
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
} else {
super(defaultMessage);
}
}
}
/**
* Error thrown when cash assembly compilation fails.
*/
export class CashAssemblyCompilationFailedError extends Error {
constructor(message?: string) {
const defaultMessage = 'Cash assembly compilation failed';
super(message ? `${defaultMessage}: ${message}` : defaultMessage);
}
}
/**
* Error thrown when a variable's runtime type does not match the type required for compilation.
*/
export class CashAssemblyVariableTypeMismatchError extends Error {
constructor(variableKey: string, expectedType: string, actualType: string) {
const defaultMessage = 'Variable type mismatch';
super(`${defaultMessage}: variableKey "${variableKey}", expected ${expectedType}, got ${actualType}`);
}
}
/**
* Error thrown when a supported primitive hint does not expose the requested method.
*/
export class CashAssemblyPrimitiveMethodMissingError extends Error {
constructor(identifier: string, methodName: string, hint: string) {
const defaultMessage = 'CashAssembly primitive method does not exist';
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", hint "${hint}"`);
}
}
/**
* Error thrown when a value cannot be resolved as bytes.
*/
export class CashAssemblyUnsupportedValueTypeError extends Error {
constructor(identifier: string, returnedType: string) {
const defaultMessage = 'CashAssembly value type is unsupported for byte resolution';
super(`${defaultMessage}: identifier "${identifier}", returnedType "${returnedType}"`);
}
}
/**
* Error thrown when a number cannot be safely encoded as a CashAssembly VM number.
*/
export class CashAssemblyNumberNotSafeIntegerError extends Error {
constructor(identifier: string, value: number) {
const defaultMessage = 'CashAssembly number is not a safe integer';
super(`${defaultMessage}: identifier "${identifier}", got ${String(value)}`);
}
}
/**
* Error thrown when a supported primitive is selected but its value is missing when provided in the variables map.
*/
export class CashAssemblyPrimitiveVariableMissingError extends Error {
constructor(identifier: string, variableName: string) {
const defaultMessage = 'CashAssembly primitive variable is missing from the variables map';
super(`${defaultMessage}: identifier "${identifier}", variableName "${variableName}"`);
}
}
/**
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
*/
export class CashAssemblyVmNumberDecodeError extends Error {
constructor(reason: string) {
const defaultMessage = 'CashAssembly evaluation could not be decoded as a VM number';
super(`${defaultMessage}: ${reason}`);
}
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Utilities for parsing, extracting, and compiling CashAssembly expressions.
*
* CashAssembly is the scripting language used by Bitauth templates to describe Bitcoin Cash
* locking and unlocking scripts.
*
* ## Syntax (CashAssembly)
*
* `<expression>` is a push statement. Compiles the contents and pushes the result onto the VM stack.
* `<someKey.public_key>` pushes the 33 byte compressed public key. `<1>` pushes the integer 1.
*
* `$(<expression>)` is an evaluation. Runs the inner script in the VM and inserts the top stack
* item as VM bytecode.
* `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
*
* `<$(<expression>)>` is a push of an evaluation result. It evaluates first then pushes.
* For a P2PKH locking script example see
* `OP_DUP OP_HASH160 <$(<someKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG`.
*
* `variableId.operation` is a variable with a compiler resolved operation. `someKey.public_key`
* produces the public key bytes. `someKey.schnorr_signature.all_outputs` produces a Schnorr
* signature.
*
* Opcodes (`OP_DUP`, `OP_HASH160`, and similar) are inserted as their bytecode equivalent directly.
*
* ## Name resolution priority (CashAssembly)
*
* When the compiler encounters an identifier it resolves it in this order.
* 1. Opcode always wins. Naming a variable or script `OP_ADD` will not shadow it.
* 2. Variable shadows scripts of the same name.
* 3. Script is the script's bytecode.
*
* ## Resolution Order (CashAssembly + Primitive Method Resolution)
*
* Supported `<base.method>` pushes are resolved to bytes before CashAssembly compiles.
* Inside CashAssembly the order is Opcode then Variable then Script.
*/
import type { CompilerBch } from '@bitauth/libauth';
import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth';
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import {
CashAssemblyCompilationFailedError,
CashAssemblyVariableTypeMismatchError,
CashAssemblyRequiredVariableMissingError,
CashAssemblyVmNumberDecodeError,
} from './errors.ts';
import {
CASHASSEMBLY_EVALUATION_PATTERN,
CASHASSEMBLY_EXPRESSION_PATTERN,
CASHASSEMBLY_LITERAL_TOKEN_PATTERN,
CASHASSEMBLY_VARIABLE_PATTERN,
} from './defaults.ts';
import { convertValueToBytes } from './bytes.ts';
import { resolvePrimitiveMethodBytes } from './primitive-evaluations.ts';
/**
* Supported decode modes for compiled CashAssembly evaluation bytes.
*/
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
/**
* Parameters for compiling CashAssembly string.
*/
export type CompileCashAssemblyStringParameters = {
/**
* Text that may embed CashAssembly evaluations such as `$(<fee>)` or `$(<expiry.toIso8601>)`.
*/
cashAssemblyText: string;
/**
* Used for both primitive method evaluations and normal CashAssembly compilation.
*/
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
/**
* The mode to decode compiled evaluation bytes into a string.
*/
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
/**
* Optional template variable definitions. When provided, each `<name.method>` push whose `hint`
* maps to a supported primitive class is resolved to bytes before CashAssembly compilation.
*/
templateVariables?: XOTemplate['variables'];
};
/**
* Checks if the expression is a CashAssembly expression.
*
* @param {unknown} expression - The expression to check.
* @returns {boolean} True if the expression is a CashAssembly expression, false otherwise.
*/
export const isCashAssemblyExpression = (expression: unknown): boolean => {
return typeof expression === 'string' && CASHASSEMBLY_EXPRESSION_PATTERN.test(expression);
};
/**
* Extracts all CashAssembly evaluations (i.e., substrings like $(...)) from the input text.
*
* @param {string} text - The input string to scan for CashAssembly evaluations.
* @returns {string[]} An array of evaluation strings found in the input.
*
* @example
* extractCashAssemblyEvaluations("OP_DUP <$(<foo>)> OP_HASH160 $(<bar>)");
* // returns ['$(<foo>)', '$(<bar>)']
*/
export const extractCashAssemblyEvaluations = (text: string): string[] => {
return text.match(CASHASSEMBLY_EVALUATION_PATTERN) ?? [];
};
/**
* Returns the segment of `identifier` before the first `.`.
*
* When there is no `.`, returns `identifier` unchanged.
* Multi segment identifiers such as `foo.bar.baz` resolve to `foo`.
*
* @param {string} identifier - Identifier that may contain a dot.
* @returns {string} The base name before the first `.`.
*/
const resolveIdentifierBaseName = (identifier: string): string => {
const firstDotIndex = identifier.indexOf('.');
if (firstDotIndex === -1) {
return identifier;
}
return identifier.slice(0, firstDotIndex);
};
/**
* Extracts unique variable identifiers enclosed in angle brackets from each evaluation string.
*
* CashAssembly literal tokens such as hex bytes, numbers, and quoted strings are excluded via
* {@link CASHASSEMBLY_LITERAL_TOKEN_PATTERN}. For example, `<0x02>` and `<"minting">` are not returned.
*
* @param {string[]} evaluations - An array of evaluation strings from which to extract variable names.
* @returns {string[]} An array of variable names.
*/
export const extractVariablesFromEvaluations = (evaluations: string[]): string[] => {
const uniqueVariables = new Set<string>();
for (const evaluation of evaluations) {
for (const [ , extractedIdentifier ] of evaluation.matchAll(CASHASSEMBLY_VARIABLE_PATTERN)) {
if (CASHASSEMBLY_LITERAL_TOKEN_PATTERN.test(extractedIdentifier)) {
continue;
}
uniqueVariables.add(extractedIdentifier);
}
}
return [ ...uniqueVariables ];
};
/**
* Decodes compiled CashAssembly evaluation bytes into a string representation.
*
* 'evaluationDecodeMode' determines how the evaluation bytes are interpreted and presented.
* Use `bigint` for numeric values like satoshis, `utf8` for text labels, `hex` for binary data
* such as hashes, and `boolean` to represent boolean values.
*
* @param {Uint8Array} compiledResult - The compiled evaluation bytecode.
* @param {CompiledCashAssemblyDecodeMode} [evaluationDecodeMode='utf8'] - The decode mode used to convert
* bytes to text.
* @returns {string} The decoded value as a string suitable for inline replacement.
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
*/
export const decodeCompiledCashAssemblyEvaluation = (
compiledResult: Uint8Array,
evaluationDecodeMode: CompiledCashAssemblyDecodeMode = 'utf8',
): string => {
// Converts the byte array to a string.
if (evaluationDecodeMode === 'uint8array') {
return String(compiledResult);
}
// Converts the byte array to a boolean string, converting the evaluation result into a true or a false.
if (evaluationDecodeMode === 'boolean') {
return compiledResult.length === 0 ? 'false' : 'true';
}
// Converts the byte array to a hex string.
if (evaluationDecodeMode === 'hex') {
return binToHex(compiledResult);
}
// Converts the byte array to a bigint string.
if (evaluationDecodeMode === 'bigint') {
const vmNumberResult = vmNumberToBigInt(compiledResult);
if (typeof vmNumberResult === 'bigint') {
return vmNumberResult.toString();
}
throw new CashAssemblyVmNumberDecodeError(vmNumberResult);
}
// Converts the byte array to a utf8 string.
return binToUtf8(compiledResult);
};
/**
* Generates bytecode for a specific CashAssembly evaluation using given variable values and a prepared compiler.
*
* @param {CompilerBch} compiler - The libauth compiler from {@link compileCashAssemblyEvaluations}.
* @param {string} evaluation - The specific evaluation string to compile.
* @param {Record<string, Uint8Array>} variables - A record mapping variable names to their values.
* @returns {Uint8Array} The compiled bytecode.
* @throws {@link CashAssemblyRequiredVariableMissingError} If a required variable is not present.
* @throws {@link CashAssemblyVariableTypeMismatchError} If a variable value is not a Uint8Array.
* @throws {@link CashAssemblyCompilationFailedError} If libauth compilation fails.
*/
export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation: string, variables: Record<string, Uint8Array>): Uint8Array => {
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
// Validate that all required variables are provided
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
if (missingVariables.length > 0) {
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
}
// Construct the bytecode object using the keys in variableNames, mapping to the values in variables
const bytecode: Record<string, Uint8Array> = {};
for (const variableName of variableNames) {
const value = variables[variableName];
// By the time execution reaches this point, the value should be a Uint8Array.
if (!(value instanceof Uint8Array)) {
throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value);
}
bytecode[variableName] = value;
}
const compiledBytecode = compiler.generateBytecode({
data: { bytecode },
scriptId: evaluation,
});
if (!compiledBytecode.success) {
// Collapse libauth's full errors list into one string because
// CashAssemblyCompilationFailedError only carries a single message.
let compilationFailureMessage = 'unknown compilation failure';
if ('errors' in compiledBytecode && compiledBytecode.errors.length > 0) {
compilationFailureMessage = compiledBytecode.errors.map((compilationError) => compilationError.error).join('; ');
}
throw new CashAssemblyCompilationFailedError(compilationFailureMessage);
}
return compiledBytecode.bytecode;
};
/**
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
*
* @param {string[]} evaluations - Array of evaluation strings (e.g., ['$(<var1>)', '$(<var2> <var3>)']).
* @returns {CompilerBch} A Libauth compiler instance for use with these evaluations.
*/
export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerBch => {
// Create a scripts object where each key is the evaluation and the value is also the evaluation
const scripts: Record<string, string> = {};
for (const evaluation of evaluations) {
scripts[evaluation] = evaluation;
}
// Get the variable names from the evaluations.
const variableNames = extractVariablesFromEvaluations(evaluations);
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
const variables: Record<string, { type: 'WalletData' }> = {};
for (const variableName of variableNames) {
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
}
// Create the libauth compiler.
const compiler = createCompilerBch({
scripts,
variables,
});
return compiler;
};
/**
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
* with a decoded string representation.
*
* @param {CompileCashAssemblyStringParameters} parameters - Parameters for compiling the CashAssembly string.
* @param {string} parameters.cashAssemblyText - The string with CashAssembly evaluations.
* @param {Record<string, XOInvitationVariableValue | Uint8Array>} parameters.variables - Object mapping
* variable names to values for compilation.
* @param {CompiledCashAssemblyDecodeMode} [parameters.evaluationDecodeMode='utf8'] - The decode mode used
* after each evaluation is compiled. See {@link decodeCompiledCashAssemblyEvaluation}.
* @param {XOTemplate['variables']} [parameters.templateVariables] - Optional template variable definitions
* used to resolve supported `<name.method>` pushes via each variable's `hint`.
* @returns {string} Compiled text with all evaluations replaced by decoded string values.
* @throws {@link CashAssemblyRequiredVariableMissingError} When a required variable is not present in the variables map.
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When a supported primitive hint has an unknown method.
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When a supported primitive method is missing its runtime value.
* @throws {@link CashAssemblyUnsupportedValueTypeError} When a primitive method return type cannot be embedded as bytes.
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number variable is not a safe integer.
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and an evaluation is not a VM number.
*/
export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => {
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables } = parameters;
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
// Extract variable identifiers required by the current evaluation.
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
identifiers: variableNames,
templateVariables,
variables,
});
// Prefer resolved method bytes. Fall back to converting the raw variable value.
const missingVariables = variableNames.filter((variableName) => {
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
return false;
}
return Object.hasOwn(variables, variableName) === false;
});
if (missingVariables.length > 0) {
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
}
// Convert each variable to its bytes before compilation.
const variableBytes: Record<string, Uint8Array> = {};
for (const variableName of variableNames) {
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
variableBytes[variableName] = primitiveMethodBytes[variableName];
continue;
}
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
}
// Compile the evaluation in isolation.
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
// Generate the bytes for the evaluation.
const compilationResult: Uint8Array = generateCashAssemblyBytecode(compiler, evaluation, variableBytes);
// Decode the bytes into a string as per the decode mode.
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
});
};
+5
View File
@@ -0,0 +1,5 @@
export * from './evaluations.ts';
export * from './primitive-evaluations.ts';
export * from './bytes.ts';
export * from './defaults.ts';
export * from './errors.ts';
@@ -0,0 +1,216 @@
import {
FungibleTokenAmount,
NFTCommitment,
PublicKey,
Satoshis,
SchnorrSignature,
TemplateIdentifier,
Timestamp,
TokenCategory,
TransactionHash,
} from '@xo-cash/primitives';
import { XOTemplatePrimitiveTypes } from '@xo-cash/types';
import type { XOTemplate, XOTemplatePrimitiveType } from '@xo-cash/types';
import { CashAssemblyPrimitiveMethodMissingError, CashAssemblyPrimitiveVariableMissingError } from './errors.ts';
import { CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN } from './defaults.ts';
import { convertValueToBytes } from './bytes.ts';
/**
* Template hint values mapped to a primitive class for resolving primitive method evaluations.
* Keys are values from XOTemplatePrimitiveTypes.
*/
const PRIMITIVE_BY_TEMPLATE_HINT = {
[XOTemplatePrimitiveTypes.FUNGIBLE_TOKEN_AMOUNT]: FungibleTokenAmount,
[XOTemplatePrimitiveTypes.NFT_COMMITMENT]: NFTCommitment,
[XOTemplatePrimitiveTypes.PUBLIC_KEY]: PublicKey,
[XOTemplatePrimitiveTypes.SATOSHIS]: Satoshis,
[XOTemplatePrimitiveTypes.SCHNORR_SIGNATURE]: SchnorrSignature,
[XOTemplatePrimitiveTypes.TEMPLATE_IDENTIFIER]: TemplateIdentifier,
[XOTemplatePrimitiveTypes.TIMESTAMP]: Timestamp,
[XOTemplatePrimitiveTypes.TOKEN_CATEGORY]: TokenCategory,
[XOTemplatePrimitiveTypes.TRANSACTION_HASH]: TransactionHash,
} as const;
type SupportedPrimitiveHint = keyof typeof PRIMITIVE_BY_TEMPLATE_HINT;
/**
* Inputs needed to call a primitive method for a `name.method` push.
*/
type CallPrimitiveMethodParameters = {
/**
* Full push identifier from the evaluation, for example `amount.toSatoshis`.
*/
identifier: string;
/**
* Method name to call on the constructed primitive, for example `toIso8601`.
*/
methodName: string;
/**
* Value for the variable.
*/
value: unknown;
/**
* Supported template hint that selects the primitive class.
*/
hint: SupportedPrimitiveHint;
};
/**
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
*/
export type ResolvePrimitiveMethodBytesParameters = {
/**
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
* `['amount.toSatoshis', 'fee.toSatoshis']`.
*/
identifiers: string[];
/**
* Variable names and values object.
*/
variables: Record<string, unknown>;
/**
* Template variable definitions. When omitted, no primitive methods are resolved.
* The `hint` on each entry selects the primitive class.
*/
templateVariables?: XOTemplate['variables'];
};
/**
* Returns true when `hint` maps to a supported primitive class in `PRIMITIVE_BY_TEMPLATE_HINT`.
*
* @param {XOTemplatePrimitiveType | undefined} hint - Template variable hint.
* @returns {boolean} True when the hint selects a supported primitive class.
*/
const isSupportedPrimitiveHint = (hint: XOTemplatePrimitiveType | undefined): hint is SupportedPrimitiveHint => {
return hint !== undefined && Object.hasOwn(PRIMITIVE_BY_TEMPLATE_HINT, hint) === true;
};
/**
* Returns true when `methodName` is an own function on the primitive class for `hint`.
*
* @param {SupportedPrimitiveHint} hint - Supported template hint.
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
* @returns {boolean} True when that class exposes the named method.
*/
const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: string): boolean => {
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
// Check own properties only so inherited Object.prototype names are rejected without needing a value.
if (Object.hasOwn(PrimitiveClass.prototype, methodName) === false) {
return false;
}
return typeof Reflect.get(PrimitiveClass.prototype, methodName) === 'function';
};
/**
* Constructs a primitive from a raw value and calls one instance method on it.
*
* Call only after `canResolvePrimitiveMethod` is true for the same hint and method.
*
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and supported hint.
* @returns {unknown} Method return value, later encoded as CashAssembly push bytes.
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the prototype member is not a function.
* @throws When the primitive constructor rejects the raw value (validation errors from `@xo-cash/primitives`).
*/
const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown => {
const { identifier, methodName, value, hint } = parameters;
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
// Constructing runs each primitive's own input validation (range checks, hex length, etc).
// `as never` satisfies TypeScript across constructors that accept different input shapes.
const primitiveInstance = new PrimitiveClass(value as never);
// Same prototype member canResolvePrimitiveMethod already verified as an own function.
const primitiveMethod = Reflect.get(PrimitiveClass.prototype, methodName);
if (typeof primitiveMethod !== 'function') {
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
}
return primitiveMethod.call(primitiveInstance);
};
/**
* Resolves supported `base.method` identifiers to CashAssembly variable bytes.
*
* Each single dot identifier whose `hint` maps to a supported primitive is resolved and stored under
* the full identifier (`base.method`). Unsupported or multi dot identifiers are left for CashAssembly.
*
* When `templateVariables` is omitted, returns an empty map.
*
* @param {ResolvePrimitiveMethodBytesParameters} parameters - Identifiers, values, and optional template metadata.
* @returns {Record<string, Uint8Array>} Resolved method identifiers mapped to bytes for CashAssembly pushes.
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the hint is a supported primitive but the method is missing.
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When the runtime value is missing from the variables map.
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the method return type cannot be embedded.
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a method return value is a number that is not a safe integer.
*/
export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBytesParameters): Record<string, Uint8Array> => {
const { identifiers, templateVariables, variables } = parameters;
// Without template metadata there is no hint to select a primitive class.
if (templateVariables === undefined) {
return {};
}
const resolvedBytes: Record<string, Uint8Array> = {};
for (const identifier of identifiers) {
// The same identifier can appear more than once. Resolve it only once.
if (Object.hasOwn(resolvedBytes, identifier) === true) {
continue;
}
// Find the primitive method reference in the identifier.
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
if (methodReferenceMatch === null) {
continue;
}
const [ , baseName, methodName ] = methodReferenceMatch;
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
// must be left for CashAssembly rather than treated as primitive failures.
if (Object.hasOwn(templateVariables, baseName) === false) {
continue;
}
const hint = templateVariables[baseName].hint;
if (isSupportedPrimitiveHint(hint) === false) {
continue;
}
// Supported hint with an unknown method should be thrown as an error.
if (canResolvePrimitiveMethod(hint, methodName) === false) {
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
}
// If the method is known but the runtime value is missing, throw an error.
if (Object.hasOwn(variables, baseName) === false) {
throw new CashAssemblyPrimitiveVariableMissingError(identifier, baseName);
}
const methodResult = callPrimitiveMethod({
identifier,
methodName,
value: variables[baseName],
hint,
});
// Store the result under identifier.
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
}
return resolvedBytes;
};
+50
View File
@@ -40,3 +40,53 @@ export class ExponentialBackoffStoppedRetriesError extends Error {
this.name = 'ExponentialBackoffStoppedRetriesError';
}
}
/**
* Error thrown when an exponential backoff option is too small
*/
export class ExponentialBackoffNumberTooSmallError extends Error {
constructor(option: string, value: number, min: number) {
super(`Exponential backoff option "${option}" is too small. Must be at least ${min}. Received value: ${value}`);
this.name = 'ExponentialBackoffNumberTooSmallError';
}
}
/**
* Error thrown when an exponential backoff option is out of bounds
*/
export class ExponentialBackoffNumberOutOfBoundsError extends Error {
constructor(option: string, value: number, min: number, max: number) {
super(`Exponential backoff option "${option}" is out of bounds. Must be between ${min} and ${max}. Received value: ${value}`);
this.name = 'ExponentialBackoffNumberOutOfBoundsError';
}
}
/**
* Error thrown when an exponential backoff option is an invalid infinite integer
*/
export class ExponentialBackoffNumberNotFiniteError extends Error {
constructor(option: string, value: number) {
super(`Exponential backoff option "${option}" is invalid. Must be a finite number. Received value: ${value}`);
this.name = 'ExponentialBackoffNumberNotFiniteError';
}
}
/**
* Error thrown when an exponential backoff option is not an integer
*/
export class ExponentialBackoffNonIntegerError extends Error {
constructor(option: string, value: number) {
super(`Exponential backoff option "${option}" is invalid. Must be an integer. Received value: ${value}`);
this.name = 'ExponentialBackoffNonIntegerError';
}
}
/**
* Error thrown when a waitFor timeout is reached
*/
export class WaitForTimeoutError extends Error {
constructor(type: string) {
super(`Timeout waiting for event "${type}"`);
this.name = 'WaitForTimeoutError';
}
}
+272
View File
@@ -0,0 +1,272 @@
import type { DeeplyReadonly } from './types.ts';
import { WaitForTimeoutError } from './errors.ts';
import { deepFreeze } from './misc.ts';
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: DeeplyReadonly<T>) => void;
/**
* Internally permits listeners for individual event payloads to be stored
* in a collection typed with the union of all event payloads.
*/
type StoredListener<T> = {
bivarianceHack(detail: DeeplyReadonly<T>): void;
}['bivarianceHack'];
/**
* A listener entry.
* @template T - The event payload type.
*/
interface ListenerEntry<T> {
listener: StoredListener<T>;
wrappedListener: StoredListener<T>;
cancel: () => void;
}
/**
* Callback returned by {@link on} and {@link once} for removing a listener.
*/
export type OffCallback = () => void;
/**
* A simple event emitter implementation.
* @template T - The event map type.
*/
export class EventEmitter<T extends EventMap> {
/**
* The listeners map.
* @private
*/
#listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
/**
* Add a listener for an event.
* @param type - The event type.
* @param listener - The listener function.
* @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events.
*/
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.cancellable(listener);
// Create a wrapped listener so that the debounce can be applied.
const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener,
cancel,
};
// Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
/**
* Add a one-time listener for an event.
* @param type - The event type.
* @param listener - The listener function.
* @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events.
*/
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: DeeplyReadonly<T[K]>) => {
this.off(type, listener);
listener(detail);
};
// Create a cancellable listener.
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener);
// Create a debounced listener.
const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener: debouncedListener,
cancel,
};
// Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
/**
* Remove a listener for an event.
* @param type - The event type.
* @param listener - The listener function.
*/
off<K extends keyof T>(type: K, listener?: Listener<T[K]>): void {
// Get the listeners for the event type.
const listeners = this.#listeners.get(type);
if (!listeners) return;
// Find the listener entries (If a listener was provided, only 1 entry will be returned. Otherwise, all entries will be returned).
const listenerEntries = Array.from(listeners).filter((entry) => !listener || entry.listener === listener || entry.wrappedListener === listener);
// Remove the listener entries from the listeners set.
listenerEntries.forEach((entry) => {
// Set the wrapped listener to a no-op function to prevent it from being called by debounced events after it's been removed.
entry.cancel();
// Remove the listener entry from the listeners set.
listeners.delete(entry);
});
// If no listener was provided and no listeners are left for the event type, remove the listeners set from the listeners map.
if (!listener || this.#listeners.get(type)?.size === 0) {
this.#listeners.delete(type);
}
}
/**
* Emit an event.
* @param type - The event type.
* @param payload - The event payload.
* @returns True if there are listeners for the event, false otherwise.
*/
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
// Get the listeners for the event type.
const listeners = this.#listeners.get(type);
if (!listeners) return false;
// Clone the payload to avoid freezing the original object.
const payloadClone = structuredClone(payload);
// Freeze the cloned payload to make it readonly.
const readonlyPayload = deepFreeze(payloadClone);
// Emit the event to all listeners.
listeners.forEach((entry) => {
try {
entry.wrappedListener(readonlyPayload);
} catch (error) {
console.error(error);
}
});
// Return true if there are listeners for the event, false otherwise.
return listeners.size > 0;
}
/**
* Remove all listeners.
*/
removeAllListeners(): void {
for (const [ type, listeners ] of this.#listeners.entries()) {
listeners.forEach((entry) => {
this.off(type, entry.listener);
});
}
}
/**
* Wait for an event to be emitted that matches the provided predicate function's criteria.
* @param type - The event type.
* @param predicate - Predicate function to filter for whether the event payload matches the criteria.
* @param timeoutMs - The timeout in milliseconds.
* @returns The event payload.
*/
async waitFor<K extends keyof T>(
type: K,
predicate: (payload: DeeplyReadonly<T[K]>) => boolean,
timeoutMs?: number,
): Promise<DeeplyReadonly<T[K]>> {
// Create a promise to wait for the event to be emitted.
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
// Create a cleanup function to remove the listener and clear the timeout if it is still pending.
const cleanup = (listener: Listener<T[K]>): void => {
// Remove the listener from the listeners map.
this.off(type, listener);
// Clear the timeout if it is still pending.
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};
// Create a listener function.
const listener = (payload: DeeplyReadonly<T[K]>): void => {
try {
// If the event payload does not match the predicate condition, return.
if (!predicate(payload)) {
return;
}
cleanup(listener);
resolve(payload);
} catch (error) {
cleanup(listener);
reject(error);
}
};
// Set up timeout if specified
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener);
reject(new WaitForTimeoutError(String(type)));
}, timeoutMs);
}
// Add the listener to the listeners map.
this.on(type, listener);
});
}
/**
* Debounce a function.
* @param func - The function to debounce.
* @param wait - The wait time in milliseconds.
* @returns The debounced function.
*/
private debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
// Create a timeout variable.
let timeout: ReturnType<typeof setTimeout>;
return (detail: DeeplyReadonly<T[K]>) => {
// If a debounce timer is already pending, clear it before scheduling the next one.
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);
};
}
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
let cancelled = false;
return {
cancel: (): boolean => (cancelled = true),
listener: (detail: DeeplyReadonly<T[K]>): void => {
if (cancelled) return;
func(detail);
},
};
}
}
+281 -230
View File
@@ -1,233 +1,12 @@
import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts';
/**
* Exponential backoff is a technique used to retry a function after a delay.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
readonly #options: ExponentialBackoffOptions;
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Maximum proportional reduction subtracted from each delay (01). Default: `0.1`.
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.#options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
};
ExponentialBackoff.validateOptions(this.#options);
}
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
* @returns The ExponentialBackoff instance
*/
static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
/**
* Run the function with exponential backoff
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options);
return backoff.run(taskFn, onError);
}
/**
* Calculate the delay before we should attempt to retry
*
* @param options - The configuration for the exponential backoff
* @param attempt - The current attempt number
* @returns The time in milliseconds before another attempt should be made
*/
public static calculateDelay(options: ExponentialBackoffOptions, attempt: number): number {
// Get the power of the growth rate
const power = options.growthRate ** attempt;
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power;
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay);
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random();
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay;
// Subtract the jitter from the delay
return cappedDelay - jitter;
}
/**
* Validate the options for the exponential backoff
*
* @param options - The options to validate
*
* @throws An error if the options are invalid
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
// Validate the max delay is a finite number not less than 0
if (!Number.isFinite(options.maxDelay)) {
throw new Error('maxDelay must be a finite number');
}
if (options.maxDelay < 0) {
throw new Error('maxDelay must be not less than 0');
}
// Validate the max attempts is a finite number not less than 0
if (!Number.isFinite(options.maxAttempts)) {
throw new Error('maxAttempts must be a finite number');
}
if (options.maxAttempts < 0) {
throw new Error('maxAttempts must be not less than 0');
}
// Validate the base delay is a finite number not less than 0
if (!Number.isFinite(options.baseDelay)) {
throw new Error('baseDelay must be a finite number');
}
if (options.baseDelay < 0) {
throw new Error('baseDelay must be not less than 0');
}
// Validate the growth rate is a finite number not less than 0
if (!Number.isFinite(options.growthRate)) {
throw new Error('growthRate must be a finite number');
}
if (options.growthRate < 0) {
throw new Error('growthRate must be not less than 0');
}
// Validate the jitter is a finite number not less than 0 or greater than 1
if (!Number.isFinite(options.jitter)) {
throw new Error('jitter must be a finite number');
}
if (options.jitter < 0 || options.jitter > 1) {
throw new Error('jitter must be not less than 0 or greater than 1');
}
}
/**
* Run the function with exponential backoff
*
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If the function fails and we have hit the max attempts, an ExponentialBackoffMaxRetriesHitError will be thrown with all the errors that were thrown by the task function
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
): Promise<T> {
// Initialize an abort signal to allow the task function to be aborted
const abortController = new AbortController();
const stopRetries = abortController.abort.bind(abortController);
// Initialize an empty array to store the errors
const errors: Error[] = [];
// Initialize the attempt counter
let attempt = 0;
// If the max attempts is 0, we should continue indefinitely.
const unlimitedAttempts = this.#options.maxAttempts === 0;
// Loop until we succeed, hit the max attempts, or the abort signal is activated
while (true) {
try {
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await taskFn({ stopRetries });
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError(errorInstance);
// If we have unlimited attempts, don't append this to the errors array to prevent a memory leak.
if (!unlimitedAttempts) {
errors.push(errorInstance);
}
}
// Calculate the count for next attempt. Do this now so we can exit before waiting and before running the next attempt.
const nextAttemptCount = attempt + 1;
const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts;
// If the next attempt exceeds the max attempts, break out of the loop
if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) {
break;
}
// Check if the abort signal has been aborted
if (abortController.signal.aborted) {
// Throw an error if the abort signal has been aborted
throw new ExponentialBackoffStoppedRetriesError(abortController.signal.reason);
}
// Wait before going to the next attempt
const delay = ExponentialBackoff.calculateDelay(this.#options, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
attempt++;
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new ExponentialBackoffMaxRetriesHitError(errors);
}
}
import {
ExponentialBackoffStoppedRetriesError,
ExponentialBackoffMaxRetriesHitError,
ExponentialBackoffNonIntegerError,
ExponentialBackoffNumberTooSmallError,
ExponentialBackoffNumberOutOfBoundsError,
ExponentialBackoffNumberNotFiniteError,
} from './errors.ts';
import { isWithinBounds } from './misc.ts';
export type ExponentialBackoffOptions = {
@@ -273,3 +52,275 @@ export type ExponentialBackoffStopRetriesFunction = (reason: unknown) => void;
export type ExponentialBackoffCallbackParameters = {
stopRetries: ExponentialBackoffStopRetriesFunction;
};
/**
* Exponential backoff is a technique used to retry a function after a delay.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
readonly #options: ExponentialBackoffOptions;
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Maximum proportional reduction subtracted from each delay (01). Default: `0.1`.
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.#options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
};
ExponentialBackoff.validateOptions(this.#options);
}
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*
* @returns The ExponentialBackoff instance
*/
public static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
/**
* Run the function with exponential backoff
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
public static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options);
return backoff.run(taskFn, onError);
}
/**
* Validate the options for the exponential backoff
*
* @param options - The options to validate
*
* @throws {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
* @throws {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
/** Validate the value is finite, throwing an {@link ExponentialBackoffInvalidInfiniteIntegerError} if the value is infinite */
const assertIsFinite = (key: string, value: number): void => {
if (!Number.isFinite(value)) {
throw new ExponentialBackoffNumberNotFiniteError(key, value);
}
};
/** Validate the value is an integer, throwing a {@link ExponentialBackoffNonIntegerError} if it is not an integer */
const assertIsInteger = (key: string, value: number): void => {
if (!Number.isInteger(value)) {
throw new ExponentialBackoffNonIntegerError(key, value);
}
};
/** Validate the value is greater than the minimum, throwing a {@link ExponentialBackoffNumberTooSmallError} if it is not */
const assertIsHigherThan = (key: string, value: number, min: number): void => {
if (value < min) {
throw new ExponentialBackoffNumberTooSmallError(key, value, min);
}
};
/** Validate the value is within the bounds, throwing a {@link ExponentialBackoffNumberOutOfBoundsError} if it is not within the bounds */
const assertIsWithinBounds = (key: string, value: number, min: number, max: number): void => {
if (!isWithinBounds(value, min, max)) {
throw new ExponentialBackoffNumberOutOfBoundsError(key, value, min, max);
}
};
// Validate the max delay
assertIsFinite('maxDelay', options.maxDelay);
assertIsHigherThan('maxDelay', options.maxDelay, 0);
// Validate the max attempts
assertIsFinite('maxAttempts', options.maxAttempts);
assertIsInteger('maxAttempts', options.maxAttempts);
assertIsHigherThan('maxAttempts', options.maxAttempts, 0);
// Validate the base delay
assertIsFinite('baseDelay', options.baseDelay);
assertIsHigherThan('baseDelay', options.baseDelay, 0);
// Validate the growth rate
assertIsFinite('growthRate', options.growthRate);
assertIsHigherThan('growthRate', options.growthRate, 0);
// Validate the jitter
assertIsFinite('jitter', options.jitter);
assertIsWithinBounds('jitter', options.jitter, 0, 1);
}
/**
* Run the function with exponential backoff
*
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If the function fails and we have hit the max attempts, an ExponentialBackoffMaxRetriesHitError will be thrown with all the errors that were thrown by the task function
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
public async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
): Promise<T> {
// Initialize an abort signal to allow the task function to be aborted
const abortController = new AbortController();
const stopRetries = abortController.abort.bind(abortController);
// Initialize an empty array to store the errors
const errors: Error[] = [];
// Initialize the attempt counter
let attempt = 0;
// If the max attempts is 0, we should continue indefinitely.
const unlimitedAttempts = this.#options.maxAttempts === 0;
// Loop until we succeed, hit the max attempts, or the abort signal is activated
while (true) {
try {
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await taskFn({ stopRetries });
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError(errorInstance);
// If we have unlimited attempts, don't append this to the errors array to prevent a memory leak.
if (!unlimitedAttempts) {
errors.push(errorInstance);
}
}
// Check if the abort signal has been activated
if (abortController.signal.aborted) {
// Throw an error if the abort signal has been activated
throw new ExponentialBackoffStoppedRetriesError(abortController.signal.reason);
}
// Calculate the count for next attempt. Do this now so we can exit before waiting and before running the next attempt.
const nextAttemptCount = attempt + 1;
const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts;
// If the next attempt exceeds the max attempts, break out of the loop
if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) {
break;
}
// Wait before going to the next attempt
const delay = this.#calculateDelay(this.#options, attempt);
// Wait for the delay or the abort signal
await new Promise((resolve, reject) => {
// Set a timeout to resolve the promise after the delay
// eslint-disable-next-line prefer-const
let timeout: ReturnType<typeof setTimeout>;
// Handle the abort signal
const abortHandler = (): void => {
clearTimeout(timeout);
abortController.signal.removeEventListener('abort', abortHandler);
reject(new ExponentialBackoffStoppedRetriesError(abortController.signal.reason));
};
// Handle the timeout
const timeoutHandler = (): void => {
abortController.signal.removeEventListener('abort', abortHandler);
resolve(undefined);
};
// Set the timeout
timeout = setTimeout(timeoutHandler, delay);
// Add the abort handler to the abort signal
abortController.signal.addEventListener('abort', abortHandler);
});
attempt++;
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new ExponentialBackoffMaxRetriesHitError(errors);
}
/**
* Calculate the delay before we should attempt to retry
*
* @param options - The configuration for the exponential backoff
* @param attempt - The current attempt number
* @returns The time in milliseconds before another attempt should be made
*/
#calculateDelay(options: ExponentialBackoffOptions, attempt: number): number {
// Get the power of the growth rate
const power = options.growthRate ** attempt;
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power;
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay);
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random();
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay;
// Subtract the jitter from the delay
return cappedDelay - jitter;
}
}
+5
View File
@@ -1,3 +1,5 @@
export * from './errors.ts';
export * from './event-emitter.ts';
export * from './exponential-backoff.ts';
export * from './extended-json.ts';
export * from './misc.ts';
@@ -7,6 +9,9 @@ export * from './template/errors.ts';
export * from './template/identifier.ts';
export * from './template/parser.ts';
export * from './template/schemas.ts';
export * from './cash-assembly/index.ts';
export * from './cash-assembly/errors.ts';
export * from './cash-assembly/defaults.ts';
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
export { serializeTemplate } from './template/serialization.ts';
+40
View File
@@ -1,3 +1,22 @@
import type { DeeplyReadonly } from './types';
/**
* Validate the value is within the bounds, returning true if it is within the bounds, false otherwise
*
* @param value - The value to validate
* @param min - The minimum value
* @param max - The maximum value
*
* @returns True if the value is within the bounds, false otherwise
*/
export const isWithinBounds = (value: number, min: number, max: number): boolean => {
if (value < min || value > max) {
return false;
}
return true;
};
/**
* Tries to execute an async function and handles any errors that occur.
* @param fn - The function to execute.
@@ -13,3 +32,24 @@ export const tryAsync = async (fn: () => unknown, onError?: (error: Error) => vo
onError?.(errorInstance);
}
};
/**
* Recursively freezes an object by iterating over all properties and freezing them.
* @param obj - The object to freeze.
* @returns The frozen object.
*/
export const deepFreeze = <T>(value: T): DeeplyReadonly<T> => {
if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
for (const key of Reflect.ownKeys(value)) {
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
if (descriptor && 'value' in descriptor) {
deepFreeze(descriptor.value);
}
}
Object.freeze(value);
}
return value;
};
+45 -45
View File
@@ -166,7 +166,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
}
/** SSE endpoint URL for this session. */
private readonly url: string;
readonly #url: string;
/**
* Per-instance configuration.
@@ -204,7 +204,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
};
/** AbortController for the currently active fetch, if any. */
private controller: AbortController | null = null;
#controller: AbortController | null = null;
/**
* Asynchronous stream of parsed SSE events for the active connection.
@@ -228,7 +228,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
public constructor(url: string, options: Partial<SSESessionOptions> = {}) {
super();
this.url = url;
this.#url = url;
this.options = {
...this.options,
...options,
@@ -242,7 +242,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
*
* Resolves once the HTTP stream is established and `"connected"` has been
* emitted. Body reading continues asynchronously in the background via
* {@link readStream}.
* {@link #readStream}.
*
* @throws When the fetch retry policy exhausts attempts or the connection
* is superseded before the reader is handed off (in the latter case the
@@ -250,16 +250,16 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
*/
public async connect(): Promise<void> {
// If there is already a controller present, we are already connected.
if (this.controller) return;
if (this.#controller) return;
// Prepare for a fresh transport. Parser state from an abandoned connection
// must not bleed into the next one; reopen messages if a prior terminal
// close ended the consumer's iteration loop.
this.resetEventParser();
this.ensureMessageStreamOpen();
this.#resetEventParser();
this.#ensureMessageStreamOpen();
const controller = new AbortController();
this.controller = controller;
this.#controller = controller;
const { method, headers, body } = this.options;
@@ -268,7 +268,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
const fetchOptions: RequestInit = {
method,
headers: headers || {},
body: fetchBody,
body: fetchBody ?? null,
signal: controller.signal,
cache: 'no-store',
};
@@ -276,22 +276,22 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = await this.options.retry.run(() => this.createReader(fetchOptions));
reader = await this.options.retry.run(() => this.#createReader(fetchOptions));
} catch (error) {
// A newer abort/connect superseded this attempt — leave state to the winner.
if (this.controller !== controller) return;
if (this.#controller !== controller) return;
this.controller = null;
this.#controller = null;
await this.notifyDisconnected();
await this.notifyError(error);
this.closeMessageStream();
await this.#notifyDisconnected();
await this.#notifyError(error);
this.#closeMessageStream();
throw error;
}
// Connection succeeded but was already replaced (for example abort during fetch).
if (this.controller !== controller) {
if (this.#controller !== controller) {
await reader.cancel();
return;
@@ -304,7 +304,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
this.emit('connected', undefined);
// Fire-and-forget: connect() resolves while the stream is consumed.
this.readStream(reader, controller).catch((error) => {
this.#readStream(reader, controller).catch((error) => {
this.options.onError(error);
});
}
@@ -319,17 +319,17 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Emits `"disconnected"` but not `"closed"`.
*/
public async abort(): Promise<void> {
if (!this.controller) return;
if (!this.#controller) return;
// Grab the current controller to ensure we are aborting the correct one.
const controller = this.controller;
this.controller = null;
const controller = this.#controller;
this.#controller = null;
// Invalidate any in-flight read loop and fetch for this transport.
controller.abort();
this.resetEventParser();
this.#resetEventParser();
await this.notifyDisconnected();
await this.#notifyDisconnected();
}
/**
@@ -339,13 +339,13 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Closes {@link messages} and emits `"closed"`.
*/
public async disconnect(): Promise<void> {
this.closeMessageStream();
this.#closeMessageStream();
this.emit('closed', undefined);
if (this.controller) {
if (this.#controller) {
await this.abort();
} else {
this.resetEventParser();
this.#resetEventParser();
}
}
@@ -355,22 +355,22 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
* tokens or `Last-Event-ID`) before the fetch runs.
*/
private async createReader(fetchOptions: RequestInit): Promise<ReadableStreamDefaultReader<Uint8Array>> {
async #createReader(fetchOptions: RequestInit): Promise<ReadableStreamDefaultReader<Uint8Array>> {
const requestOptions = await this.options.onRequest(fetchOptions);
const response = await this.options.fetch(this.url, requestOptions);
const response = await this.options.fetch(this.#url, requestOptions);
if (!response.ok) {
const responseCode = response.status;
const responseText = await response.text();
const error = new HTTPError(responseCode, responseText);
void this.notifyError(error);
void this.#notifyError(error);
throw error;
}
if (!response.body) {
const error = new ResponseBodyNullError();
void this.notifyError(error);
void this.#notifyError(error);
throw error;
}
@@ -381,24 +381,24 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Reads bytes from an established stream until it ends, errors, or is
* superseded by a newer connection.
*/
private async readStream(reader: ReadableStreamDefaultReader<Uint8Array>, controller: AbortController): Promise<void> {
async #readStream(reader: ReadableStreamDefaultReader<Uint8Array>, controller: AbortController): Promise<void> {
try {
while (this.controller === controller) {
while (this.#controller === controller) {
const { done, value } = await reader.read();
// abort() or a newer connect() may have landed while we were awaiting.
if (this.controller !== controller) return;
if (this.#controller !== controller) return;
if (done) {
this.controller = null;
this.#controller = null;
await this.notifyDisconnected();
await this.#notifyDisconnected();
if (this.options.persistent) {
// Server closed gracefully — reopen unless the consumer opted out.
await this.connect();
} else {
this.closeMessageStream();
this.#closeMessageStream();
}
return;
@@ -414,28 +414,28 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
}
} catch (error) {
// If the controller is different, we already started a new connection and it would be confusing to handle this error.
if (controller !== this.controller) return;
if (controller !== this.#controller) return;
// Invalidate the current controller to allow for reconnection if needed
this.controller = null;
this.#controller = null;
await this.notifyDisconnected();
await this.#notifyDisconnected();
// Expected path for abort() — do not treat as an error or reconnect.
if (controller.signal.aborted) return;
await this.notifyError(error);
await this.#notifyError(error);
if (this.options.attemptReconnect) {
await this.connect();
} else {
this.closeMessageStream();
this.#closeMessageStream();
}
}
}
/** Clears partial SSE frames left over from an abandoned transport. */
private resetEventParser(): void {
#resetEventParser(): void {
this.options.eventParser.reset();
}
@@ -443,21 +443,21 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
* Creates a new {@link messages} iterator when the previous one was closed
* by a terminal disconnect or server stream end.
*/
private ensureMessageStreamOpen(): void {
#ensureMessageStreamOpen(): void {
if (!this.messages.closed) return;
this.messages = new AsyncPushIterator<SSEvent>();
}
/** Ends the message iteration loop for the current connection span. */
private closeMessageStream(): void {
#closeMessageStream(): void {
if (this.messages.closed) return;
this.messages.close();
}
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
private async notifyDisconnected(): Promise<void> {
async #notifyDisconnected(): Promise<void> {
await tryAsync(
() => this.options.onDisconnected(),
(error) => this.options.onError(error),
@@ -466,7 +466,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
}
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
private async notifyError(error: unknown): Promise<void> {
async #notifyError(error: unknown): Promise<void> {
const errorInstance = error instanceof Error ? error : new Error(String(error));
await tryAsync(
+15 -9
View File
@@ -1,5 +1,5 @@
/* eslint-disable @stylistic/newline-per-chained-call */
import { BchVmVersions, XOTemplateLockingTypes, XOTemplateNftCapabilities, XOTemplatePrimitiveTypes } from '@xo-cash/types';
import { BchVmVersions, XOTemplateBaseTypes, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
import { z } from 'zod';
// ============================================================
@@ -59,8 +59,14 @@ export const xoTemplateNftCapabilitySchema = z.enum(XOTemplateNftCapabilities);
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
/**
* Validation schema for a primitive type identifier. Defines the set of primitive types
* that can be declared in an XO template. Used by constants, variables, and data fields.
* Validation schema for a base type identifier.
* Accepts values from XOTemplateBaseTypes for the `type` field on constants, variables, and data.
*/
export const xoTemplateBaseTypeSchema = z.enum(XOTemplateBaseTypes);
/**
* Validation schema for a primitive type identifier.
* Accepts values from XOTemplatePrimitiveTypes for the `hint` field on constants, variables, and data.
*/
export const xoTemplatePrimitiveTypeSchema = z.enum(XOTemplatePrimitiveTypes);
@@ -732,9 +738,9 @@ export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
*/
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
.extend({
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this constant.'),
type: xoTemplateBaseTypeSchema.describe('The data type of this constant.'),
value: z.unknown().describe('The value of this constant.'),
hint: z.string().optional().describe('An optional hint to help apps and users understand what this constant represents.'),
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand what this constant represents.'),
})
.strict();
@@ -751,9 +757,9 @@ export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
*/
export const xoTemplateDataSchema = z
.object({
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this data field.'),
type: xoTemplateBaseTypeSchema.describe('The data type of this data field.'),
value: z.unknown().describe('The value for this data field.'),
hint: z.string().optional().describe('An optional hint to help apps and users understand this data field.'),
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand this data field.'),
})
.strict();
@@ -789,8 +795,8 @@ export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
*/
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
.extend({
type: xoTemplatePrimitiveTypeSchema.optional().describe('The data type of this variable.'),
hint: z.string().optional().describe('A hint to help users understand what value to provide.'),
type: xoTemplateBaseTypeSchema.optional().describe('The data type of this variable.'),
hint: xoTemplatePrimitiveTypeSchema.optional().describe('A hint to help users understand what value to provide.'),
// A neutral intent that the engine uses to populate the default value for this variable.
// View properties (name, description, icon) may contain CashASM expressions that the
+8
View File
@@ -0,0 +1,8 @@
/**
* A deeply readonly type.
* @template T - The type to make deeply readonly.
* @returns The deeply readonly type.
*/
export type DeeplyReadonly<T> = {
readonly [K in keyof T]: T[K] extends (...args: never[]) => unknown ? T[K] : DeeplyReadonly<T[K]>;
};