Merge branch 'sse-and-backoff' into HEAD
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "0.1",
|
"version": "0.1",
|
||||||
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
|
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
|
||||||
"words": ["nonfungible", "lockscript"],
|
"words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue", "bivariance"],
|
||||||
"ignorePaths": ["source/template/xo-template.schema.json"]
|
"ignorePaths": ["source/template/xo-template.schema.json"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+720
-724
File diff suppressed because it is too large
Load Diff
+7
-11
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xo-cash/utils",
|
"name": "@xo-cash/utils",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "XO Cash utilities",
|
"description": "XO Cash utilities",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/index.d.mts",
|
"types": "./dist/index.d.mts",
|
||||||
@@ -45,24 +45,21 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
"@xo-cash/types": "0.0.3",
|
"@xo-cash/primitives": "0.0.2",
|
||||||
"eventemitter3": "^5.0.4",
|
"@xo-cash/types": "0.0.4",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"echarts": "6.1.0"
|
"echarts": "6.1.0",
|
||||||
|
"minimatch": "10.2.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chalp/eslint-airbnb": "^1.3.0",
|
|
||||||
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||||
"@stylistic/eslint-plugin": "^5.7.0",
|
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
|
||||||
"@typescript-eslint/parser": "^8.53.1",
|
|
||||||
"@vitest/coverage-v8": "^4.0.17",
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||||
"@xo-cash/eslint-config": "1.0.1",
|
"@xo-cash/eslint-config": "1.0.2",
|
||||||
"@xo-cash/templates": "0.0.1",
|
"@xo-cash/templates": "0.0.3",
|
||||||
"cspell": "^9.6.0",
|
"cspell": "^9.6.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
@@ -70,7 +67,6 @@
|
|||||||
"typedoc": "^0.28.16",
|
"typedoc": "^0.28.16",
|
||||||
"typedoc-plugin-coverage": "^4.0.2",
|
"typedoc-plugin-coverage": "^4.0.2",
|
||||||
"typescript": "^5.3.2",
|
"typescript": "^5.3.2",
|
||||||
"typescript-eslint": "^8.53.1",
|
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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 = /^([^.]+)\.([^.]+)$/;
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -40,3 +40,53 @@ export class ExponentialBackoffStoppedRetriesError extends Error {
|
|||||||
this.name = 'ExponentialBackoffStoppedRetriesError';
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,233 +1,12 @@
|
|||||||
import { ExponentialBackoffStoppedRetriesError, ExponentialBackoffMaxRetriesHitError } from './errors.ts';
|
import {
|
||||||
|
ExponentialBackoffStoppedRetriesError,
|
||||||
/**
|
ExponentialBackoffMaxRetriesHitError,
|
||||||
* Exponential backoff is a technique used to retry a function after a delay.
|
ExponentialBackoffNonIntegerError,
|
||||||
*
|
ExponentialBackoffNumberTooSmallError,
|
||||||
* The delay increases exponentially with each attempt, up to a maximum delay.
|
ExponentialBackoffNumberOutOfBoundsError,
|
||||||
*
|
ExponentialBackoffNumberNotFiniteError,
|
||||||
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
|
} from './errors.ts';
|
||||||
*
|
import { isWithinBounds } from './misc.ts';
|
||||||
* 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 (0–1). 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExponentialBackoffOptions = {
|
export type ExponentialBackoffOptions = {
|
||||||
|
|
||||||
@@ -273,3 +52,275 @@ export type ExponentialBackoffStopRetriesFunction = (reason: unknown) => void;
|
|||||||
export type ExponentialBackoffCallbackParameters = {
|
export type ExponentialBackoffCallbackParameters = {
|
||||||
stopRetries: ExponentialBackoffStopRetriesFunction;
|
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 (0–1). 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export * from './errors.ts';
|
||||||
|
export * from './event-emitter.ts';
|
||||||
export * from './exponential-backoff.ts';
|
export * from './exponential-backoff.ts';
|
||||||
export * from './extended-json.ts';
|
export * from './extended-json.ts';
|
||||||
export * from './misc.ts';
|
export * from './misc.ts';
|
||||||
@@ -7,6 +9,9 @@ export * from './template/errors.ts';
|
|||||||
export * from './template/identifier.ts';
|
export * from './template/identifier.ts';
|
||||||
export * from './template/parser.ts';
|
export * from './template/parser.ts';
|
||||||
export * from './template/schemas.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.
|
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
|
||||||
export { serializeTemplate } from './template/serialization.ts';
|
export { serializeTemplate } from './template/serialization.ts';
|
||||||
|
|||||||
@@ -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.
|
* Tries to execute an async function and handles any errors that occur.
|
||||||
* @param fn - The function to execute.
|
* @param fn - The function to execute.
|
||||||
@@ -13,3 +32,24 @@ export const tryAsync = async (fn: () => unknown, onError?: (error: Error) => vo
|
|||||||
onError?.(errorInstance);
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** SSE endpoint URL for this session. */
|
/** SSE endpoint URL for this session. */
|
||||||
private readonly url: string;
|
readonly #url: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-instance configuration.
|
* Per-instance configuration.
|
||||||
@@ -204,7 +204,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** AbortController for the currently active fetch, if any. */
|
/** 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.
|
* 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> = {}) {
|
public constructor(url: string, options: Partial<SSESessionOptions> = {}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.url = url;
|
this.#url = url;
|
||||||
this.options = {
|
this.options = {
|
||||||
...this.options,
|
...this.options,
|
||||||
...options,
|
...options,
|
||||||
@@ -242,7 +242,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
*
|
*
|
||||||
* Resolves once the HTTP stream is established and `"connected"` has been
|
* Resolves once the HTTP stream is established and `"connected"` has been
|
||||||
* emitted. Body reading continues asynchronously in the background via
|
* emitted. Body reading continues asynchronously in the background via
|
||||||
* {@link readStream}.
|
* {@link #readStream}.
|
||||||
*
|
*
|
||||||
* @throws When the fetch retry policy exhausts attempts or the connection
|
* @throws When the fetch retry policy exhausts attempts or the connection
|
||||||
* is superseded before the reader is handed off (in the latter case the
|
* 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> {
|
public async connect(): Promise<void> {
|
||||||
// If there is already a controller present, we are already connected.
|
// 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
|
// Prepare for a fresh transport. Parser state from an abandoned connection
|
||||||
// must not bleed into the next one; reopen messages if a prior terminal
|
// must not bleed into the next one; reopen messages if a prior terminal
|
||||||
// close ended the consumer's iteration loop.
|
// close ended the consumer's iteration loop.
|
||||||
this.resetEventParser();
|
this.#resetEventParser();
|
||||||
this.ensureMessageStreamOpen();
|
this.#ensureMessageStreamOpen();
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.controller = controller;
|
this.#controller = controller;
|
||||||
|
|
||||||
const { method, headers, body } = this.options;
|
const { method, headers, body } = this.options;
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
const fetchOptions: RequestInit = {
|
const fetchOptions: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: headers || {},
|
headers: headers || {},
|
||||||
body: fetchBody,
|
body: fetchBody ?? null,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
};
|
};
|
||||||
@@ -276,22 +276,22 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
reader = await this.options.retry.run(() => this.createReader(fetchOptions));
|
reader = await this.options.retry.run(() => this.#createReader(fetchOptions));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A newer abort/connect superseded this attempt — leave state to the winner.
|
// 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.#notifyDisconnected();
|
||||||
await this.notifyError(error);
|
await this.#notifyError(error);
|
||||||
this.closeMessageStream();
|
this.#closeMessageStream();
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection succeeded but was already replaced (for example abort during fetch).
|
// Connection succeeded but was already replaced (for example abort during fetch).
|
||||||
if (this.controller !== controller) {
|
if (this.#controller !== controller) {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -304,7 +304,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
this.emit('connected', undefined);
|
this.emit('connected', undefined);
|
||||||
|
|
||||||
// Fire-and-forget: connect() resolves while the stream is consumed.
|
// 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);
|
this.options.onError(error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -319,17 +319,17 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
* Emits `"disconnected"` but not `"closed"`.
|
* Emits `"disconnected"` but not `"closed"`.
|
||||||
*/
|
*/
|
||||||
public async abort(): Promise<void> {
|
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.
|
// Grab the current controller to ensure we are aborting the correct one.
|
||||||
const controller = this.controller;
|
const controller = this.#controller;
|
||||||
this.controller = null;
|
this.#controller = null;
|
||||||
|
|
||||||
// Invalidate any in-flight read loop and fetch for this transport.
|
// Invalidate any in-flight read loop and fetch for this transport.
|
||||||
controller.abort();
|
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"`.
|
* Closes {@link messages} and emits `"closed"`.
|
||||||
*/
|
*/
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
this.closeMessageStream();
|
this.#closeMessageStream();
|
||||||
this.emit('closed', undefined);
|
this.emit('closed', undefined);
|
||||||
|
|
||||||
if (this.controller) {
|
if (this.#controller) {
|
||||||
await this.abort();
|
await this.abort();
|
||||||
} else {
|
} else {
|
||||||
this.resetEventParser();
|
this.#resetEventParser();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,22 +355,22 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
|
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
|
||||||
* tokens or `Last-Event-ID`) before the fetch runs.
|
* 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 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) {
|
if (!response.ok) {
|
||||||
const responseCode = response.status;
|
const responseCode = response.status;
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
|
|
||||||
const error = new HTTPError(responseCode, responseText);
|
const error = new HTTPError(responseCode, responseText);
|
||||||
void this.notifyError(error);
|
void this.#notifyError(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
const error = new ResponseBodyNullError();
|
const error = new ResponseBodyNullError();
|
||||||
void this.notifyError(error);
|
void this.#notifyError(error);
|
||||||
throw 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
|
* Reads bytes from an established stream until it ends, errors, or is
|
||||||
* superseded by a newer connection.
|
* 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 {
|
try {
|
||||||
while (this.controller === controller) {
|
while (this.#controller === controller) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
// abort() or a newer connect() may have landed while we were awaiting.
|
// abort() or a newer connect() may have landed while we were awaiting.
|
||||||
if (this.controller !== controller) return;
|
if (this.#controller !== controller) return;
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
this.controller = null;
|
this.#controller = null;
|
||||||
|
|
||||||
await this.notifyDisconnected();
|
await this.#notifyDisconnected();
|
||||||
|
|
||||||
if (this.options.persistent) {
|
if (this.options.persistent) {
|
||||||
// Server closed gracefully — reopen unless the consumer opted out.
|
// Server closed gracefully — reopen unless the consumer opted out.
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} else {
|
} else {
|
||||||
this.closeMessageStream();
|
this.#closeMessageStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -414,28 +414,28 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the controller is different, we already started a new connection and it would be confusing to handle this 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
|
// 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.
|
// Expected path for abort() — do not treat as an error or reconnect.
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
await this.notifyError(error);
|
await this.#notifyError(error);
|
||||||
|
|
||||||
if (this.options.attemptReconnect) {
|
if (this.options.attemptReconnect) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
} else {
|
} else {
|
||||||
this.closeMessageStream();
|
this.#closeMessageStream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clears partial SSE frames left over from an abandoned transport. */
|
/** Clears partial SSE frames left over from an abandoned transport. */
|
||||||
private resetEventParser(): void {
|
#resetEventParser(): void {
|
||||||
this.options.eventParser.reset();
|
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
|
* Creates a new {@link messages} iterator when the previous one was closed
|
||||||
* by a terminal disconnect or server stream end.
|
* by a terminal disconnect or server stream end.
|
||||||
*/
|
*/
|
||||||
private ensureMessageStreamOpen(): void {
|
#ensureMessageStreamOpen(): void {
|
||||||
if (!this.messages.closed) return;
|
if (!this.messages.closed) return;
|
||||||
|
|
||||||
this.messages = new AsyncPushIterator<SSEvent>();
|
this.messages = new AsyncPushIterator<SSEvent>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends the message iteration loop for the current connection span. */
|
/** Ends the message iteration loop for the current connection span. */
|
||||||
private closeMessageStream(): void {
|
#closeMessageStream(): void {
|
||||||
if (this.messages.closed) return;
|
if (this.messages.closed) return;
|
||||||
|
|
||||||
this.messages.close();
|
this.messages.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
|
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
|
||||||
private async notifyDisconnected(): Promise<void> {
|
async #notifyDisconnected(): Promise<void> {
|
||||||
await tryAsync(
|
await tryAsync(
|
||||||
() => this.options.onDisconnected(),
|
() => this.options.onDisconnected(),
|
||||||
(error) => this.options.onError(error),
|
(error) => this.options.onError(error),
|
||||||
@@ -466,7 +466,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
|
/** 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));
|
const errorInstance = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
await tryAsync(
|
await tryAsync(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable @stylistic/newline-per-chained-call */
|
/* 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';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -59,8 +59,14 @@ export const xoTemplateNftCapabilitySchema = z.enum(XOTemplateNftCapabilities);
|
|||||||
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
|
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation schema for a primitive type identifier. Defines the set of primitive types
|
* Validation schema for a base type identifier.
|
||||||
* that can be declared in an XO template. Used by constants, variables, and data fields.
|
* 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);
|
export const xoTemplatePrimitiveTypeSchema = z.enum(XOTemplatePrimitiveTypes);
|
||||||
|
|
||||||
@@ -732,9 +738,9 @@ export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
|
|||||||
*/
|
*/
|
||||||
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||||
.extend({
|
.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.'),
|
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();
|
.strict();
|
||||||
|
|
||||||
@@ -751,9 +757,9 @@ export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
|||||||
*/
|
*/
|
||||||
export const xoTemplateDataSchema = z
|
export const xoTemplateDataSchema = z
|
||||||
.object({
|
.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.'),
|
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();
|
.strict();
|
||||||
|
|
||||||
@@ -789,8 +795,8 @@ export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
|
|||||||
*/
|
*/
|
||||||
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
|
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
|
||||||
.extend({
|
.extend({
|
||||||
type: xoTemplatePrimitiveTypeSchema.optional().describe('The data type of this variable.'),
|
type: xoTemplateBaseTypeSchema.optional().describe('The data type of this variable.'),
|
||||||
hint: z.string().optional().describe('A hint to help users understand what value to provide.'),
|
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.
|
// 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
|
// View properties (name, description, icon) may contain CashASM expressions that the
|
||||||
|
|||||||
@@ -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]>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,813 @@
|
|||||||
|
import { expect, test } from 'vitest';
|
||||||
|
import {
|
||||||
|
bigIntToVmNumber,
|
||||||
|
binToHex,
|
||||||
|
createCompilerBch,
|
||||||
|
createVirtualMachineBch,
|
||||||
|
encodeDataPush,
|
||||||
|
generatePrivateKey,
|
||||||
|
hash256,
|
||||||
|
secp256k1,
|
||||||
|
utf8ToBin,
|
||||||
|
} from '@bitauth/libauth';
|
||||||
|
import { XOTemplateBaseTypes, XOTemplatePrimitiveTypes } from '@xo-cash/types';
|
||||||
|
import {
|
||||||
|
isCashAssemblyExpression,
|
||||||
|
extractCashAssemblyEvaluations,
|
||||||
|
extractVariablesFromEvaluations,
|
||||||
|
decodeCompiledCashAssemblyEvaluation,
|
||||||
|
compileCashAssemblyString,
|
||||||
|
generateCashAssemblyBytecode,
|
||||||
|
compileCashAssemblyEvaluations,
|
||||||
|
resolvePrimitiveMethodBytes,
|
||||||
|
} from '../source/cash-assembly/index.ts';
|
||||||
|
import {
|
||||||
|
CashAssemblyRequiredVariableMissingError,
|
||||||
|
CashAssemblyCompilationFailedError,
|
||||||
|
CashAssemblyVariableTypeMismatchError,
|
||||||
|
CashAssemblyPrimitiveMethodMissingError,
|
||||||
|
CashAssemblyPrimitiveVariableMissingError,
|
||||||
|
CashAssemblyVmNumberDecodeError,
|
||||||
|
CashAssemblyNumberNotSafeIntegerError,
|
||||||
|
} from '../source/cash-assembly/errors.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that isCashAssemblyExpression recognizes a string made up entirely of one evaluation.
|
||||||
|
*/
|
||||||
|
const testIsCashAssemblyExpressionMatchesFullExpression = (): void => {
|
||||||
|
expect(isCashAssemblyExpression('$(<requestedSatoshis>)')).toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that isCashAssemblyExpression rejects a string that contains an evaluation plus other text.
|
||||||
|
*/
|
||||||
|
const testIsCashAssemblyExpressionRejectsSurroundingText = (): void => {
|
||||||
|
expect(isCashAssemblyExpression('Received $(<requestedSatoshis>)')).toBe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that isCashAssemblyExpression rejects plain text with no evaluation at all.
|
||||||
|
*/
|
||||||
|
const testIsCashAssemblyExpressionRejectsPlainText = (): void => {
|
||||||
|
expect(isCashAssemblyExpression('Received funds')).toBe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that isCashAssemblyExpression rejects an empty evaluation, since it references no variables.
|
||||||
|
*/
|
||||||
|
const testIsCashAssemblyExpressionRejectsEmptyEvaluation = (): void => {
|
||||||
|
expect(isCashAssemblyExpression('$()')).toBe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that isCashAssemblyExpression rejects non-string input rather than coercing it.
|
||||||
|
*/
|
||||||
|
const testIsCashAssemblyExpressionRejectsNonStringInput = (): void => {
|
||||||
|
// A number can never be a CashAssembly expression, regardless of its value
|
||||||
|
expect(isCashAssemblyExpression(5000)).toBe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that extractCashAssemblyEvaluations finds a single evaluation embedded in a larger string.
|
||||||
|
*/
|
||||||
|
const testExtractCashAssemblyEvaluationsFindsSingleEvaluation = (): void => {
|
||||||
|
const satoshisDescription = 'Received $(<requestedSatoshis>) satoshis from sender.';
|
||||||
|
|
||||||
|
// Only the evaluation substring is returned, not the surrounding text
|
||||||
|
expect(extractCashAssemblyEvaluations(satoshisDescription)).toStrictEqual([ '$(<requestedSatoshis>)' ]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that extractCashAssemblyEvaluations returns an empty array when the text has no evaluations.
|
||||||
|
*/
|
||||||
|
const testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound = (): void => {
|
||||||
|
expect(extractCashAssemblyEvaluations('Received funds')).toStrictEqual([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that extractVariablesFromEvaluations returns each referenced variable name once,
|
||||||
|
* even when it appears in more than one evaluation.
|
||||||
|
*/
|
||||||
|
const testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations = (): void => {
|
||||||
|
const evaluations = [ '$(<requestedTokenAmount> <decimalsFactor> OP_DIV)', '$(<requestedTokenAmount> <decimalsFactor> OP_MOD)' ];
|
||||||
|
|
||||||
|
// requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once
|
||||||
|
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that extractVariablesFromEvaluations excludes numeric and quoted-string literal tokens,
|
||||||
|
* returning only the true variable reference.
|
||||||
|
*/
|
||||||
|
const testExtractVariablesFromEvaluationsExcludesLiteralTokens = (): void => {
|
||||||
|
const evaluation = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
|
||||||
|
|
||||||
|
// <0x02>, <"minting">, and <"immutable"> are literals, not variables, so only tokenCapability is returned
|
||||||
|
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'tokenCapability' ]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that decodeCompiledCashAssemblyEvaluation converts VM number bytes to their decimal string
|
||||||
|
* when decode mode is bigint.
|
||||||
|
*/
|
||||||
|
const testDecodeCompiledCashAssemblyEvaluationDecodesBigint = (): void => {
|
||||||
|
const compiledResult = bigIntToVmNumber(1234n);
|
||||||
|
|
||||||
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'bigint')).toBe('1234');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that decodeCompiledCashAssemblyEvaluation throws when bigint mode receives bytes that are not a VM number.
|
||||||
|
*/
|
||||||
|
const testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber = (): void => {
|
||||||
|
const decodeNonMinimalZero = (): string => decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 0x00 ]), 'bigint');
|
||||||
|
const expectedMessage =
|
||||||
|
'CashAssembly evaluation could not be decoded as a VM number: Failed to decode VM Number: the number is not minimally-encoded.';
|
||||||
|
|
||||||
|
expect(decodeNonMinimalZero).toThrow(CashAssemblyVmNumberDecodeError);
|
||||||
|
expect(decodeNonMinimalZero).toThrow(expectedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that decodeCompiledCashAssemblyEvaluation converts bytes to a hex string when decode mode is hex.
|
||||||
|
*/
|
||||||
|
const testDecodeCompiledCashAssemblyEvaluationDecodesHex = (): void => {
|
||||||
|
const compiledResult = new Uint8Array([ 0xab, 0xcd ]);
|
||||||
|
|
||||||
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'hex')).toBe('abcd');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that decodeCompiledCashAssemblyEvaluation reports an empty byte array as 'false' and any
|
||||||
|
* non-empty byte array as 'true' when decode mode is boolean.
|
||||||
|
*/
|
||||||
|
const testDecodeCompiledCashAssemblyEvaluationDecodesBoolean = (): void => {
|
||||||
|
// An empty byte array is falsy on the BCH VM
|
||||||
|
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array(0), 'boolean')).toBe('false');
|
||||||
|
|
||||||
|
// Any non-empty byte array is truthy on the BCH VM
|
||||||
|
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 1 ]), 'boolean')).toBe('true');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that decodeCompiledCashAssemblyEvaluation defaults to utf8 decoding when no mode is given.
|
||||||
|
*/
|
||||||
|
const testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8 = (): void => {
|
||||||
|
const compiledResult = utf8ToBin('hello');
|
||||||
|
|
||||||
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult)).toBe('hello');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that decodeCompiledCashAssemblyEvaluation returns the comma-separated decimal byte values
|
||||||
|
* when decode mode is uint8array.
|
||||||
|
*/
|
||||||
|
const testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array = (): void => {
|
||||||
|
const compiledResult = new Uint8Array([ 1, 2, 255 ]);
|
||||||
|
|
||||||
|
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'uint8array')).toBe('1,2,255');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString compiles a single evaluation and decodes it as a bigint.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint = (): void => {
|
||||||
|
const compiledSatoshisText = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<requestedSatoshis>)',
|
||||||
|
variables: { requestedSatoshis: 5000n },
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledSatoshisText).toBe('5000');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString compiles multiple evaluations embedded in the same string.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString = (): void => {
|
||||||
|
const tokenDescription =
|
||||||
|
'Transferred $(<requestedTokenAmount> <decimalsFactor> OP_DIV).$(<requestedTokenAmount> <decimalsFactor> OP_MOD) tokens.';
|
||||||
|
|
||||||
|
expect(extractCashAssemblyEvaluations(tokenDescription)).toStrictEqual([
|
||||||
|
'$(<requestedTokenAmount> <decimalsFactor> OP_DIV)',
|
||||||
|
'$(<requestedTokenAmount> <decimalsFactor> OP_MOD)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const compiledTokenText = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: tokenDescription,
|
||||||
|
variables: { requestedTokenAmount: 1050n, decimalsFactor: 100n },
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledTokenText).toBe('Transferred 10.50 tokens.');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that a variable referenced twice in one evaluation uses the same supplied value both times.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation = (): void => {
|
||||||
|
const evaluation = '$(<amount> <amount> OP_ADD)';
|
||||||
|
|
||||||
|
// The name appears twice in the script body but is required only once in the variables map.
|
||||||
|
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'amount' ]);
|
||||||
|
|
||||||
|
const compiledText = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: evaluation,
|
||||||
|
variables: { amount: 21n },
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledText).toBe('42');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString respects OP_IF/OP_ELSE branching driven by a variable.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringEvaluatesConditionalLiterals = (): void => {
|
||||||
|
const conditionalExpression = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
|
||||||
|
|
||||||
|
// tokenCapability equal to 2 takes the OP_IF branch
|
||||||
|
const mintingResult = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: conditionalExpression,
|
||||||
|
variables: { tokenCapability: 2n },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
expect(mintingResult).toBe('minting');
|
||||||
|
|
||||||
|
// tokenCapability not equal to 2 takes the OP_ELSE branch
|
||||||
|
const immutableResult = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: conditionalExpression,
|
||||||
|
variables: { tokenCapability: 0n },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
expect(immutableResult).toBe('immutable');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString defaults to utf8 decoding when evaluationDecodeMode is omitted.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringDefaultsToUtf8DecodeMode = (): void => {
|
||||||
|
const compiledLabel = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<label>)',
|
||||||
|
variables: { label: 'hello' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledLabel).toBe('hello');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString accepts a Uint8Array variable value directly,
|
||||||
|
* and decodes the result as hex.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringAcceptsUint8ArrayVariable = (): void => {
|
||||||
|
const compiledHash = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<hashBytes>)',
|
||||||
|
variables: { hashBytes: new Uint8Array([ 0xde, 0xad, 0xbe, 0xef ]) },
|
||||||
|
evaluationDecodeMode: 'hex',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledHash).toBe('deadbeef');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString throws CashAssemblyRequiredVariableMissingError, naming the missing
|
||||||
|
* variable, when the variables map does not contain a variable referenced by the text.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringThrowsForMissingVariable = (): void => {
|
||||||
|
const compileWithMissingVariable = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<requestedSatoshis>)',
|
||||||
|
variables: {},
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compileWithMissingVariable).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||||
|
|
||||||
|
// The message states the reason (missing from the variables map) and names the specific variable
|
||||||
|
expect(compileWithMissingVariable).toThrow('Missing required variable: variableNames [requestedSatoshis]');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString throws for a non integer number, and that the same display text
|
||||||
|
* can be produced with integer DIV and MOD instead of a float variable.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod = (): void => {
|
||||||
|
const compileWithNonInteger = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<amount>)',
|
||||||
|
variables: { amount: 12.5 },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
const expectedMessage = 'CashAssembly number is not a safe integer: identifier "amount", got 12.5';
|
||||||
|
|
||||||
|
expect(compileWithNonInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||||
|
expect(compileWithNonInteger).toThrow(expectedMessage);
|
||||||
|
|
||||||
|
// Floats are rejected. Format decimals from integer quantity and scale with OP_DIV and OP_MOD.
|
||||||
|
const compiledText = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<amount> <base> OP_DIV).$(<amount> <base> OP_MOD)',
|
||||||
|
variables: { amount: 125n, base: 10n },
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledText).toBe('12.5');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString throws when a number variable is outside the safe integer range.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber = (): void => {
|
||||||
|
const unsafeInteger = Number.MAX_SAFE_INTEGER + 2;
|
||||||
|
const compileWithUnsafeInteger = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<amount>)',
|
||||||
|
variables: { amount: unsafeInteger },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
const expectedMessage = `CashAssembly number is not a safe integer: identifier "amount", got ${String(unsafeInteger)}`;
|
||||||
|
|
||||||
|
expect(compileWithUnsafeInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||||
|
expect(compileWithUnsafeInteger).toThrow(expectedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that compileCashAssemblyString throws for non finite number values.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringThrowsForNonFiniteNumber = (): void => {
|
||||||
|
const compileWithNaN = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<amount>)',
|
||||||
|
variables: { amount: Number.NaN },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
const compileWithInfinity = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<amount>)',
|
||||||
|
variables: { amount: Number.POSITIVE_INFINITY },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compileWithNaN).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||||
|
expect(compileWithNaN).toThrow('CashAssembly number is not a safe integer: identifier "amount", got NaN');
|
||||||
|
expect(compileWithInfinity).toThrow(CashAssemblyNumberNotSafeIntegerError);
|
||||||
|
expect(compileWithInfinity).toThrow('CashAssembly number is not a safe integer: identifier "amount", got Infinity');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that a decoded variable value which happens to contain text shaped like a CashAssembly
|
||||||
|
* evaluation (e.g. "$(<real>)") is left alone as plain output text
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText = (): void => {
|
||||||
|
const compiledText = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<label>)$(<real>)',
|
||||||
|
variables: { label: '$(<real>)', real: '999' },
|
||||||
|
evaluationDecodeMode: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledText).toBe('$(<real>)999');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that omitting templateVariables leaves method shaped evaluations for CashAssembly.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables = (): void => {
|
||||||
|
const compileWithoutTemplateVariables = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<expiry.toIso8601>)',
|
||||||
|
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compileWithoutTemplateVariables).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||||
|
expect(compileWithoutTemplateVariables).toThrow('Missing required variable: variableNames [expiry.toIso8601]');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that an unsupported or missing hint leaves the evaluation for CashAssembly instead of throwing.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive = (): void => {
|
||||||
|
const compileWithNonPrimitiveHint = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<label.toIso8601>)',
|
||||||
|
variables: { label: 'hello' },
|
||||||
|
templateVariables: {
|
||||||
|
label: {
|
||||||
|
name: 'Label',
|
||||||
|
description: 'A text label',
|
||||||
|
type: XOTemplateBaseTypes.STRING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compileWithNonPrimitiveHint).toThrow(CashAssemblyRequiredVariableMissingError);
|
||||||
|
expect(compileWithNonPrimitiveHint).toThrow('Missing required variable: variableNames [label.toIso8601]');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that a supported hint with an unknown method throws CashAssemblyPrimitiveMethodMissingError.
|
||||||
|
*
|
||||||
|
* Once the hint maps to a supported primitive class, a missing method is a primitive resolution
|
||||||
|
* failure rather than a CashAssembly missing variable.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive = (): void => {
|
||||||
|
const compileWithUnknownMethod = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<expiry.notARealMethod>)',
|
||||||
|
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
|
||||||
|
templateVariables: {
|
||||||
|
expiry: {
|
||||||
|
name: 'Expiry',
|
||||||
|
description: 'Invitation expiry time',
|
||||||
|
type: XOTemplateBaseTypes.INTEGER,
|
||||||
|
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compileWithUnknownMethod).toThrow(CashAssemblyPrimitiveMethodMissingError);
|
||||||
|
expect(compileWithUnknownMethod).toThrow('CashAssembly primitive method does not exist: identifier "expiry.notARealMethod", methodName "notARealMethod", hint "timestamp"');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that a committed supported transform throws when the runtime value is missing.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue = (): void => {
|
||||||
|
const compileWithMissingValue = (): string =>
|
||||||
|
compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<expiry.toIso8601>)',
|
||||||
|
variables: {},
|
||||||
|
templateVariables: {
|
||||||
|
expiry: {
|
||||||
|
name: 'Expiry',
|
||||||
|
description: 'Invitation expiry time',
|
||||||
|
type: XOTemplateBaseTypes.INTEGER,
|
||||||
|
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compileWithMissingValue).toThrow(CashAssemblyPrimitiveVariableMissingError);
|
||||||
|
expect(compileWithMissingValue).toThrow('CashAssembly primitive variable is missing from the variables map: identifier "expiry.toIso8601", variableName "expiry"');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that multiple supported `<base.method>` pushes inside one evaluation.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation = (): void => {
|
||||||
|
const compiledText = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: '$(<amount.toSatoshis> <fee.toSatoshis> OP_SUB)',
|
||||||
|
variables: {
|
||||||
|
amount: 5000n,
|
||||||
|
fee: 1000n,
|
||||||
|
},
|
||||||
|
templateVariables: {
|
||||||
|
amount: {
|
||||||
|
name: 'Amount',
|
||||||
|
description: 'Payment in satoshis',
|
||||||
|
type: XOTemplateBaseTypes.BIGINT,
|
||||||
|
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||||
|
},
|
||||||
|
fee: {
|
||||||
|
name: 'Fee',
|
||||||
|
description: 'Network fee in satoshis',
|
||||||
|
type: XOTemplateBaseTypes.BIGINT,
|
||||||
|
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(compiledText).toBe('4000');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that lock and display evaluations are extracted from one string and that the lock
|
||||||
|
* hash evaluation compiles to the HASH256 of the redeem script.
|
||||||
|
*/
|
||||||
|
const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText = (): void => {
|
||||||
|
// Test only key material. Never use with real funds.
|
||||||
|
const privateKeyBytes = generatePrivateKey();
|
||||||
|
const publicKeyResult = secp256k1.derivePublicKeyCompressed(privateKeyBytes);
|
||||||
|
|
||||||
|
expect(typeof publicKeyResult).not.toBe('string');
|
||||||
|
const publicKeyBytes = publicKeyResult as Uint8Array;
|
||||||
|
|
||||||
|
// Minimal P2S redeem body. Push the compressed public key, then OP_CHECKSIG.
|
||||||
|
const redeemScript = Uint8Array.from([ ...encodeDataPush(publicKeyBytes), 0xac ]);
|
||||||
|
const expectedRedeemHashHex = binToHex(hash256(redeemScript));
|
||||||
|
|
||||||
|
const lockText = 'Lock OP_HASH256 <$(<redeemScript> OP_HASH256)> OP_EQUAL. ';
|
||||||
|
const displayTextSource = 'Expires $(<expiry.toIso8601>). Amount $(<amount.toBCH>) BCH ($(<amount.toString>) satoshis).';
|
||||||
|
const cashAssemblyText = `${lockText}${displayTextSource}`;
|
||||||
|
|
||||||
|
const evaluations = extractCashAssemblyEvaluations(cashAssemblyText);
|
||||||
|
expect(evaluations).toStrictEqual([ '$(<redeemScript> OP_HASH256)', '$(<expiry.toIso8601>)', '$(<amount.toBCH>)', '$(<amount.toString>)' ]);
|
||||||
|
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'redeemScript', 'expiry.toIso8601', 'amount.toBCH', 'amount.toString' ]);
|
||||||
|
|
||||||
|
const redeemHashHex = compileCashAssemblyString({
|
||||||
|
cashAssemblyText: evaluations[0],
|
||||||
|
variables: { redeemScript },
|
||||||
|
evaluationDecodeMode: 'hex',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(redeemHashHex).toBe(expectedRedeemHashHex);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests a P2SH32 spend whose redeem script mixes primitive method evaluations with a real Schnorr checksig.
|
||||||
|
*
|
||||||
|
* Redeem requires:
|
||||||
|
* 1. A P2PKH style signature check against owner.public_key.
|
||||||
|
* 2. UTXO value equal to price + fee, where price and fee are bound via Satoshis.toSatoshis then OP_ADD.
|
||||||
|
*/
|
||||||
|
const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = (): void => {
|
||||||
|
// Test only key material. Never use with real funds.
|
||||||
|
const privateKeyBytes = generatePrivateKey();
|
||||||
|
const priceSatoshis = 5000n;
|
||||||
|
const feeSatoshis = 1000n;
|
||||||
|
const totalSatoshis = 6000n;
|
||||||
|
|
||||||
|
const redeemScriptSource =
|
||||||
|
'OP_DUP OP_HASH160 <$(<owner.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIGVERIFY '
|
||||||
|
+ 'OP_INPUTINDEX OP_UTXOVALUE <$(<price.toSatoshis> <fee.toSatoshis> OP_ADD)> OP_EQUAL';
|
||||||
|
|
||||||
|
// Resolve primitive method pushes so CashAssembly receives VM number bytes under the full identifiers.
|
||||||
|
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||||
|
identifiers: [ 'price.toSatoshis', 'fee.toSatoshis' ],
|
||||||
|
variables: {
|
||||||
|
price: priceSatoshis,
|
||||||
|
fee: feeSatoshis,
|
||||||
|
},
|
||||||
|
templateVariables: {
|
||||||
|
price: {
|
||||||
|
name: 'Price',
|
||||||
|
description: 'Base payment in satoshis',
|
||||||
|
type: XOTemplateBaseTypes.BIGINT,
|
||||||
|
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||||
|
},
|
||||||
|
fee: {
|
||||||
|
name: 'Fee',
|
||||||
|
description: 'Network fee in satoshis',
|
||||||
|
type: XOTemplateBaseTypes.BIGINT,
|
||||||
|
hint: XOTemplatePrimitiveTypes.SATOSHIS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(primitiveMethodBytes)).toStrictEqual([ 'price.toSatoshis', 'fee.toSatoshis' ]);
|
||||||
|
|
||||||
|
const redeemCompiler = createCompilerBch({
|
||||||
|
scripts: {
|
||||||
|
redeem: redeemScriptSource,
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
owner: { type: 'Key' },
|
||||||
|
price: { type: 'WalletData' },
|
||||||
|
fee: { type: 'WalletData' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const redeemResult = redeemCompiler.generateBytecode({
|
||||||
|
data: {
|
||||||
|
keys: { privateKeys: { owner: privateKeyBytes } },
|
||||||
|
bytecode: primitiveMethodBytes,
|
||||||
|
},
|
||||||
|
scriptId: 'redeem',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(redeemResult.success).toBe(true);
|
||||||
|
if (redeemResult.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redeemBytecode = redeemResult.bytecode;
|
||||||
|
|
||||||
|
// P2SH32 lock is OP_HASH256 <hash256(redeem)> OP_EQUAL.
|
||||||
|
const lockingBytecode = Uint8Array.from([ 0xaa, ...encodeDataPush(hash256(redeemBytecode)), 0x87 ]);
|
||||||
|
|
||||||
|
// Unlock compiler signs against the same redeem body used as the covered bytecode for P2SH.
|
||||||
|
const unlockCompiler = createCompilerBch({
|
||||||
|
scripts: {
|
||||||
|
lock: redeemScriptSource,
|
||||||
|
unlock: '<owner.schnorr_signature.all_outputs> <owner.public_key>',
|
||||||
|
},
|
||||||
|
unlockingScripts: {
|
||||||
|
unlock: 'lock',
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
owner: { type: 'Key' },
|
||||||
|
price: { type: 'WalletData' },
|
||||||
|
fee: { type: 'WalletData' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = {
|
||||||
|
inputIndex: 0,
|
||||||
|
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis }],
|
||||||
|
transaction: {
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointIndex: 0,
|
||||||
|
outpointTransactionHash: new Uint8Array(32),
|
||||||
|
sequenceNumber: 0,
|
||||||
|
unlockingBytecode: new Uint8Array(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locktime: 0,
|
||||||
|
outputs: [{ lockingBytecode: new Uint8Array(), valueSatoshis: totalSatoshis }],
|
||||||
|
version: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlockResult = unlockCompiler.generateBytecode({
|
||||||
|
data: {
|
||||||
|
keys: { privateKeys: { owner: privateKeyBytes } },
|
||||||
|
bytecode: primitiveMethodBytes,
|
||||||
|
compilationContext: program,
|
||||||
|
},
|
||||||
|
scriptId: 'unlock',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(unlockResult.success).toBe(true);
|
||||||
|
if (unlockResult.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// P2SH unlock pushes signature and public key, then the redeem script itself.
|
||||||
|
program.transaction.inputs[0].unlockingBytecode = Uint8Array.from([ ...unlockResult.bytecode, ...encodeDataPush(redeemBytecode) ]);
|
||||||
|
|
||||||
|
const virtualMachine = createVirtualMachineBch();
|
||||||
|
const evaluationResult = virtualMachine.evaluate(program);
|
||||||
|
|
||||||
|
expect(virtualMachine.stateSuccess(evaluationResult)).toBe(true);
|
||||||
|
|
||||||
|
// Wrong UTXO value must fail the baked price + fee equality check.
|
||||||
|
const wrongValueProgram = {
|
||||||
|
...program,
|
||||||
|
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis - 1n }],
|
||||||
|
transaction: {
|
||||||
|
...program.transaction,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
...program.transaction.inputs[0],
|
||||||
|
unlockingBytecode: new Uint8Array(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrongValueUnlockResult = unlockCompiler.generateBytecode({
|
||||||
|
data: {
|
||||||
|
keys: { privateKeys: { owner: privateKeyBytes } },
|
||||||
|
bytecode: primitiveMethodBytes,
|
||||||
|
compilationContext: wrongValueProgram,
|
||||||
|
},
|
||||||
|
scriptId: 'unlock',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrongValueUnlockResult.success).toBe(true);
|
||||||
|
if (wrongValueUnlockResult.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrongValueProgram.transaction.inputs[0].unlockingBytecode = Uint8Array.from([
|
||||||
|
...wrongValueUnlockResult.bytecode,
|
||||||
|
...encodeDataPush(redeemBytecode),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(virtualMachine.stateSuccess(virtualMachine.evaluate(wrongValueProgram))).not.toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that generateCashAssemblyBytecode produces the pushed bytes for a variable evaluation.
|
||||||
|
*/
|
||||||
|
const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void => {
|
||||||
|
const evaluation = '$(<inputValue>)';
|
||||||
|
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||||
|
|
||||||
|
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) });
|
||||||
|
|
||||||
|
expect(bytecode).toStrictEqual(new Uint8Array([ 4 ]));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that generateCashAssemblyBytecode throws CashAssemblyVariableTypeMismatchError when a variable value is not a Uint8Array.
|
||||||
|
*/
|
||||||
|
const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void => {
|
||||||
|
const evaluation = '$(<inputValue>)';
|
||||||
|
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||||
|
|
||||||
|
const generateWithTypeMismatch = (): Uint8Array =>
|
||||||
|
generateCashAssemblyBytecode(
|
||||||
|
compiler,
|
||||||
|
evaluation,
|
||||||
|
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
|
||||||
|
{ inputValue: 4 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(generateWithTypeMismatch).toThrow(CashAssemblyVariableTypeMismatchError);
|
||||||
|
|
||||||
|
expect(generateWithTypeMismatch).toThrow('Variable type mismatch: variableKey "inputValue", expected Uint8Array, got number');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that generateCashAssemblyBytecode throws CashAssemblyCompilationFailedError when the
|
||||||
|
* evaluation references an identifier the compiler cannot resolve as an opcode, variable, or script.
|
||||||
|
*/
|
||||||
|
const testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier = (): void => {
|
||||||
|
const evaluation = '$(<inputValue> unresolvedIdentifier)';
|
||||||
|
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||||
|
|
||||||
|
const variables = { inputValue: new Uint8Array([ 1 ]) };
|
||||||
|
|
||||||
|
const generateWithUnresolvedIdentifier = (): Uint8Array => generateCashAssemblyBytecode(compiler, evaluation, variables);
|
||||||
|
|
||||||
|
expect(generateWithUnresolvedIdentifier).toThrow(CashAssemblyCompilationFailedError);
|
||||||
|
|
||||||
|
expect(generateWithUnresolvedIdentifier).toThrow('Cash assembly compilation failed: Unknown identifier "unresolvedIdentifier".');
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTests = async (): Promise<void> => {
|
||||||
|
test('isCashAssemblyExpression: matches a full expression', testIsCashAssemblyExpressionMatchesFullExpression);
|
||||||
|
test('isCashAssemblyExpression: rejects surrounding text', testIsCashAssemblyExpressionRejectsSurroundingText);
|
||||||
|
test('isCashAssemblyExpression: rejects plain text', testIsCashAssemblyExpressionRejectsPlainText);
|
||||||
|
test('isCashAssemblyExpression: rejects an empty evaluation', testIsCashAssemblyExpressionRejectsEmptyEvaluation);
|
||||||
|
test('isCashAssemblyExpression: rejects non-string input', testIsCashAssemblyExpressionRejectsNonStringInput);
|
||||||
|
|
||||||
|
test('extractCashAssemblyEvaluations: finds a single evaluation', testExtractCashAssemblyEvaluationsFindsSingleEvaluation);
|
||||||
|
test(
|
||||||
|
'extractCashAssemblyEvaluations: returns an empty array when none are found',
|
||||||
|
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('extractVariablesFromEvaluations: deduplicates across evaluations', testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations);
|
||||||
|
test('extractVariablesFromEvaluations: excludes literal tokens', testExtractVariablesFromEvaluationsExcludesLiteralTokens);
|
||||||
|
|
||||||
|
test('decodeCompiledCashAssemblyEvaluation: decodes bigint', testDecodeCompiledCashAssemblyEvaluationDecodesBigint);
|
||||||
|
test(
|
||||||
|
'decodeCompiledCashAssemblyEvaluation: throws when bigint bytes are not a VM number',
|
||||||
|
testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber,
|
||||||
|
);
|
||||||
|
test('decodeCompiledCashAssemblyEvaluation: decodes hex', testDecodeCompiledCashAssemblyEvaluationDecodesHex);
|
||||||
|
test('decodeCompiledCashAssemblyEvaluation: decodes boolean', testDecodeCompiledCashAssemblyEvaluationDecodesBoolean);
|
||||||
|
test('decodeCompiledCashAssemblyEvaluation: defaults to utf8', testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8);
|
||||||
|
test('decodeCompiledCashAssemblyEvaluation: decodes uint8array', testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array);
|
||||||
|
|
||||||
|
test('compileCashAssemblyString: decodes a single evaluation as bigint', testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: compiles multiple evaluations in one string',
|
||||||
|
testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: reuses the same variable twice in one evaluation',
|
||||||
|
testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation,
|
||||||
|
);
|
||||||
|
test('compileCashAssemblyString: evaluates conditional literals', testCompileCashAssemblyStringEvaluatesConditionalLiterals);
|
||||||
|
test('compileCashAssemblyString: defaults to utf8 decode mode', testCompileCashAssemblyStringDefaultsToUtf8DecodeMode);
|
||||||
|
test('compileCashAssemblyString: accepts a Uint8Array variable', testCompileCashAssemblyStringAcceptsUint8ArrayVariable);
|
||||||
|
test('compileCashAssemblyString: throws for a missing variable', testCompileCashAssemblyStringThrowsForMissingVariable);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: throws for a non-integer number and produces the same display with integer DIV and MOD',
|
||||||
|
testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod,
|
||||||
|
);
|
||||||
|
test('compileCashAssemblyString: throws for an unsafe integer number', testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber);
|
||||||
|
test('compileCashAssemblyString: throws for a non-finite number', testCompileCashAssemblyStringThrowsForNonFiniteNumber);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: does not reinterpret injected evaluation-looking text',
|
||||||
|
testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: leaves method evaluations for CashAssembly without templateVariables',
|
||||||
|
testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: skips when hint is not a supported primitive',
|
||||||
|
testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: throws when method does not exist on the primitive',
|
||||||
|
testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: throws when a supported transform is missing its runtime value',
|
||||||
|
testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: resolves multiple primitive method pushes in one evaluation',
|
||||||
|
testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'compileCashAssemblyString: hex-compiles a P2S lock hash extracted from mixed lock and display text',
|
||||||
|
testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText,
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'P2SH32 redeem: primitive OP_ADD and Schnorr signature spend successfully',
|
||||||
|
testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('generateCashAssemblyBytecode: produces raw bytes for a variable', testGenerateCashAssemblyBytecodeProducesRawBytesForVariable);
|
||||||
|
test('generateCashAssemblyBytecode: throws for a non-Uint8Array variable', testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable);
|
||||||
|
test('generateCashAssemblyBytecode: throws for an unresolved identifier', testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier);
|
||||||
|
};
|
||||||
|
|
||||||
|
await runTests();
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
import { expect, test, vi } from 'vitest';
|
||||||
|
import { EventEmitter } from '../source/event-emitter.ts';
|
||||||
|
|
||||||
|
/** Simple event map used across these tests. */
|
||||||
|
type TestEvents = {
|
||||||
|
message: string;
|
||||||
|
count: number;
|
||||||
|
nested: { nested: { value: number } };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that EventEmitter invokes listeners when an event is emitted.
|
||||||
|
*/
|
||||||
|
const testEventEmitterCallsListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
// Register the listener and emit an event.
|
||||||
|
emitter.on('message', listener);
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
// Expect the listener to have been called with the emitted payload.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(listener).toHaveBeenCalledWith('hello');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that EventEmitter invokes all listeners registered for the same event.
|
||||||
|
*/
|
||||||
|
const testEventEmitterCallsMultipleListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const firstListener = vi.fn();
|
||||||
|
const secondListener = vi.fn();
|
||||||
|
|
||||||
|
// Register two listeners for the same event type.
|
||||||
|
emitter.on('count', firstListener);
|
||||||
|
emitter.on('count', secondListener);
|
||||||
|
emitter.emit('count', 42);
|
||||||
|
|
||||||
|
// Expect both listeners to receive the same payload.
|
||||||
|
expect(firstListener).toHaveBeenCalledOnce();
|
||||||
|
expect(firstListener).toHaveBeenCalledWith(42);
|
||||||
|
expect(secondListener).toHaveBeenCalledOnce();
|
||||||
|
expect(secondListener).toHaveBeenCalledWith(42);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that EventEmitter only invokes listeners registered for the emitted event type.
|
||||||
|
*/
|
||||||
|
const testEventEmitterCallsOnlyMatchingListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const messageListener = vi.fn();
|
||||||
|
const countListener = vi.fn();
|
||||||
|
|
||||||
|
// Register listeners on different event types.
|
||||||
|
emitter.on('message', messageListener);
|
||||||
|
emitter.on('count', countListener);
|
||||||
|
|
||||||
|
// Emit only the message event.
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
// Expect only the matching listener to have been called.
|
||||||
|
expect(messageListener).toHaveBeenCalledOnce();
|
||||||
|
expect(countListener).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that EventEmitter.emit returns false when no listeners are registered.
|
||||||
|
*/
|
||||||
|
const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
const hasListeners = emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
// Expect emit to report that nobody was listening.
|
||||||
|
expect(hasListeners).toBe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that EventEmitter.emit returns true when listeners are registered.
|
||||||
|
*/
|
||||||
|
const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
emitter.on('message', vi.fn());
|
||||||
|
|
||||||
|
const hasListeners = emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
// Expect emit to report that at least one listener was invoked.
|
||||||
|
expect(hasListeners).toBe(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that EventEmitter.emit continues after a listener throws an error.
|
||||||
|
*/
|
||||||
|
const testEventEmitterEmitContinuesAfterListenerThrows = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const secondListener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', (): void => {
|
||||||
|
throw new Error('listener failure');
|
||||||
|
});
|
||||||
|
emitter.on('message', secondListener);
|
||||||
|
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
expect(secondListener).toHaveBeenCalledOnce();
|
||||||
|
expect(secondListener).toHaveBeenCalledWith('hello');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that emitted events cannot be mutated.
|
||||||
|
*/
|
||||||
|
const testEventEmitterEmittedEventsCannotBeMutated = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
const payload = { nested: { value: 1 } };
|
||||||
|
|
||||||
|
emitter.on('nested', listener, 100);
|
||||||
|
|
||||||
|
// Arm the debounce timer with the current payload.
|
||||||
|
emitter.emit('nested', payload);
|
||||||
|
|
||||||
|
// Mutate the original object while the timer is still pending.
|
||||||
|
payload.nested.value = 999;
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
// The listener must receive a snapshot from emit time, not the mutated value.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Expect the listener to have received the original payload object without any mutations
|
||||||
|
expect(listener).toHaveBeenCalledWith({
|
||||||
|
nested: {
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expect the payload object to have been mutated
|
||||||
|
expect(payload).toStrictEqual({
|
||||||
|
nested: {
|
||||||
|
value: 999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that the off callback returned by on() removes the listener.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOffCallbackRemovesListener = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
// on() returns an off callback that removes the listener.
|
||||||
|
const off = emitter.on('message', listener);
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
|
||||||
|
// Unsubscribe before emitting again.
|
||||||
|
off();
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
|
||||||
|
// Expect the listener to have only received the first event.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(listener).toHaveBeenCalledWith('first');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that off() removes a listener when given the same function reference.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOffRemovesListenerByReference = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', listener);
|
||||||
|
emitter.off('message', listener);
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
// Expect the listener to have been removed before the emit.
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that off() removes all listeners for an event type when no listener is provided.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOffRemovesAllListenersForEventType = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', listener);
|
||||||
|
emitter.off('message');
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(emitter.emit('message', 'hello')).toBe(false);
|
||||||
|
expect(emitter.emit('count', 42)).toBe(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that off() does nothing when given an unknown listener reference.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOffIgnoresUnknownListener = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', listener);
|
||||||
|
|
||||||
|
// Try to remove a different function reference.
|
||||||
|
emitter.off('message', vi.fn());
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
// Expect the original listener to still receive the event.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that off() does nothing when called for an event type with no listeners.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOffIgnoresUnregisteredEventType = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
// Call off without ever registering this listener.
|
||||||
|
emitter.off('message', listener);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that once() listeners are invoked only one time.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOnceListenerFiresOnce = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.once('message', listener);
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
|
||||||
|
// Expect the listener to auto-unsubscribe after the first emit.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(listener).toHaveBeenCalledWith('first');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that once() can be added when regular listeners already exist for the event type.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOnceWorksWithExistingListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const existingListener = vi.fn();
|
||||||
|
const onceListener = vi.fn();
|
||||||
|
|
||||||
|
// Register a regular listener first so the event type already exists in the map.
|
||||||
|
emitter.on('message', existingListener);
|
||||||
|
emitter.once('message', onceListener);
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
expect(existingListener).toHaveBeenCalledOnce();
|
||||||
|
expect(onceListener).toHaveBeenCalledOnce();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that the off callback returned by once() removes the listener before it fires.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const off = emitter.once('message', listener);
|
||||||
|
|
||||||
|
// Unsubscribe before the event is ever emitted.
|
||||||
|
off();
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that debounced listeners do not receive the debounced event if the listener is removed.
|
||||||
|
*/
|
||||||
|
const testEventEmitterOffCancelsPendingDebouncedCallback = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const off = emitter.on('message', listener, 100);
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
|
||||||
|
off();
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that removeAllListeners() clears every registered listener.
|
||||||
|
*/
|
||||||
|
const testEventEmitterRemoveAllListeners = (): void => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const messageListener = vi.fn();
|
||||||
|
const countListener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', messageListener);
|
||||||
|
emitter.on('count', countListener);
|
||||||
|
emitter.removeAllListeners();
|
||||||
|
|
||||||
|
// Emit on both event types after clearing all listeners.
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
emitter.emit('count', 1);
|
||||||
|
|
||||||
|
expect(messageListener).not.toHaveBeenCalled();
|
||||||
|
expect(countListener).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that removeAllListeners() cancels a pending debounced callback.
|
||||||
|
*/
|
||||||
|
const testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
// Arm a debounce timer, then clear every listener before it expires.
|
||||||
|
emitter.on('message', listener, 100);
|
||||||
|
emitter.emit('message', 'should not arrive');
|
||||||
|
emitter.removeAllListeners();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
// Cleared listeners must not receive delayed debounced delivery.
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() resolves when a matching event is emitted.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForResolvesOnMatch = async (): Promise<void> => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
// Wait until an event matches the predicate.
|
||||||
|
const waitPromise = emitter.waitFor('count', (payload) => payload === 42);
|
||||||
|
|
||||||
|
// Emit a non-matching event first, then the matching one.
|
||||||
|
emitter.emit('count', 41);
|
||||||
|
emitter.emit('count', 42);
|
||||||
|
|
||||||
|
await expect(waitPromise).resolves.toBe(42);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() ignores non-matching events while other listeners still receive them.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise<void> => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done');
|
||||||
|
|
||||||
|
// A regular listener should still receive every emit while waitFor filters.
|
||||||
|
emitter.on('message', listener);
|
||||||
|
emitter.emit('message', 'pending');
|
||||||
|
emitter.emit('message', 'done');
|
||||||
|
|
||||||
|
await expect(waitPromise).resolves.toBe('done');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() rejects when the timeout expires.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForRejectsOnTimeout = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
const waitPromise = emitter.waitFor('message', () => true, 100);
|
||||||
|
|
||||||
|
// Attach the rejection handler before advancing timers so the rejection is handled.
|
||||||
|
const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
await assertion;
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() clears its timeout when it resolves before expiry.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
// Register waitFor with a timeout, then resolve it before the timer fires.
|
||||||
|
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100);
|
||||||
|
|
||||||
|
emitter.emit('message', 'done');
|
||||||
|
|
||||||
|
await expect(waitPromise).resolves.toBe('done');
|
||||||
|
|
||||||
|
// If clearTimeout was not called, advancing past the timeout would reject the promise.
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() removes its listener after resolving.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise<void> => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
const waitPromise = emitter.waitFor('message', () => true);
|
||||||
|
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
await expect(waitPromise).resolves.toBe('first');
|
||||||
|
|
||||||
|
// Register a second waitFor so we can verify the first listener was cleaned up.
|
||||||
|
const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second');
|
||||||
|
|
||||||
|
// Emit a payload that only the second waitFor should accept.
|
||||||
|
emitter.emit('message', 'ignored');
|
||||||
|
|
||||||
|
// Track whether the second waitFor resolves too early.
|
||||||
|
let resolvedEarly = false;
|
||||||
|
/* eslint-disable-next-line */
|
||||||
|
secondWaitPromise.then(() => {
|
||||||
|
resolvedEarly = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Yield so any premature resolution would have a chance to run.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(resolvedEarly).toBe(false);
|
||||||
|
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
await expect(secondWaitPromise).resolves.toBe('second');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that the first debounced emit does not call clearTimeout.
|
||||||
|
*/
|
||||||
|
const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', listener, 100);
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
|
||||||
|
// The first emit starts the debounce timer; there is nothing to clear yet.
|
||||||
|
expect(clearTimeoutSpy).not.toHaveBeenCalled();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that debounced on() listeners receive only the last payload after the debounce window.
|
||||||
|
*/
|
||||||
|
const testEventEmitterDebouncedOnListener = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('message', listener, 100);
|
||||||
|
|
||||||
|
// Emit several events in quick succession.
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
emitter.emit('message', 'third');
|
||||||
|
|
||||||
|
// Expect the listener to not have fired yet.
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance past the debounce window.
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
// Expect only the last payload to have been delivered.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(listener).toHaveBeenCalledWith('third');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that repeated debounced emits reset the debounce timer.
|
||||||
|
*/
|
||||||
|
const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.on('count', listener, 100);
|
||||||
|
emitter.emit('count', 1);
|
||||||
|
|
||||||
|
// Advance halfway through the debounce window and emit again.
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
emitter.emit('count', 2);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// The timer was reset, so the listener should not have fired yet.
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance the remaining time for the reset timer to expire.
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(listener).toHaveBeenCalledWith(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that a debounce time of zero behaves like a normal listener.
|
||||||
|
*/
|
||||||
|
const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
// A debounce time of zero should behave like a normal listener.
|
||||||
|
emitter.on('message', listener, 0);
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that debounced once() listeners fire once with the last payload.
|
||||||
|
*/
|
||||||
|
const testEventEmitterDebouncedOnceListener = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
emitter.once('message', listener, 100);
|
||||||
|
emitter.emit('message', 'first');
|
||||||
|
emitter.emit('message', 'second');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
// Expect the debounced once listener to fire once with the last payload.
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
expect(listener).toHaveBeenCalledWith('second');
|
||||||
|
|
||||||
|
// Emit again after the debounce window; the once listener should stay removed.
|
||||||
|
emitter.emit('message', 'third');
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that the `waitFor` method rejects if the predicate function throws
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForRejectsOnPredicateError = async (): Promise<void> => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
const waitPromise = emitter.waitFor('message', () => {
|
||||||
|
throw new Error('predicate error');
|
||||||
|
});
|
||||||
|
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
|
||||||
|
await expect(waitPromise).rejects.toThrow('predicate error');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() removes its listener after the predicate function throws an error.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForRemovesListenerAfterPredicateError = async (): Promise<void> => {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
|
||||||
|
// Create a predicate function that throws an error.
|
||||||
|
const predicate = vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('predicate error');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the predicate function to throw an error.
|
||||||
|
const waitPromise = emitter.waitFor('message', predicate);
|
||||||
|
|
||||||
|
// Emit an event, expecting the predicate function to throw an error.
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
await expect(waitPromise).rejects.toThrow('predicate error');
|
||||||
|
|
||||||
|
// Expect the predicate function to have been called once.
|
||||||
|
expect(predicate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A later emit must not invoke the failed waitFor predicate again.
|
||||||
|
emitter.emit('message', 'again');
|
||||||
|
|
||||||
|
// Expect the predicate function to still have been called once.
|
||||||
|
expect(predicate).toHaveBeenCalledTimes(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that waitFor() clears its timeout when the predicate function throws an error.
|
||||||
|
*/
|
||||||
|
const testEventEmitterWaitForClearsTimeoutAfterPredicateError = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emitter = new EventEmitter<TestEvents>();
|
||||||
|
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
|
||||||
|
|
||||||
|
const waitPromise = emitter.waitFor(
|
||||||
|
'message',
|
||||||
|
(): boolean => {
|
||||||
|
throw new Error('predicate error');
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
emitter.emit('message', 'hello');
|
||||||
|
await expect(waitPromise).rejects.toThrow('predicate error');
|
||||||
|
|
||||||
|
// The timeout scheduled for waitFor must be cleared on predicate failure.
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advancing past the original timeout must not produce a second rejection path.
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTests = async (): Promise<void> => {
|
||||||
|
test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners);
|
||||||
|
test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners);
|
||||||
|
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
|
||||||
|
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
|
||||||
|
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
|
||||||
|
test('EventEmitter: continues after a listener throws an error', testEventEmitterEmitContinuesAfterListenerThrows);
|
||||||
|
test('EventEmitter: emitted events cannot be mutated', testEventEmitterEmittedEventsCannotBeMutated);
|
||||||
|
test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener);
|
||||||
|
test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference);
|
||||||
|
test(
|
||||||
|
'EventEmitter: removes all listeners for an event type when off is called with no listener',
|
||||||
|
testEventEmitterOffRemovesAllListenersForEventType,
|
||||||
|
);
|
||||||
|
test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener);
|
||||||
|
test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType);
|
||||||
|
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
|
||||||
|
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
|
||||||
|
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener);
|
||||||
|
test(
|
||||||
|
'EventEmitter: debounced listeners do not receive the debounced event if the listener is removed',
|
||||||
|
testEventEmitterOffCancelsPendingDebouncedCallback,
|
||||||
|
);
|
||||||
|
test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
|
||||||
|
test(
|
||||||
|
'EventEmitter: cancels a pending debounced callback when removeAllListeners is called',
|
||||||
|
testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback,
|
||||||
|
);
|
||||||
|
test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch);
|
||||||
|
test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);
|
||||||
|
test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout);
|
||||||
|
test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve);
|
||||||
|
test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve);
|
||||||
|
test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout);
|
||||||
|
test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener);
|
||||||
|
test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits);
|
||||||
|
test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce);
|
||||||
|
test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener);
|
||||||
|
test('EventEmitter: rejects waitFor when the predicate function throws', testEventEmitterWaitForRejectsOnPredicateError);
|
||||||
|
test(
|
||||||
|
'EventEmitter: removes the waitFor listener after it rejects due to predicate error',
|
||||||
|
testEventEmitterWaitForRemovesListenerAfterPredicateError,
|
||||||
|
);
|
||||||
|
test('EventEmitter: clears the timeout when waitFor rejects due to predicate error', testEventEmitterWaitForClearsTimeoutAfterPredicateError);
|
||||||
|
};
|
||||||
|
|
||||||
|
await runTests();
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
import { ExponentialBackoff } from '../source/exponential-backoff.ts';
|
import { ExponentialBackoff } from '../source/exponential-backoff.ts';
|
||||||
import { ExponentialBackoffMaxRetriesHitError, ExponentialBackoffStoppedRetriesError } from '../source/errors.ts';
|
import {
|
||||||
|
ExponentialBackoffMaxRetriesHitError,
|
||||||
|
ExponentialBackoffNumberNotFiniteError,
|
||||||
|
ExponentialBackoffStoppedRetriesError,
|
||||||
|
} from '../source/errors.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
||||||
@@ -295,6 +299,48 @@ const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise<voi
|
|||||||
expect(abortAndThrowStringFn).not.toHaveResolved();
|
expect(abortAndThrowStringFn).not.toHaveResolved();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that the delay is aborted when the abort signal is activated.
|
||||||
|
*/
|
||||||
|
const testExponentialBackoffRunDelayAbortedWhenAbortSignal = async (): Promise<void> => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let abort: (reason: unknown) => void;
|
||||||
|
|
||||||
|
const taskFn = vi.fn(async ({ stopRetries }) => {
|
||||||
|
abort = stopRetries;
|
||||||
|
throw new Error('error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the exponential backoff and wait for it to complete
|
||||||
|
const result = ExponentialBackoff.run(taskFn, () => {}, {
|
||||||
|
baseDelay: 1000,
|
||||||
|
jitter: 0,
|
||||||
|
maxAttempts: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Advance the timer by 500 (mid delay)
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
|
||||||
|
// Make sure the abort function is defined (That the taskFn was called)
|
||||||
|
if (!abort!) {
|
||||||
|
throw new Error('abort is not defined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the abort function is defined
|
||||||
|
expect(abort).toBeDefined();
|
||||||
|
|
||||||
|
// Abort the exponential backoff
|
||||||
|
abort?.(new Error('exponential backoff aborted'));
|
||||||
|
|
||||||
|
// Expect the result to be rejected with an ExponentialBackoffStoppedRetriesError
|
||||||
|
await expect(result).rejects.toThrow(ExponentialBackoffStoppedRetriesError);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
|
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
|
||||||
* as an alternative to the static helper.
|
* as an alternative to the static helper.
|
||||||
@@ -498,7 +544,7 @@ const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
})).toThrow(`${field} must be not less than 0`);
|
})).toThrow(`Exponential backoff option "${field}" is too small. Must be at least 0`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -515,7 +561,7 @@ const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
jitter: value,
|
jitter: value,
|
||||||
})).toThrow('jitter must be not less than 0 or greater than 1');
|
})).toThrow('Exponential backoff option "jitter" is out of bounds. Must be between 0 and 1');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -538,7 +584,24 @@ const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void =>
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
})).toThrow(`${field} must be a finite number`);
|
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-integer values.
|
||||||
|
*/
|
||||||
|
const testExponentialBackoffValidateOptionsRejectsNonIntegerValues = (): void => {
|
||||||
|
// Define our test cases with each value being a non-integer
|
||||||
|
const nonIntegerCases = [{ field: 'maxAttempts', value: 1.5 }] as const;
|
||||||
|
|
||||||
|
// Iterate through the test cases and expect an error to be thrown
|
||||||
|
for (const { field, value } of nonIntegerCases) {
|
||||||
|
expect(() =>
|
||||||
|
ExponentialBackoff.validateOptions({
|
||||||
|
...validExponentialBackoffOptions,
|
||||||
|
[field]: value,
|
||||||
|
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be an integer`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -561,10 +624,25 @@ const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
|
|||||||
ExponentialBackoff.validateOptions({
|
ExponentialBackoff.validateOptions({
|
||||||
...validExponentialBackoffOptions,
|
...validExponentialBackoffOptions,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
})).toThrow(`${field} must be a finite number`);
|
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tests that passing undefined into the constructor does not cause an error during spread */
|
||||||
|
const testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread = async (): Promise<void> => {
|
||||||
|
const options = {
|
||||||
|
baseDelay: undefined,
|
||||||
|
growthRate: undefined,
|
||||||
|
jitter: undefined,
|
||||||
|
maxDelay: undefined,
|
||||||
|
maxAttempts: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// We expect an error during validation as undefined is not a finite number, not an issue with the spread operator
|
||||||
|
// @ts-expect-error - Passing undefined is allowed if the exactOptionalPropertyTypes option is set to false in TS Compiler options.
|
||||||
|
expect(() => new ExponentialBackoff(options)).toThrow(ExponentialBackoffNumberNotFiniteError);
|
||||||
|
};
|
||||||
|
|
||||||
const runTests = async (): Promise<void> => {
|
const runTests = async (): Promise<void> => {
|
||||||
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
|
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
|
||||||
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
|
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
|
||||||
@@ -580,6 +658,7 @@ const runTests = async (): Promise<void> => {
|
|||||||
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
|
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
|
||||||
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
|
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
|
||||||
test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError);
|
test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError);
|
||||||
|
test('ExponentialBackoff: aborts with abort signal, skipping delay', testExponentialBackoffRunDelayAbortedWhenAbortSignal);
|
||||||
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
|
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
|
||||||
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
|
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
|
||||||
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
|
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
|
||||||
@@ -589,7 +668,9 @@ const runTests = async (): Promise<void> => {
|
|||||||
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
||||||
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
||||||
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
||||||
|
test('ExponentialBackoff.validateOptions: rejects non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues);
|
||||||
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
||||||
|
test('ExponentialBackoff: constructor does not cause an error during spread', testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread);
|
||||||
};
|
};
|
||||||
|
|
||||||
await runTests();
|
await runTests();
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
|
|||||||
/* eslint-disable-next-line */
|
/* eslint-disable-next-line */
|
||||||
for await (const _value of iterator) {
|
for await (const _value of iterator) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
failureFlag();
|
failureFlag();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true
|
"declarationMap": true
|
||||||
|
|||||||
Reference in New Issue
Block a user