Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ef5acd3f | ||
|
|
0c95b9b2b1 | ||
|
|
d907946804 | ||
|
|
4a5d6486a4 | ||
|
|
04db9f4c0e | ||
|
|
752cda0706 | ||
|
|
e96ab6457b | ||
|
|
c3dd0162d9 | ||
|
|
bd2f1044e7 | ||
|
|
a9d96d7a0d | ||
|
|
32eca1e016 | ||
|
|
947cbb2a7c | ||
|
|
c73c6b216e | ||
|
|
4e555c6ffe | ||
|
|
0cd615249e | ||
|
|
0fc1eb7aec |
+20
-1
@@ -1,6 +1,25 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
|
||||
"words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue", "bivariance"],
|
||||
"words": [
|
||||
"nonfungible",
|
||||
"lockscript",
|
||||
"cashassembly",
|
||||
"checksigverify",
|
||||
"inputindex",
|
||||
"utxovalue",
|
||||
"bivariance",
|
||||
"bytecodes",
|
||||
"outputbytecode",
|
||||
"utxobytecode",
|
||||
"outputtokencategory",
|
||||
"utxotokencategory",
|
||||
"utxotokenamount",
|
||||
"outputtokenamount",
|
||||
"outputvalue",
|
||||
"numequal",
|
||||
"Prefault",
|
||||
"cashtoken"
|
||||
],
|
||||
"ignorePaths": ["source/template/xo-template.schema.json"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import baseConfig from '@xo-cash/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [ 'scripts/**', 'docs/**', 'source/parser' ],
|
||||
ignores: [ 'scripts/**' ],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
|
||||
Generated
+527
-563
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "@xo-cash/utils",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.10",
|
||||
"description": "XO Cash utilities",
|
||||
"type": "module",
|
||||
"types": "./dist/index.d.mts",
|
||||
"source": "./source/index.ts",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -23,8 +26,7 @@
|
||||
"spellcheck": "cspell 'source/**' 'test/**' 'playground/**'",
|
||||
"style": "eslint",
|
||||
"syntax": "tsc --noEmit",
|
||||
"test": "vitest --dir test/ --test-timeout=15000 --passWithNoTests --run --coverage",
|
||||
"prepare": "npm run build"
|
||||
"test": "vitest --dir test/ --test-timeout=15000 --passWithNoTests --run --coverage"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -46,17 +48,18 @@
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.8",
|
||||
"@xo-cash/primitives": "0.0.2",
|
||||
"@xo-cash/types": "0.0.4",
|
||||
"@xo-cash/types": "0.0.8",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"echarts": "6.1.0",
|
||||
"minimatch": "10.2.6"
|
||||
"minimatch": "10.2.6",
|
||||
"qs": "6.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||
"@xo-cash/eslint-config": "1.0.2",
|
||||
"@xo-cash/templates": "0.0.3",
|
||||
@@ -67,6 +70,6 @@
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-coverage": "^4.0.2",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^4.0.17"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ declare const process: {
|
||||
|
||||
// Recommended URL: https://openrouter.ai/api/v1/chat/completions
|
||||
// Recommended Model: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
|
||||
// Exmaple Command: API_KEY="your-api-key" URL="https://openrouter.ai/api/v1/chat/completions" MODEL="ibm-granite/granite-4.1-8b" PROMPT="Hello, Tell me a joke about robots?" npx tsx ./sandbox/sandbox-llm.ts
|
||||
// Example Command: API_KEY="your-api-key" URL="https://openrouter.ai/api/v1/chat/completions" MODEL="ibm-granite/granite-4.1-8b" PROMPT="Hello, Tell me a joke about robots?" npx tsx ./sandbox/sandbox-llm.ts
|
||||
|
||||
// Read the Environemt Variables for url, model, prompt and api key
|
||||
// Read the Environment Variables for url, model, prompt and api key
|
||||
const url = process.env.URL ?? 'https://openrouter.ai/api/v1/chat/completions';
|
||||
const model = process.env.MODEL ?? 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
|
||||
const prompt = process.env.PROMPT ?? 'Hello, Tell me a joke about robots?';
|
||||
@@ -76,7 +76,7 @@ for await (const message of sseSession.messages) {
|
||||
// First, we will parse the event to JSON
|
||||
const responseJson = JSON.parse(message.data);
|
||||
|
||||
// Then we will grab the relavent part of the response (we want to first grab the choices array)
|
||||
// Then we will grab the relevant part of the response (we want to first grab the choices array)
|
||||
const choices = responseJson.choices;
|
||||
|
||||
// Then we will grab the first choice
|
||||
|
||||
@@ -6,9 +6,9 @@ import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueType
|
||||
*
|
||||
* @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.
|
||||
* @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) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describeExpectedInput, parseScript } from '@bitauth/libauth';
|
||||
import type { CashAssemblyScriptSegment } from '@bitauth/libauth';
|
||||
import { CashAssemblyCompilationFailedError } from './errors.ts';
|
||||
import type { CashAssemblyTemplateScripts } from './types.ts';
|
||||
|
||||
/**
|
||||
* Shared state while scanning a parse tree and visiting nested template scripts.
|
||||
*/
|
||||
type CollectFromParseTreeContext = {
|
||||
|
||||
/**
|
||||
* Script ids present in the template scripts map.
|
||||
*/
|
||||
knownScriptIdentifiers: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Script ids whose sources were already visited.
|
||||
*/
|
||||
visitedScriptIdentifiers: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Pending script ids queued to visit.
|
||||
*/
|
||||
scriptIdentifiersToVisit: string[];
|
||||
|
||||
/**
|
||||
* WalletData variable names.
|
||||
*/
|
||||
variableNames: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for scanning one parsed Script node.
|
||||
*/
|
||||
type CollectFromParsedScriptParameters = CollectFromParseTreeContext & {
|
||||
|
||||
/**
|
||||
* Parsed Script whose children are Push, Evaluation, Identifier, or literals.
|
||||
*/
|
||||
scriptSegment: CashAssemblyScriptSegment;
|
||||
|
||||
/**
|
||||
* True when this Script is the value of a Push.
|
||||
* Direct Identifier children are WalletData unless they are template script ids.
|
||||
*/
|
||||
isDirectPushContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for handling one Identifier node.
|
||||
*/
|
||||
type CollectFromParsedIdentifierParameters = CollectFromParseTreeContext & {
|
||||
|
||||
/**
|
||||
* Identifier text from parseScript, which may include `.` and `_`.
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* True when this Identifier is a direct child of a Push Script.
|
||||
*/
|
||||
isDirectPushContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enqueues a template script id so its source can be visited when it exists in the scripts map.
|
||||
*
|
||||
* @param {string} scriptIdentifier - Script id from an evaluation or nested script source.
|
||||
* @param {ReadonlySet<string>} knownScriptIdentifiers - Script ids present in the template scripts map.
|
||||
* @param {ReadonlySet<string>} visitedScriptIdentifiers - Script ids whose sources were already visited.
|
||||
* @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit.
|
||||
*/
|
||||
const enqueueReachableTemplateScriptIdentifier = (
|
||||
scriptIdentifier: string,
|
||||
knownScriptIdentifiers: ReadonlySet<string>,
|
||||
visitedScriptIdentifiers: ReadonlySet<string>,
|
||||
scriptIdentifiersToVisit: string[],
|
||||
): void => {
|
||||
// Return when this id is not a template script.
|
||||
if (knownScriptIdentifiers.has(scriptIdentifier) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip ids whose sources were already visited.
|
||||
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid duplicate queue entries when the same id is referenced more than once.
|
||||
if (scriptIdentifiersToVisit.includes(scriptIdentifier) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue this id so its source is parsed after the current scan finishes.
|
||||
scriptIdentifiersToVisit.push(scriptIdentifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses CashAssembly source with parseScript and throws when the parse fails.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Evaluation or script source to parse.
|
||||
* @returns {CashAssemblyScriptSegment} - Parsed Script root.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the source.
|
||||
*/
|
||||
const parseCashAssemblySource = (cashAssemblyText: string): CashAssemblyScriptSegment => {
|
||||
// Parse so Push and Identifier nodes are available for collection.
|
||||
const parseResult = parseScript(cashAssemblyText);
|
||||
|
||||
// parseScript returns a union. status false is the failure arm and must not be read as a Script.
|
||||
if (parseResult.status === false) {
|
||||
// describeExpectedInput turns the expected input list into readable text for the typed error.
|
||||
const expectedInputDescription = describeExpectedInput(parseResult.expected);
|
||||
|
||||
throw new CashAssemblyCompilationFailedError(`${expectedInputDescription} Line ${String(parseResult.index.line)}, column ${String(parseResult.index.column)}.`);
|
||||
}
|
||||
|
||||
return parseResult.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifier visitor for the parseScript tree.
|
||||
* Records WalletData or queues a nested script from one Identifier node.
|
||||
*
|
||||
* @param {CollectFromParsedIdentifierParameters} parameters - Identifier text and the parse tree context.
|
||||
*/
|
||||
const collectFromParsedIdentifier = (parameters: CollectFromParsedIdentifierParameters): void => {
|
||||
const { identifier, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } = parameters;
|
||||
|
||||
// Script ids are visited whether they appear in a Push or beside opcodes.
|
||||
if (knownScriptIdentifiers.has(identifier) === true) {
|
||||
enqueueReachableTemplateScriptIdentifier(identifier, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct Push names that are not script ids are WalletData, including dotted names.
|
||||
if (isDirectPushContent === true) {
|
||||
variableNames.add(identifier);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Visitor for one Script node in the parseScript tree.
|
||||
* Recurses into Push and Evaluation children and dispatches Identifier nodes.
|
||||
*
|
||||
* @param {CollectFromParsedScriptParameters} parameters - Parsed Script, Push depth flag, and parse tree context.
|
||||
*/
|
||||
const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters): void => {
|
||||
const { scriptSegment, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } =
|
||||
parameters;
|
||||
|
||||
for (const child of scriptSegment.value) {
|
||||
if (child.name === 'Push') {
|
||||
// Inner Script of a Push is where `<name>` WalletData lives, including nested angle brackets.
|
||||
collectFromParsedScript({
|
||||
scriptSegment: child.value,
|
||||
isDirectPushContent: true,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.name === 'Evaluation') {
|
||||
// Evaluation contents are opcodes and nested Pushes, not WalletData by themselves.
|
||||
collectFromParsedScript({
|
||||
scriptSegment: child.value,
|
||||
isDirectPushContent: false,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.name === 'Identifier') {
|
||||
collectFromParsedIdentifier({
|
||||
identifier: child.value,
|
||||
isDirectPushContent,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects WalletData names from the parseScript tree using the visitor pattern.
|
||||
*
|
||||
* parseScript returns a tree of Script, Push, Evaluation, and Identifier nodes. Nested Push and
|
||||
* Evaluation nodes are entered immediately. A template script id is queued whether it appears inside a Push or next to
|
||||
* opcodes. Queued script sources are visited after the current tree finishes, in the order they were
|
||||
* first seen.
|
||||
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
|
||||
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
|
||||
* for example `<$(<someKey.public_key> OP_HASH160)>` collects `someKey.public_key`.
|
||||
*
|
||||
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
|
||||
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
|
||||
* @returns {string[]} - WalletData names from reachable Pushes. Script ids are omitted when
|
||||
* `templateScripts` is provided.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the starting text or a visited script source.
|
||||
*/
|
||||
export const collectVariablesUsingParseScript = (evaluation: string, templateScripts: CashAssemblyTemplateScripts = {}): string[] => {
|
||||
// Create a set of the script identifiers from the template scripts map.
|
||||
const knownScriptIdentifiers = new Set(Object.keys(templateScripts));
|
||||
|
||||
const visitedScriptIdentifiers = new Set<string>();
|
||||
const scriptIdentifiersToVisit: string[] = [];
|
||||
const variableNames = new Set<string>();
|
||||
|
||||
// Share by reference context.
|
||||
const parseTreeContext: CollectFromParseTreeContext = {
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
};
|
||||
|
||||
// Parse the starting text so nested Pushes are visible before visiting nested script sources.
|
||||
const startingScriptSegment = parseCashAssemblySource(evaluation);
|
||||
|
||||
// Scan the starting parse tree before visiting nested script sources.
|
||||
collectFromParsedScript({
|
||||
...parseTreeContext,
|
||||
scriptSegment: startingScriptSegment,
|
||||
isDirectPushContent: false,
|
||||
});
|
||||
|
||||
for (const scriptIdentifier of scriptIdentifiersToVisit) {
|
||||
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedScriptIdentifiers.add(scriptIdentifier);
|
||||
|
||||
// Get the script definition from the template scripts map.
|
||||
const scriptDefinition = templateScripts[scriptIdentifier];
|
||||
|
||||
const nestedScriptSegment = parseCashAssemblySource(scriptDefinition);
|
||||
|
||||
collectFromParsedScript({
|
||||
...parseTreeContext,
|
||||
scriptSegment: nestedScriptSegment,
|
||||
isDirectPushContent: false,
|
||||
});
|
||||
}
|
||||
|
||||
return [ ...variableNames ];
|
||||
};
|
||||
@@ -1,61 +1,41 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
* For example `expiry.toIso8601` matches with base expiry and method toIso8601.
|
||||
* `requestedSatoshis` does not match because it has no method.
|
||||
* `key.schnorr_signature.all_outputs` does not match because it has more than one dot.
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
|
||||
|
||||
/**
|
||||
* Character count of the `$(` evaluation opener, which is two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so scanning
|
||||
* starts on the evaluation contents rather than on the opening parenthesis.
|
||||
*/
|
||||
export const CASHASSEMBLY_EVALUATION_START_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Character count of the `//` and `/*` comment openers, which are both two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so the '*'
|
||||
* of a block comment opener cannot be reused as the '*' of its closer.
|
||||
*/
|
||||
export const CASHASSEMBLY_COMMENT_START_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Character count of the block comment closer, which is two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so the
|
||||
* closing slash cannot pair with the next character and open another comment.
|
||||
*/
|
||||
export const CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* `$()` with nothing between the parentheses.
|
||||
*
|
||||
* Callers use this to detect an evaluation that has no contents to compile.
|
||||
*/
|
||||
export const EMPTY_CASHASSEMBLY_EVALUATION = '$()';
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import type { IdentifierResolutionType } from '@bitauth/libauth';
|
||||
|
||||
/**
|
||||
* Error thrown when a required variable is missing.
|
||||
*/
|
||||
export class CashAssemblyRequiredVariableMissingError extends Error {
|
||||
constructor(variableNames?: string[]) {
|
||||
/**
|
||||
* Variable names that were required but absent from the variables map.
|
||||
*/
|
||||
readonly variableNames: string[];
|
||||
|
||||
constructor(variableNames: string[] = []) {
|
||||
const defaultMessage = 'Missing required variable';
|
||||
if (variableNames !== undefined && variableNames.length > 0) {
|
||||
if (variableNames.length > 0) {
|
||||
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
|
||||
} else {
|
||||
super(defaultMessage);
|
||||
}
|
||||
|
||||
this.variableNames = variableNames;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +32,104 @@ export class CashAssemblyCompilationFailedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a quoted string inside `$()` never closes.
|
||||
*/
|
||||
export class CashAssemblyQuotedLiteralUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the opening quote that never found a closer.
|
||||
*/
|
||||
readonly openingQuoteIndex: number;
|
||||
|
||||
/**
|
||||
* Quote character that opened the literal.
|
||||
*/
|
||||
readonly quoteCharacter: string;
|
||||
|
||||
/**
|
||||
* Text from the opening quote through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(openingQuoteIndex: number, quoteCharacter: string, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(openingQuoteIndex);
|
||||
const defaultMessage = 'Quoted literal in a CashAssembly evaluation is unclosed';
|
||||
const details = [
|
||||
`quoteCharacter ${JSON.stringify(quoteCharacter)}`,
|
||||
`openingQuoteIndex ${String(openingQuoteIndex)}`,
|
||||
`unclosedText ${JSON.stringify(unclosedText)}`,
|
||||
].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.openingQuoteIndex = openingQuoteIndex;
|
||||
this.quoteCharacter = quoteCharacter;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a block comment inside `$()` never closes.
|
||||
*/
|
||||
export class CashAssemblyBlockCommentUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the first slash of the block comment opener.
|
||||
*/
|
||||
readonly commentStartIndex: number;
|
||||
|
||||
/**
|
||||
* Text from the block comment opener through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(commentStartIndex: number, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(commentStartIndex);
|
||||
const defaultMessage = 'Block comment in a CashAssembly evaluation is unclosed';
|
||||
const details = [ `commentStartIndex ${String(commentStartIndex)}`, `unclosedText ${JSON.stringify(unclosedText)}` ].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.commentStartIndex = commentStartIndex;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a `$()` evaluation never finds its matching closer.
|
||||
*/
|
||||
export class CashAssemblyEvaluationUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the `$` that opened the evaluation.
|
||||
*/
|
||||
readonly evaluationStartIndex: number;
|
||||
|
||||
/**
|
||||
* How many `$()` remain open at the end of the string, including nested evaluations.
|
||||
*/
|
||||
readonly remainingOpenEvaluations: number;
|
||||
|
||||
/**
|
||||
* Text from the opening `$` through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(evaluationStartIndex: number, remainingOpenEvaluations: number, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(evaluationStartIndex);
|
||||
const defaultMessage = 'CashAssembly evaluation is unclosed';
|
||||
const details = [
|
||||
`evaluationStartIndex ${String(evaluationStartIndex)}`,
|
||||
`remainingOpenEvaluations ${String(remainingOpenEvaluations)}`,
|
||||
`unclosedText ${JSON.stringify(unclosedText)}`,
|
||||
].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.evaluationStartIndex = evaluationStartIndex;
|
||||
this.remainingOpenEvaluations = remainingOpenEvaluations;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a variable's runtime type does not match the type required for compilation.
|
||||
*/
|
||||
@@ -35,12 +141,12 @@ export class CashAssemblyVariableTypeMismatchError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a supported primitive hint does not expose the requested method.
|
||||
* Error thrown when a method resolvable primitive type does not expose the requested method.
|
||||
*/
|
||||
export class CashAssemblyPrimitiveMethodMissingError extends Error {
|
||||
constructor(identifier: string, methodName: string, hint: string) {
|
||||
constructor(identifier: string, methodName: string, type: string) {
|
||||
const defaultMessage = 'CashAssembly primitive method does not exist';
|
||||
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", hint "${hint}"`);
|
||||
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", type "${type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +171,7 @@ export class CashAssemblyNumberNotSafeIntegerError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a supported primitive is selected but its value is missing when provided in the variables map.
|
||||
* Error thrown when a method resolvable primitive is selected but its value is missing from the variables map.
|
||||
*/
|
||||
export class CashAssemblyPrimitiveVariableMissingError extends Error {
|
||||
constructor(identifier: string, variableName: string) {
|
||||
@@ -74,6 +180,30 @@ export class CashAssemblyPrimitiveVariableMissingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when one identifier would resolve as more than one {@link IdentifierResolutionType}.
|
||||
*/
|
||||
export class CashAssemblyIdentifierCollisionError extends Error {
|
||||
/**
|
||||
* Identifier that more than one resolution type would match.
|
||||
*/
|
||||
readonly identifier: string;
|
||||
|
||||
/**
|
||||
* The list of resolution types that the identifier matches.
|
||||
*/
|
||||
readonly resolutionTypes: IdentifierResolutionType[];
|
||||
|
||||
constructor(identifier: string, resolutionTypes: IdentifierResolutionType[]) {
|
||||
const defaultMessage = 'CashAssembly identifier exists for more than one resolution type';
|
||||
|
||||
super(`${defaultMessage}: identifier "${identifier}", resolutionTypes [${resolutionTypes.join(', ')}]`);
|
||||
|
||||
this.identifier = identifier;
|
||||
this.resolutionTypes = resolutionTypes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
|
||||
*/
|
||||
|
||||
+259
-175
@@ -7,26 +7,26 @@
|
||||
* ## 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.
|
||||
* For example `<someKey.public_key>` pushes the 33 byte compressed public key and `<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.
|
||||
* For example `$(<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
|
||||
* For example a P2PKH locking script looks like
|
||||
* `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.
|
||||
* `variableId.operation` is a variable with a compiler resolved operation.
|
||||
* For example `someKey.public_key` produces the public key bytes and
|
||||
* `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.
|
||||
* 1. Opcode always wins. For example 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.
|
||||
*
|
||||
@@ -34,80 +34,40 @@
|
||||
*
|
||||
* Supported `<base.method>` pushes are resolved to bytes before CashAssembly compiles.
|
||||
* Inside CashAssembly the order is Opcode then Variable then Script.
|
||||
*
|
||||
* ## This compile path
|
||||
*
|
||||
* This file is a layer on CashAssembly. Text outside `$()` is kept. Only `$()` is compiled.
|
||||
* For example `Paid $(scriptA) of $(scriptB)` keeps the words Paid and of.
|
||||
*
|
||||
* `$()` is found by scanning left to right with parenthesis matching by depth count. Quoted strings
|
||||
* and comments are skipped so a `)` there cannot end `$()`. Nested `$()` stay inside the outer
|
||||
* evaluation. That text is never passed to parseScript.
|
||||
*
|
||||
* Empty `$()` still uses this mixed path. When the string contains no `$()` at all, the entire
|
||||
* string is compiled as CashAssembly so push opcodes remain.
|
||||
*/
|
||||
import type { CompilerBch } from '@bitauth/libauth';
|
||||
import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth';
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
import {
|
||||
CashAssemblyCompilationFailedError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyRequiredVariableMissingError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
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) ?? [];
|
||||
};
|
||||
import { assertNoIdentifierCollisions } from './identifier-collisions.ts';
|
||||
import { collectVariablesUsingParseScript } from './collect-evaluations.ts';
|
||||
import { EMPTY_CASHASSEMBLY_EVALUATION } from './defaults.ts';
|
||||
import { scanCashAssemblyEvaluations } from './scan-evaluations.ts';
|
||||
import type {
|
||||
CompileCashAssemblyEvaluationsParameters,
|
||||
CompileCashAssemblySourceToBytesParameters,
|
||||
CompileCashAssemblySourceToDecodedTextParameters,
|
||||
CompileCashAssemblyStringParameters,
|
||||
CompiledCashAssemblyDecodeMode,
|
||||
} from './types.ts';
|
||||
|
||||
/**
|
||||
* Returns the segment of `identifier` before the first `.`.
|
||||
@@ -116,7 +76,7 @@ export const extractCashAssemblyEvaluations = (text: string): string[] => {
|
||||
* 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 `.`.
|
||||
* @returns {string} - The base name before the first `.`.
|
||||
*/
|
||||
const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
const firstDotIndex = identifier.indexOf('.');
|
||||
@@ -128,31 +88,6 @@ const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
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.
|
||||
*
|
||||
@@ -161,10 +96,9 @@ export const extractVariablesFromEvaluations = (evaluations: string[]): string[]
|
||||
* 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.
|
||||
* @param {CompiledCashAssemblyDecodeMode} evaluationDecodeMode - 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,
|
||||
@@ -206,43 +140,52 @@ export const decodeCompiledCashAssemblyEvaluation = (
|
||||
* @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.
|
||||
* @param {string[]} requiredVariableNames - WalletData names from {@link collectVariablesUsingParseScript} that must be present.
|
||||
* @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 ]);
|
||||
export const generateCashAssemblyBytecode = (
|
||||
compiler: CompilerBch,
|
||||
evaluation: string,
|
||||
variables: Record<string, Uint8Array>,
|
||||
requiredVariableNames: string[],
|
||||
): Uint8Array => {
|
||||
// Missing WalletData must fail here. Libauth would otherwise compile as if the value were empty bytes.
|
||||
const missingVariables = requiredVariableNames.filter((name: string) => Object.hasOwn(variables, name) === false);
|
||||
|
||||
// Validate that all required variables are provided
|
||||
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
|
||||
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
|
||||
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) {
|
||||
|
||||
// Pass only required names so extra keys in `variables` are not sent to Libauth.
|
||||
for (const variableName of requiredVariableNames) {
|
||||
const value = variables[variableName];
|
||||
|
||||
// By the time execution reaches this point, the value should be a Uint8Array.
|
||||
if (!(value instanceof Uint8Array)) {
|
||||
// Libauth WalletData is always bytes. A number would compile incorrectly if we trusted TypeScript alone.
|
||||
if (value instanceof Uint8Array === false) {
|
||||
throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value);
|
||||
}
|
||||
|
||||
bytecode[variableName] = value;
|
||||
}
|
||||
|
||||
// `scriptId` is the source string registered in compileCashAssemblyEvaluations.
|
||||
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.
|
||||
// A failed compile must not return partial bytecode.
|
||||
if (compiledBytecode.success === false) {
|
||||
// CashAssemblyCompilationFailedError carries one message. Libauth may report several errors.
|
||||
let compilationFailureMessage = 'unknown compilation failure';
|
||||
|
||||
// Prefer Libauth's own error text when the failure object includes an errors list.
|
||||
if ('errors' in compiledBytecode && compiledBytecode.errors.length > 0) {
|
||||
compilationFailureMessage = compiledBytecode.errors.map((compilationError) => compilationError.error).join('; ');
|
||||
}
|
||||
@@ -254,28 +197,50 @@ export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation:
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
|
||||
* Prepares a compiler for the provided CashAssembly source strings, 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.
|
||||
* Each evaluation string is registered as a script whose id and source are that string. Template
|
||||
* scripts are then copied onto the same table. If a source is a template id such as `scriptA`, that
|
||||
* id must compile the template source. Leaving the id as its own source would compile the text
|
||||
* `scriptA` and include itself.
|
||||
*
|
||||
* @param {CompileCashAssemblyEvaluationsParameters} parameters - Source strings, optional template scripts, and already collected WalletData names.
|
||||
* @returns {CompilerBch} - A Libauth compiler instance for use with these source strings.
|
||||
*/
|
||||
export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerBch => {
|
||||
// Create a scripts object where each key is the evaluation and the value is also the evaluation
|
||||
export const compileCashAssemblyEvaluations = (parameters: CompileCashAssemblyEvaluationsParameters): CompilerBch => {
|
||||
const { evaluations, templateScripts, variableNames } = parameters;
|
||||
const scripts: Record<string, string> = {};
|
||||
|
||||
// Libauth looks up scripts by id. Using the source string as the id lets generateBytecode request that exact source.
|
||||
for (const evaluation of evaluations) {
|
||||
scripts[evaluation] = evaluation;
|
||||
}
|
||||
|
||||
// Get the variable names from the evaluations.
|
||||
const variableNames = extractVariablesFromEvaluations(evaluations);
|
||||
const knownScriptIdentifiers = new Set<string>();
|
||||
|
||||
if (templateScripts !== undefined) {
|
||||
for (const [ scriptIdentifier, scriptDefinition ] of Object.entries(templateScripts)) {
|
||||
// Compiling `scriptA` must run the template source. Otherwise the id compiles as the text `scriptA` and includes itself.
|
||||
scripts[scriptIdentifier] = scriptDefinition;
|
||||
|
||||
// Template ids compile as scripts. They must not also be registered as WalletData.
|
||||
knownScriptIdentifiers.add(scriptIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
|
||||
const variables: Record<string, { type: 'WalletData' }> = {};
|
||||
|
||||
// Register caller collected names. This function does not parse or collect again.
|
||||
for (const variableName of variableNames) {
|
||||
// A name that is a template script id is already a compiler script, not WalletData.
|
||||
if (knownScriptIdentifiers.has(variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Libauth WalletData is the name before the first `.`. `someKey.public_key` registers as `someKey`.
|
||||
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
|
||||
}
|
||||
|
||||
// Create the libauth compiler.
|
||||
const compiler = createCompilerBch({
|
||||
scripts,
|
||||
variables,
|
||||
@@ -285,69 +250,188 @@ export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerB
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
|
||||
* with a decoded string representation.
|
||||
* Compiles one CashAssembly source string to bytecode.
|
||||
*
|
||||
* The source may be an evaluation such as `$(scriptA)` or CashAssembly such as
|
||||
* `scriptA`. WalletData names come from {@link collectVariablesUsingParseScript}.
|
||||
*
|
||||
* @param {CompileCashAssemblySourceToBytesParameters} parameters - Source text and compilation context.
|
||||
* @returns {Uint8Array} - Compiled bytecode for that source.
|
||||
* @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 CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
*/
|
||||
export const compileCashAssemblySourceToBytes = (parameters: CompileCashAssemblySourceToBytesParameters): Uint8Array => {
|
||||
const { cashAssemblySource, variables, templateVariables, templateScripts } = parameters;
|
||||
|
||||
// Throw if there is a identifier collision between opcodes, variable, or template script.
|
||||
assertNoIdentifierCollisions({ variables, templateScripts });
|
||||
|
||||
// Collect WalletData names from the source and every reachable nested script source.
|
||||
const variableNames = collectVariablesUsingParseScript(cashAssemblySource, templateScripts);
|
||||
|
||||
// Resolve supported primitive methods to bytes before treating remaining names as WalletData.
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
variableNames,
|
||||
templateVariables,
|
||||
variables,
|
||||
});
|
||||
|
||||
// Collect names that still need a caller supplied value after primitive resolution.
|
||||
const missingVariables: string[] = [];
|
||||
for (const variableName of variableNames) {
|
||||
// Primitive methods already produced bytes under the full name.
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A present runtime value will be converted to bytes below.
|
||||
if (Object.hasOwn(variables, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missingVariables.push(variableName);
|
||||
}
|
||||
|
||||
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
|
||||
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) {
|
||||
// Prefer the primitive method result so CashAssembly sees bytes under `base.method`.
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
variableBytes[variableName] = primitiveMethodBytes[variableName];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remaining names are caller WalletData that still needs a byte encoding.
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
|
||||
// Reuse the names collected above so this compiler is not built from a second parse.
|
||||
const compiler = compileCashAssemblyEvaluations({
|
||||
evaluations: [ cashAssemblySource ],
|
||||
templateScripts,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
return generateCashAssemblyBytecode(compiler, cashAssemblySource, variableBytes, variableNames);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles one CashAssembly source to bytes and decodes those bytes to text.
|
||||
*
|
||||
* @param {CompileCashAssemblySourceToDecodedTextParameters} parameters - Source text, WalletData, optional template context, and decode mode.
|
||||
* @returns {string} - Decoded compilation result for that source.
|
||||
* @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 compiled bytes are not a VM number.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
*/
|
||||
const compileCashAssemblySourceToDecodedText = (parameters: CompileCashAssemblySourceToDecodedTextParameters): string => {
|
||||
const { cashAssemblySource, variables, templateVariables, templateScripts, evaluationDecodeMode } = parameters;
|
||||
|
||||
const compilationResult = compileCashAssemblySourceToBytes({
|
||||
cashAssemblySource,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
});
|
||||
|
||||
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles CashAssembly text. WalletData names come from parseScript. `$()` evaluations in text
|
||||
* are found by scanning the string and matching parentheses.
|
||||
*
|
||||
* When `cashAssemblyText` contains `$()`, each non empty evaluation is compiled and
|
||||
* replaced in place. Empty `$()` is copied through. Surrounding text is kept. Evaluation drops
|
||||
* push opcodes and keeps the stack payload.
|
||||
*
|
||||
* When `cashAssemblyText` contains no `$()` at all, the entire string is compiled as CashAssembly.
|
||||
* Push opcodes from `<...>` remain. Pass a template script id such as `scriptA` or
|
||||
* concatenated ids such as `scriptA scriptB`.
|
||||
*
|
||||
* Required WalletData names come from {@link collectVariablesUsingParseScript}.
|
||||
*
|
||||
* @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.
|
||||
* @returns {string} - Compiled text with evaluations replaced, or decoded bytecode when the whole string is compiled as CashAssembly.
|
||||
* @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 compiled bytes are not a VM number.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => {
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables } = parameters;
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables, templateScripts } = parameters;
|
||||
|
||||
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
|
||||
// Extract variable identifiers required by the current evaluation.
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
// Scan once so empty `$()` still copies text around `$()` instead of compiling as CashAssembly.
|
||||
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
|
||||
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
identifiers: variableNames,
|
||||
templateVariables,
|
||||
if (scannedEvaluations.length === 0) {
|
||||
return compileCashAssemblySourceToDecodedText({
|
||||
cashAssemblySource: cashAssemblyText,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
evaluationDecodeMode,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Copy text outside `$()` and compile each `$()`.
|
||||
let textWithCompiledEvaluations = '';
|
||||
|
||||
return Object.hasOwn(variables, variableName) === false;
|
||||
});
|
||||
// Start of the next original text slice in cashAssemblyText. Gaps between evaluations must stay intact.
|
||||
let nextTextStartIndex = 0;
|
||||
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
// When every `$()` is empty, return cashAssemblyText so the original text is unchanged.
|
||||
let hasCompiledNonEmptyEvaluation = false;
|
||||
|
||||
for (const scannedEvaluation of scannedEvaluations) {
|
||||
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex, scannedEvaluation.startIndex);
|
||||
|
||||
// Empty `$()` has no contents to compile.
|
||||
if (scannedEvaluation.evaluationText === EMPTY_CASHASSEMBLY_EVALUATION) {
|
||||
textWithCompiledEvaluations += scannedEvaluation.evaluationText;
|
||||
} else {
|
||||
hasCompiledNonEmptyEvaluation = true;
|
||||
textWithCompiledEvaluations += compileCashAssemblySourceToDecodedText({
|
||||
cashAssemblySource: scannedEvaluation.evaluationText,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
evaluationDecodeMode,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Advance past this `$()` so nested evaluations inside it are not copied again.
|
||||
nextTextStartIndex = scannedEvaluation.closeIndex + 1;
|
||||
}
|
||||
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
// Copy text after the last `$()` so the suffix is kept.
|
||||
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex);
|
||||
|
||||
// Compile the evaluation in isolation.
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
if (hasCompiledNonEmptyEvaluation === false) {
|
||||
return cashAssemblyText;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
return textWithCompiledEvaluations;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { generateBytecodeMap, IdentifierResolutionType, OpcodesBchSpec } from '@bitauth/libauth';
|
||||
import { CashAssemblyIdentifierCollisionError } from './errors.ts';
|
||||
import type { CompileCashAssemblyContext } from './types.ts';
|
||||
|
||||
/**
|
||||
* Set of opcode names that the compiler recognizes.
|
||||
*/
|
||||
const COMPILER_OPCODE_NAMES: ReadonlySet<string> = new Set(Object.keys(generateBytecodeMap(OpcodesBchSpec)));
|
||||
|
||||
/**
|
||||
* Parameters for {@link assertNoIdentifierCollisions} function.
|
||||
*/
|
||||
export type AssertNoIdentifierCollisionsParameters = Pick<CompileCashAssemblyContext, 'variables' | 'templateScripts'>;
|
||||
|
||||
/**
|
||||
* Libauth resolves opcodes, then variables, then scripts, and the first match silently hides the
|
||||
* rest, leading to unexpected compilation results and no errors. This function throws when one identifier matches more than one resolution type.
|
||||
*
|
||||
* @param {AssertNoIdentifierCollisionsParameters} parameters - Provided variables and optional template scripts.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches more than one resolution type.
|
||||
*/
|
||||
export const assertNoIdentifierCollisions = (parameters: AssertNoIdentifierCollisionsParameters): void => {
|
||||
const { variables, templateScripts } = parameters;
|
||||
|
||||
// Create a set of the script identifiers from the template scripts map.
|
||||
const scriptIdentifiers = new Set(Object.keys(templateScripts ?? {}));
|
||||
|
||||
const variableNames = new Set(Object.keys(variables));
|
||||
|
||||
// Combine both, the variableNames and the scriptIdentifiers, to get a set of all identifiers.
|
||||
const declaredIdentifiers = new Set([ ...variableNames, ...scriptIdentifiers ]);
|
||||
|
||||
// This loop will check for more than one occurrence of an identifier in OP_CODES, variables, and scripts
|
||||
for (const identifier of declaredIdentifiers) {
|
||||
// Resolution types is a native construct from libauth.
|
||||
const resolutionTypes: IdentifierResolutionType[] = [];
|
||||
|
||||
// If the identifier is an opcode, then push it to the resolution types array, this will also be
|
||||
// detected by either or both of the variableNames and scriptIdentifiers checks.
|
||||
if (COMPILER_OPCODE_NAMES.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.opcode);
|
||||
}
|
||||
|
||||
// If the identifier is in variables, then push it to the resolution types array.
|
||||
if (variableNames.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.variable);
|
||||
}
|
||||
|
||||
// If the identifier is a script identifier, then push it to the resolution types array.
|
||||
if (scriptIdentifiers.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.script);
|
||||
}
|
||||
|
||||
// If there are more than one resolution types, then throw an error.
|
||||
if (resolutionTypes.length > 1) {
|
||||
throw new CashAssemblyIdentifierCollisionError(identifier, resolutionTypes);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './evaluations.ts';
|
||||
export * from './primitive-evaluations.ts';
|
||||
export * from './bytes.ts';
|
||||
export * from './defaults.ts';
|
||||
export { collectVariablesUsingParseScript } from './collect-evaluations.ts';
|
||||
export * from './errors.ts';
|
||||
export { compileCashAssemblyString } from './evaluations.ts';
|
||||
export { extractCashAssemblyEvaluations, isCashAssemblyExpression } from './scan-evaluations.ts';
|
||||
export type { CompiledCashAssemblyDecodeMode, CompileCashAssemblyStringParameters } from './types.ts';
|
||||
|
||||
@@ -16,10 +16,10 @@ 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.
|
||||
* Maps method resolvable template types to `@xo-cash/primitives` classes for `base.method` resolution.
|
||||
* Keys are a subset of {@link XOTemplatePrimitiveTypes}.
|
||||
*/
|
||||
const PRIMITIVE_BY_TEMPLATE_HINT = {
|
||||
const RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE = {
|
||||
[XOTemplatePrimitiveTypes.FUNGIBLE_TOKEN_AMOUNT]: FungibleTokenAmount,
|
||||
[XOTemplatePrimitiveTypes.NFT_COMMITMENT]: NFTCommitment,
|
||||
[XOTemplatePrimitiveTypes.PUBLIC_KEY]: PublicKey,
|
||||
@@ -31,7 +31,11 @@ const PRIMITIVE_BY_TEMPLATE_HINT = {
|
||||
[XOTemplatePrimitiveTypes.TRANSACTION_HASH]: TransactionHash,
|
||||
} as const;
|
||||
|
||||
type SupportedPrimitiveHint = keyof typeof PRIMITIVE_BY_TEMPLATE_HINT;
|
||||
/**
|
||||
* Template primitive types that map to an `@xo-cash/primitives` class for `base.method` resolution.
|
||||
* This is a subset of {@link XOTemplatePrimitiveType}, not every declared template type.
|
||||
*/
|
||||
type ResolvablePrimitiveType = keyof typeof RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE;
|
||||
|
||||
/**
|
||||
* Inputs needed to call a primitive method for a `name.method` push.
|
||||
@@ -54,21 +58,21 @@ type CallPrimitiveMethodParameters = {
|
||||
value: unknown;
|
||||
|
||||
/**
|
||||
* Supported template hint that selects the primitive class.
|
||||
* Method resolvable template type that selects the primitive class.
|
||||
*/
|
||||
hint: SupportedPrimitiveHint;
|
||||
type: ResolvablePrimitiveType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
|
||||
* Inputs needed to resolve primitive method pushes from collected CashAssembly variable names.
|
||||
*/
|
||||
export type ResolvePrimitiveMethodBytesParameters = {
|
||||
|
||||
/**
|
||||
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
|
||||
* Variable names from {@link collectVariablesUsingParseScript}, for example
|
||||
* `['amount.toSatoshis', 'fee.toSatoshis']`.
|
||||
*/
|
||||
identifiers: string[];
|
||||
variableNames: string[];
|
||||
|
||||
/**
|
||||
* Variable names and values object.
|
||||
@@ -77,30 +81,30 @@ export type ResolvePrimitiveMethodBytesParameters = {
|
||||
|
||||
/**
|
||||
* Template variable definitions. When omitted, no primitive methods are resolved.
|
||||
* The `hint` on each entry selects the primitive class.
|
||||
* The `type` 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`.
|
||||
* Returns true when `type` maps to a primitive class in `RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE` for method resolution.
|
||||
*
|
||||
* @param {XOTemplatePrimitiveType | undefined} hint - Template variable hint.
|
||||
* @returns {boolean} True when the hint selects a supported primitive class.
|
||||
* @param {XOTemplatePrimitiveType | undefined} type - Template variable type.
|
||||
* @returns {boolean} True when the type can resolve `base.method` through a primitive class.
|
||||
*/
|
||||
const isSupportedPrimitiveHint = (hint: XOTemplatePrimitiveType | undefined): hint is SupportedPrimitiveHint => {
|
||||
return hint !== undefined && Object.hasOwn(PRIMITIVE_BY_TEMPLATE_HINT, hint) === true;
|
||||
const isResolvablePrimitiveType = (type: XOTemplatePrimitiveType | undefined): type is ResolvablePrimitiveType => {
|
||||
return type !== undefined && Object.hasOwn(RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE, type) === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when `methodName` is an own function on the primitive class for `hint`.
|
||||
* Returns true when `methodName` is an own function on the primitive class for `type`.
|
||||
*
|
||||
* @param {SupportedPrimitiveHint} hint - Supported template hint.
|
||||
* @param {ResolvablePrimitiveType} type - Method resolvable template type.
|
||||
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
|
||||
* @returns {boolean} True when that class exposes the named method.
|
||||
* @returns {boolean} - True when that class exposes the named method.
|
||||
*/
|
||||
const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: string): boolean => {
|
||||
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
|
||||
const canResolvePrimitiveMethod = (type: ResolvablePrimitiveType, methodName: string): boolean => {
|
||||
const PrimitiveClass = RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE[type];
|
||||
|
||||
// Check own properties only so inherited Object.prototype names are rejected without needing a value.
|
||||
if (Object.hasOwn(PrimitiveClass.prototype, methodName) === false) {
|
||||
@@ -113,17 +117,17 @@ const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: str
|
||||
/**
|
||||
* 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.
|
||||
* Call only after `canResolvePrimitiveMethod` is true for the same type and method.
|
||||
*
|
||||
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and supported hint.
|
||||
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and resolvable type.
|
||||
* @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 { identifier, methodName, value, type } = parameters;
|
||||
|
||||
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
|
||||
const PrimitiveClass = RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE[type];
|
||||
|
||||
// Constructing runs each primitive's own input validation (range checks, hex length, etc).
|
||||
// `as never` satisfies TypeScript across constructors that accept different input shapes.
|
||||
@@ -133,45 +137,45 @@ const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown
|
||||
const primitiveMethod = Reflect.get(PrimitiveClass.prototype, methodName);
|
||||
|
||||
if (typeof primitiveMethod !== 'function') {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, type);
|
||||
}
|
||||
|
||||
return primitiveMethod.call(primitiveInstance);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves supported `base.method` identifiers to CashAssembly variable bytes.
|
||||
* Resolves method resolvable `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.
|
||||
* Each single dot identifier whose `type` is in `RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE` is resolved and stored under
|
||||
* the full identifier (`base.method`). Other template types 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 CashAssemblyPrimitiveMethodMissingError} When the type is method resolvable 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;
|
||||
const { variableNames, templateVariables, variables } = parameters;
|
||||
|
||||
// Without template metadata there is no hint to select a primitive class.
|
||||
// Without template metadata there is no type 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) {
|
||||
for (const variableName of variableNames) {
|
||||
// The same name can appear more than once. Resolve it only once.
|
||||
if (Object.hasOwn(resolvedBytes, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the primitive method reference in the identifier.
|
||||
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
// Find the primitive method reference in the name.
|
||||
const methodReferenceMatch = variableName.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
|
||||
if (methodReferenceMatch === null) {
|
||||
continue;
|
||||
@@ -179,37 +183,38 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
|
||||
|
||||
const [ , baseName, methodName ] = methodReferenceMatch;
|
||||
|
||||
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
|
||||
// Unknown names 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;
|
||||
const type = templateVariables[baseName].type;
|
||||
|
||||
if (isSupportedPrimitiveHint(hint) === false) {
|
||||
// Template types without a mapped primitive class are left for CashAssembly.
|
||||
if (isResolvablePrimitiveType(type) === 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);
|
||||
// Method resolvable type with an unknown method should throw.
|
||||
if (canResolvePrimitiveMethod(type, methodName) === false) {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(variableName, methodName, type);
|
||||
}
|
||||
|
||||
// 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);
|
||||
throw new CashAssemblyPrimitiveVariableMissingError(variableName, baseName);
|
||||
}
|
||||
|
||||
const methodResult = callPrimitiveMethod({
|
||||
identifier,
|
||||
identifier: variableName,
|
||||
methodName,
|
||||
value: variables[baseName],
|
||||
hint,
|
||||
type,
|
||||
});
|
||||
|
||||
// Store the result under identifier.
|
||||
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
|
||||
// CashAssembly looks up the full `base.method` name as WalletData bytes.
|
||||
resolvedBytes[variableName] = convertValueToBytes(methodResult, variableName);
|
||||
}
|
||||
|
||||
return resolvedBytes;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH,
|
||||
CASHASSEMBLY_COMMENT_START_LENGTH,
|
||||
CASHASSEMBLY_EVALUATION_START_LENGTH,
|
||||
EMPTY_CASHASSEMBLY_EVALUATION,
|
||||
} from './defaults.ts';
|
||||
import { CashAssemblyBlockCommentUnclosedError, CashAssemblyEvaluationUnclosedError, CashAssemblyQuotedLiteralUnclosedError } from './errors.ts';
|
||||
|
||||
/**
|
||||
* Skips a quoted CashAssembly string so a `)` inside it cannot end `$()`.
|
||||
*
|
||||
* CashAssembly UTF8 literals use `"..."` or `'...'`. Everything between the quotes is payload,
|
||||
* including parentheses.
|
||||
* For example `$(<"hello)world">)` must close after the literal, not at the `)` inside the quotes.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the quoted literal.
|
||||
* @param {number} openingQuoteIndex - Index of the opening `"` or `'`.
|
||||
* @returns {number} - Index of the character after the closing quote.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When the quoted literal never closes.
|
||||
*/
|
||||
const skipQuotedCashAssemblyLiteral = (cashAssemblyText: string, openingQuoteIndex: number): number => {
|
||||
const quoteCharacter = cashAssemblyText[openingQuoteIndex];
|
||||
let currentIndex = openingQuoteIndex + 1;
|
||||
|
||||
// CashAssembly puts payload `)` inside the quotes. Stop only at the matching quote.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === quoteCharacter) {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
throw new CashAssemblyQuotedLiteralUnclosedError(openingQuoteIndex, quoteCharacter, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function skips a `//` comment so a `)` written in the comment cannot end `$()`.
|
||||
* A line comment runs from `//` to the newline, or to the end of the string when there is no newline.
|
||||
* A `)` in that span is comment text, not the closer of `$()`.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the line comment.
|
||||
* @param {number} commentStartIndex - Index of the first `/` of `//`.
|
||||
* @returns {number} - Index after the newline that ends the comment, or the end of the string if there is no newline.
|
||||
*/
|
||||
const skipSingleLineCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
|
||||
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
|
||||
|
||||
// CashAssembly line comments hide `)` until the newline. End of string also ends them.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === '\n') {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function skips a block comment so a `)` written in the comment cannot end `$()`.
|
||||
* A `)` inside a block comment is comment text, not the closer of `$()`.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the block comment.
|
||||
* @param {number} commentStartIndex - Index of the first slash of the block comment opener.
|
||||
* @returns {number} - Index of the character after the comment closer.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When the block comment never closes.
|
||||
*/
|
||||
const skipBlockCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
|
||||
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
|
||||
|
||||
// CashAssembly block comments hide `)` until the closer is found.
|
||||
while (currentIndex + 1 < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === '*' && cashAssemblyText[currentIndex + 1] === '/') {
|
||||
return currentIndex + CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
throw new CashAssemblyBlockCommentUnclosedError(commentStartIndex, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parenthesis matching with a depth count.
|
||||
*
|
||||
* Returns the index of the `)` that closes the `$()` starting at `evaluationStartIndex`.
|
||||
* Nested `$()` raise the depth so an inner closer cannot finish the outer evaluation.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the evaluation.
|
||||
* @param {number} evaluationStartIndex - Index of the `$` that opens this evaluation.
|
||||
* @returns {number} - Index of the matching closing parenthesis.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When the evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal inside the evaluation never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment inside the evaluation never closes.
|
||||
*/
|
||||
const findCashAssemblyEvaluationCloseIndex = (cashAssemblyText: string, evaluationStartIndex: number): number => {
|
||||
let currentIndex = evaluationStartIndex + CASHASSEMBLY_EVALUATION_START_LENGTH;
|
||||
let remainingOpenEvaluations = 1;
|
||||
|
||||
// Scan the evaluation contents until the matching closer at depth zero.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
const currentCharacter = cashAssemblyText[currentIndex];
|
||||
const nextCharacter = cashAssemblyText[currentIndex + 1];
|
||||
|
||||
// CashAssembly allows `)` inside `"..."` and `'...'`. Skip the literal so that closer is not structural.
|
||||
if (currentCharacter === '"' || currentCharacter === "'") {
|
||||
currentIndex = skipQuotedCashAssemblyLiteral(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CashAssembly `//` comments may mention `)`. Skip them so that `)` is not treated as a closer.
|
||||
if (currentCharacter === '/' && nextCharacter === '/') {
|
||||
currentIndex = skipSingleLineCashAssemblyComment(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CashAssembly block comments may mention `)`. Skip them so that `)` is not treated as a closer.
|
||||
if (currentCharacter === '/' && nextCharacter === '*') {
|
||||
currentIndex = skipBlockCashAssemblyComment(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nested `$()` must close before this evaluation can close.
|
||||
if (currentCharacter === '$' && nextCharacter === '(') {
|
||||
// Since all previous checks have passed then it means this is start of another evaluation, it we must
|
||||
// increment the remainingOpenEvaluations counter and move the currentIndex to the next character.
|
||||
remainingOpenEvaluations += 1;
|
||||
currentIndex += CASHASSEMBLY_EVALUATION_START_LENGTH;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A closer at depth one ends this evaluation. Inner closers only reduce nested depth.
|
||||
if (currentCharacter === ')') {
|
||||
// Since all previous checks have passed then it means this is a closer for the current evaluation, we must
|
||||
// decrement the remainingOpenEvaluations counter and check if it is now zero. If it is then we have found the
|
||||
// matching closer and we can return the currentIndex.
|
||||
remainingOpenEvaluations -= 1;
|
||||
|
||||
if (remainingOpenEvaluations === 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currentIndex to the next character.
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
// The matching closer was never found.
|
||||
throw new CashAssemblyEvaluationUnclosedError(evaluationStartIndex, remainingOpenEvaluations, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* One `$()` found in text that is not nested inside another `$()`.
|
||||
*/
|
||||
type CashAssemblyEvaluationScanMatch = {
|
||||
|
||||
/**
|
||||
* Complete `$()` text including the opening `$` and the matching closer.
|
||||
*/
|
||||
evaluationText: string;
|
||||
|
||||
/**
|
||||
* Index of the `$` that opens this evaluation.
|
||||
*/
|
||||
startIndex: number;
|
||||
|
||||
/**
|
||||
* Index of the matching closing parenthesis.
|
||||
*/
|
||||
closeIndex: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scans text and returns every `$()` that starts in this string, including empty `$()`.
|
||||
*
|
||||
* Nested `$()` stay inside the outer evaluation.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that may contain `$()`.
|
||||
* @returns {CashAssemblyEvaluationScanMatch[]} - Evaluations in left to right order.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const scanCashAssemblyEvaluations = (cashAssemblyText: string): CashAssemblyEvaluationScanMatch[] => {
|
||||
const scannedEvaluations: CashAssemblyEvaluationScanMatch[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
// Find `$()` that starts in this string. Nested evaluations stay inside the outer evaluation.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
const currentCharacter = cashAssemblyText[currentIndex];
|
||||
const nextCharacter = cashAssemblyText[currentIndex + 1];
|
||||
|
||||
// A `$` that is followed by `(`.
|
||||
if (currentCharacter === '$' && nextCharacter === '(') {
|
||||
// Count parentheses so quotes, comments, and nested `$()` cannot close this evaluation early.
|
||||
const closeIndex = findCashAssemblyEvaluationCloseIndex(cashAssemblyText, currentIndex);
|
||||
// Slice the complete `$()` so later steps do not re-scan these characters.
|
||||
const evaluationText = cashAssemblyText.slice(currentIndex, closeIndex + 1);
|
||||
|
||||
// Store the outer `$()`. Nested evaluations are already inside evaluationText.
|
||||
scannedEvaluations.push({
|
||||
closeIndex,
|
||||
evaluationText,
|
||||
startIndex: currentIndex,
|
||||
});
|
||||
|
||||
// Skip to after this `$()` so nested evaluations are not scanned as their own matches.
|
||||
currentIndex = closeIndex + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return scannedEvaluations;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns each `$()` that starts in this string.
|
||||
* Empty `$()` is omitted.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that may contain `$()`.
|
||||
* @returns {string[]} - Complete non empty evaluation strings in left to right order.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const extractCashAssemblyEvaluations = (cashAssemblyText: string): string[] => {
|
||||
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
|
||||
const evaluations: string[] = [];
|
||||
|
||||
// Omit empty `$()` so callers that compile extracted text do not treat it as an evaluation.
|
||||
for (const scannedEvaluation of scannedEvaluations) {
|
||||
if (scannedEvaluation.evaluationText !== EMPTY_CASHASSEMBLY_EVALUATION) {
|
||||
evaluations.push(scannedEvaluation.evaluationText);
|
||||
}
|
||||
}
|
||||
|
||||
return evaluations;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns true when the value is exactly one CashAssembly `$()`.
|
||||
* Text with characters outside `$()`, empty `$()`, or a failed scan returns false.
|
||||
*
|
||||
* @param {unknown} expression - Value to test.
|
||||
* @returns {boolean} - True when the value is a string that is one complete evaluation and nothing else.
|
||||
*/
|
||||
export const isCashAssemblyExpression = (expression: unknown): boolean => {
|
||||
// Numbers and other types are never CashAssembly evaluations.
|
||||
if (typeof expression !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const evaluations = extractCashAssemblyEvaluations(expression);
|
||||
|
||||
// If there is only one evaluation extracted and the returned value matches to that of the expression passed in the parameter,
|
||||
// then return true.
|
||||
return evaluations.length === 1 && evaluations[0] === expression;
|
||||
} catch {
|
||||
// A failed scan is not a complete evaluation.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Supported decode modes for compiled CashAssembly evaluation bytes.
|
||||
*/
|
||||
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
|
||||
|
||||
/**
|
||||
* Template script map passed into CashAssembly compilation so evaluations can resolve bare script ids.
|
||||
*/
|
||||
export type CashAssemblyTemplateScripts = NonNullable<XOTemplate['scripts']>;
|
||||
|
||||
/**
|
||||
* WalletData and template context shared by CashAssembly compilation.
|
||||
*/
|
||||
export type CompileCashAssemblyContext = {
|
||||
|
||||
/**
|
||||
* Runtime WalletData keyed by variable name.
|
||||
* {@link compileCashAssemblySourceToBytes} collects required names with
|
||||
* {@link collectVariablesUsingParseScript} then looks them up here for
|
||||
* primitive method resolution and CashAssembly compilation. A collected name that is absent throws
|
||||
* {@link CashAssemblyRequiredVariableMissingError}. Missing keys are never replaced with defaults.
|
||||
*/
|
||||
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
|
||||
|
||||
/**
|
||||
* 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'] | undefined;
|
||||
|
||||
/**
|
||||
* Named CashAssembly scripts from {@link XOTemplate.scripts}.
|
||||
* Each key is a script id such as `scriptA`. Each value is that script's source.
|
||||
* Pass this when compilation refers to those ids, for example `$(scriptA)`, `scriptA`, or
|
||||
* `scriptA scriptB OP_ADD`. The compiler looks up those ids here. The visitor collects
|
||||
* WalletData from those sources and from nested ids.
|
||||
* When omitted, named scripts are not given to the compiler and only WalletData pushes in the
|
||||
* starting text are collected.
|
||||
*/
|
||||
templateScripts?: CashAssemblyTemplateScripts | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling CashAssembly text.
|
||||
*/
|
||||
export type CompileCashAssemblyStringParameters = CompileCashAssemblyContext & {
|
||||
|
||||
/**
|
||||
* Text to compile, which may contain `$()`.
|
||||
*
|
||||
* When this text contains `$()`, each CashAssembly evaluation is compiled and replaced with its
|
||||
* decoded payload. Surrounding text is kept.
|
||||
* When this text contains no `$()`, the entire string is compiled as CashAssembly so push
|
||||
* opcodes remain.
|
||||
*/
|
||||
cashAssemblyText: string;
|
||||
|
||||
/**
|
||||
* The mode to decode compiled bytecode into a string.
|
||||
*/
|
||||
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling one CashAssembly source string to bytecode.
|
||||
*/
|
||||
export type CompileCashAssemblySourceToBytesParameters = CompileCashAssemblyContext & {
|
||||
|
||||
/**
|
||||
* Evaluation or CashAssembly source passed to the compiler as a script id.
|
||||
*/
|
||||
cashAssemblySource: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling one CashAssembly source string to decoded text.
|
||||
*/
|
||||
export type CompileCashAssemblySourceToDecodedTextParameters = CompileCashAssemblySourceToBytesParameters & {
|
||||
|
||||
/**
|
||||
* The mode to decode compiled bytecode into a string.
|
||||
*/
|
||||
evaluationDecodeMode: CompiledCashAssemblyDecodeMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for preparing a Libauth compiler for CashAssembly source strings.
|
||||
*/
|
||||
export type CompileCashAssemblyEvaluationsParameters = {
|
||||
|
||||
/**
|
||||
* CashAssembly source strings such as `$(<var1>)` or `scriptA`.
|
||||
*/
|
||||
evaluations: string[];
|
||||
|
||||
/**
|
||||
* Optional template scripts so nested ids compile to the template sources.
|
||||
*/
|
||||
templateScripts?: CashAssemblyTemplateScripts | undefined;
|
||||
|
||||
/**
|
||||
* WalletData names already collected with {@link collectVariablesUsingParseScript}.
|
||||
* This function does not collect again.
|
||||
*/
|
||||
variableNames: string[];
|
||||
};
|
||||
+22
-73
@@ -1,85 +1,34 @@
|
||||
/**
|
||||
* Error thrown when a response body is null
|
||||
*/
|
||||
export class ResponseBodyNullError extends Error {
|
||||
constructor() {
|
||||
super('Response body is null');
|
||||
this.name = 'ResponseBodyNullError';
|
||||
}
|
||||
}
|
||||
import type { $ZodIssue } from 'zod/v4/core';
|
||||
|
||||
/**
|
||||
* Error thrown when an HTTP error occurs
|
||||
* Formats the Zod validation failures into a single string with one line each: "- <field>: <message>" and top level failures
|
||||
* with no field path show as "(root)" for better readability.
|
||||
*
|
||||
* @param issues The Zod validation failures to format.
|
||||
* @returns A human readable error string for better debugging.
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
constructor(status: number, message: string) {
|
||||
super(`HTTP error! Status: ${status} - ${message}`);
|
||||
this.name = 'HTTPError';
|
||||
}
|
||||
}
|
||||
export const buildErrorDescription = (issues: $ZodIssue[]): string => {
|
||||
// Initialize an empty array to store the formatted lines.
|
||||
const lines: string[] = [];
|
||||
|
||||
/**
|
||||
* Error thrown when the maximum number of retries is hit in an exponential backoff
|
||||
*/
|
||||
export class ExponentialBackoffMaxRetriesHitError extends Error {
|
||||
constructor(errors: Array<Error>) {
|
||||
super('Exponential backoff: Max retries hit', { cause: errors });
|
||||
this.name = 'ExponentialBackoffMaxRetriesHitError';
|
||||
}
|
||||
}
|
||||
// Iterate over the issues and format them into a string.
|
||||
for (const issue of issues) {
|
||||
// Get the issue path.
|
||||
const issuePath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
||||
|
||||
/**
|
||||
* Error thrown when the exponential backoff retries are stopped
|
||||
*/
|
||||
export class ExponentialBackoffStoppedRetriesError extends Error {
|
||||
constructor(reason: unknown) {
|
||||
// Convert the reason to an error if it is not an error
|
||||
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
|
||||
// The prefix that Zod adds to messages.
|
||||
const messagePrefix = 'Invalid input: ';
|
||||
|
||||
super(`Exponential backoff was aborted: "${reasonError.message}"`, { cause: reasonError });
|
||||
this.name = 'ExponentialBackoffStoppedRetriesError';
|
||||
}
|
||||
}
|
||||
// Remove the prefix for better readability.
|
||||
const issueMessage = issue.message.startsWith(messagePrefix) ? issue.message.slice(messagePrefix.length) : issue.message;
|
||||
|
||||
/**
|
||||
* 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';
|
||||
// Add the formatted line to the array.
|
||||
lines.push(`- ${issuePath}: ${issueMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
// Return the formatted string.
|
||||
return `\n${lines.join('\n')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Error thrown when a waitFor timeout is reached
|
||||
|
||||
+31
-25
@@ -1,18 +1,18 @@
|
||||
import type { DeeplyReadonly } from './types.ts';
|
||||
import type { deepFreeze } from './misc.ts';
|
||||
|
||||
import { WaitForTimeoutError } from './errors.ts';
|
||||
import { deepFreeze } from './misc.ts';
|
||||
|
||||
export type EventMap = Record<string, unknown>;
|
||||
|
||||
type Listener<T> = (detail: DeeplyReadonly<T>) => void;
|
||||
type Listener<T> = (detail: 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(detail: T): void;
|
||||
}['bivarianceHack'];
|
||||
|
||||
/**
|
||||
@@ -49,10 +49,10 @@ export class EventEmitter<T extends EventMap> {
|
||||
* @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);
|
||||
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;
|
||||
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)) {
|
||||
@@ -81,16 +81,16 @@ export class EventEmitter<T extends EventMap> {
|
||||
* @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]>) => {
|
||||
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
|
||||
this.off(type, listener);
|
||||
listener(detail);
|
||||
};
|
||||
|
||||
// Create a cancellable listener.
|
||||
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener);
|
||||
const { cancel, listener: cancellableListener } = this.#cancellable(wrappedListener);
|
||||
|
||||
// Create a debounced listener.
|
||||
const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
|
||||
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)) {
|
||||
@@ -141,6 +141,13 @@ export class EventEmitter<T extends EventMap> {
|
||||
|
||||
/**
|
||||
* Emit an event.
|
||||
*
|
||||
* @remarks The caller is responsible for ensuring the payload suits the intended mutability requirements.
|
||||
* By default, the payload will be mutable, so listeners may mutate the payload, effecting both
|
||||
* the original object and the other listeners.
|
||||
* To prevent this, the caller can use the {@link Object.freeze} or {@link deepFreeze} function to freeze the payload.
|
||||
* This will need to be defined in the EventMap using the built-in {@link Readonly} type or the provided {@link DeeplyReadonly} type.
|
||||
*
|
||||
* @param type - The event type.
|
||||
* @param payload - The event payload.
|
||||
* @returns True if there are listeners for the event, false otherwise.
|
||||
@@ -150,16 +157,10 @@ export class EventEmitter<T extends EventMap> {
|
||||
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);
|
||||
entry.wrappedListener(payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -187,11 +188,7 @@ export class EventEmitter<T extends EventMap> {
|
||||
* @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]>> {
|
||||
async waitFor<K extends keyof T>(type: K, predicate: (payload: T[K]) => boolean, timeoutMs?: number): Promise<T[K]> {
|
||||
// Create a promise to wait for the event to be emitted.
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -208,7 +205,7 @@ export class EventEmitter<T extends EventMap> {
|
||||
};
|
||||
|
||||
// Create a listener function.
|
||||
const listener = (payload: DeeplyReadonly<T[K]>): void => {
|
||||
const listener = (payload: T[K]): void => {
|
||||
try {
|
||||
// If the event payload does not match the predicate condition, return.
|
||||
if (!predicate(payload)) {
|
||||
@@ -238,15 +235,19 @@ export class EventEmitter<T extends EventMap> {
|
||||
|
||||
/**
|
||||
* Debounce a function.
|
||||
*
|
||||
* @remarks If {@link off} is called on a listener while it is debounced, the timeout is not cleared with clearTimeout.
|
||||
* Instead, the function is no-oped.
|
||||
*
|
||||
* @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]> {
|
||||
#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]>) => {
|
||||
return (detail: T[K]) => {
|
||||
// If a debounce timer is already pending, clear it before scheduling the next one.
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
@@ -258,12 +259,17 @@ export class EventEmitter<T extends EventMap> {
|
||||
};
|
||||
}
|
||||
|
||||
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
|
||||
/**
|
||||
* Make a function cancellable.
|
||||
* @param func - The function to make cancelable.
|
||||
* @returns The cancellable function with a cancel method.
|
||||
*/
|
||||
#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 => {
|
||||
listener: (detail: T[K]): void => {
|
||||
if (cancelled) return;
|
||||
func(detail);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Error thrown when the maximum number of retries is hit in an exponential backoff
|
||||
*/
|
||||
export class ExponentialBackoffMaxRetriesHitError extends Error {
|
||||
constructor(errors: Array<Error>) {
|
||||
super('Exponential backoff: Max retries hit', { cause: errors });
|
||||
this.name = 'ExponentialBackoffMaxRetriesHitError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when the exponential backoff retries are stopped
|
||||
*/
|
||||
export class ExponentialBackoffStoppedRetriesError extends Error {
|
||||
constructor(reason: unknown) {
|
||||
// Convert the reason to an error if it is not an error
|
||||
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
|
||||
|
||||
super(`Exponential backoff was aborted: "${reasonError.message}"`, { cause: reasonError });
|
||||
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 an externally aborted exponential backoff is aborted
|
||||
* due to the external abort signal that was passed in to the constructor being aborted by an upstream consumer
|
||||
*/
|
||||
export class ExternallyAbortedExponentialBackoffExternalSignalAbortedError extends Error {
|
||||
constructor(reason: unknown) {
|
||||
// Convert the reason to an error if it is not an error
|
||||
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
|
||||
|
||||
super(`Externally aborted exponential backoff: "${reasonError.message}"`, { cause: reasonError });
|
||||
this.name = 'ExternallyAbortedExponentialBackoffExternalSignalAbortedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an externally aborted exponential backoff is aborted
|
||||
* due to the internal abort signal being aborted using the .abort() method
|
||||
*/
|
||||
export class ExternallyAbortedExponentialBackoffInternalSignalAbortedError extends Error {
|
||||
constructor(reason: unknown) {
|
||||
// Convert the reason to an error if it is not an error
|
||||
const reasonError = reason instanceof Error ? reason : new Error(`${reason}`);
|
||||
|
||||
super(`Externally aborted exponential backoff: "${reasonError.message}"`, { cause: reasonError });
|
||||
this.name = 'ExternallyAbortedExponentialBackoffInternalSignalAbortedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ExternallyAbortedExponentialBackoffExternalSignalAbortedError,
|
||||
ExternallyAbortedExponentialBackoffInternalSignalAbortedError,
|
||||
} from './errors.ts';
|
||||
import { ExponentialBackoff } from './exponential-backoff.ts';
|
||||
import type { ExponentialBackoffCallbackParameters, ExponentialBackoffOptions, ExponentialBackoffRunOptions } from './exponential-backoff.ts';
|
||||
|
||||
/**
|
||||
* Options for the ExponentialBackoffExternallyAbortable class
|
||||
*
|
||||
* @extends Partial<ExponentialBackoffOptions>
|
||||
* @property abortSignal - The abort signal to use for this instance
|
||||
*/
|
||||
export type ExponentialBackoffExternallyAbortableOptions = Partial<ExponentialBackoffOptions> & {
|
||||
abortSignal: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* An exponential backoff that can be stopped by calling `.abort()` or by passing an
|
||||
* `abortSignal` to the constructor.
|
||||
*
|
||||
* @remarks One instance can run many tasks. Aborting it stops retries for every run.
|
||||
*/
|
||||
export class ExponentialBackoffExternallyAbortable {
|
||||
readonly #abortController = new AbortController();
|
||||
readonly #exponentialBackoff: ExponentialBackoff;
|
||||
|
||||
constructor(options: Partial<ExponentialBackoffExternallyAbortableOptions> = {}) {
|
||||
const { abortSignal, ...backoffOptions } = options;
|
||||
|
||||
this.#exponentialBackoff = new ExponentialBackoff(backoffOptions);
|
||||
|
||||
// Listen for the provided abort signal to be aborted
|
||||
abortSignal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason));
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
// Clean up this listener if the internal abort controller is aborted
|
||||
signal: this.#abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
// If the abort signal is already aborted, immediately abort the internal abort controller
|
||||
if (abortSignal?.aborted) {
|
||||
this.#abortController.abort(new ExternallyAbortedExponentialBackoffExternalSignalAbortedError(abortSignal.reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 every attempt fails, an ExponentialBackoffMaxRetriesHitError will be thrown
|
||||
* with all errors from the task function.
|
||||
*
|
||||
* @param taskFn - The function to run
|
||||
* @param options - Options that control this run
|
||||
*
|
||||
* @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
|
||||
* @throws An {@link ExternallyAbortedExponentialBackoffExternalSignalAbortedError}
|
||||
* if the abort signal that was provided during construction is activated
|
||||
* @throws An {@link ExternallyAbortedExponentialBackoffInternalSignalAbortedError}
|
||||
* if {@link ExponentialBackoffExternallyAbortable.abort} is called on this class
|
||||
*
|
||||
* @returns The result of the function
|
||||
*/
|
||||
public run<T>(
|
||||
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
|
||||
options: Partial<ExponentialBackoffRunOptions> = {},
|
||||
): Promise<T> {
|
||||
let signal = this.#abortController.signal;
|
||||
|
||||
// If a signal was provided for this run, compose it with the instance-wide signal
|
||||
if (options.signal !== undefined) {
|
||||
signal = AbortSignal.any([ signal, options.signal ]);
|
||||
}
|
||||
|
||||
// Call the exponential backoff run method with the composed signal
|
||||
return this.#exponentialBackoff.run(taskFn, { ...options, signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops retries for all current and future runs.
|
||||
*
|
||||
* @param reason - The reason for stopping retries
|
||||
*/
|
||||
public abort(reason: unknown): void {
|
||||
this.#abortController.abort(new ExternallyAbortedExponentialBackoffInternalSignalAbortedError(reason));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ExponentialBackoffNumberOutOfBoundsError,
|
||||
ExponentialBackoffNumberNotFiniteError,
|
||||
} from './errors.ts';
|
||||
import { isWithinBounds } from './misc.ts';
|
||||
import { isWithinBounds } from '../misc.ts';
|
||||
|
||||
export type ExponentialBackoffOptions = {
|
||||
|
||||
@@ -53,6 +53,27 @@ export type ExponentialBackoffCallbackParameters = {
|
||||
stopRetries: ExponentialBackoffStopRetriesFunction;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options that control a single exponential-backoff run.
|
||||
*/
|
||||
export type ExponentialBackoffRunOptions = {
|
||||
|
||||
/**
|
||||
* Called after each failed task attempt.
|
||||
*/
|
||||
onError?: ((error: Error) => void) | undefined;
|
||||
|
||||
/**
|
||||
* Stops pending delays and prevents future attempts when aborted.
|
||||
*/
|
||||
signal?: AbortSignal | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options accepted by the static exponential-backoff run helper.
|
||||
*/
|
||||
export type ExponentialBackoffStaticRunOptions = Partial<ExponentialBackoffOptions> & ExponentialBackoffRunOptions;
|
||||
|
||||
/**
|
||||
* Exponential backoff is a technique used to retry a function after a delay.
|
||||
*
|
||||
@@ -117,8 +138,7 @@ export class ExponentialBackoff {
|
||||
* 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
|
||||
* @param options - Backoff configuration and options for this run
|
||||
*
|
||||
* @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
|
||||
@@ -127,12 +147,13 @@ export class ExponentialBackoff {
|
||||
*/
|
||||
public static run<T>(
|
||||
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
|
||||
onError = (_error: Error): void => {},
|
||||
options?: Partial<ExponentialBackoffOptions>,
|
||||
options: Partial<ExponentialBackoffStaticRunOptions> = {},
|
||||
): Promise<T> {
|
||||
const backoff = ExponentialBackoff.from(options);
|
||||
// Grab onError and signal from the options
|
||||
const { onError, signal, ...backoffOptions } = options;
|
||||
const backoff = ExponentialBackoff.from(backoffOptions);
|
||||
|
||||
return backoff.run(taskFn, onError);
|
||||
return backoff.run(taskFn, { onError, signal });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +202,7 @@ export class ExponentialBackoff {
|
||||
// Validate the max attempts
|
||||
assertIsFinite('maxAttempts', options.maxAttempts);
|
||||
assertIsInteger('maxAttempts', options.maxAttempts);
|
||||
assertIsHigherThan('maxAttempts', options.maxAttempts, -1);
|
||||
assertIsHigherThan('maxAttempts', options.maxAttempts, 0);
|
||||
|
||||
// Validate the base delay
|
||||
assertIsFinite('baseDelay', options.baseDelay);
|
||||
@@ -202,10 +223,11 @@ export class ExponentialBackoff {
|
||||
* 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
|
||||
* If every attempt fails, an ExponentialBackoffMaxRetriesHitError will be thrown
|
||||
* with all errors from the task function.
|
||||
*
|
||||
* @param taskFn - The function to run
|
||||
* @param onError - The callback to call when an error occurs
|
||||
* @param options - Options that control this run
|
||||
*
|
||||
* @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
|
||||
@@ -214,12 +236,30 @@ export class ExponentialBackoff {
|
||||
*/
|
||||
public async run<T>(
|
||||
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
|
||||
onError = (_error: Error): void => {},
|
||||
options: ExponentialBackoffRunOptions = {},
|
||||
): Promise<T> {
|
||||
// Initialize an abort signal to allow the task function to be aborted
|
||||
const { onError, signal: suppliedSignal } = options;
|
||||
|
||||
// The task can stop its own retries without owning the signal supplied by the caller.
|
||||
const abortController = new AbortController();
|
||||
const stopRetries = abortController.abort.bind(abortController);
|
||||
|
||||
// Start with the abort controller signal
|
||||
const signals = [ abortController.signal ];
|
||||
|
||||
// If we received a signal from the caller, add it to the signals array
|
||||
if (suppliedSignal !== undefined) {
|
||||
signals.push(suppliedSignal);
|
||||
}
|
||||
|
||||
// Compose the signals together
|
||||
const signal = AbortSignal.any(signals);
|
||||
|
||||
// If the composed signal is already aborted, throw an error
|
||||
if (signal.aborted) {
|
||||
throw new ExponentialBackoffStoppedRetriesError(signal.reason);
|
||||
}
|
||||
|
||||
// Initialize an empty array to store the errors
|
||||
const errors: Error[] = [];
|
||||
|
||||
@@ -238,7 +278,7 @@ export class ExponentialBackoff {
|
||||
} catch (error) {
|
||||
// Store the error in case we fail every attempt
|
||||
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
|
||||
onError(errorInstance);
|
||||
onError?.(errorInstance);
|
||||
|
||||
// If we have unlimited attempts, don't append this to the errors array to prevent a memory leak.
|
||||
if (!unlimitedAttempts) {
|
||||
@@ -247,9 +287,9 @@ export class ExponentialBackoff {
|
||||
}
|
||||
|
||||
// Check if the abort signal has been activated
|
||||
if (abortController.signal.aborted) {
|
||||
if (signal.aborted) {
|
||||
// Throw an error if the abort signal has been activated
|
||||
throw new ExponentialBackoffStoppedRetriesError(abortController.signal.reason);
|
||||
throw new ExponentialBackoffStoppedRetriesError(signal.reason);
|
||||
}
|
||||
|
||||
// Calculate the count for next attempt. Do this now so we can exit before waiting and before running the next attempt.
|
||||
@@ -265,21 +305,19 @@ export class ExponentialBackoff {
|
||||
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
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 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));
|
||||
reject(new ExponentialBackoffStoppedRetriesError(signal.reason));
|
||||
};
|
||||
|
||||
// Handle the timeout
|
||||
const timeoutHandler = (): void => {
|
||||
abortController.signal.removeEventListener('abort', abortHandler);
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
resolve(undefined);
|
||||
};
|
||||
|
||||
@@ -287,7 +325,7 @@ export class ExponentialBackoff {
|
||||
timeout = setTimeout(timeoutHandler, delay);
|
||||
|
||||
// Add the abort handler to the abort signal
|
||||
abortController.signal.addEventListener('abort', abortHandler);
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
});
|
||||
|
||||
attempt++;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './errors.ts';
|
||||
export * from './exponential-backoff.ts';
|
||||
export * from './exponential-backoff-externally-aborted.ts';
|
||||
+3
-10
@@ -1,17 +1,10 @@
|
||||
export * from './errors.ts';
|
||||
export * from './event-emitter.ts';
|
||||
export * from './exponential-backoff.ts';
|
||||
export * from './exponential-backoff/index.ts';
|
||||
export * from './extended-json.ts';
|
||||
export * from './invitation/index.ts';
|
||||
export * from './misc.ts';
|
||||
export * from './script.ts';
|
||||
export * from './sse-session/index.ts';
|
||||
export * from './template/errors.ts';
|
||||
export * from './template/identifier.ts';
|
||||
export * from './template/parser.ts';
|
||||
export * from './template/schemas.ts';
|
||||
export * from './template/index.ts';
|
||||
export * from './cash-assembly/index.ts';
|
||||
export * from './cash-assembly/errors.ts';
|
||||
export * from './cash-assembly/defaults.ts';
|
||||
|
||||
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
|
||||
export { serializeTemplate } from './template/serialization.ts';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Thrown when a value does not satisfy the XOInvitation schema.
|
||||
*/
|
||||
export class InvitationInvalidError extends Error {
|
||||
constructor(details: string) {
|
||||
const defaultMessage = `Invitation invalid, expected a value matching the XOInvitation type, but got: ${details}`;
|
||||
super(defaultMessage);
|
||||
this.name = 'InvitationInvalidError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when invitation share JSON cannot be parsed.
|
||||
*/
|
||||
export class InvitationJsonMalformedError extends Error {
|
||||
/**
|
||||
* Parser failure forwarded from JSON parsing.
|
||||
*/
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string) {
|
||||
const defaultMessage = `Invitation JSON malformed: expected a valid JSON string, but got: ${reason}`;
|
||||
super(defaultMessage);
|
||||
this.name = 'InvitationJsonMalformedError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when invitation serialization fails.
|
||||
*/
|
||||
export class InvitationSerializationFailedError extends Error {
|
||||
/**
|
||||
* Serializer failure forwarded from the serialization path.
|
||||
*/
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string) {
|
||||
const defaultMessage = `Invitation serialization failed: ${reason}`;
|
||||
super(defaultMessage);
|
||||
this.name = 'InvitationSerializationFailedError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './errors.ts';
|
||||
export * from './parser.ts';
|
||||
export * from './schemas.ts';
|
||||
export * from './serialization.ts';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { buildErrorDescription } from '../errors.ts';
|
||||
import { InvitationInvalidError } from './errors.ts';
|
||||
import { xoInvitationSchema } from './schemas.ts';
|
||||
|
||||
/**
|
||||
* Validates an untyped value against the XOInvitation schema and returns it as a typed invitation.
|
||||
*
|
||||
* The schema parser returns a rebuilt copy whose keys follow the schema order. Commit signatures cover the JSON text of
|
||||
* each commit's data, and that text depends on key order, so a rebuilt copy no longer matches its signatures. Only the
|
||||
* value that was received keeps the order it was signed with.
|
||||
*
|
||||
* The received value itself is returned, not a copy, so the key order its commit signatures were made over is kept.
|
||||
*
|
||||
* @param {unknown} invitationValue - Value to validate, such as the output of Extended JSON parsing.
|
||||
* @returns {XOInvitation} - The validated invitation, as the same object that was passed in.
|
||||
* @throws {@link InvitationInvalidError} - When any field is missing, has the wrong type, or is not part of the invitation type.
|
||||
*/
|
||||
export const parseInvitation = (invitationValue: unknown): XOInvitation => {
|
||||
const parseResult = xoInvitationSchema.safeParse(invitationValue);
|
||||
|
||||
if (parseResult.success === false) {
|
||||
throw new InvitationInvalidError(buildErrorDescription(parseResult.error.issues));
|
||||
}
|
||||
|
||||
return invitationValue as XOInvitation;
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
/* eslint-disable @stylistic/newline-per-chained-call */
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { z } from 'zod';
|
||||
import { satoshisSchema, uint8ArraySchema, xoTemplateNftCapabilitySchema } from '../template/schemas.ts';
|
||||
|
||||
/**
|
||||
* Validation schema for a reference to an input or output in an earlier commit that a later item extends.
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* "inputs|outputs": [
|
||||
* {
|
||||
* "mergesWith": { "commitIdentifier": "...", "itemIndex": 0 } ← this schema
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const xoInvitationMergesWithReferenceSchema = z
|
||||
.object({
|
||||
commitIdentifier: z.string().describe('The commit that holds the item being extended.'),
|
||||
itemIndex: z.number().int().nonnegative().describe('The position of the extended item inside that commit.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for the template action a commit is resolved against when it differs from the invitation root.
|
||||
*/
|
||||
export const xoInvitationActionReferenceSchema = z
|
||||
.object({
|
||||
templateIdentifier: z.string().describe('The template that defines the action.'),
|
||||
actionIdentifier: z.string().describe('The action inside that template.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ============================================================
|
||||
// Commit data
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validation schema for a variable value. Matches every value type an invitation variable can hold.
|
||||
*/
|
||||
export const xoInvitationVariableValueSchema = z
|
||||
.union([ z.bigint(), z.boolean(), z.number(), z.string() ])
|
||||
.describe('A variable value expressed as a bigint, boolean, number, or string.');
|
||||
|
||||
/**
|
||||
* Validation schema for a variable supplied by a commit.
|
||||
*/
|
||||
export const xoInvitationVariableSchema = z
|
||||
.object({
|
||||
variableIdentifier: z.string().describe('The template variable this value is for.'),
|
||||
value: xoInvitationVariableValueSchema,
|
||||
roleIdentifier: z.string().exactOptional().describe('The role this variable belongs to.'),
|
||||
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a cashtoken.
|
||||
*/
|
||||
export const outputTokenSchema = z
|
||||
.object({
|
||||
category: uint8ArraySchema.describe('The token category.'),
|
||||
amount: z.bigint().describe('The fungible token amount.'),
|
||||
nft: z
|
||||
.object({
|
||||
capability: xoTemplateNftCapabilitySchema,
|
||||
commitment: uint8ArraySchema.describe('The NFT commitment.'),
|
||||
})
|
||||
.strict()
|
||||
.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a cashtoken supplied by a commit output. Every field is optional because a later commit may
|
||||
* supply the fields an earlier commit left unset.
|
||||
*/
|
||||
export const xoInvitationTokenSchema = z
|
||||
.object({
|
||||
amount: z.bigint().exactOptional().describe('The fungible token amount.'),
|
||||
category: uint8ArraySchema.exactOptional().describe('The token category.'),
|
||||
nft: z
|
||||
.object({
|
||||
capability: xoTemplateNftCapabilitySchema.exactOptional(),
|
||||
commitment: uint8ArraySchema.exactOptional().describe('The NFT commitment.'),
|
||||
})
|
||||
.strict()
|
||||
.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a value one participant resolved from its own template key, such as a public key or a signature.
|
||||
*/
|
||||
export const xoInvitationResolutionSchema = z
|
||||
.object({
|
||||
resolutionIdentifier: z.string().describe('The key operation this value resolves, such as someKey.public_key.'),
|
||||
value: uint8ArraySchema.describe('The resolved bytes.'),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role whose key this value was resolved from.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction input supplied by a commit.
|
||||
*/
|
||||
export const xoInvitationInputSchema = z
|
||||
.object({
|
||||
outpointIndex: z.number().int().nonnegative().exactOptional().describe('The index of the spent output in its transaction.'),
|
||||
outpointTransactionHash: uint8ArraySchema.exactOptional().describe('The hash of the transaction that holds the spent output.'),
|
||||
sequenceNumber: z.number().int().nonnegative().exactOptional().describe('The sequence number of this input.'),
|
||||
unlockingBytecode: uint8ArraySchema.exactOptional().describe('The script that unlocks the spent output.'),
|
||||
inputIdentifier: z.string().exactOptional().describe('The template input this input fills.'),
|
||||
transactionIndex: z.number().int().nonnegative().exactOptional().describe('The position this input must take in the final transaction.'),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role this input belongs to.'),
|
||||
resolutions: z.array(xoInvitationResolutionSchema).exactOptional(),
|
||||
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
|
||||
valueSatoshis: satoshisSchema.exactOptional(),
|
||||
token: outputTokenSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction output supplied by a commit.
|
||||
*/
|
||||
export const xoInvitationOutputSchema = z
|
||||
.object({
|
||||
lockingBytecode: uint8ArraySchema.exactOptional().describe('The locking bytecode of this output.'),
|
||||
valueSatoshis: satoshisSchema.exactOptional(),
|
||||
token: xoInvitationTokenSchema.exactOptional(),
|
||||
outputIdentifier: z.string().exactOptional().describe('The template output this output fills.'),
|
||||
transactionIndex: z.number().int().nonnegative().exactOptional().describe('The position this output must take in the final transaction.'),
|
||||
secretIdentifier: z.string().exactOptional().describe('The secret used to derive this output.'),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role this output belongs to.'),
|
||||
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for the data a commit supplies.
|
||||
*/
|
||||
export const xoInvitationCommitDataSchema = z
|
||||
.object({
|
||||
transactionVersion: z.number().int().nonnegative().exactOptional().describe('The transaction version.'),
|
||||
transactionLocktime: z.number().int().nonnegative().exactOptional().describe('The transaction locktime.'),
|
||||
variables: z.array(xoInvitationVariableSchema).exactOptional(),
|
||||
inputs: z.array(xoInvitationInputSchema).exactOptional(),
|
||||
outputs: z.array(xoInvitationOutputSchema).exactOptional(),
|
||||
resolutions: z.array(xoInvitationResolutionSchema).exactOptional(),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role the committing entity takes in the action with this commit.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for one signed commit in an invitation.
|
||||
*/
|
||||
export const xoInvitationCommitSchema = z
|
||||
.object({
|
||||
commitIdentifier: z.string().describe('The identifier of this commit.'),
|
||||
// The type declares this key on every commit, but serialization drops it on the first commit where it is undefined.
|
||||
// Prefault restores the missing key, in this case it's undefined.
|
||||
previousCommitIdentifier: z.string().optional().prefault(undefined).describe('The commit this one follows. Undefined on the first commit.'),
|
||||
entityIdentifier: z.string().describe('The entity that made this commit.'),
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When this commit was created, as a Unix timestamp in milliseconds.'),
|
||||
actionReference: xoInvitationActionReferenceSchema.exactOptional(),
|
||||
data: xoInvitationCommitDataSchema,
|
||||
signature: z.string().describe('The signature over this commit.'),
|
||||
expiresAtTimestamp: z.number().int().nonnegative().describe('When this commit expires, as a Unix timestamp in milliseconds.'),
|
||||
memo: z.string().exactOptional().describe('A free text note attached to this commit.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ============================================================
|
||||
// Results
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction result that has been created but not broadcast.
|
||||
*/
|
||||
export const xoInvitationTransactionCreatedResultSchema = z
|
||||
.object({
|
||||
transactionHash: z.string().describe('The hash of the created transaction.'),
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When the transaction was created, as a Unix timestamp in milliseconds.'),
|
||||
createdByEntityIdentifier: z.string().describe('The entity that created the transaction.'),
|
||||
createdBySignature: z.string().describe('The signature over the created result.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction result that has been created and broadcast.
|
||||
*/
|
||||
export const xoInvitationTransactionBroadcastedResultSchema = xoInvitationTransactionCreatedResultSchema
|
||||
.extend({
|
||||
broadcastedAtTimestamp: z.number().int().nonnegative().describe('When the transaction was broadcasted, as a Unix timestamp in milliseconds.'),
|
||||
broadcastedByEntityIdentifier: z.string().describe('The entity that broadcasted the transaction.'),
|
||||
broadcastedBySignature: z.string().describe('The signature over the broadcasted result.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a data result created on an invitation.
|
||||
*/
|
||||
export const xoInvitationCreatedResultSchema = z
|
||||
.object({
|
||||
value: xoInvitationVariableValueSchema,
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When the value was created, as a Unix timestamp in milliseconds.'),
|
||||
createdByEntityIdentifier: z.string().describe('The entity that created the value.'),
|
||||
createdBySignature: z.string().describe('The signature over the created value.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for the results created on an invitation.
|
||||
*/
|
||||
export const xoInvitationResultSchema = z
|
||||
.object({
|
||||
transactions: z
|
||||
.record(z.string(), z.union([ xoInvitationTransactionBroadcastedResultSchema, xoInvitationTransactionCreatedResultSchema ]))
|
||||
.exactOptional(),
|
||||
data: z.record(z.string(), xoInvitationCreatedResultSchema).exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ============================================================
|
||||
// Invitation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validation schema for an invitation.
|
||||
*/
|
||||
export const xoInvitationSchema: z.ZodType<XOInvitation> = z
|
||||
.object({
|
||||
invitationIdentifier: z.string().describe('The identifier of this invitation.'),
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When this invitation was created, as a Unix timestamp in milliseconds.'),
|
||||
templateIdentifier: z.string().describe('The template this invitation uses.'),
|
||||
actionIdentifier: z.string().describe('The template action this invitation performs.'),
|
||||
commits: z.array(xoInvitationCommitSchema),
|
||||
result: xoInvitationResultSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { fromExtendedJson, toExtendedJson } from '../extended-json.ts';
|
||||
import { InvitationJsonMalformedError, InvitationSerializationFailedError } from './errors.ts';
|
||||
import { parseInvitation } from './parser.ts';
|
||||
|
||||
/**
|
||||
* Serializes an invitation for sharing. Encodes bigint and Uint8Array fields using Extended JSON.
|
||||
*
|
||||
* @param invitation Shared invitation to export.
|
||||
* @returns JSON string that {@link deserializeInvitation} can restore.
|
||||
* @throws {InvitationSerializationFailedError} When JSON serialization fails.
|
||||
*/
|
||||
export const serializeInvitation = (invitation: XOInvitation): string => {
|
||||
try {
|
||||
return toExtendedJson(invitation);
|
||||
} catch (serializationError) {
|
||||
const reason = serializationError instanceof Error ? serializationError.message : 'unknown error while serializing invitation';
|
||||
|
||||
throw new InvitationSerializationFailedError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deserializes a shared invitation JSON string into an invitation.
|
||||
*
|
||||
* Every field is checked against its declared type, so the returned invitation can be trusted to match
|
||||
* {@link XOInvitation}.
|
||||
*
|
||||
* @param serializedInvitation JSON produced by {@link serializeInvitation}.
|
||||
* @returns Parsed invitation whose fields match their declared types.
|
||||
* @throws {InvitationJsonMalformedError} When the string is not valid JSON.
|
||||
* @throws {InvitationInvalidError} When the parsed value does not match the XOInvitation type.
|
||||
*/
|
||||
export const deserializeInvitation = (serializedInvitation: string): XOInvitation => {
|
||||
let parsedValue: unknown;
|
||||
|
||||
try {
|
||||
parsedValue = fromExtendedJson(serializedInvitation);
|
||||
} catch (parsingError) {
|
||||
const reason = parsingError instanceof Error ? parsingError.message : 'unknown error while deserializing invitation';
|
||||
|
||||
throw new InvitationJsonMalformedError(reason);
|
||||
}
|
||||
|
||||
return parseInvitation(parsedValue);
|
||||
};
|
||||
+29
-33
@@ -1,38 +1,5 @@
|
||||
import type { DeeplyReadonly } from './types';
|
||||
|
||||
/**
|
||||
* Validate the value is within the bounds, returning true if it is within the bounds, false otherwise
|
||||
*
|
||||
* @param value - The value to validate
|
||||
* @param min - The minimum value
|
||||
* @param max - The maximum value
|
||||
*
|
||||
* @returns True if the value is within the bounds, false otherwise
|
||||
*/
|
||||
export const isWithinBounds = (value: number, min: number, max: number): boolean => {
|
||||
if (value < min || value > max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tries to execute an async function and handles any errors that occur.
|
||||
* @param fn - The function to execute.
|
||||
* @param onError - The callback to call if the function fails.
|
||||
* @returns The result of the function.
|
||||
*/
|
||||
export const tryAsync = async (fn: () => unknown, onError?: (error: Error) => void): Promise<void> => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
|
||||
|
||||
onError?.(errorInstance);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively freezes an object by iterating over all properties and freezing them.
|
||||
* @param obj - The object to freeze.
|
||||
@@ -53,3 +20,32 @@ export const deepFreeze = <T>(value: T): DeeplyReadonly<T> => {
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a non-error to an error
|
||||
* @param error - The error to convert
|
||||
* @returns The error
|
||||
*/
|
||||
export const normalizeError = (error: unknown): Error => {
|
||||
// Convert the error to an error if it is not already an error
|
||||
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
|
||||
|
||||
return errorInstance;
|
||||
};
|
||||
|
||||
@@ -32,3 +32,26 @@ export const SSE_TRAILING_NEWLINE_REGEX = /\n$/;
|
||||
* buffered partial lines between streamed chunks before the next parse call.
|
||||
*/
|
||||
export const NEW_LINE = '\n';
|
||||
|
||||
/**
|
||||
* Default exponential backoff options for the SSE session.
|
||||
*/
|
||||
export const SSE_SESSION_EXPONENTIAL_BACKOFF_DEFAULTS = {
|
||||
baseDelay: 250,
|
||||
maxDelay: 10000,
|
||||
maxAttempts: 0,
|
||||
growthRate: 1.3,
|
||||
jitter: 0.3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default attempt reconnect flag for the SSE session.
|
||||
* @default true
|
||||
*/
|
||||
export const SSE_SESSION_ATTEMPT_RECONNECT_DEFAULT = true;
|
||||
|
||||
/**
|
||||
* Default persistent flag for the SSE session.
|
||||
* @default false
|
||||
*/
|
||||
export const SSE_SESSION_PERSISTENT_DEFAULT = false;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Error thrown when a response body is null
|
||||
*/
|
||||
export class ResponseBodyNullError extends Error {
|
||||
constructor() {
|
||||
super('Response body is null');
|
||||
this.name = 'ResponseBodyNullError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an HTTP error occurs
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
constructor(status: number, message: string) {
|
||||
super(`HTTP error! Status: ${status} - ${message}`);
|
||||
this.name = 'HTTPError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a plugin's unsubscribe function is not a function
|
||||
*/
|
||||
export class SSESessionUnsubscribePluginNotAFunctionError extends Error {
|
||||
constructor(pluginName: string) {
|
||||
super(`${pluginName}'s unsubscribe function is not a function`);
|
||||
this.name = 'SSESessionUnsubscribePluginNotAFunctionError';
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export class SSEEventParser {
|
||||
* @returns Zero or more complete parsed SSE events from this chunk.
|
||||
*/
|
||||
public parseEvents(chunk: Uint8Array): SSEvent[] {
|
||||
const lines = this.getBufferedLines(chunk);
|
||||
const lines = this.#getBufferedLines(chunk);
|
||||
|
||||
const eventLines = lines.slice(0, -1);
|
||||
|
||||
@@ -91,7 +91,7 @@ export class SSEEventParser {
|
||||
// A blank line indicates the end of an event. If we have received data, we can complete the event
|
||||
if (line === '') {
|
||||
if (event.data !== undefined) {
|
||||
events.push(this.completeEvent(event));
|
||||
events.push(this.#completeEvent(event));
|
||||
event = {};
|
||||
processedLineCount = index + 1;
|
||||
}
|
||||
@@ -99,10 +99,10 @@ export class SSEEventParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.parseLine(line, event);
|
||||
this.#parseLine(line, event);
|
||||
}
|
||||
|
||||
this.storeRemainingLines(lines, processedLineCount);
|
||||
this.#storeRemainingLines(lines, processedLineCount);
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export class SSEEventParser {
|
||||
* Accepts `\r\n`, `\r`, and `\n` line endings so events parse correctly
|
||||
* regardless of server or platform conventions.
|
||||
*/
|
||||
private getBufferedLines(chunk: Uint8Array): string[] {
|
||||
#getBufferedLines(chunk: Uint8Array): string[] {
|
||||
this.#messageBuffer += this.#textDecoder.decode(chunk, { stream: true });
|
||||
|
||||
return this.#messageBuffer.split(SSE_LINE_ENDINGS);
|
||||
@@ -126,7 +126,7 @@ export class SSEEventParser {
|
||||
* Lines without a colon are ignored. A single optional space after the colon
|
||||
* is stripped from the field value, per the SSE spec.
|
||||
*/
|
||||
private parseLine(line: string, event: Partial<SSEvent>): void {
|
||||
#parseLine(line: string, event: Partial<SSEvent>): void {
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) return;
|
||||
|
||||
@@ -150,7 +150,7 @@ export class SSEEventParser {
|
||||
return;
|
||||
|
||||
case 'retry':
|
||||
this.parseRetry(value, event);
|
||||
this.#parseRetry(value, event);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export class SSEEventParser {
|
||||
*
|
||||
* Non-numeric values are ignored rather than failing the parse.
|
||||
*/
|
||||
private parseRetry(value: string, event: Partial<SSEvent>): void {
|
||||
#parseRetry(value: string, event: Partial<SSEvent>): void {
|
||||
const retry = parseInt(value, 10);
|
||||
|
||||
if (!isNaN(retry)) {
|
||||
@@ -175,7 +175,7 @@ export class SSEEventParser {
|
||||
* Trims a trailing newline from multi-line `data` values so callers receive
|
||||
* the payload without an extra line break at the end.
|
||||
*/
|
||||
private completeEvent(event: Partial<SSEvent>): SSEvent {
|
||||
#completeEvent(event: Partial<SSEvent>): SSEvent {
|
||||
return {
|
||||
...event,
|
||||
data: event.data?.replace(SSE_TRAILING_NEWLINE_REGEX, ''),
|
||||
@@ -188,7 +188,7 @@ export class SSEEventParser {
|
||||
* Only lines that were fully processed (through a completed event boundary)
|
||||
* are discarded; the remainder is re-encoded into {@link messageBuffer}.
|
||||
*/
|
||||
private storeRemainingLines(lines: string[], processedLineCount: number): void {
|
||||
#storeRemainingLines(lines: string[], processedLineCount: number): void {
|
||||
this.#messageBuffer = lines.slice(processedLineCount).join(NEW_LINE);
|
||||
}
|
||||
}
|
||||
|
||||
+306
-149
@@ -1,13 +1,13 @@
|
||||
import type { SSESessionOptions, SSESessionEventMap, SSEvent } from './types.ts';
|
||||
import type { SSESessionOptions, SSESessionEventMap, SSEvent, SSESessionEventCallbacks, SSESessionOperationContext } from './types.ts';
|
||||
import { SSE_SESSION_EXPONENTIAL_BACKOFF_DEFAULTS, SSE_SESSION_ATTEMPT_RECONNECT_DEFAULT, SSE_SESSION_PERSISTENT_DEFAULT } from './constants.ts';
|
||||
|
||||
import { EventEmitter } from '../event-emitter.ts';
|
||||
|
||||
import { HTTPError, ResponseBodyNullError } from '../errors.ts';
|
||||
import { tryAsync } from '../misc.ts';
|
||||
import { ExponentialBackoff } from '../exponential-backoff.ts';
|
||||
import { EventEmitter, type OffCallback } from '../event-emitter.ts';
|
||||
import { ExponentialBackoff } from '../exponential-backoff/index.ts';
|
||||
import { normalizeError } from '../misc.ts';
|
||||
|
||||
import { SSEEventParser } from './sse-event-parser.ts';
|
||||
import { AsyncPushIterator } from './async-push-iterator.ts';
|
||||
import { HTTPError, ResponseBodyNullError, SSESessionUnsubscribePluginNotAFunctionError } from './errors.ts';
|
||||
|
||||
/**
|
||||
* A fetch-based Server-Sent Events (SSE) client with reconnect and optional
|
||||
@@ -38,12 +38,11 @@ import { AsyncPushIterator } from './async-push-iterator.ts';
|
||||
*
|
||||
* - {@link connect} opens (or reopens) the transport. It resolves once the
|
||||
* HTTP stream is established; reading continues in the background.
|
||||
* - {@link abort} stops the in-flight fetch without ending the session.
|
||||
* - {@link disconnect} stops the in-flight fetch without ending the session.
|
||||
* Used internally for tab visibility. The {@link messages} iterator stays
|
||||
* open so an existing consumer resumes when the tab becomes visible again.
|
||||
* - {@link disconnect} aborts the transport, closes {@link messages}, emits
|
||||
* `"closed"`, and disables attached visibility handlers until the next
|
||||
* manual {@link connect}.
|
||||
* - {@link close} aborts the transport, closes {@link messages}, and emits
|
||||
* `"closed"`.
|
||||
*
|
||||
* Automatic reconnect is controlled by {@link SSESessionOptions.persistent}
|
||||
* (server closed the stream) and
|
||||
@@ -51,10 +50,8 @@ import { AsyncPushIterator } from './async-push-iterator.ts';
|
||||
*
|
||||
* ## Connection supersession
|
||||
*
|
||||
* Each {@link connect} or {@link abort} bumps an internal `connectionId`.
|
||||
* Background read loops capture their id at start and exit quietly when a
|
||||
* newer connection supersedes them, avoiding duplicate events or errors from
|
||||
* stale transports.
|
||||
* Each {@link connect} will create a new controller if one does not exist. Otherwise,
|
||||
* it will return immediately.
|
||||
*/
|
||||
export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
/**
|
||||
@@ -65,11 +62,11 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
* @returns A connected session.
|
||||
* @throws When the initial connection cannot be established.
|
||||
*/
|
||||
static async create(url: string, options: Partial<SSESessionOptions> = {}): Promise<SSESession> {
|
||||
const client = new SSESession(url, options);
|
||||
await client.connect();
|
||||
static async create(url: string, options: Partial<SSESessionOptions & SSESessionEventCallbacks> = {}): Promise<SSESession> {
|
||||
const session = new SSESession(url, options);
|
||||
await session.connect();
|
||||
|
||||
return client;
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,39 +81,79 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
* The existing {@link onRequest} callback is preserved and runs after the
|
||||
* header is applied, so auth or other header mutations continue to work.
|
||||
*
|
||||
* Attach as early in the session lifetime as possible. When added after
|
||||
* {@link create}, the initial connection omits the header (no id yet);
|
||||
* all later reconnects include it. To instrument before the first connect,
|
||||
* call this on the session returned from {@link withBrowserVisibility}
|
||||
* before awaiting a separate {@link connect} when the tab starts hidden.
|
||||
* Since this relies on listening to the `"message"` event, this should ideally
|
||||
* be called before "{@link connect} is called so no messages (and no ids) are missed.
|
||||
*
|
||||
* ```ts
|
||||
* const session = await SSESession.create(url);
|
||||
* await SSESession.addLastEventIdReconnect(session);
|
||||
* const session = new SSESession(url);
|
||||
* SSESession.addLastEventIdReconnect(session);
|
||||
* await session.connect();
|
||||
* // Reconnects send Last-Event-ID once an event with an id is received.
|
||||
* ```
|
||||
*
|
||||
* @param client - The session to instrument.
|
||||
* @param session - The session to instrument.
|
||||
* @returns The same session, for chaining.
|
||||
*/
|
||||
static async addLastEventIdReconnect(client: SSESession): Promise<SSESession> {
|
||||
static addLastEventIdReconnect(session: SSESession): { session: SSESession; removeListener?: OffCallback } {
|
||||
// If the plugin is already installed, we return the session and the remove listener function.
|
||||
if (session.plugins.has(SSESession.addLastEventIdReconnect)) {
|
||||
// Get the remove listener function from the session.
|
||||
const removeListener = session.plugins.get(SSESession.addLastEventIdReconnect) as OffCallback;
|
||||
|
||||
// If the plugin was added with a non-function listener, we throw an error.
|
||||
if (typeof removeListener !== 'function') {
|
||||
throw new SSESessionUnsubscribePluginNotAFunctionError('addLastEventIdReconnect');
|
||||
}
|
||||
|
||||
// If the plugin is already installed, we return the session and the remove listener function.
|
||||
return { session, removeListener };
|
||||
}
|
||||
|
||||
// Store the last event id so we can send it on reconnect.
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
client.on('message', (event) => {
|
||||
lastEventId = event.id;
|
||||
});
|
||||
// Define the message listener that will store the last event id and begin listening for 'message' events.
|
||||
const messageListener = (event: SSEvent): void => {
|
||||
// If the event has no id, we should not update the lastEventId and instead keep the old one.
|
||||
lastEventId = event.id ?? lastEventId;
|
||||
};
|
||||
|
||||
const originalOnRequest = client.options.onRequest;
|
||||
session.on('message', messageListener);
|
||||
|
||||
client.options.onRequest = async (request: RequestInit): Promise<RequestInit> => {
|
||||
if (lastEventId) {
|
||||
// Store the original onRequest callback so we can extend its behavior to write the reconnect header.
|
||||
const originalOnRequest = session.options.onRequest;
|
||||
|
||||
// Whether we actively send the Last-Event-ID header on reconnect.
|
||||
// Because the `onRequest` callback may be extended further by other code, we can't just roll-back to the `originalOnRequest`
|
||||
// Instead, we just use this flag to determine if we should write the Last-Event-ID header.
|
||||
let enabled = true;
|
||||
|
||||
// Extend the onRequest callback to write the Last-Event-ID header if it is enabled.
|
||||
session.options.onRequest = async (request: RequestInit): Promise<RequestInit> => {
|
||||
if (lastEventId && enabled) {
|
||||
request.headers = { ...request.headers, 'Last-Event-ID': lastEventId };
|
||||
}
|
||||
|
||||
return originalOnRequest(request);
|
||||
};
|
||||
|
||||
return client;
|
||||
// Define the remove listener function that will disable the Last-Event-ID header on reconnect.
|
||||
const removeListener = (): void => {
|
||||
enabled = false;
|
||||
session.off('message', messageListener);
|
||||
|
||||
// If the remove listener function is the same as the existing remove listener function, we remove the plugin from the session.
|
||||
// This protects against the `removeListener` function being called onto after a new instance of this plugin is added
|
||||
const existingRemoveListener = session.plugins.get(SSESession.addLastEventIdReconnect) as OffCallback;
|
||||
if (existingRemoveListener === removeListener) {
|
||||
session.plugins.delete(SSESession.addLastEventIdReconnect);
|
||||
}
|
||||
};
|
||||
|
||||
// Store the plugin in the session.
|
||||
session.plugins.set(SSESession.addLastEventIdReconnect, removeListener);
|
||||
|
||||
return { session, removeListener };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,45 +161,99 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
*
|
||||
* Uses the Page Visibility API (`document.visibilitychange`):
|
||||
*
|
||||
* - **hidden** — {@link abort} stops the active fetch. {@link messages}
|
||||
* - **hidden** — {@link disconnect} stops the active fetch. {@link messages}
|
||||
* stays open; `"disconnected"` fires but `"closed"` does not.
|
||||
* - **visible** — {@link connect} re-establishes the stream if needed.
|
||||
*
|
||||
* The listener is removed when {@link disconnect} emits `"closed"`, and
|
||||
* re-attached automatically on the next `"connected"` event.
|
||||
* This controller will not re-connect if the session was disconnected by something else.
|
||||
*
|
||||
* No-op in non-browser environments where `document` is undefined.
|
||||
*
|
||||
* @param client - The session to manage.
|
||||
* @param session - The session to manage.
|
||||
*/
|
||||
static addBrowserVisibilityHandler(client: SSESession): SSESession {
|
||||
if (typeof document === 'undefined') return client;
|
||||
static addBrowserVisibilityHandler(session: SSESession): { session: SSESession; removeListener?: OffCallback } {
|
||||
if (typeof document === 'undefined') return { session };
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void client.abort();
|
||||
// If the plugin is already installed, we return the session and the remove listener function.
|
||||
if (session.plugins.has(SSESession.addBrowserVisibilityHandler)) {
|
||||
// Get the remove listener function from the session.
|
||||
const removeListener = session.plugins.get(SSESession.addBrowserVisibilityHandler) as OffCallback;
|
||||
|
||||
// If the plugin was added with a non-function listener, we throw an error.
|
||||
if (typeof removeListener !== 'function') {
|
||||
throw new SSESessionUnsubscribePluginNotAFunctionError('addBrowserVisibilityHandler');
|
||||
}
|
||||
|
||||
// If the plugin is already installed, we return the session and the remove listener function.
|
||||
return { session, removeListener };
|
||||
}
|
||||
|
||||
// Create a listener that will reconnect the session
|
||||
// This listener is added when the hidden->disconnect listener disconnects the session, but will not be added
|
||||
// if the session was disconnected by something else, like a manual disconnect, server-disconnect, or another plugin.
|
||||
const reconnectOnVisible = async (): Promise<void> => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Remove this listener
|
||||
document.removeEventListener('visibilitychange', reconnectOnVisible);
|
||||
|
||||
// Reconnect the session
|
||||
await session.connect();
|
||||
}
|
||||
};
|
||||
|
||||
// Create a listener that will disable the reconnect listener if the session is disconnected by something else.
|
||||
const disableOnExternalDisconnect = ({ source }: SSESessionOperationContext): void => {
|
||||
// If the source is not the browser visibility handler, we remove the listener.
|
||||
if (source !== SSESession.addBrowserVisibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', reconnectOnVisible);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a listener that will live as-long as this session is not closed
|
||||
// This listener will create a new 'on visible' listener that will reconnect the session when it disconnects the session.
|
||||
// This prevents it from trying to reconnect the session if the session was disconnected by something else
|
||||
const handleVisibilityChange = async (): Promise<void> => {
|
||||
// If the session is not connected, we do nothing.
|
||||
if (!session.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.connect().catch(() => {
|
||||
// connect() reports failures via onError and the "error" event.
|
||||
});
|
||||
// If the session is connected and the visibility is hidden, we disconnect it.
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// Add the listener for connected event to reconnect the session.
|
||||
document.addEventListener('visibilitychange', reconnectOnVisible);
|
||||
|
||||
// Disconnect the session.
|
||||
session.disconnect({ source: SSESession.addBrowserVisibilityHandler });
|
||||
}
|
||||
};
|
||||
|
||||
// Add the listener for visibility change.
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Stop managing visibility after an explicit disconnect; re-register
|
||||
// when the same instance is manually connected again.
|
||||
client.once('closed', () => {
|
||||
// Add a listener for `beforeDisconnect` event to disable the reconnect listener if the session is disconnected by something else.
|
||||
session.on('beforeDisconnect', disableOnExternalDisconnect);
|
||||
|
||||
// Remove the listener for visibility change.
|
||||
const removeListener = (): void => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.removeEventListener('visibilitychange', reconnectOnVisible);
|
||||
|
||||
client.once('connected', () => {
|
||||
SSESession.addBrowserVisibilityHandler(client);
|
||||
});
|
||||
});
|
||||
session.off('beforeDisconnect', disableOnExternalDisconnect);
|
||||
|
||||
return client;
|
||||
// If the remove listener function is the same as the existing remove listener function, we remove the plugin from the session.
|
||||
// This protects against the `removeListener` function being called onto after a new instance of this plugin is added
|
||||
const existingRemoveListener = session.plugins.get(SSESession.addBrowserVisibilityHandler) as OffCallback;
|
||||
if (existingRemoveListener === removeListener) {
|
||||
session.plugins.delete(SSESession.addBrowserVisibilityHandler);
|
||||
}
|
||||
};
|
||||
|
||||
// Store the plugin in the session.
|
||||
session.plugins.set(SSESession.addBrowserVisibilityHandler, removeListener);
|
||||
|
||||
// Return the session and the remove listener function.
|
||||
return { session, removeListener };
|
||||
}
|
||||
|
||||
/** SSE endpoint URL for this session. */
|
||||
@@ -175,6 +266,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
* gets its own {@link SSEEventParser} and {@link ExponentialBackoff}.
|
||||
*/
|
||||
public options: SSESessionOptions = {
|
||||
// Fetch options
|
||||
fetch: (...args) => fetch(...args),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -183,39 +275,46 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
},
|
||||
body: new FormData(),
|
||||
|
||||
// callback to mutate the fetch options before the fetch is made.
|
||||
onRequest: (request) => Promise.resolve(request),
|
||||
onConnected: () => {},
|
||||
onDisconnected: () => {},
|
||||
onError: (error) => console.error('SSEClient error:', error),
|
||||
|
||||
// Retry the initial fetch until it succeeds (maxAttempts: 0 = unlimited).
|
||||
// Retry the initial fetch.
|
||||
// Use the spread operator so it's impossible for us to override the default options. (default is exponential backoff)
|
||||
retry: new ExponentialBackoff({
|
||||
baseDelay: 1000,
|
||||
maxDelay: 10000,
|
||||
maxAttempts: 0,
|
||||
growthRate: 1.3,
|
||||
jitter: 0.3,
|
||||
...SSE_SESSION_EXPONENTIAL_BACKOFF_DEFAULTS,
|
||||
}),
|
||||
|
||||
attemptReconnect: true,
|
||||
persistent: false,
|
||||
// Reconnection options
|
||||
attemptReconnect: SSE_SESSION_ATTEMPT_RECONNECT_DEFAULT,
|
||||
persistent: SSE_SESSION_PERSISTENT_DEFAULT,
|
||||
|
||||
// Event parser options
|
||||
eventParser: new SSEEventParser(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Registered plugins for the session.
|
||||
* This can be used to prevent duplicate plugins from being added to the session.
|
||||
* The value for a plugin is arbitrary, for example a function that removes the plugin.
|
||||
*/
|
||||
public readonly plugins: Map<unknown, unknown> = new Map();
|
||||
|
||||
/** AbortController for the currently active fetch, if any. */
|
||||
#controller: AbortController | null = null;
|
||||
#connectionController: AbortController | null = null;
|
||||
|
||||
/** The server's requested retry interval in milliseconds (per SSE spec) if any */
|
||||
#retryInterval: number | null = null;
|
||||
|
||||
/**
|
||||
* Asynchronous stream of parsed SSE events for the active connection.
|
||||
*
|
||||
* Stays open across {@link abort} and automatic reconnects so an existing
|
||||
* Stays open across {@link disconnect} and automatic reconnects so an existing
|
||||
* `for await` consumer keeps receiving events after visibility resumes.
|
||||
*
|
||||
* Closes when:
|
||||
* - the server ends the stream and {@link SSESessionOptions.persistent}
|
||||
* is false,
|
||||
* - {@link disconnect} is called, or
|
||||
* - {@link close} is called, or
|
||||
* - a transport error occurs with
|
||||
* {@link SSESessionOptions.attemptReconnect} disabled.
|
||||
*
|
||||
@@ -225,24 +324,48 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
*/
|
||||
public messages: AsyncPushIterator<SSEvent> = new AsyncPushIterator<SSEvent>();
|
||||
|
||||
public constructor(url: string, options: Partial<SSESessionOptions> = {}) {
|
||||
public constructor(url: string, options: Partial<SSESessionOptions & SSESessionEventCallbacks> = {}) {
|
||||
super();
|
||||
|
||||
const { onConnected, onDisconnected, onError, ...restOptions } = options;
|
||||
|
||||
this.#url = url;
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options,
|
||||
...restOptions,
|
||||
// Shallow merge would drop default headers when options.headers is set.
|
||||
headers: { ...this.options.headers, ...options.headers },
|
||||
};
|
||||
|
||||
// Add the onConnected, onDisconnected, and onError as event listeners to the session.
|
||||
// We accept these as options to the constructor so that we can both instantiate the session and add the event listeners at the same time,
|
||||
// avoiding any potential race-conditions or async/await issues.
|
||||
if (onConnected) {
|
||||
this.on('connected', onConnected);
|
||||
}
|
||||
|
||||
if (onDisconnected) {
|
||||
this.on('disconnected', onDisconnected);
|
||||
}
|
||||
|
||||
if (onError) {
|
||||
this.on('error', onError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the session is active by checking if the controller is not null.
|
||||
*/
|
||||
public get active(): boolean {
|
||||
return this.#connectionController !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects or reconnects to the SSE endpoint.
|
||||
*
|
||||
* Resolves once the HTTP stream is established and `"connected"` has been
|
||||
* emitted. Body reading continues asynchronously in the background via
|
||||
* {@link #readStream}.
|
||||
* emitted. Body reading continues asynchronously in the background via the
|
||||
* internal `#readStream` method.
|
||||
*
|
||||
* @throws When the fetch retry policy exhausts attempts or the connection
|
||||
* is superseded before the reader is handed off (in the latter case the
|
||||
@@ -250,7 +373,7 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
// If there is already a controller present, we are already connected.
|
||||
if (this.#controller) return;
|
||||
if (this.#connectionController) return;
|
||||
|
||||
// Prepare for a fresh transport. Parser state from an abandoned connection
|
||||
// must not bleed into the next one; reopen messages if a prior terminal
|
||||
@@ -258,78 +381,102 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
this.#resetEventParser();
|
||||
this.#ensureMessageStreamOpen();
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#controller = controller;
|
||||
const connectionController = new AbortController();
|
||||
this.#connectionController = connectionController;
|
||||
|
||||
const { method, headers, body } = this.options;
|
||||
|
||||
const fetchBody = method === 'POST' ? body : null;
|
||||
|
||||
// Create the fetch options.
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: headers || {},
|
||||
body: fetchBody ?? null,
|
||||
signal: controller.signal,
|
||||
signal: connectionController.signal,
|
||||
cache: 'no-store',
|
||||
};
|
||||
|
||||
// If the method is POST, we will set the body of the fetch options.
|
||||
// NOTE: RequestInit doesn't allow body to be `undefined`, so if its false-y we will set it to null.
|
||||
if (method === 'POST') {
|
||||
fetchOptions.body = body || null;
|
||||
}
|
||||
|
||||
// Create a reader for the response body.
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
||||
|
||||
try {
|
||||
reader = await this.options.retry.run(() => this.#createReader(fetchOptions));
|
||||
// Retry the fetch using the provided retry policy. (default is exponential backoff)
|
||||
reader = await this.options.retry.run(() => this.#createReader(fetchOptions), {
|
||||
signal: connectionController.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
// A newer abort/connect superseded this attempt — leave state to the winner.
|
||||
if (this.#controller !== controller) return;
|
||||
// A newer `connect()` superseded this attempt — leave state to the winner.
|
||||
if (this.#connectionController !== connectionController) return;
|
||||
|
||||
this.#controller = null;
|
||||
// Reset the controller to null to allow for reconnection if needed.
|
||||
this.#connectionController = null;
|
||||
|
||||
await this.#notifyDisconnected();
|
||||
await this.#notifyError(error);
|
||||
// Normalize the error so it can be passed to the event listeners.
|
||||
const normalizedError = normalizeError(error);
|
||||
|
||||
// Emit the disconnected and error events.
|
||||
this.emit('disconnected', { reason: 'error', error: normalizedError });
|
||||
this.emit('error', normalizedError);
|
||||
|
||||
// Close the message stream so `for await...of` consumers resolve.
|
||||
this.#closeMessageStream();
|
||||
|
||||
// Throw the error so the caller can handle it.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Connection succeeded but was already replaced (for example abort during fetch).
|
||||
if (this.#controller !== controller) {
|
||||
// Connection succeeded but was already replaced (for example disconnect during fetch).
|
||||
if (this.#connectionController !== connectionController) {
|
||||
await reader.cancel();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await tryAsync(
|
||||
() => this.options.onConnected(),
|
||||
(error) => this.options.onError(error),
|
||||
);
|
||||
this.emit('connected', undefined);
|
||||
|
||||
// Fire-and-forget: connect() resolves while the stream is consumed.
|
||||
this.#readStream(reader, controller).catch((error) => {
|
||||
this.options.onError(error);
|
||||
// Data is emitted as a `message` event and from the `messages` iterator.
|
||||
this.#readStream(reader, connectionController).catch((error) => {
|
||||
this.emit('error', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts only the currently active transport.
|
||||
* Disconnects only the currently active transport.
|
||||
*
|
||||
* The session remains reusable: {@link messages} stays open, visibility
|
||||
* handling stays attached, and {@link connect} can reopen the stream.
|
||||
* Partial parser state from the abandoned transport is discarded.
|
||||
* `beforeDisconnect` is emitted on every invocation, even when no
|
||||
* transport is currently active. This allows observers to react to
|
||||
* an explicit disconnect operation.
|
||||
*
|
||||
* Emits `"disconnected"` but not `"closed"`.
|
||||
* @param context - Context describing the operation initiator.
|
||||
* @emits `beforeDisconnect` with the supplied operation context.
|
||||
* @emits `disconnected` with reason `"disconnect"` when an active
|
||||
* transport is actually disconnected.
|
||||
*/
|
||||
public async abort(): Promise<void> {
|
||||
if (!this.#controller) return;
|
||||
public disconnect(context: SSESessionOperationContext = {}): void {
|
||||
this.emit('beforeDisconnect', context);
|
||||
|
||||
// Grab the current controller to ensure we are aborting the correct one.
|
||||
const controller = this.#controller;
|
||||
this.#controller = null;
|
||||
if (this.#connectionController) {
|
||||
// Grab the current controller to ensure we are aborting the correct one.
|
||||
const connectionController = this.#connectionController;
|
||||
this.#connectionController = null;
|
||||
|
||||
// Invalidate any in-flight read loop and fetch for this transport.
|
||||
controller.abort();
|
||||
// Invalidate any in-flight read loop and fetch for this transport.
|
||||
connectionController.abort();
|
||||
|
||||
// Emit the disconnected event.
|
||||
this.emit('disconnected', {
|
||||
reason: 'disconnect',
|
||||
source: context.source,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the event parser buffer so data is not carried through resulting in malformed events.
|
||||
this.#resetEventParser();
|
||||
|
||||
await this.#notifyDisconnected();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,15 +485,15 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
*
|
||||
* Closes {@link messages} and emits `"closed"`.
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this.#closeMessageStream();
|
||||
this.emit('closed', undefined);
|
||||
public close(): void {
|
||||
// Disconnect the session.
|
||||
this.disconnect();
|
||||
|
||||
if (this.#controller) {
|
||||
await this.abort();
|
||||
} else {
|
||||
this.#resetEventParser();
|
||||
}
|
||||
// Close the message stream so `for await...of` consumers resolve.
|
||||
this.#closeMessageStream();
|
||||
|
||||
// Emit the closed event.
|
||||
this.emit('closed', undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -359,21 +506,28 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
const requestOptions = await this.options.onRequest(fetchOptions);
|
||||
const response = await this.options.fetch(this.#url, requestOptions);
|
||||
|
||||
// Handle a bad response from the server
|
||||
if (!response.ok) {
|
||||
// Get the response code and text.
|
||||
const responseCode = response.status;
|
||||
const responseText = await response.text();
|
||||
|
||||
// Create a new error and emit it.
|
||||
const error = new HTTPError(responseCode, responseText);
|
||||
void this.#notifyError(error);
|
||||
this.emit('error', error);
|
||||
|
||||
// Throw the error so the caller can handle it.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle a response with no body (SSE still uses the response body)
|
||||
if (!response.body) {
|
||||
const error = new ResponseBodyNullError();
|
||||
void this.#notifyError(error);
|
||||
this.emit('error', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Return the reader for the response body.
|
||||
return response.body.getReader();
|
||||
}
|
||||
|
||||
@@ -381,23 +535,30 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
* Reads bytes from an established stream until it ends, errors, or is
|
||||
* superseded by a newer connection.
|
||||
*/
|
||||
async #readStream(reader: ReadableStreamDefaultReader<Uint8Array>, controller: AbortController): Promise<void> {
|
||||
async #readStream(reader: ReadableStreamDefaultReader<Uint8Array>, connectionController: AbortController): Promise<void> {
|
||||
try {
|
||||
while (this.#controller === controller) {
|
||||
while (this.#connectionController === connectionController) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
// abort() or a newer connect() may have landed while we were awaiting.
|
||||
if (this.#controller !== controller) return;
|
||||
// `disconnect()` or a newer connect() may have landed while we were awaiting.
|
||||
if (this.#connectionController !== connectionController) return;
|
||||
|
||||
// If the stream is done, we've been disconnected.
|
||||
// We need to clean up and either complete or reconnect the session.
|
||||
if (done) {
|
||||
this.#controller = null;
|
||||
// Reset the controller to null to allow for reconnection if needed.
|
||||
this.#connectionController = null;
|
||||
|
||||
await this.#notifyDisconnected();
|
||||
// Emit the disconnected event.
|
||||
this.emit('disconnected', { reason: 'remote' });
|
||||
|
||||
// If the session is persistent, we will attempt to reconnect.
|
||||
// NOTE: in very rare cases, a malfunctioning server can make this code turn into an infinite loop.
|
||||
if (this.options.persistent) {
|
||||
// Server closed gracefully — reopen unless the consumer opted out.
|
||||
await this.connect();
|
||||
} else {
|
||||
// Close the message stream so `for await...of` consumers resolve.
|
||||
this.#closeMessageStream();
|
||||
}
|
||||
|
||||
@@ -407,28 +568,44 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
// Some environments yield `{ done: false, value: undefined }`.
|
||||
if (!value) continue;
|
||||
|
||||
// Emit the parsed events to the event listeners and the messages iterator.
|
||||
for (const event of this.options.eventParser.parseEvents(value)) {
|
||||
// If the event has a retry interval, we will store it.
|
||||
if (event.retry) {
|
||||
this.#retryInterval = event.retry;
|
||||
}
|
||||
|
||||
this.emit('message', event);
|
||||
this.messages.push(event);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If the controller is different, we already started a new connection and it would be confusing to handle this error.
|
||||
if (controller !== this.#controller) return;
|
||||
// The controller can also be null here if the session was disconnected.
|
||||
if (connectionController !== this.#connectionController) return;
|
||||
|
||||
// Invalidate the current controller to allow for reconnection if needed
|
||||
this.#controller = null;
|
||||
this.#connectionController = null;
|
||||
|
||||
await this.#notifyDisconnected();
|
||||
const normalizedError = normalizeError(error);
|
||||
this.emit('disconnected', { reason: 'error', error: normalizedError });
|
||||
|
||||
// Expected path for abort() — do not treat as an error or reconnect.
|
||||
if (controller.signal.aborted) return;
|
||||
// Expected path for `disconnect()` — do not treat as an error or reconnect.
|
||||
if (connectionController.signal.aborted) return;
|
||||
|
||||
await this.#notifyError(error);
|
||||
this.emit('error', normalizedError);
|
||||
|
||||
// If the session is configured to attempt reconnect, we will attempt to reconnect.
|
||||
if (this.options.attemptReconnect) {
|
||||
// If the server has requested a retry interval, we will wait for it before attempting to reconnect.
|
||||
if (this.#retryInterval) {
|
||||
await new Promise((resolve) => setTimeout(resolve, this.#retryInterval!));
|
||||
}
|
||||
|
||||
// Attempt to reconnect.
|
||||
await this.connect();
|
||||
} else {
|
||||
// Close the message stream so `for await...of` consumers resolve.
|
||||
this.#closeMessageStream();
|
||||
}
|
||||
}
|
||||
@@ -455,24 +632,4 @@ export class SSESession extends EventEmitter<SSESessionEventMap> {
|
||||
|
||||
this.messages.close();
|
||||
}
|
||||
|
||||
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
|
||||
async #notifyDisconnected(): Promise<void> {
|
||||
await tryAsync(
|
||||
() => this.options.onDisconnected(),
|
||||
(error) => this.options.onError(error),
|
||||
);
|
||||
this.emit('disconnected', undefined);
|
||||
}
|
||||
|
||||
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
|
||||
async #notifyError(error: unknown): Promise<void> {
|
||||
const errorInstance = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
await tryAsync(
|
||||
() => this.options.onError(errorInstance),
|
||||
(callbackError) => console.error('SSESession error:', callbackError),
|
||||
);
|
||||
this.emit('error', errorInstance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ExponentialBackoffRunOptions } from '../exponential-backoff/exponential-backoff';
|
||||
|
||||
export type SSERequestInit = {
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,9 @@ export type SSESessionCallbacks = {
|
||||
* a `Last-Event-ID` for resume semantics.
|
||||
*/
|
||||
onRequest: (request: RequestInit) => Promise<RequestInit>;
|
||||
};
|
||||
|
||||
export type SSESessionEventCallbacks = {
|
||||
|
||||
/**
|
||||
* Called after the HTTP stream is established and before body reading begins.
|
||||
@@ -45,15 +50,15 @@ export type SSESessionCallbacks = {
|
||||
onConnected: () => void;
|
||||
|
||||
/**
|
||||
* Called when the active transport ends — including {@link SSESession.abort},
|
||||
* server stream completion, and errors. Not paired with {@link SSESessionCallbacks.onConnected}
|
||||
* Called when the active transport ends — including {@link SSESession.disconnect},
|
||||
* server stream completion, and errors. Not paired with {@link SSESessionEventCallbacks.onConnected}
|
||||
* when the initial connect never succeeds.
|
||||
*/
|
||||
onDisconnected: () => void;
|
||||
|
||||
/**
|
||||
* Called on fetch or read failures. Not invoked for intentional
|
||||
* {@link SSESession.abort} aborts.
|
||||
* {@link SSESession.disconnect} disconnects.
|
||||
*/
|
||||
onError: (error: Error) => void;
|
||||
};
|
||||
@@ -66,7 +71,7 @@ export type SSESessionRetryInterface = {
|
||||
* unlimited attempts.
|
||||
*/
|
||||
retry: {
|
||||
run<T>(fn: () => Promise<T>, onError?: (error: Error) => void): Promise<T>;
|
||||
run<T>(fn: () => Promise<T>, options?: ExponentialBackoffRunOptions): Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -88,29 +93,66 @@ export type SSELifecycleOptions = {
|
||||
|
||||
/**
|
||||
* When true, {@link SSESession} calls {@link SSESession.connect} again after
|
||||
* a transport **error** (not an intentional abort).
|
||||
* a transport **error** (not an intentional disconnect).
|
||||
* @default true
|
||||
*/
|
||||
attemptReconnect: boolean;
|
||||
|
||||
/**
|
||||
* When true, {@link SSESession} calls {@link SSESession.connect} again after
|
||||
* the **server** closes the stream normally (`done`).
|
||||
* @default false
|
||||
*/
|
||||
persistent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Context from the source that triggered the event.
|
||||
*/
|
||||
export type SSESessionOperationContext = {
|
||||
source?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discriminated context for the reason the session was disconnected internally.
|
||||
*/
|
||||
export type SSESessionDisconnectInternalContext = {
|
||||
reason: 'disconnect';
|
||||
} & SSESessionOperationContext;
|
||||
|
||||
/**
|
||||
* Discriminated context for the reason the session was disconnected remotely.
|
||||
*/
|
||||
export type SSESessionDisconnectRemoteContext = {
|
||||
reason: 'remote';
|
||||
};
|
||||
|
||||
/**
|
||||
* Discriminated context for the reason the session was disconnected due to an error.
|
||||
*/
|
||||
export type SSESessionDisconnectErrorContext = {
|
||||
reason: 'error';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discriminated context for the reason the session was disconnected.
|
||||
*/
|
||||
export type SSESessionDisconnectContext = SSESessionDisconnectInternalContext | SSESessionDisconnectRemoteContext | SSESessionDisconnectErrorContext;
|
||||
|
||||
/**
|
||||
* Events emitted by {@link SSESession}.
|
||||
*
|
||||
* - `"connected"` — HTTP stream established.
|
||||
* - `"message"` — A complete SSE event was parsed.
|
||||
* - `"disconnected"` — The active transport ended (including {@link SSESession.abort}).
|
||||
* - `"disconnected"` — The active transport ended (including {@link SSESession.disconnect}).
|
||||
* - `"error"` — An unexpected fetch or read failure.
|
||||
* - `"closed"` — {@link SSESession.disconnect} was called; visibility handling is detached.
|
||||
* - `"closed"` — {@link SSESession.close} was called.
|
||||
*/
|
||||
export type SSESessionEventMap = {
|
||||
connected: void;
|
||||
disconnected: void;
|
||||
beforeDisconnect: SSESessionOperationContext;
|
||||
disconnected: SSESessionDisconnectContext;
|
||||
error: Error;
|
||||
message: SSEvent;
|
||||
closed: void;
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import type { $ZodIssue } from 'zod/v4/core';
|
||||
|
||||
/**
|
||||
* Formats the Zod validation failures into a single string with one line each: "- <field>: <message>" and top level failures
|
||||
* with no field path show as "(root)" for better readability.
|
||||
*
|
||||
* @param issues The Zod validation failures to format.
|
||||
* @returns A human readable error string for better debugging.
|
||||
*/
|
||||
export const buildErrorDescription = (issues: $ZodIssue[]): string => {
|
||||
// Initialize an empty array to store the formatted lines.
|
||||
const lines: string[] = [];
|
||||
|
||||
// Iterate over the issues and format them into a string.
|
||||
for (const issue of issues) {
|
||||
// Get the issue path.
|
||||
const issuePath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
||||
|
||||
// The prefix that Zod adds to messages.
|
||||
const messagePrefix = 'Invalid input: ';
|
||||
|
||||
// Remove the prefix for better readability.
|
||||
const issueMessage = issue.message.startsWith(messagePrefix) ? issue.message.slice(messagePrefix.length) : issue.message;
|
||||
|
||||
// Add the formatted line to the array.
|
||||
lines.push(`- ${issuePath}: ${issueMessage}`);
|
||||
}
|
||||
|
||||
// Return the formatted string.
|
||||
return `\n${lines.join('\n')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when the provided template does not satisfy the XOTemplate schema.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './errors.ts';
|
||||
export * from './identifier.ts';
|
||||
export * from './parser.ts';
|
||||
export * from './schemas.ts';
|
||||
|
||||
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
|
||||
export { serializeTemplate } from './serialization.ts';
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import { xoTemplateSchema } from './schemas.ts';
|
||||
import { TemplateInvalidError, buildErrorDescription } from './errors.ts';
|
||||
import { buildErrorDescription } from '../errors.ts';
|
||||
import { TemplateInvalidError } from './errors.ts';
|
||||
import { deserializeTemplate, serializeTemplate } from './serialization.ts';
|
||||
|
||||
/**
|
||||
|
||||
+16
-13
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @stylistic/newline-per-chained-call */
|
||||
import { BchVmVersions, XOTemplateBaseTypes, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
|
||||
import { BchVmVersions, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================
|
||||
@@ -58,12 +58,6 @@ export const xoTemplateNftCapabilitySchema = z.enum(XOTemplateNftCapabilities);
|
||||
*/
|
||||
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
|
||||
|
||||
/**
|
||||
* Validation schema for a base type identifier.
|
||||
* Accepts values from XOTemplateBaseTypes for the `type` field on constants, variables, and data.
|
||||
*/
|
||||
export const xoTemplateBaseTypeSchema = z.enum(XOTemplateBaseTypes);
|
||||
|
||||
/**
|
||||
* Validation schema for a primitive type identifier.
|
||||
* Accepts values from XOTemplatePrimitiveTypes for the `hint` field on constants, variables, and data.
|
||||
@@ -521,6 +515,11 @@ export const xoTemplateLockingScriptRoleSchema = xoTemplateViewPropertiesSchema
|
||||
actions: z.array(xoTemplateActionIntentSchema).optional().describe('List of action references available to this role.'),
|
||||
balance: xoTemplateAssetAmountsSchema.partial().optional().describe('Estimated ownership in the optional set of asset amounts specified.'),
|
||||
selectable: z.boolean().optional().describe('Whether outputs locked to this script should be available for coin selection.'),
|
||||
relevant: z
|
||||
.union([ z.boolean(), z.string() ])
|
||||
.optional()
|
||||
.describe('Whether this output or locking script should be tracked by the engine, When omitted, the engine treats this field as true.'
|
||||
+ 'Accepts true, false, or a CashASM expression that evaluates to a boolean relevance value.'),
|
||||
privacy: z
|
||||
.union([ z.number(), z.string() ])
|
||||
.optional()
|
||||
@@ -738,9 +737,9 @@ export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
|
||||
*/
|
||||
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||
.extend({
|
||||
type: xoTemplateBaseTypeSchema.describe('The data type of this constant.'),
|
||||
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this constant.'),
|
||||
value: z.unknown().describe('The value of this constant.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand what this constant represents.'),
|
||||
hint: z.unknown().optional().describe('An optional hint to help apps and users understand what this constant represents.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -757,9 +756,9 @@ export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||
*/
|
||||
export const xoTemplateDataSchema = z
|
||||
.object({
|
||||
type: xoTemplateBaseTypeSchema.describe('The data type of this data field.'),
|
||||
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this data field.'),
|
||||
value: z.unknown().describe('The value for this data field.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand this data field.'),
|
||||
hint: z.unknown().optional().describe('An optional hint to help apps and users understand this data field.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -795,8 +794,8 @@ export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
|
||||
*/
|
||||
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
|
||||
.extend({
|
||||
type: xoTemplateBaseTypeSchema.optional().describe('The data type of this variable.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('A hint to help users understand what value to provide.'),
|
||||
type: xoTemplatePrimitiveTypeSchema.optional().describe('The data type of this variable.'),
|
||||
hint: z.unknown().optional().describe('A hint to help users understand what value to provide.'),
|
||||
|
||||
// A neutral intent that the engine uses to populate the default value for this variable.
|
||||
// View properties (name, description, icon) may contain CashASM expressions that the
|
||||
@@ -864,6 +863,10 @@ export const xoTemplateIconSchema = xoTemplateViewPropertiesSchema
|
||||
export const xoTemplateDefaultsSchema = z
|
||||
.object({
|
||||
change: xoTemplateOutputIntentSchema.optional().describe('Instructions for how to construct automated change output.'),
|
||||
address: xoTemplateLockingScriptIntentSchema
|
||||
.optional()
|
||||
.describe('Configure the default intent structure for creating receiving addresses / locking scripts.'),
|
||||
receive: xoTemplateOutputIntentSchema.optional().describe('Configure the default intent structure for creating receive outputs.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
+837
-48
File diff suppressed because it is too large
Load Diff
+124
-24
@@ -1,11 +1,15 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { EventEmitter } from '../source/event-emitter.ts';
|
||||
|
||||
import type { DeeplyReadonly } from '../source/types.ts';
|
||||
import { deepFreeze } from '../source/misc.ts';
|
||||
|
||||
/** Simple event map used across these tests. */
|
||||
type TestEvents = {
|
||||
message: string;
|
||||
count: number;
|
||||
nested: { nested: { value: number } };
|
||||
readonly: Readonly<{ value: number }>;
|
||||
deeplyReadonly: DeeplyReadonly<{ nested: { value: number } }>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -97,6 +101,9 @@ const testEventEmitterEmitContinuesAfterListenerThrows = (): void => {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const secondListener = vi.fn();
|
||||
|
||||
// Spy on the console.error method
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
emitter.on('message', (): void => {
|
||||
throw new Error('listener failure');
|
||||
});
|
||||
@@ -106,45 +113,66 @@ const testEventEmitterEmitContinuesAfterListenerThrows = (): void => {
|
||||
|
||||
expect(secondListener).toHaveBeenCalledOnce();
|
||||
expect(secondListener).toHaveBeenCalledWith('hello');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledOnce();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(new Error('listener failure'));
|
||||
|
||||
// Remove the mock on console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that emitted events cannot be mutated.
|
||||
* Tests that it supports Object.freeze() frozen payloads.
|
||||
*/
|
||||
const testEventEmitterEmittedEventsCannotBeMutated = async (): Promise<void> => {
|
||||
const testEventEmitterSupportsObjectFreezeFrozenPayloads = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
const payload = { nested: { value: 1 } };
|
||||
const payload = Object.freeze({ value: 42 });
|
||||
|
||||
emitter.on('nested', listener, 100);
|
||||
emitter.on('readonly', listener, 100);
|
||||
emitter.emit('readonly', payload);
|
||||
|
||||
// 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;
|
||||
// @ts-expect-error - We are trying to mutate a frozen payload.
|
||||
expect(() => (payload.value = 43)).toThrow();
|
||||
|
||||
// Advance the timer to ensure the debounce timer goes off
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// The listener must receive a snapshot from emit time, not the mutated value.
|
||||
// Expect the listener to have been called with the frozen payload.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith(payload);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
// Expect the listener to have received the original payload object without any mutations
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
nested: {
|
||||
value: 1,
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Tests that it supports deepFreeze() frozen payloads.
|
||||
*/
|
||||
const testEventEmitterSupportsDeepFreezeFrozenPayloads = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Expect the payload object to have been mutated
|
||||
expect(payload).toStrictEqual({
|
||||
nested: {
|
||||
value: 999,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
const payload = deepFreeze({ nested: { value: 42 } });
|
||||
|
||||
emitter.on('deeplyReadonly', listener, 100);
|
||||
emitter.emit('deeplyReadonly', payload);
|
||||
|
||||
// @ts-expect-error - We are trying to mutate a frozen payload.
|
||||
expect(() => (payload.nested.value = 43)).toThrow();
|
||||
|
||||
// Advance the timer to ensure the debounce timer goes off
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Expect the listener to have been called with the frozen payload.
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith(payload);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
@@ -349,6 +377,72 @@ const testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback = async
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that timeouts are eventually cleared when removeAllListeners is called.
|
||||
*/
|
||||
const testEventEmitterTimeoutsAreClearedAfterTheyRunWhenRemoveAllListenersIsCalled = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const emitter = new EventEmitter<TestEvents>();
|
||||
const listener = vi.fn();
|
||||
|
||||
emitter.on('message', listener, 100);
|
||||
emitter.emit('message', 'pending');
|
||||
|
||||
// We expect there to be a timer since we emitted a debounced event
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
// Remove all listeners
|
||||
emitter.removeAllListeners();
|
||||
|
||||
// Advance the timer to ensure the debounce timer is cleared.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Make sure the listener was not called
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
// Make sure the timer was cleared
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that timeouts are eventually cleared when off is called.
|
||||
*/
|
||||
const testEventEmitterTimeoutsAreClearedWhenOffIsCalled = 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', 'pending');
|
||||
|
||||
// We expect there to be a timer since we emitted a debounced event
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
// Cancel the listener
|
||||
off();
|
||||
|
||||
// Advance the timer to ensure the debounce timer is cleared.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Make sure the listener was not called
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
// Make sure the timer was cleared
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that waitFor() resolves when a matching event is emitted.
|
||||
*/
|
||||
@@ -686,7 +780,8 @@ const runTests = async (): Promise<void> => {
|
||||
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: supports Object.freeze() frozen payloads', testEventEmitterSupportsObjectFreezeFrozenPayloads);
|
||||
test('EventEmitter: supports deepFreeze() frozen payloads', testEventEmitterSupportsDeepFreezeFrozenPayloads);
|
||||
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(
|
||||
@@ -707,6 +802,11 @@ const runTests = async (): Promise<void> => {
|
||||
'EventEmitter: cancels a pending debounced callback when removeAllListeners is called',
|
||||
testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback,
|
||||
);
|
||||
test(
|
||||
'EventEmitter: timeouts are cleared after they run when removeAllListeners is called',
|
||||
testEventEmitterTimeoutsAreClearedAfterTheyRunWhenRemoveAllListenersIsCalled,
|
||||
);
|
||||
test('EventEmitter: timeouts are cleared when off is called', testEventEmitterTimeoutsAreClearedWhenOffIsCalled);
|
||||
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);
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { ExponentialBackoff } from '../source/exponential-backoff.ts';
|
||||
import {
|
||||
ExponentialBackoffMaxRetriesHitError,
|
||||
ExponentialBackoffNumberNotFiniteError,
|
||||
ExponentialBackoffStoppedRetriesError,
|
||||
} from '../source/errors.ts';
|
||||
|
||||
/**
|
||||
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
||||
*/
|
||||
const validExponentialBackoffOptions = {
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 10,
|
||||
baseDelay: 1_000,
|
||||
growthRate: 2,
|
||||
jitter: 0.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
|
||||
* with library defaults (including the default 1000ms base delay) when no options are passed.
|
||||
*/
|
||||
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
|
||||
// Fake timers let us advance time without waiting real seconds between retries.
|
||||
vi.useFakeTimers();
|
||||
// Pin Math.random to 0 so jitter does not reduce the default delay.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
|
||||
try {
|
||||
// The wrapped function fails on its first invocation and succeeds on the second.
|
||||
// That forces ExponentialBackoff.run down the retry path using default options.
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('static-result');
|
||||
|
||||
// Call the static helper with no onError and no options — defaults apply entirely.
|
||||
const promise = ExponentialBackoff.run(rejectThenResolveFn);
|
||||
|
||||
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
|
||||
await Promise.resolve();
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
// The retry should have succeeded and returned the resolved value from the mock.
|
||||
await expect(promise).resolves.toBe('static-result');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
|
||||
* with defaults, still retrying when only some fields are overridden.
|
||||
*/
|
||||
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
|
||||
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('done');
|
||||
|
||||
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
|
||||
// onError is explicitly undefined to verify the default no-op handler is used.
|
||||
const result = await ExponentialBackoff.run(rejectThenResolveFn, undefined, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
expect(result).toBe('done');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
|
||||
* the instance's stored options when no per-run options are supplied.
|
||||
*/
|
||||
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('instance-result');
|
||||
|
||||
// Options live on the instance; run(fn) should read them instead of static defaults.
|
||||
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
|
||||
|
||||
const result = await backoff.run(rejectThenResolveFn);
|
||||
|
||||
expect(result).toBe('instance-result');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
|
||||
*/
|
||||
const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise<void> => {
|
||||
// Always resolves — never enters the catch/retry branch.
|
||||
const resolveFn = vi.fn(async () => 'success');
|
||||
const onError = vi.fn();
|
||||
|
||||
const result = await ExponentialBackoff.run(resolveFn, onError, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(resolveFn).toHaveBeenCalledOnce();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that retries continue across multiple failures until the function eventually resolves.
|
||||
*/
|
||||
const testExponentialBackoffRetriesUntilSuccess = async (): Promise<void> => {
|
||||
// Three invocations: two rejections then a success on the third call.
|
||||
const tripleRejectFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
|
||||
const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the onError callback is invoked once for every failed attempt, including the last one
|
||||
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
|
||||
*/
|
||||
const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void> => {
|
||||
const error = new Error('temporary failure');
|
||||
|
||||
// Always rejects with the same error — we will exhaust all attempts.
|
||||
const rejectFn = vi.fn().mockRejectedValue(error);
|
||||
const onError = vi.fn();
|
||||
|
||||
// maxAttempts: 3 means three tries total, all of which will fail.
|
||||
await expect(ExponentialBackoff.run(rejectFn, onError, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
})).rejects.toThrow(ExponentialBackoffMaxRetriesHitError);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
|
||||
* with every task error preserved in order on the cause.
|
||||
*/
|
||||
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise<void> => {
|
||||
const firstError = new Error('first');
|
||||
const lastError = new Error('last');
|
||||
|
||||
// Two distinct errors so we can prove both are collected, not just the last one.
|
||||
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
|
||||
.mockRejectedValueOnce(lastError);
|
||||
|
||||
try {
|
||||
await ExponentialBackoff.run(doubleRejectFn, () => {}, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 2,
|
||||
});
|
||||
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
|
||||
expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]);
|
||||
}
|
||||
|
||||
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
|
||||
* so callers always observe a consistent error type in the callback.
|
||||
*/
|
||||
const testExponentialBackoffWrapsNonErrorThrows = async (): Promise<void> => {
|
||||
// Reject with a plain string — not an Error subclass.
|
||||
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
|
||||
const onError = vi.fn();
|
||||
|
||||
// Single attempt — we fail fast and inspect what onError received.
|
||||
try {
|
||||
await ExponentialBackoff.run(rejectedFn, onError, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 1,
|
||||
});
|
||||
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
|
||||
const [ wrappedError ] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[];
|
||||
expect(wrappedError).toBeInstanceOf(Error);
|
||||
expect(wrappedError.message).toBe('not-an-error');
|
||||
}
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(onError.mock.calls[0][0].message).toBe('not-an-error');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
|
||||
* and the onError callback is not called.
|
||||
*/
|
||||
const testExponentialBackoffRunSuccessAndAbortSignal = async (): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and succeeds
|
||||
const abortAndSucceedFn = vi.fn(({ stopRetries }) => {
|
||||
stopRetries(new Error('retry me'));
|
||||
|
||||
return Promise.resolve('success');
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback
|
||||
const result = await ExponentialBackoff.run(abortAndSucceedFn, onErrorFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
// Expect the result to be the success message
|
||||
expect(result).toBe('success');
|
||||
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
|
||||
|
||||
// Expect the onError callback to not have been called
|
||||
expect(onErrorFn).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the abort signal is aborted with an error, an ExponentialBackoffStoppedRetriesError is thrown
|
||||
* with the error as the message.
|
||||
*/
|
||||
const testExponentialBackoffRunWithAbortSignal = async (): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and throws an error
|
||||
const abortAndThrowFn = vi.fn(({ stopRetries }) => {
|
||||
stopRetries(new Error('exponential backoff aborted'));
|
||||
throw new Error('error message');
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Define the expected error
|
||||
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
|
||||
await expect(ExponentialBackoff.run(abortAndThrowFn, onErrorFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
})).rejects.toThrow(expectedError);
|
||||
|
||||
// Expect the onError callback to have been called once with the error
|
||||
expect(onErrorFn).toHaveBeenCalledOnce();
|
||||
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
|
||||
|
||||
// Expect the function to have been called once and not to have resolved
|
||||
expect(abortAndThrowFn).toHaveBeenCalledOnce();
|
||||
expect(abortAndThrowFn).not.toHaveResolved();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the abort signal is aborted with a string, an ExponentialBackoffStoppedRetriesError is thrown
|
||||
* with the string as the message.
|
||||
*/
|
||||
const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and throws an error
|
||||
const abortAndThrowStringFn = vi.fn(({ stopRetries }) => {
|
||||
stopRetries('exponential backoff aborted');
|
||||
|
||||
// eslint-disable-next-line
|
||||
throw 'error message';
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Define the expected error, Note that we "stopRetries" with just a string, not an error. They are treated equivalently.
|
||||
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
|
||||
await expect(ExponentialBackoff.run(abortAndThrowStringFn, onErrorFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
})).rejects.toThrow(expectedError);
|
||||
|
||||
// Expect the onError callback to have been called once with the error
|
||||
expect(onErrorFn).toHaveBeenCalledOnce();
|
||||
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
|
||||
|
||||
// Expect the function to have been called once and not to have resolved
|
||||
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
|
||||
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}
|
||||
* as an alternative to the static helper.
|
||||
*/
|
||||
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
|
||||
const successfullyResolve = vi.fn(async () => 42);
|
||||
|
||||
// from() is a convenience constructor; run() on the result should behave like the static path.
|
||||
const backoff = ExponentialBackoff.from({
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
const result = await backoff.run(successfullyResolve);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(successfullyResolve).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
|
||||
*/
|
||||
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise<void> => {
|
||||
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
|
||||
const tripleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockRejectedValueOnce(new Error('attempt 3'))
|
||||
.mockResolvedValueOnce('eventually');
|
||||
|
||||
const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe('eventually');
|
||||
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
|
||||
* (with jitter disabled so the math is exact).
|
||||
*/
|
||||
const testExponentialBackoffIncreasesDelayExponentially = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
try {
|
||||
const doubleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
|
||||
baseDelay: 100,
|
||||
growthRate: 2,
|
||||
jitter: 0,
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
// Attempt 0 fires synchronously on the first microtask tick.
|
||||
await Promise.resolve();
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
|
||||
*/
|
||||
const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
try {
|
||||
const doubleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
|
||||
baseDelay: 1_000,
|
||||
growthRate: 4,
|
||||
jitter: 0,
|
||||
maxDelay: 2_000,
|
||||
maxAttempts: 5,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
|
||||
*/
|
||||
const testExponentialBackoffAppliesJitter = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
|
||||
try {
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, {
|
||||
baseDelay: 1_000,
|
||||
growthRate: 1,
|
||||
jitter: 0.1,
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
|
||||
await vi.advanceTimersByTimeAsync(899);
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
|
||||
const validCases = [
|
||||
validExponentialBackoffOptions,
|
||||
{
|
||||
...validExponentialBackoffOptions,
|
||||
maxDelay: 0,
|
||||
maxAttempts: 0,
|
||||
baseDelay: 0,
|
||||
growthRate: 0,
|
||||
jitter: 0,
|
||||
},
|
||||
{
|
||||
...validExponentialBackoffOptions,
|
||||
jitter: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const options of validCases) {
|
||||
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
|
||||
// Define our test cases with each value being less than 0
|
||||
const negativeCases = [
|
||||
{ field: 'maxDelay', value: -1 },
|
||||
{ field: 'maxAttempts', value: -1 },
|
||||
{ field: 'baseDelay', value: -1 },
|
||||
{ field: 'growthRate', value: -1 },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of negativeCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).toThrow(`Exponential backoff option "${field}" is too small. Must be at least 0`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
|
||||
// Define our test cases with each value being less than 0 or greater than 1
|
||||
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { value } of invalidJitterCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
jitter: value,
|
||||
})).toThrow('Exponential backoff option "jitter" is out of bounds. Must be between 0 and 1');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
|
||||
// Define our test cases with each value being Infinity
|
||||
const nonFiniteCases = [
|
||||
{ field: 'maxDelay', value: Infinity },
|
||||
{ field: 'maxAttempts', value: Infinity },
|
||||
{ field: 'baseDelay', value: Infinity },
|
||||
{ field: 'growthRate', value: Infinity },
|
||||
{ field: 'jitter', value: Infinity },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of nonFiniteCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).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`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
|
||||
// Define our test cases with each value being NaN
|
||||
const nanCases = [
|
||||
{ field: 'maxDelay', value: Number.NaN },
|
||||
{ field: 'maxAttempts', value: Number.NaN },
|
||||
{ field: 'baseDelay', value: Number.NaN },
|
||||
{ field: 'growthRate', value: Number.NaN },
|
||||
{ field: 'jitter', value: Number.NaN },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of nanCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).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> => {
|
||||
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: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
|
||||
test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt);
|
||||
test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess);
|
||||
test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure);
|
||||
test(
|
||||
'ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted',
|
||||
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted,
|
||||
);
|
||||
test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows);
|
||||
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
|
||||
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
|
||||
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: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
|
||||
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
|
||||
test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay);
|
||||
test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter);
|
||||
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
|
||||
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
||||
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
||||
test('ExponentialBackoff: constructor does not cause an error during spread', testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
@@ -0,0 +1,307 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { ExponentialBackoffExternallyAbortable } from '../../source/exponential-backoff/exponential-backoff-externally-aborted.ts';
|
||||
import type { ExponentialBackoffExternallyAbortableOptions } from '../../source/exponential-backoff/exponential-backoff-externally-aborted.ts';
|
||||
import {
|
||||
ExponentialBackoffNumberTooSmallError,
|
||||
ExponentialBackoffStoppedRetriesError,
|
||||
ExternallyAbortedExponentialBackoffExternalSignalAbortedError,
|
||||
ExternallyAbortedExponentialBackoffInternalSignalAbortedError,
|
||||
} from '../../source/exponential-backoff/errors.ts';
|
||||
import { testExponentialBackoff } from './exponential-backoff-run.shared.ts';
|
||||
|
||||
/**
|
||||
* Tests that invalid constructor options are still rejected by the inner ExponentialBackoff.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffConstructorValidatesOptions = (): void => {
|
||||
expect(() => new ExponentialBackoffExternallyAbortable({ maxAttempts: -1 })).toThrow(ExponentialBackoffNumberTooSmallError);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoffExternallyAbortable.abort} before `run` prevents the task from
|
||||
* ever being invoked and rejects with a stopped-retries error whose cause is the internal abort error.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffAbortBeforeRun = async (): Promise<void> => {
|
||||
const taskFn = vi.fn(async () => 'should not run');
|
||||
const onError = vi.fn();
|
||||
const abortReason = 'stopped by abort()';
|
||||
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
// Abort before any attempt so the wrapper should short-circuit on the first inner call.
|
||||
backoff.abort(abortReason);
|
||||
|
||||
try {
|
||||
await backoff.run(taskFn, { onError });
|
||||
expect.fail('Expected ExponentialBackoffStoppedRetriesError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffInternalSignalAbortedError);
|
||||
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toEqual(new Error(abortReason));
|
||||
}
|
||||
|
||||
// The user task must not run; the wrapper throws before forwarding to it.
|
||||
expect(taskFn).not.toHaveBeenCalled();
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoffExternallyAbortable.abort} during a retry delay takes effect
|
||||
* immediately without calling the task again.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffAbortBetweenAttempts = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const taskFn = vi.fn(async () => {
|
||||
throw new Error('retry me');
|
||||
});
|
||||
const onError = vi.fn();
|
||||
const abortReason = new Error('stopped between attempts');
|
||||
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
baseDelay: 1_000,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
});
|
||||
|
||||
const errorPromise = backoff.run(taskFn, { onError }).catch((error: unknown) => error);
|
||||
|
||||
// First attempt runs immediately and fails, then a 1000ms delay is scheduled.
|
||||
await Promise.resolve();
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
backoff.abort(abortReason);
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffInternalSignalAbortedError);
|
||||
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toBe(abortReason);
|
||||
|
||||
// The user task ran only on the first attempt; the aborted second attempt never forwarded to it.
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0][0].message).toBe('retry me');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a constructor `abortSignal` that is already aborted prevents the task from running.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffAbortSignalAlreadyAborted = async (): Promise<void> => {
|
||||
const taskFn = vi.fn(async () => 'should not run');
|
||||
const onError = vi.fn();
|
||||
const abortReason = 'signal already aborted';
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortController.abort(abortReason);
|
||||
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
abortSignal: abortController.signal,
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
try {
|
||||
await backoff.run(taskFn, { onError });
|
||||
expect.fail('Expected ExponentialBackoffStoppedRetriesError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffExternalSignalAbortedError);
|
||||
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toEqual(new Error(abortReason));
|
||||
}
|
||||
|
||||
expect(taskFn).not.toHaveBeenCalled();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that aborting the constructor `abortSignal` interrupts the current retry delay.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffAbortSignalBetweenAttempts = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const taskFn = vi.fn(async () => {
|
||||
throw new Error('retry me');
|
||||
});
|
||||
const onError = vi.fn();
|
||||
const abortReason = new Error('signal aborted between attempts');
|
||||
|
||||
const abortController = new AbortController();
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
abortSignal: abortController.signal,
|
||||
baseDelay: 1_000,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
});
|
||||
|
||||
const errorPromise = backoff.run(taskFn, { onError }).catch((error: unknown) => error);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
abortController.abort(abortReason);
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBeInstanceOf(ExternallyAbortedExponentialBackoffExternalSignalAbortedError);
|
||||
expect(((error as ExponentialBackoffStoppedRetriesError).cause as Error).cause).toBe(abortReason);
|
||||
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0][0].message).toBe('retry me');
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a single {@link ExponentialBackoffExternallyAbortable.abort} stops every concurrent `run` on that instance.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffAbortStopsConcurrentRuns = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const firstTaskFn = vi.fn(async () => {
|
||||
throw new Error('first run failed');
|
||||
});
|
||||
const secondTaskFn = vi.fn(async () => {
|
||||
throw new Error('second run failed');
|
||||
});
|
||||
const abortReason = new Error('stop all runs');
|
||||
|
||||
// One instance is shared across concurrent runs, matching the class remark that abort stops all of them.
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
baseDelay: 1_000,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
});
|
||||
|
||||
// Handle both rejections before aborting the shared instance.
|
||||
const firstErrorPromise = backoff.run(firstTaskFn).catch((error: unknown) => error);
|
||||
const secondErrorPromise = backoff.run(secondTaskFn).catch((error: unknown) => error);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(firstTaskFn).toHaveBeenCalledTimes(1);
|
||||
expect(secondTaskFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
backoff.abort(abortReason);
|
||||
|
||||
await expect(firstErrorPromise).resolves.toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
await expect(secondErrorPromise).resolves.toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
|
||||
expect(firstTaskFn).toHaveBeenCalledTimes(1);
|
||||
expect(secondTaskFn).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a signal supplied for one run composes with the instance-wide abort sources.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffRunSignalInterruptsDelay = async (): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const abortReason = new Error('stop this run');
|
||||
const abortController = new AbortController();
|
||||
const taskFn = vi.fn().mockRejectedValue(new Error('retry me'));
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
baseDelay: 1_000,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
});
|
||||
|
||||
const errorPromise = backoff.run(taskFn, { signal: abortController.signal }).catch((error: unknown) => error);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(taskFn).toHaveBeenCalledOnce();
|
||||
|
||||
abortController.abort(abortReason);
|
||||
|
||||
const error = await errorPromise;
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffStoppedRetriesError);
|
||||
expect((error as ExponentialBackoffStoppedRetriesError).cause).toBe(abortReason);
|
||||
expect(taskFn).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that a successful run is unaffected by a later abort of the shared instance.
|
||||
*/
|
||||
const testExternallyAbortedExponentialBackoffSuccessfulRunIgnoresLaterAbort = async (): Promise<void> => {
|
||||
const taskFn = vi.fn(async () => 'success');
|
||||
|
||||
const backoff = new ExponentialBackoffExternallyAbortable({
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
const result = await backoff.run(taskFn);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(taskFn).toHaveBeenCalledOnce();
|
||||
|
||||
// Aborting after success must not throw retroactively; it only affects subsequent runs.
|
||||
backoff.abort(new Error('too late'));
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
const factory = (options?: Partial<ExponentialBackoffExternallyAbortableOptions>): ExponentialBackoffExternallyAbortable => {
|
||||
return new ExponentialBackoffExternallyAbortable(options);
|
||||
};
|
||||
|
||||
// Shared retry/delay/stopRetries contract, run against an adapter around instance run.
|
||||
testExponentialBackoff('ExternallyAbortedExponentialBackoff', factory);
|
||||
|
||||
// Behavior that only exists on the externally-aborted wrapper.
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: constructor still validates options',
|
||||
testExternallyAbortedExponentialBackoffConstructorValidatesOptions,
|
||||
);
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: abort before run stops without calling the task',
|
||||
testExternallyAbortedExponentialBackoffAbortBeforeRun,
|
||||
);
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: abort between attempts interrupts the delay',
|
||||
testExternallyAbortedExponentialBackoffAbortBetweenAttempts,
|
||||
);
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: abortSignal already aborted stops without calling the task',
|
||||
testExternallyAbortedExponentialBackoffAbortSignalAlreadyAborted,
|
||||
);
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: abortSignal between attempts interrupts the delay',
|
||||
testExternallyAbortedExponentialBackoffAbortSignalBetweenAttempts,
|
||||
);
|
||||
test('ExternallyAbortedExponentialBackoff: abort stops every concurrent run', testExternallyAbortedExponentialBackoffAbortStopsConcurrentRuns);
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: a per-run signal interrupts the delay',
|
||||
testExternallyAbortedExponentialBackoffRunSignalInterruptsDelay,
|
||||
);
|
||||
test(
|
||||
'ExternallyAbortedExponentialBackoff: successful run is unaffected by a later abort',
|
||||
testExternallyAbortedExponentialBackoffSuccessfulRunIgnoresLaterAbort,
|
||||
);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
@@ -0,0 +1,516 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
ExponentialBackoffCallbackParameters,
|
||||
ExponentialBackoffOptions,
|
||||
ExponentialBackoffRunOptions,
|
||||
} from '../../source/exponential-backoff/index.ts';
|
||||
|
||||
import {
|
||||
ExponentialBackoffMaxRetriesHitError,
|
||||
ExponentialBackoffNumberNotFiniteError,
|
||||
ExponentialBackoffStoppedRetriesError,
|
||||
} from '../../source/exponential-backoff/index.ts';
|
||||
|
||||
/**
|
||||
* The common run interface implemented by ExponentialBackoff and ExponentialBackoffExternallyAbortable.
|
||||
*/
|
||||
export type ExponentialBackoffInstance = {
|
||||
run: <T>(
|
||||
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
|
||||
options?: Partial<ExponentialBackoffRunOptions>,
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function passed into the tests to create an {@link ExponentialBackoffInstance}.
|
||||
*/
|
||||
export type ExponentialBackoffFactory = (options?: Partial<ExponentialBackoffOptions>) => ExponentialBackoffInstance;
|
||||
|
||||
/**
|
||||
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
|
||||
*/
|
||||
const testExponentialBackoffSucceedsOnFirstAttempt = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Always resolves — never enters the catch/retry branch.
|
||||
const resolveFn = vi.fn(async () => 'success');
|
||||
const onError = vi.fn();
|
||||
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
};
|
||||
|
||||
const result = await exponentialBackoffFactory(options).run(resolveFn, { onError });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(resolveFn).toHaveBeenCalledOnce();
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that retries continue across multiple failures until the function eventually resolves.
|
||||
*/
|
||||
const testExponentialBackoffRetriesUntilSuccess = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Three invocations: two rejections then a success on the third call.
|
||||
const tripleRejectFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 5,
|
||||
};
|
||||
|
||||
const result = await exponentialBackoffFactory(options).run(tripleRejectFn);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the onError callback is invoked once for every failed attempt, including the last one
|
||||
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
|
||||
*/
|
||||
const testExponentialBackoffCallsOnErrorForEachFailure = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
const error = new Error('temporary failure');
|
||||
|
||||
// Always rejects with the same error — we will exhaust all attempts.
|
||||
const rejectFn = vi.fn().mockRejectedValue(error);
|
||||
const onError = vi.fn();
|
||||
|
||||
// maxAttempts: 3 means three tries total, all of which will fail.
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
};
|
||||
|
||||
await expect(exponentialBackoffFactory(options).run(rejectFn, { onError })).rejects.toThrow(ExponentialBackoffMaxRetriesHitError);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
|
||||
* with every task error preserved in order on the cause.
|
||||
*/
|
||||
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
const firstError = new Error('first');
|
||||
const lastError = new Error('last');
|
||||
|
||||
// Two distinct errors so we can prove both are collected, not just the last one.
|
||||
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
|
||||
.mockRejectedValueOnce(lastError);
|
||||
|
||||
try {
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 2,
|
||||
};
|
||||
|
||||
await exponentialBackoffFactory(options).run(doubleRejectFn, { onError: vi.fn() });
|
||||
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
|
||||
expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]);
|
||||
}
|
||||
|
||||
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
|
||||
* so callers always observe a consistent error type in the callback.
|
||||
*/
|
||||
const testExponentialBackoffWrapsNonErrorThrows = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Reject with a plain string — not an Error subclass.
|
||||
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
|
||||
const onError = vi.fn();
|
||||
|
||||
// Single attempt — we fail fast and inspect what onError received.
|
||||
try {
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 1,
|
||||
};
|
||||
|
||||
await exponentialBackoffFactory(options).run(rejectedFn, { onError });
|
||||
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
|
||||
const [ wrappedError ] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[];
|
||||
expect(wrappedError).toBeInstanceOf(Error);
|
||||
expect(wrappedError.message).toBe('not-an-error');
|
||||
}
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(onError.mock.calls[0][0].message).toBe('not-an-error');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
|
||||
* and the onError callback is not called.
|
||||
*/
|
||||
const testExponentialBackoffRunSuccessAndAbortSignal = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and succeeds
|
||||
const abortAndSucceedFn = vi.fn(({ stopRetries }) => {
|
||||
stopRetries(new Error('retry me'));
|
||||
|
||||
return Promise.resolve('success');
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
};
|
||||
|
||||
const result = await exponentialBackoffFactory(options).run(abortAndSucceedFn, { onError: onErrorFn });
|
||||
|
||||
// Expect the result to be the success message
|
||||
expect(result).toBe('success');
|
||||
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
|
||||
|
||||
// Expect the onError callback to not have been called
|
||||
expect(onErrorFn).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the abort signal is aborted with an error, an ExponentialBackoffStoppedRetriesError is thrown
|
||||
* with the error as the message.
|
||||
*/
|
||||
const testExponentialBackoffRunWithAbortSignal = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and throws an error
|
||||
const abortAndThrowFn = vi.fn(({ stopRetries }) => {
|
||||
stopRetries(new Error('exponential backoff aborted'));
|
||||
throw new Error('error message');
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Define the expected error
|
||||
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
};
|
||||
|
||||
await expect(exponentialBackoffFactory(options).run(abortAndThrowFn, { onError: onErrorFn })).rejects.toThrow(expectedError);
|
||||
|
||||
// Expect the onError callback to have been called once with the error
|
||||
expect(onErrorFn).toHaveBeenCalledOnce();
|
||||
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
|
||||
|
||||
// Expect the function to have been called once and not to have resolved
|
||||
expect(abortAndThrowFn).toHaveBeenCalledOnce();
|
||||
expect(abortAndThrowFn).not.toHaveResolved();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that when the abort signal is aborted with a string, an ExponentialBackoffStoppedRetriesError is thrown
|
||||
* with the string as the message.
|
||||
*/
|
||||
const testExponentialBackoffRunAbortedStringCreatesError = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Define the function which aborts the exponential backoff and throws an error
|
||||
const abortAndThrowStringFn = vi.fn(({ stopRetries }) => {
|
||||
stopRetries('exponential backoff aborted');
|
||||
|
||||
// eslint-disable-next-line
|
||||
throw 'error message';
|
||||
});
|
||||
const onErrorFn = vi.fn();
|
||||
|
||||
// Define the expected error, Note that we "stopRetries" with just a string, not an error. They are treated equivalently.
|
||||
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
|
||||
|
||||
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
};
|
||||
|
||||
await expect(exponentialBackoffFactory(options).run(abortAndThrowStringFn, { onError: onErrorFn })).rejects.toThrow(expectedError);
|
||||
|
||||
// Expect the onError callback to have been called once with the error
|
||||
expect(onErrorFn).toHaveBeenCalledOnce();
|
||||
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
|
||||
|
||||
// Expect the function to have been called once and not to have resolved
|
||||
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
|
||||
expect(abortAndThrowStringFn).not.toHaveResolved();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the delay is aborted when the abort signal is activated.
|
||||
*/
|
||||
const testExponentialBackoffSignalInterruptsDelay = async (exponentialBackoffFactory: ExponentialBackoffFactory): 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 options = {
|
||||
baseDelay: 1000,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
};
|
||||
|
||||
const result = exponentialBackoffFactory(options).run(taskFn, { onError: vi.fn() });
|
||||
|
||||
// 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 that the exponential backoff is cancelled if the abort signal was activated before run()
|
||||
*/
|
||||
const testExponentialBackoffAlreadyAbortedSignalPreventsRun = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
const abortController = new AbortController();
|
||||
const abortSignal = abortController.signal;
|
||||
abortController.abort(new Error('already aborted'));
|
||||
|
||||
const taskFn = vi.fn();
|
||||
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
};
|
||||
|
||||
const promise = exponentialBackoffFactory(options).run(taskFn, { onError: vi.fn(), signal: abortSignal });
|
||||
|
||||
await expect(promise).rejects.toThrow(ExponentialBackoffStoppedRetriesError);
|
||||
|
||||
expect(taskFn).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
|
||||
*/
|
||||
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (factory: ExponentialBackoffFactory): Promise<void> => {
|
||||
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
|
||||
const tripleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockRejectedValueOnce(new Error('attempt 3'))
|
||||
.mockResolvedValueOnce('eventually');
|
||||
|
||||
const options = {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 0,
|
||||
};
|
||||
|
||||
const result = await factory(options).run(tripleRejectThenResolveFn, { onError: vi.fn() });
|
||||
|
||||
expect(result).toBe('eventually');
|
||||
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
|
||||
* (with jitter disabled so the math is exact).
|
||||
*/
|
||||
const testExponentialBackoffIncreasesDelayExponentially = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
try {
|
||||
const doubleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const options = {
|
||||
baseDelay: 100,
|
||||
growthRate: 2,
|
||||
jitter: 0,
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 5,
|
||||
};
|
||||
|
||||
const promise = exponentialBackoffFactory(options).run(doubleRejectThenResolveFn, { onError: vi.fn() });
|
||||
|
||||
// Attempt 0 fires synchronously on the first microtask tick.
|
||||
await Promise.resolve();
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
|
||||
*/
|
||||
const testExponentialBackoffCapsDelayAtMaxDelay = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
|
||||
try {
|
||||
const doubleRejectThenResolveFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockRejectedValueOnce(new Error('attempt 2'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const options = {
|
||||
baseDelay: 1_000,
|
||||
growthRate: 4,
|
||||
jitter: 0,
|
||||
maxDelay: 2_000,
|
||||
maxAttempts: 5,
|
||||
};
|
||||
|
||||
const promise = exponentialBackoffFactory(options).run(doubleRejectThenResolveFn, { onError: vi.fn() });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
|
||||
*/
|
||||
const testExponentialBackoffAppliesJitter = async (exponentialBackoffFactory: ExponentialBackoffFactory): Promise<void> => {
|
||||
vi.useFakeTimers();
|
||||
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||
|
||||
try {
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
|
||||
.mockResolvedValueOnce('success');
|
||||
|
||||
const options = {
|
||||
baseDelay: 1_000,
|
||||
growthRate: 1,
|
||||
jitter: 0.1,
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 3,
|
||||
};
|
||||
|
||||
const promise = exponentialBackoffFactory(options).run(rejectThenResolveFn, { onError: vi.fn() });
|
||||
|
||||
await Promise.resolve();
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
|
||||
await vi.advanceTimersByTimeAsync(899);
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
await expect(promise).resolves.toBe('success');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that passing undefined into the constructor does not cause an error during spread
|
||||
*/
|
||||
const testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread = async (factory: ExponentialBackoffFactory): 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(() => factory(options)).toThrow(ExponentialBackoffNumberNotFiniteError);
|
||||
};
|
||||
|
||||
export const testExponentialBackoff = (name: string, exponentialBackoffFactory: ExponentialBackoffFactory): void => {
|
||||
describe(name, () => {
|
||||
test('ExponentialBackoff: returns the result on first success', () =>
|
||||
testExponentialBackoffSucceedsOnFirstAttempt(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: retries until the function succeeds', () => testExponentialBackoffRetriesUntilSuccess(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: calls onError for each failed attempt', () =>
|
||||
testExponentialBackoffCallsOnErrorForEachFailure(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted', () =>
|
||||
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: wraps non-Error throws before calling onError', () =>
|
||||
testExponentialBackoffWrapsNonErrorThrows(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: succeeds and aborts with abort signal', () =>
|
||||
testExponentialBackoffRunSuccessAndAbortSignal(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: aborts with abort signal', () => testExponentialBackoffRunWithAbortSignal(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: aborts with aborted string creates error', () =>
|
||||
testExponentialBackoffRunAbortedStringCreatesError(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: aborts with abort signal, interrupts delay', () =>
|
||||
testExponentialBackoffSignalInterruptsDelay(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: already aborted signal prevents run', () =>
|
||||
testExponentialBackoffAlreadyAbortedSignalPreventsRun(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', () =>
|
||||
testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: increases delay exponentially between attempts', () =>
|
||||
testExponentialBackoffIncreasesDelayExponentially(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: caps delay at maxDelay', () => testExponentialBackoffCapsDelayAtMaxDelay(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: subtracts jitter from the capped delay', () => testExponentialBackoffAppliesJitter(exponentialBackoffFactory));
|
||||
test('ExponentialBackoff: constructor does not cause an error during spread', () =>
|
||||
testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread(exponentialBackoffFactory));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { testExponentialBackoff } from './exponential-backoff-run.shared.ts';
|
||||
|
||||
import { ExponentialBackoff } from '../../source/exponential-backoff/exponential-backoff.ts';
|
||||
import type { ExponentialBackoffOptions } from '../../source/exponential-backoff/exponential-backoff.ts';
|
||||
|
||||
/**
|
||||
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
|
||||
*/
|
||||
const validExponentialBackoffOptions = {
|
||||
maxDelay: 10_000,
|
||||
maxAttempts: 10,
|
||||
baseDelay: 1_000,
|
||||
growthRate: 2,
|
||||
jitter: 0.1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
|
||||
* as an alternative to the static helper.
|
||||
*/
|
||||
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
|
||||
const successfullyResolve = vi.fn(async () => 42);
|
||||
|
||||
// from() is a convenience constructor; run() on the result should behave like the static path.
|
||||
const backoff = ExponentialBackoff.from({
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
});
|
||||
|
||||
const result = await backoff.run(successfullyResolve);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(successfullyResolve).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
|
||||
* with library defaults (including the default 1000ms base delay) when no options are passed.
|
||||
*/
|
||||
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
|
||||
// Fake timers let us advance time without waiting real seconds between retries.
|
||||
vi.useFakeTimers();
|
||||
// Pin Math.random to 0 so jitter does not reduce the default delay.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
|
||||
try {
|
||||
// The wrapped function fails on its first invocation and succeeds on the second.
|
||||
// That forces ExponentialBackoff.run down the retry path using default options.
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('static-result');
|
||||
|
||||
// Call the static helper with no onError and no options — defaults apply entirely.
|
||||
const promise = ExponentialBackoff.run(rejectThenResolveFn);
|
||||
|
||||
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
|
||||
await Promise.resolve();
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
// The retry should have succeeded and returned the resolved value from the mock.
|
||||
await expect(promise).resolves.toBe('static-result');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
|
||||
* with defaults, still retrying when only some fields are overridden.
|
||||
*/
|
||||
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
|
||||
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('done');
|
||||
|
||||
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
|
||||
// onError is omitted to verify the default no-op handler is used.
|
||||
const result = await ExponentialBackoff.run(rejectThenResolveFn, {
|
||||
baseDelay: 0,
|
||||
jitter: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
expect(result).toBe('done');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
|
||||
* the instance's stored options when no per-run options are supplied.
|
||||
*/
|
||||
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
|
||||
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
|
||||
.mockResolvedValueOnce('instance-result');
|
||||
|
||||
// Options live on the instance; run(fn) should read them instead of static defaults.
|
||||
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
|
||||
|
||||
const result = await backoff.run(rejectThenResolveFn);
|
||||
|
||||
expect(result).toBe('instance-result');
|
||||
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
|
||||
const validCases = [
|
||||
validExponentialBackoffOptions,
|
||||
{
|
||||
...validExponentialBackoffOptions,
|
||||
maxDelay: 0,
|
||||
maxAttempts: 0,
|
||||
baseDelay: 0,
|
||||
growthRate: 0,
|
||||
jitter: 0,
|
||||
},
|
||||
{
|
||||
...validExponentialBackoffOptions,
|
||||
jitter: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const options of validCases) {
|
||||
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
|
||||
// Define our test cases with each value being less than 0
|
||||
const negativeCases = [
|
||||
{ field: 'maxDelay', value: -1 },
|
||||
{ field: 'maxAttempts', value: -1 },
|
||||
{ field: 'baseDelay', value: -1 },
|
||||
{ field: 'growthRate', value: -1 },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of negativeCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).toThrow(`Exponential backoff option "${field}" is too small. Must be at least 0`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
|
||||
// Define our test cases with each value being less than 0 or greater than 1
|
||||
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { value } of invalidJitterCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
jitter: value,
|
||||
})).toThrow('Exponential backoff option "jitter" is out of bounds. Must be between 0 and 1');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
|
||||
// Define our test cases with each value being Infinity
|
||||
const nonFiniteCases = [
|
||||
{ field: 'maxDelay', value: Infinity },
|
||||
{ field: 'maxAttempts', value: Infinity },
|
||||
{ field: 'baseDelay', value: Infinity },
|
||||
{ field: 'growthRate', value: Infinity },
|
||||
{ field: 'jitter', value: Infinity },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of nonFiniteCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).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`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
|
||||
*/
|
||||
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
|
||||
// Define our test cases with each value being NaN
|
||||
const nanCases = [
|
||||
{ field: 'maxDelay', value: Number.NaN },
|
||||
{ field: 'maxAttempts', value: Number.NaN },
|
||||
{ field: 'baseDelay', value: Number.NaN },
|
||||
{ field: 'growthRate', value: Number.NaN },
|
||||
{ field: 'jitter', value: Number.NaN },
|
||||
] as const;
|
||||
|
||||
// Iterate through the test cases and expect an error to be thrown
|
||||
for (const { field, value } of nanCases) {
|
||||
expect(() =>
|
||||
ExponentialBackoff.validateOptions({
|
||||
...validExponentialBackoffOptions,
|
||||
[field]: value,
|
||||
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
|
||||
}
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
const exponentialBackoffFactory = (options?: Partial<ExponentialBackoffOptions>): ExponentialBackoff => {
|
||||
return new ExponentialBackoff(options);
|
||||
};
|
||||
|
||||
// Test the shared ExponentialBackoff contract.
|
||||
testExponentialBackoff('ExponentialBackoff', exponentialBackoffFactory);
|
||||
|
||||
// APIs that only exist on ExponentialBackoff itself.
|
||||
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
|
||||
test('ExponentialBackoff: uses the default options when no options are passed', testExponentialBackoffRunUsesDefaultOptions);
|
||||
test('ExponentialBackoff: accepts partial options and merges them with defaults', testExponentialBackoffRunWithPartialOptions);
|
||||
test('ExponentialBackoff: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
|
||||
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
|
||||
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
|
||||
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues);
|
||||
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
@@ -0,0 +1,248 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { binToHex, generateRandomBytes, hexToBin } from '@bitauth/libauth';
|
||||
import {
|
||||
COMMIT_IDENTIFIER_BYTE_LENGTH,
|
||||
INVITATION_IDENTIFIER_BYTE_LENGTH,
|
||||
TEMPLATE_IDENTIFIER_BYTE_LENGTH,
|
||||
TOKEN_CATEGORY_BYTE_LENGTH,
|
||||
TRANSACTION_HASH_BYTE_LENGTH,
|
||||
} from '@xo-cash/primitives';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import {
|
||||
InvitationInvalidError,
|
||||
InvitationJsonMalformedError,
|
||||
InvitationSerializationFailedError,
|
||||
deserializeInvitation,
|
||||
parseInvitation,
|
||||
serializeInvitation,
|
||||
} from '../source/index.ts';
|
||||
|
||||
const invitationIdentifier = binToHex(generateRandomBytes(INVITATION_IDENTIFIER_BYTE_LENGTH));
|
||||
const templateIdentifier = binToHex(generateRandomBytes(TEMPLATE_IDENTIFIER_BYTE_LENGTH));
|
||||
const commitIdentifier = binToHex(generateRandomBytes(COMMIT_IDENTIFIER_BYTE_LENGTH));
|
||||
const outpointTransactionHash = binToHex(generateRandomBytes(TRANSACTION_HASH_BYTE_LENGTH));
|
||||
const tokenCategory = binToHex(generateRandomBytes(TOKEN_CATEGORY_BYTE_LENGTH));
|
||||
|
||||
// Valid invitation
|
||||
const buildValidInvitation = (): XOInvitation => {
|
||||
return {
|
||||
invitationIdentifier,
|
||||
createdAtTimestamp: 1700000000000,
|
||||
templateIdentifier,
|
||||
actionIdentifier: 'receive',
|
||||
commits: [
|
||||
{
|
||||
commitIdentifier,
|
||||
previousCommitIdentifier: undefined,
|
||||
entityIdentifier: 'xpub661MyMwAqRbcEngHLkoUWCKMBMGeqdqCGkqtzrHaTZub9ALw2oRfHA6fQP5n5X9VHStaNTBYomkSb8BFhUGavwD3RG1qvMkEKceTavTp2Tm',
|
||||
createdAtTimestamp: 1700000000000,
|
||||
data: {
|
||||
transactionVersion: 2,
|
||||
transactionLocktime: 0,
|
||||
variables: [{ variableIdentifier: 'amount', value: 18014398509481985n, roleIdentifier: 'sender' }],
|
||||
inputs: [
|
||||
{
|
||||
outpointTransactionHash: hexToBin(outpointTransactionHash),
|
||||
outpointIndex: 0,
|
||||
sequenceNumber: 0,
|
||||
inputIdentifier: 'fundingInput',
|
||||
roleIdentifier: 'sender',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
outputIdentifier: 'receiveOutput',
|
||||
valueSatoshis: 1000n,
|
||||
lockingBytecode: hexToBin('76a914000000000000000000000000000000000000000088ac'),
|
||||
token: { category: hexToBin(tokenCategory), amount: 21n },
|
||||
roleIdentifier: 'receiver',
|
||||
},
|
||||
],
|
||||
},
|
||||
signature:
|
||||
'5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b',
|
||||
expiresAtTimestamp: 1700003600000,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that parseInvitation accepts a valid invitation and returns the same object it received.
|
||||
*/
|
||||
const testParseInvitationReturnsReceivedObject = (): void => {
|
||||
const invitation = buildValidInvitation();
|
||||
|
||||
expect(parseInvitation(invitation)).toBe(invitation);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that an invitation survives a serialize and deserialize round trip.
|
||||
*/
|
||||
const testSerializeInvitationRoundTripsBigintAndBytes = (): void => {
|
||||
const invitation = buildValidInvitation();
|
||||
|
||||
const restoredInvitation = deserializeInvitation(serializeInvitation(invitation));
|
||||
|
||||
expect(restoredInvitation).toEqual(invitation);
|
||||
|
||||
const [ restoredCommit ] = restoredInvitation.commits;
|
||||
expect(restoredCommit!.data.variables![0]!.value).toBe(18014398509481985n);
|
||||
expect(restoredCommit!.data.outputs![0]!.valueSatoshis).toBe(1000n);
|
||||
expect(restoredCommit!.data.outputs![0]!.lockingBytecode).toBeInstanceOf(Uint8Array);
|
||||
expect(binToHex(restoredCommit!.data.inputs![0]!.outpointTransactionHash!)).toBe(outpointTransactionHash);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that parseInvitation throws InvitationInvalidError listing every missing required field.
|
||||
*/
|
||||
const testParseInvitationThrowsOnMissingRequiredFields = (): void => {
|
||||
let thrownError: unknown;
|
||||
|
||||
try {
|
||||
parseInvitation({});
|
||||
} catch (caughtError) {
|
||||
thrownError = caughtError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
||||
|
||||
const expectedMessage =
|
||||
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
|
||||
+ '- invitationIdentifier: expected string, received undefined\n'
|
||||
+ '- createdAtTimestamp: expected number, received undefined\n'
|
||||
+ '- templateIdentifier: expected string, received undefined\n'
|
||||
+ '- actionIdentifier: expected string, received undefined\n'
|
||||
+ '- commits: expected array, received undefined';
|
||||
|
||||
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that parseInvitation throws InvitationInvalidError reporting every field with a wrong type or out of range value.
|
||||
*/
|
||||
const testParseInvitationThrowsOnFieldTypeViolations = (): void => {
|
||||
const invitationWithWrongFieldTypes = { ...buildValidInvitation(), createdAtTimestamp: -1, commits: 'none' };
|
||||
|
||||
let thrownError: unknown;
|
||||
|
||||
try {
|
||||
parseInvitation(invitationWithWrongFieldTypes);
|
||||
} catch (caughtError) {
|
||||
thrownError = caughtError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
||||
|
||||
const expectedMessage =
|
||||
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
|
||||
+ '- createdAtTimestamp: Too small: expected number to be >=0\n'
|
||||
+ '- commits: expected array, received string';
|
||||
|
||||
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that parseInvitation throws InvitationInvalidError for an unrecognized property at the top level.
|
||||
*/
|
||||
const testParseInvitationThrowsOnUnknownProperties = (): void => {
|
||||
const invitationWithUnknownProperty = { ...buildValidInvitation(), unknownProperty: 'unexpected' };
|
||||
|
||||
let thrownError: unknown;
|
||||
|
||||
try {
|
||||
parseInvitation(invitationWithUnknownProperty);
|
||||
} catch (caughtError) {
|
||||
thrownError = caughtError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
||||
|
||||
const expectedMessage =
|
||||
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- (root): Unrecognized key: "unknownProperty"';
|
||||
|
||||
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that parseInvitation throws InvitationInvalidError with the full path of an unrecognized property inside commit data.
|
||||
*/
|
||||
const testParseInvitationThrowsOnDeepUnknownProperties = (): void => {
|
||||
const invitation = buildValidInvitation();
|
||||
const [ firstCommit ] = invitation.commits;
|
||||
const invitationWithDeepUnknown = { ...invitation, commits: [{ ...firstCommit, data: { ...firstCommit!.data, unknownData: 1 } }] };
|
||||
|
||||
let thrownError: unknown;
|
||||
|
||||
try {
|
||||
parseInvitation(invitationWithDeepUnknown);
|
||||
} catch (caughtError) {
|
||||
thrownError = caughtError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
|
||||
|
||||
const expectedMessage =
|
||||
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- commits.0.data: Unrecognized key: "unknownData"';
|
||||
|
||||
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that deserializeInvitation throws InvitationJsonMalformedError for text that is not valid JSON.
|
||||
*/
|
||||
const testDeserializeInvitationThrowsOnMalformedJson = (): void => {
|
||||
let thrownError: unknown;
|
||||
|
||||
try {
|
||||
deserializeInvitation('{"invitationIdentifier":');
|
||||
} catch (caughtError) {
|
||||
thrownError = caughtError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(InvitationJsonMalformedError);
|
||||
|
||||
const expectedMessage = 'Invitation JSON malformed: expected a valid JSON string, but got: Unexpected end of JSON input';
|
||||
|
||||
expect((thrownError as InvitationJsonMalformedError).message).toBe(expectedMessage);
|
||||
expect((thrownError as InvitationJsonMalformedError).reason).toBe('Unexpected end of JSON input');
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that serializeInvitation throws InvitationSerializationFailedError when the value cannot be written as JSON.
|
||||
*/
|
||||
const testSerializeInvitationThrowsOnCircularValue = (): void => {
|
||||
// A value that refers to itself has no JSON form
|
||||
const circularInvitation: Record<string, unknown> = { ...buildValidInvitation() };
|
||||
circularInvitation.self = circularInvitation;
|
||||
|
||||
let thrownError: unknown;
|
||||
|
||||
try {
|
||||
serializeInvitation(circularInvitation as XOInvitation);
|
||||
} catch (caughtError) {
|
||||
thrownError = caughtError;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(InvitationSerializationFailedError);
|
||||
|
||||
const expectedMessage =
|
||||
'Invitation serialization failed: Converting circular structure to JSON\n'
|
||||
+ " --> starting at object with constructor 'Object'\n"
|
||||
+ " --- property 'self' closes the circle";
|
||||
|
||||
expect((thrownError as InvitationSerializationFailedError).message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('parseInvitation: returns the received object for a valid invitation', testParseInvitationReturnsReceivedObject);
|
||||
test('serializeInvitation: round trips bigint and Uint8Array fields', testSerializeInvitationRoundTripsBigintAndBytes);
|
||||
test('parseInvitation: throws InvitationInvalidError on missing required fields', testParseInvitationThrowsOnMissingRequiredFields);
|
||||
test('parseInvitation: throws InvitationInvalidError on field type violations', testParseInvitationThrowsOnFieldTypeViolations);
|
||||
test('parseInvitation: throws InvitationInvalidError on unknown top-level property', testParseInvitationThrowsOnUnknownProperties);
|
||||
test('parseInvitation: throws InvitationInvalidError on unknown property inside commit data', testParseInvitationThrowsOnDeepUnknownProperties);
|
||||
test('deserializeInvitation: throws InvitationJsonMalformedError on malformed JSON', testDeserializeInvitationThrowsOnMalformedJson);
|
||||
test('serializeInvitation: throws InvitationSerializationFailedError on a circular value', testSerializeInvitationThrowsOnCircularValue);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
+49
-60
@@ -1,70 +1,59 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { tryAsync } from '../source/misc.ts';
|
||||
import { expect, test } from 'vitest';
|
||||
import { normalizeError } from '../source/misc.ts';
|
||||
|
||||
/** Spy used to confirm the wrapped async function ran successfully. */
|
||||
const successFlagFn = vi.fn();
|
||||
|
||||
/** Spy used to confirm the error callback was invoked on failure. */
|
||||
const errorFlagFn = vi.fn();
|
||||
/** Test cases for the normalizeError function. */
|
||||
const normalizeErrorTestCases = [
|
||||
{
|
||||
input: new Error('test error'),
|
||||
expected: new Error('test error'),
|
||||
},
|
||||
{
|
||||
input: 'test error',
|
||||
expected: new Error('test error'),
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: new Error('1'),
|
||||
},
|
||||
{
|
||||
input: undefined,
|
||||
expected: new Error('undefined'),
|
||||
},
|
||||
{
|
||||
input: null,
|
||||
expected: new Error('null'),
|
||||
},
|
||||
{
|
||||
input: false,
|
||||
expected: new Error('false'),
|
||||
},
|
||||
{
|
||||
input: true,
|
||||
expected: new Error('true'),
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
expected: new Error('[object Object]'),
|
||||
},
|
||||
{
|
||||
input: [],
|
||||
expected: new Error(),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Tests that tryAsync invokes the function and skips the error callback on success.
|
||||
* Tests that normalizeError converts non-Error throws to Error instances.
|
||||
*/
|
||||
const testTryAsyncCallsFunctionOnSuccess = async (): Promise<void> => {
|
||||
// Reset spies so prior test runs do not affect call counts.
|
||||
vi.clearAllMocks();
|
||||
|
||||
const successFn = async (): Promise<void> => {
|
||||
successFlagFn();
|
||||
};
|
||||
|
||||
await tryAsync(successFn);
|
||||
|
||||
// The wrapped function should run and no error handler should be called.
|
||||
expect(successFlagFn).toHaveBeenCalledOnce();
|
||||
expect(errorFlagFn).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that tryAsync invokes the error callback when the function throws.
|
||||
*/
|
||||
const testTryAsyncCallsErrorCallbackOnFailure = async (): Promise<void> => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const errorFn = async (): Promise<void> => {
|
||||
throw new Error('test');
|
||||
};
|
||||
|
||||
await tryAsync(errorFn, errorFlagFn);
|
||||
|
||||
// The success path should not run; the error callback should receive the failure.
|
||||
expect(successFlagFn).not.toHaveBeenCalled();
|
||||
expect(errorFlagFn).toHaveBeenCalledOnce();
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests that tryAsync wraps non-Error throws in Error instances before calling the error callback.
|
||||
*/
|
||||
const testTryAsyncConvertsNonErrorThrows = async (): Promise<void> => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const errorFn = async (): Promise<void> => {
|
||||
/* eslint-disable-next-line */
|
||||
throw 'test';
|
||||
};
|
||||
|
||||
await tryAsync(errorFn, errorFlagFn);
|
||||
|
||||
// Non-Error throws must be normalized to Error before onError is called.
|
||||
expect(successFlagFn).not.toHaveBeenCalled();
|
||||
expect(errorFlagFn).toHaveBeenCalledOnce();
|
||||
expect(errorFlagFn).toHaveBeenCalledWith(new Error('test'));
|
||||
const testNormalizeError = async (): Promise<void> => {
|
||||
for (const testCase of normalizeErrorTestCases) {
|
||||
const result = normalizeError(testCase.input);
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(result.message).toBe(testCase.expected.message);
|
||||
}
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
test('tryAsync: calls the function and skips the error callback on success', testTryAsyncCallsFunctionOnSuccess);
|
||||
test('tryAsync: calls the error callback when the function fails', testTryAsyncCallsErrorCallbackOnFailure);
|
||||
test('tryAsync: converts non-Error throws to Error instances', testTryAsyncConvertsNonErrorThrows);
|
||||
test('normalizeError: converts non-Error throws to Error instances', testNormalizeError);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { expect, test, vi, type Mock } from 'vitest';
|
||||
import { afterEach, expect, test, vi, type Mock } from 'vitest';
|
||||
|
||||
import { SSESession } from '../../source/sse-session/sse-session.ts';
|
||||
import { ExponentialBackoff } from '../../source/exponential-backoff.ts';
|
||||
import type { SSESessionOptions, SSEvent } from '../../source/sse-session/types.ts';
|
||||
import { ExponentialBackoff, ExponentialBackoffMaxRetriesHitError } from '../../source/exponential-backoff/index.ts';
|
||||
import type { SSESessionEventCallbacks, SSESessionOptions, SSEvent } from '../../source/sse-session/types.ts';
|
||||
|
||||
import { HTTPError, ResponseBodyNullError, SSESessionUnsubscribePluginNotAFunctionError } from '../../source/sse-session/errors.ts';
|
||||
import * as utils from '../../source/misc.ts';
|
||||
|
||||
import { SseTestStream, type SseTestStreamOptions } from './helpers/sse-stream.ts';
|
||||
import { priceOracleEvents, storageEvents } from './fixtures/events.fixtures.ts';
|
||||
import { ExponentialBackoffMaxRetriesHitError, HTTPError, ResponseBodyNullError } from '../../source/errors.ts';
|
||||
|
||||
class DocumentStub extends EventTarget {
|
||||
public visibilityState: DocumentVisibilityState = 'visible';
|
||||
}
|
||||
|
||||
const documentStub = new DocumentStub();
|
||||
|
||||
vi.stubGlobal('document', documentStub);
|
||||
|
||||
/** URL passed to every session under test. */
|
||||
const EVENTS_URL = '/events';
|
||||
@@ -75,7 +85,7 @@ const defaultSessionOptions: Partial<SSESessionOptions> = {
|
||||
* @param fetch - Mock fetch implementation.
|
||||
* @param options - Per-test session overrides merged on top of defaults.
|
||||
*/
|
||||
const createSession = async (fetch: FetchFn, options: Partial<SSESessionOptions> = {}): Promise<SSESession> => {
|
||||
const createSession = async (fetch: FetchFn, options: Partial<SSESessionOptions & SSESessionEventCallbacks> = {}): Promise<SSESession> => {
|
||||
return SSESession.create(EVENTS_URL, {
|
||||
...defaultSessionOptions,
|
||||
onError: vi.fn(),
|
||||
@@ -85,7 +95,7 @@ const createSession = async (fetch: FetchFn, options: Partial<SSESessionOptions>
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a callback against a session and always disconnects afterward.
|
||||
* Runs a callback against a session and always closes the session afterward.
|
||||
* Most tests use this so session lifecycle (connect on create, disconnect on exit)
|
||||
* is consistent and resources are not leaked between cases.
|
||||
*
|
||||
@@ -93,13 +103,17 @@ const createSession = async (fetch: FetchFn, options: Partial<SSESessionOptions>
|
||||
* @param options - Session options.
|
||||
* @param run - Test body receiving the connected session.
|
||||
*/
|
||||
const withSession = async <T>(fetch: FetchFn, options: Partial<SSESessionOptions>, run: (session: SSESession) => Promise<T>): Promise<T> => {
|
||||
const withSession = async <T>(
|
||||
fetch: FetchFn,
|
||||
options: Partial<SSESessionOptions & SSESessionEventCallbacks>,
|
||||
run: (session: SSESession) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const session = await createSession(fetch, options);
|
||||
|
||||
try {
|
||||
return await run(session);
|
||||
} finally {
|
||||
await session.disconnect();
|
||||
session.close();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,13 +184,42 @@ const testSseSessionConnectCallsFetchWithExpectedOptions = async (): Promise<voi
|
||||
);
|
||||
|
||||
// While the stream is active the abort signal must not yet be triggered.
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false);
|
||||
// Grab the first call to fetchMock
|
||||
const firstCall = fetchMock.mock.calls[0];
|
||||
|
||||
// Grab the second parameter of the first call to fetchMock (the request init)
|
||||
// - `fetch(url, options)` - we are grabbing the options object.
|
||||
const firstCallParams = firstCall?.[1];
|
||||
|
||||
// Assert the abort signal is not yet triggered. If it is, the stream is closed.
|
||||
expect(firstCallParams?.signal?.aborted).toBe(false);
|
||||
});
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
const testSseSessionUsesDefaultFetchImplementation = async (): Promise<void> => {
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const session = new SSESession('http://test.com');
|
||||
await session.connect();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://test.com',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: a client connects and the application wants a lifecycle callback when the stream is ready.
|
||||
* Verifies onConnected fires after the transport is established.
|
||||
@@ -206,9 +249,14 @@ const testSseSessionConnectDoesNotOpenSecondTransport = async (): Promise<void>
|
||||
// Pull one event so the first transport is fully established and reading.
|
||||
await readMessages(session, 1);
|
||||
|
||||
// The session has already been connected, so we expect the fetchMock to have been called once.
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
|
||||
// Idempotent connect — should be a no-op at the fetch layer.
|
||||
// If this were to create a second transport, it would increment the fetchMock call count by one.
|
||||
await session.connect();
|
||||
|
||||
// Since we are testing that a second transport is not opened, we expect the fetchMock to still have only been called once.
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
} finally {
|
||||
@@ -325,7 +373,7 @@ const testSseSessionEmitsMessageEvents = async (): Promise<void> => {
|
||||
|
||||
expect(emitted).toEqual([ parsed![0] ]);
|
||||
} finally {
|
||||
await session.disconnect();
|
||||
session.close();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
@@ -379,24 +427,31 @@ const testSseSessionClosesOnServerCloseWhenNotPersistent = async (): Promise<voi
|
||||
* Verifies a second fetch opens and events flow again (non-persistent manual reconnect path).
|
||||
*/
|
||||
const testSseSessionReconnectsAfterTerminalClose = async (): Promise<void> => {
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }));
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
// Override the default responder: first fetch closes after one event, second stays open.
|
||||
// First fetch closes after one event, second fetch stays open.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }))
|
||||
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ]));
|
||||
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }))
|
||||
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ]));
|
||||
|
||||
try {
|
||||
await withSession(fetchMock, {}, async (session) => {
|
||||
await readMessages(session, 1);
|
||||
// Read the first streams message
|
||||
const [ event0 ] = await readMessages(session, 1);
|
||||
expect(event0).toEqual(priceOracleEvents[0]!.parsed![0]);
|
||||
|
||||
// Wait for the stream to close
|
||||
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
|
||||
|
||||
// App-initiated reconnect after the first stream ended.
|
||||
await session.connect();
|
||||
|
||||
const [ event ] = await readMessages(session, 1);
|
||||
expect(event).toEqual(storageEvents[0]!.parsed![0]);
|
||||
// Read the next message from the stream
|
||||
// It should be different because we should be using a different fetch call instance)
|
||||
const [ event1 ] = await readMessages(session, 1);
|
||||
expect(event1).toEqual(priceOracleEvents[1]!.parsed![0]);
|
||||
|
||||
// Assert that we have made two fetch calls
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
} finally {
|
||||
@@ -409,8 +464,9 @@ const testSseSessionReconnectsAfterTerminalClose = async (): Promise<void> => {
|
||||
* Verifies two fetch calls and events from both streams arrive on the same message iterator.
|
||||
*/
|
||||
const testSseSessionPersistentReconnectsOnServerClose = async (): Promise<void> => {
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }));
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
// First fetch closes after one event, second fetch stays open.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }))
|
||||
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ]));
|
||||
@@ -425,8 +481,13 @@ const testSseSessionPersistentReconnectsOnServerClose = async (): Promise<void>
|
||||
async (session) => {
|
||||
const [ first, second ] = await readMessages(session, 2);
|
||||
|
||||
// Assert that we have received the first event
|
||||
expect(first).toEqual(priceOracleEvents[0]!.parsed![0]);
|
||||
|
||||
// Assert that we have received the second event
|
||||
expect(second).toEqual(priceOracleEvents[1]!.parsed![0]);
|
||||
|
||||
// Assert that we have made two fetch calls
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
},
|
||||
);
|
||||
@@ -440,7 +501,7 @@ const testSseSessionPersistentReconnectsOnServerClose = async (): Promise<void>
|
||||
* Verifies the session fetches again and the iterator continues delivering events.
|
||||
*/
|
||||
const testSseSessionReconnectsOnTransportError = async (): Promise<void> => {
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ]));
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
fetchMock
|
||||
// First connection: delivers one event then the stream errors (failingStreamAfter).
|
||||
@@ -494,14 +555,14 @@ const testSseSessionClosesOnTransportErrorWhenNotReconnecting = async (): Promis
|
||||
* Scenario: the user aborts an active connection (e.g. navigation away) but may reconnect later.
|
||||
* Verifies onDisconnected fires, fetch is aborted, but the message iterator stays open.
|
||||
*/
|
||||
const testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen = async (): Promise<void> => {
|
||||
const testSseSessionDisconnectEmitsDisconnectedAndKeepsMessagesOpen = async (): Promise<void> => {
|
||||
const onDisconnected = vi.fn();
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
|
||||
|
||||
try {
|
||||
await withSession(fetchMock, { onDisconnected }, async (session) => {
|
||||
await readMessages(session, 1);
|
||||
await session.abort();
|
||||
session.disconnect();
|
||||
|
||||
expect(onDisconnected).toHaveBeenCalled();
|
||||
expect(session.messages.closed).toBe(false);
|
||||
@@ -513,16 +574,16 @@ const testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen = async (): Promi
|
||||
|
||||
/**
|
||||
* Scenario: the application fully tears down the session (logout, component unmount, etc.).
|
||||
* Verifies disconnect closes the message iterator and emits the closed event.
|
||||
* Verifies close closes the message iterator and emits the closed event.
|
||||
*/
|
||||
const testSseSessionDisconnectClosesMessagesAndEmitsClosed = async (): Promise<void> => {
|
||||
const testSseSessionCloseClosesMessagesAndEmitsClosed = async (): Promise<void> => {
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
|
||||
|
||||
const session = await createSession(fetchMock, {});
|
||||
const closed = new Promise<void>((resolve) => session.once('closed', () => resolve()));
|
||||
|
||||
try {
|
||||
await session.disconnect();
|
||||
session.close();
|
||||
|
||||
expect(session.messages.closed).toBe(true);
|
||||
await expect(closed).resolves.toBeUndefined();
|
||||
@@ -549,12 +610,108 @@ const testSseSessionHttpErrorCallsOnErrorAndThrows = async (): Promise<void> =>
|
||||
await expect(createSession(fetchMock, { onError })).rejects.toThrow(expectedError);
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
|
||||
|
||||
// Grab the parameters of the first call to onError
|
||||
const firstCall = onError.mock.calls[0];
|
||||
|
||||
// Grab the First Parameter of the first call to onError
|
||||
// - `onError(error)` - we are grabbing the error object.
|
||||
const firstCallParams = firstCall?.[0];
|
||||
|
||||
// Assert the error is an instance of Error that matches the expected error.
|
||||
expect(firstCallParams).toBeInstanceOf(Error);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The connectionController changes between the reader being created and #readStream being called.
|
||||
* Verifies the session closes the reader and doesn't continue reading the stream.
|
||||
*/
|
||||
const testSseSessionClosesReaderIfConnectionChanges = async (): Promise<void> => {
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
|
||||
const onConnected = vi.fn();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const onRequest = async (requestInit: RequestInit): Promise<RequestInit> => {
|
||||
// Swap out the signal (so it doesn't close the connection)
|
||||
requestInit.signal = abortController.signal;
|
||||
|
||||
return requestInit;
|
||||
};
|
||||
|
||||
const session = new SSESession('http://test.com', { fetch: fetchMock, onRequest, onConnected, persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Provide a mock retry which will swap out the connection controller after the session connects
|
||||
const retry = {
|
||||
run: vi.fn().mockImplementation(async (fn) => {
|
||||
const result = await fn();
|
||||
|
||||
// "Disconnect" the session
|
||||
// This won't disconnect the fetch since we replaced the signal with a different one
|
||||
session.disconnect();
|
||||
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
|
||||
// Replace the retry with our mock retry
|
||||
session.options.retry = retry;
|
||||
|
||||
// Connect the session
|
||||
await session.connect();
|
||||
|
||||
// Expect the session to not have connected (it should have hit the exceptional behavior block)
|
||||
expect(onConnected).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The #readStream throws an unhandled error
|
||||
* Verifies the error event is emitted and the session is ready for a new connection.
|
||||
*/
|
||||
const testSseSessionEmitsErrorEventOnReadStreamError = async (): Promise<void> => {
|
||||
// The mock fetch will return a "reader" that throws from .read() so that we can hit the catch block in #readStream.
|
||||
// We will also mock the `normalizeError` method to throw, this should cascade up to the .catch handler in `connect`
|
||||
const fetchMock = createFetchMock(() => {
|
||||
return {
|
||||
body: {
|
||||
getReader: vi.fn().mockReturnValue({
|
||||
read: vi.fn().mockRejectedValue(new Error('Network failure')),
|
||||
}),
|
||||
},
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
ok: true,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
// Mock the normalizeError method to throw, this should cascade up to the .catch handler in `connect`
|
||||
const normalizeErrorSpy = vi.spyOn(utils, 'normalizeError').mockImplementation(() => {
|
||||
throw new Error('Normalize Error failed');
|
||||
});
|
||||
|
||||
// Create listeners for the error and disconnected events
|
||||
const onError = vi.fn();
|
||||
const onDisconnected = vi.fn();
|
||||
|
||||
// Create a session
|
||||
const session = new SSESession('http://test.com', { fetch: fetchMock, onError, onDisconnected, persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Connect the session
|
||||
await session.connect();
|
||||
|
||||
// Wait for the normalizeError method to be called (since #readStream is run as "fire and forget", connect resolved before our error is thrown)
|
||||
await vi.waitFor(() => expect(normalizeErrorSpy).toHaveBeenCalled());
|
||||
|
||||
// Expect the error event to be emitted with the error from the normalizeError method
|
||||
expect(onError).toHaveBeenCalledWith(new Error('Normalize Error failed'));
|
||||
|
||||
// OnDisconnected should not have been called because the error should have thrown earlier than this emitter
|
||||
expect(onDisconnected).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: fetch returns 200 but with a null body (misconfigured proxy or server bug).
|
||||
* Verifies create rejects because SSE requires a readable stream body.
|
||||
@@ -580,37 +737,397 @@ const testSseSessionRejectsWhenResponseBodyIsNull = async (): Promise<void> => {
|
||||
*/
|
||||
const testSseSessionSendsLastEventIdOnReconnect = async (): Promise<void> => {
|
||||
const { raw } = storageEvents[0]!;
|
||||
let reconnectHeaders: Record<string, string> | undefined;
|
||||
const fetchMock = createFetchMock(() => sseFetchResponse([ raw ]));
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
fetchMock
|
||||
// First connection: heartbeat chunk, then the event (with id: 1234), then server closes.
|
||||
.mockResolvedValueOnce(sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 10, closeWhenDone: true }))
|
||||
// Second connection: capture whatever headers the reconnect logic attached.
|
||||
.mockImplementationOnce(async (_url, init) => {
|
||||
reconnectHeaders = init.headers as Record<string, string>;
|
||||
.mockResolvedValueOnce(sseFetchResponse([ ':\n\n', raw ], { closeWhenDone: true }))
|
||||
// Second connection: just connects, we don't really care
|
||||
.mockResolvedValueOnce(sseFetchResponse([]));
|
||||
|
||||
return sseFetchResponse([]);
|
||||
});
|
||||
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) });
|
||||
const session = new SSESession('http://test.com', { fetch: fetchMock, persistent: true, retry: testRetry(2) });
|
||||
|
||||
try {
|
||||
// Registers an onRequest hook that copies the last seen event id into reconnect headers.
|
||||
await SSESession.addLastEventIdReconnect(session);
|
||||
SSESession.addLastEventIdReconnect(session);
|
||||
|
||||
// Connect the session
|
||||
await session.connect();
|
||||
|
||||
// Wait for the first message to be read, then we will disable the listener
|
||||
await readMessages(session, 1);
|
||||
|
||||
// Wait for us to fetch 2 times
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
expect(reconnectHeaders?.['Last-Event-ID']).toBe('1234');
|
||||
|
||||
// Expect that the second fetch (so after our first message) includes the Last-Event-ID header with the id of the first message
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Last-Event-ID': '1234',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.disconnect();
|
||||
session.close();
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: Attempt to add 2 `lastEventIdReconnect` plugins to the same session.
|
||||
* Verifies the second plugin is not added, but no error is thrown
|
||||
*/
|
||||
const testSseSessionIdempotentlyHandlesAddingTheSamePluginTwice = async (): Promise<void> => {
|
||||
// Create a new session
|
||||
const session = new SSESession('http://test.com', { persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Add the listener the first time
|
||||
SSESession.addLastEventIdReconnect(session);
|
||||
|
||||
// Expect it to be added and that there is only a single plugin installed
|
||||
expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(true);
|
||||
expect(session.plugins.size).toBe(1);
|
||||
|
||||
// Add the listener the second time
|
||||
SSESession.addLastEventIdReconnect(session);
|
||||
|
||||
// Expect it to be added and that there is only a single plugin installed
|
||||
expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(true);
|
||||
expect(session.plugins.size).toBe(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: A non-function was provided as the unsubscribe function for a plugin.
|
||||
* Verifies an error is thrown and the plugin is not added.
|
||||
*/
|
||||
const testSseSessionThrowsWhenProvidingANonFunctionAsTheUnsubscribeFunction = async (): Promise<void> => {
|
||||
// Create a new session
|
||||
const session = new SSESession('http://test.com', { persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Set the plugin to a non-function
|
||||
session.plugins.set(SSESession.addLastEventIdReconnect, 'not a function');
|
||||
|
||||
// Add the listener with a non-function
|
||||
expect(() => SSESession.addLastEventIdReconnect(session)).toThrow(SSESessionUnsubscribePluginNotAFunctionError);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The lastEventIdReconnect plugin can be removed when the unsubscribe method is called.
|
||||
* Verifies the plugin is removed and the session no longer sends the Last-Event-ID header on reconnect.
|
||||
*/
|
||||
const testSseSessionLastEventIdReconnectPluginCanBeRemoved = async (): Promise<void> => {
|
||||
const events = [{ raw: 'id: 1\ndata: 1\n\n' }, { raw: 'id: 2\ndata: 2\n\n' }, { raw: 'data: 3\n\n' }];
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
// Create a mock fetch that will be used to create the session
|
||||
fetchMock
|
||||
// First connection: Send event with id: 1
|
||||
.mockResolvedValueOnce(sseFetchResponse([ events[0]!.raw ], { closeWhenDone: true }))
|
||||
// Second connection: Send event with id: 2
|
||||
.mockResolvedValueOnce(sseFetchResponse([ events[1]!.raw ], { closeWhenDone: true }))
|
||||
// Third connection: Send event with id: 3, we don't expect to see the header with id 2 here
|
||||
.mockResolvedValueOnce(sseFetchResponse([ events[2]!.raw ], { closeWhenDone: false }));
|
||||
|
||||
// Create a new session
|
||||
const session = new SSESession('http://test.com', { fetch: fetchMock, persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Add the plugin to the session
|
||||
const { removeListener } = SSESession.addLastEventIdReconnect(session);
|
||||
|
||||
// Expect the plugin to be added
|
||||
expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(true);
|
||||
|
||||
// Connect the session
|
||||
await session.connect();
|
||||
|
||||
// Wait for the first message to be read, then we will disable the listener
|
||||
await readMessages(session, 1);
|
||||
|
||||
// Call the unsubscribe method
|
||||
removeListener?.();
|
||||
|
||||
// Expect the plugin to be removed
|
||||
expect(session.plugins.has(SSESession.addLastEventIdReconnect)).toBe(false);
|
||||
|
||||
// Wait for the rest of the messages to be read
|
||||
await readMessages(session, 2);
|
||||
|
||||
// Wait for us to fetch 3 times
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
||||
|
||||
// Expect that the second fetch (so after our first message) includes the Last-Event-ID header with the id of the first message
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Last-Event-ID': '1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Our second reconnect, the listener was disabled, so we don't expect to see the Last-Event-ID header with the id of the second message
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
'Last-Event-ID': expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The browser visibility change handler is supported
|
||||
* Verifies the session is connected and disconnected when the document visibility state changes.
|
||||
*/
|
||||
const testSseSessionBrowserVisibilityChangeIsSupported = async (): Promise<void> => {
|
||||
// Create a fetch mock
|
||||
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
|
||||
const fetchMock = createFetchMock(() => response);
|
||||
|
||||
// Create mock callbacks for connection to test connection state changes
|
||||
const onConnected = vi.fn();
|
||||
const onDisconnected = vi.fn();
|
||||
|
||||
// Create, and connect, a session
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected });
|
||||
|
||||
// Expect the session to be connected
|
||||
await vi.waitFor(() => expect(onConnected).toHaveBeenCalled());
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Add browser visibility change listener
|
||||
SSESession.addBrowserVisibilityHandler(session);
|
||||
|
||||
// Expect the session to be connected
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Set the document visibility state to hidden and dispatch a visibilitychange event
|
||||
documentStub.visibilityState = 'hidden';
|
||||
documentStub.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
// Expect the session to be disconnected
|
||||
expect(session.active).toBe(false);
|
||||
await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalledWith({ reason: 'disconnect', source: SSESession.addBrowserVisibilityHandler }));
|
||||
|
||||
// Wait for the session to be disconnected
|
||||
documentStub.visibilityState = 'visible';
|
||||
documentStub.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
// Expect the session to be connected again
|
||||
expect(session.active).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The browser visibility change handler is only added once
|
||||
* Verifies the handler is only added once and that the session is connected and disconnected when the document visibility state changes.
|
||||
*/
|
||||
const testSseSessionBrowserVisibilityOnlyAddsSingleHandler = async (): Promise<void> => {
|
||||
// Create a fetch mock
|
||||
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
|
||||
const fetchMock = createFetchMock(() => response);
|
||||
|
||||
// Create a spy on the document.addEventListener method
|
||||
const addEventListenerSpy = vi.spyOn(documentStub, 'addEventListener');
|
||||
|
||||
// Create, and connect, a session
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Expect the session to be connected
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Add browser visibility change listener
|
||||
SSESession.addBrowserVisibilityHandler(session);
|
||||
|
||||
// Expect the plugin to be added and that there is only a single plugin installed
|
||||
expect(session.plugins.size).toBe(1);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Add the listener the second time
|
||||
SSESession.addBrowserVisibilityHandler(session);
|
||||
|
||||
// Expect the plugin to be added and that there is only a single plugin installed
|
||||
expect(session.plugins.size).toBe(1);
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The browser visibility change handler throws an error if the existing plugin is not a function
|
||||
* Verifies the handler is not added and an error is thrown.
|
||||
*/
|
||||
const testSseSessionBrowserVisibilityThrowsErrorIfExistingPluginIsNotAFunction = async (): Promise<void> => {
|
||||
// Create a fetch mock
|
||||
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
|
||||
const fetchMock = createFetchMock(() => response);
|
||||
|
||||
// Spy on the document.addEventListener method
|
||||
const addEventListenerSpy = vi.spyOn(documentStub, 'addEventListener');
|
||||
|
||||
// Create a new session
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) });
|
||||
|
||||
// Set the plugin to a non-function
|
||||
session.plugins.set(SSESession.addBrowserVisibilityHandler, 'not a function');
|
||||
|
||||
// Expect an error to be thrown
|
||||
expect(() => SSESession.addBrowserVisibilityHandler(session)).toThrow(SSESessionUnsubscribePluginNotAFunctionError);
|
||||
|
||||
// Expect the document.addEventListener method to not have been called
|
||||
expect(addEventListenerSpy).toHaveBeenCalledTimes(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The browser visibility change handler is removed if the session is disconnected by `disconnect`.
|
||||
* Verifies the handler is removed and the session is no longer connected when the document visibility state changes.
|
||||
*/
|
||||
const testSseSessionBrowserVisibilityRemovesVisibleHandlerIfDisconnectIsCalled = async (): Promise<void> => {
|
||||
// Create a fetch mock
|
||||
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
|
||||
const fetchMock = createFetchMock(() => response);
|
||||
|
||||
// Create a mock callbacks for connection to test connection state changes
|
||||
const onConnected = vi.fn();
|
||||
const onDisconnected = vi.fn();
|
||||
const onBeforeDisconnect = vi.fn();
|
||||
|
||||
// Create a spy on the document.removeEventListener method
|
||||
const removeEventListenerSpy = vi.spyOn(documentStub, 'removeEventListener');
|
||||
|
||||
// Create, and connect, a session
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected });
|
||||
session.on('beforeDisconnect', onBeforeDisconnect);
|
||||
|
||||
// Expect the session to be connected
|
||||
await vi.waitFor(() => expect(onConnected).toHaveBeenCalled());
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Add browser visibility change listener
|
||||
SSESession.addBrowserVisibilityHandler(session);
|
||||
|
||||
// Expect the session to be connected
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Set the document visibility state to hidden and dispatch a visibilitychange event
|
||||
documentStub.visibilityState = 'hidden';
|
||||
documentStub.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(session.active).toBe(false);
|
||||
await vi.waitFor(() => expect(onBeforeDisconnect).toHaveBeenCalledWith({ source: SSESession.addBrowserVisibilityHandler }));
|
||||
await vi.waitFor(() => expect(onDisconnected).toHaveBeenCalledWith({ reason: 'disconnect', source: SSESession.addBrowserVisibilityHandler }));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Disconnect the session
|
||||
session.disconnect({ source: 'force-disconnect' });
|
||||
|
||||
// Should still be disconnected
|
||||
expect(session.active).toBe(false);
|
||||
await vi.waitFor(() => expect(onBeforeDisconnect).toHaveBeenLastCalledWith({ source: 'force-disconnect' }));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Set the document visibility state to visible and dispatch a visibilitychange event
|
||||
documentStub.visibilityState = 'visible';
|
||||
documentStub.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
// Should still be disconnected
|
||||
expect(session.active).toBe(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The browser visibility change handler is removed if the `removeListener` method is called.
|
||||
* Verifies the handler is removed and the session is no longer connected when the document visibility state changes.
|
||||
*/
|
||||
const testSseSessionBrowserVisibilityRemovesVisibleHandlerWhenUnsubscribed = async (): Promise<void> => {
|
||||
// Create a fetch mock
|
||||
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
|
||||
const fetchMock = createFetchMock(() => response);
|
||||
|
||||
const onConnected = vi.fn();
|
||||
const onDisconnected = vi.fn();
|
||||
|
||||
// Create a new session
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected });
|
||||
|
||||
expect(onConnected).toHaveBeenCalled();
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Add browser visibility change listener
|
||||
const { removeListener } = SSESession.addBrowserVisibilityHandler(session);
|
||||
|
||||
// Expect the plugin to be added and that there is only a single plugin installed
|
||||
expect(session.plugins.size).toBe(1);
|
||||
|
||||
// Call the unsubscribe method
|
||||
removeListener?.();
|
||||
|
||||
// Expect the plugin to be removed
|
||||
expect(session.plugins.size).toBe(0);
|
||||
|
||||
// Test the disconnect by hiding the document and dispatching a visibilitychange event
|
||||
documentStub.visibilityState = 'hidden';
|
||||
documentStub.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
// Expect the session to be disconnected
|
||||
expect(session.active).toBe(true);
|
||||
expect(onDisconnected).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
/**
|
||||
* Scenario: The browser visibility change handler is not removed if the method to remove the handler is not the same
|
||||
* Verifies the handler is not removed and the session is still connected when the document visibility state changes.
|
||||
*/
|
||||
const testSseSessionBrowserVisibilityDoesNotRemoveHandlerIfPluginIsNotTheSame = async (): Promise<void> => {
|
||||
// Create a fetch mock
|
||||
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
|
||||
const fetchMock = createFetchMock(() => response);
|
||||
|
||||
const onConnected = vi.fn();
|
||||
const onDisconnected = vi.fn();
|
||||
|
||||
// Create a new session
|
||||
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2), onConnected, onDisconnected });
|
||||
|
||||
// Assert that the session connected
|
||||
expect(onConnected).toHaveBeenCalled();
|
||||
expect(session.active).toBe(true);
|
||||
|
||||
// Add browser visibility change listener
|
||||
const { removeListener } = SSESession.addBrowserVisibilityHandler(session);
|
||||
|
||||
// Overwrite the plugin with a different function
|
||||
session.plugins.set(SSESession.addBrowserVisibilityHandler, () => 1);
|
||||
|
||||
// Call the unsubscribe method
|
||||
removeListener?.();
|
||||
|
||||
// Expect the plugin to be removed
|
||||
expect(session.plugins.size).toBe(1);
|
||||
|
||||
// Test the disconnect by hiding the document and dispatching a visibilitychange event
|
||||
documentStub.visibilityState = 'hidden';
|
||||
documentStub.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
// Expect the session to be disconnected
|
||||
expect(session.active).toBe(true);
|
||||
await vi.waitFor(() => expect(onDisconnected).not.toHaveBeenCalled());
|
||||
};
|
||||
|
||||
const runTests = async (): Promise<void> => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
|
||||
documentStub.visibilityState = 'visible';
|
||||
});
|
||||
|
||||
test('SSESession.connect: calls injected fetch with method, headers, and abort signal', testSseSessionConnectCallsFetchWithExpectedOptions);
|
||||
test('SSESession.connect: uses default fetch implementation if no fetch is provided', testSseSessionUsesDefaultFetchImplementation);
|
||||
test('SSESession.connect: invokes onConnected when the stream is established', testSseSessionConnectInvokesOnConnected);
|
||||
test('SSESession.connect: does not open a second transport when connect is called again', testSseSessionConnectDoesNotOpenSecondTransport);
|
||||
test('SSESession.connect: passes request mutations from onRequest to fetch', testSseSessionConnectPassesOnRequestMutations);
|
||||
@@ -623,11 +1140,46 @@ const runTests = async (): Promise<void> => {
|
||||
test('SSESession: reconnects when the server closes the stream and persistent is true', testSseSessionPersistentReconnectsOnServerClose);
|
||||
test('SSESession: reconnects when attemptReconnect is true', testSseSessionReconnectsOnTransportError);
|
||||
test('SSESession: closes messages and emits error when attemptReconnect is false', testSseSessionClosesOnTransportErrorWhenNotReconnecting);
|
||||
test('SSESession.abort: aborts fetch, emits disconnected, and keeps messages open', testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen);
|
||||
test('SSESession.disconnect: closes messages and emits closed', testSseSessionDisconnectClosesMessagesAndEmitsClosed);
|
||||
test(
|
||||
'SSESession.disconnect: disconnects fetch, emits disconnected, and keeps messages open',
|
||||
testSseSessionDisconnectEmitsDisconnectedAndKeepsMessagesOpen,
|
||||
);
|
||||
test('SSESession.close: closes messages and emits closed', testSseSessionCloseClosesMessagesAndEmitsClosed);
|
||||
test('SSESession: calls onError, emits error, and throws from create on HTTP error', testSseSessionHttpErrorCallsOnErrorAndThrows);
|
||||
test('SSESession: closes reader if connection changes', testSseSessionClosesReaderIfConnectionChanges);
|
||||
test('SSESession: emits error event on read stream error', testSseSessionEmitsErrorEventOnReadStreamError);
|
||||
test('SSESession: rejects when the response body is null', testSseSessionRejectsWhenResponseBodyIsNull);
|
||||
test('SSESession: sends Last-Event-ID on reconnect after receiving an event with an id', testSseSessionSendsLastEventIdOnReconnect);
|
||||
test(
|
||||
'SSESession: lastEventIdReconnect sends Last-Event-ID on reconnect after receiving an event with an id',
|
||||
testSseSessionSendsLastEventIdOnReconnect,
|
||||
);
|
||||
test(
|
||||
'SSESession: lastEventIdReconnect idempotently handles adding the same plugin twice',
|
||||
testSseSessionIdempotentlyHandlesAddingTheSamePluginTwice,
|
||||
);
|
||||
test(
|
||||
'SSESession: lastEventIdReconnect throws when providing a non-function as the unsubscribe function',
|
||||
testSseSessionThrowsWhenProvidingANonFunctionAsTheUnsubscribeFunction,
|
||||
);
|
||||
test('SSESession: lastEventIdReconnect plugin can be removed', testSseSessionLastEventIdReconnectPluginCanBeRemoved);
|
||||
test('SSESession: browser visibility change is supported', testSseSessionBrowserVisibilityChangeIsSupported);
|
||||
test('SSESession: browser visibility only adds a single handler', testSseSessionBrowserVisibilityOnlyAddsSingleHandler);
|
||||
test(
|
||||
'SSESession: browser visibility throws error if existing plugin is not a function',
|
||||
testSseSessionBrowserVisibilityThrowsErrorIfExistingPluginIsNotAFunction,
|
||||
);
|
||||
test(
|
||||
'SSESession: browser visibility removes visible handler if disconnect is called',
|
||||
testSseSessionBrowserVisibilityRemovesVisibleHandlerIfDisconnectIsCalled,
|
||||
);
|
||||
test(
|
||||
'SSESession: browser visibility removes visible handler when unsubscribed',
|
||||
testSseSessionBrowserVisibilityRemovesVisibleHandlerWhenUnsubscribed,
|
||||
);
|
||||
test(
|
||||
'SSESession: browser visibility does not remove handler if plugin is not the same',
|
||||
testSseSessionBrowserVisibilityDoesNotRemoveHandlerIfPluginIsNotTheSame,
|
||||
);
|
||||
};
|
||||
|
||||
await runTests();
|
||||
|
||||
Reference in New Issue
Block a user