Author SHA1 Message Date
Kuldeep b0ef5acd3f Merge branch 'feat/parse_invitation' into 'development'
Feat/parse invitation

See merge request GeneralProtocols/xo/utils!22
2026-10-05 12:59:22 +00:00
Kuldeep 0c95b9b2b1 Feat/parse invitation 2026-10-05 12:59:22 +00:00
Kuldeep d907946804 Merge branch 'update/defaults' into 'development'
Add receive and address in default

See merge request GeneralProtocols/xo/utils!21
2026-09-25 11:57:29 +00:00
kiok46 4a5d6486a4 Add receive and address in default 2026-09-25 17:06:36 +05:30
Kuldeep 04db9f4c0e Merge branch 'sse-and-backoff' into 'development'
SSE Session and examples

See merge request GeneralProtocols/xo/utils!8
2026-09-17 07:51:49 +00:00
HarvmasterandKuldeep 752cda0706 SSE Session and examples 2026-09-17 07:51:48 +00:00
Kuldeep e96ab6457b Merge branch 'feat/cash-assembly-reimplement' into 'development'
Change implementation strategy of cashassembly, add scripts, comments support and tests

See merge request GeneralProtocols/xo/utils!19
2026-09-11 10:02:20 +00:00
Kuldeep c3dd0162d9 Change implementation strategy of cashassembly, add scripts, comments support and tests 2026-09-11 10:02:20 +00:00
Kuldeep bd2f1044e7 Merge branch 'improve/primitives' into 'development'
remove base type, keep primitive type and make hints as unknown optional

See merge request GeneralProtocols/xo/utils!20
2026-09-11 05:21:16 +00:00
Kuldeep a9d96d7a0d remove base type, keep primitive type and make hints as unknown optional 2026-09-11 05:21:16 +00:00
Kuldeep 32eca1e016 Merge branch 'feat/relevant_property' into 'development'
Add relevant field property in the schema

See merge request GeneralProtocols/xo/utils!18
2026-08-28 19:03:34 +00:00
kiok46 947cbb2a7c Add relevant field property in the schema 2026-08-28 19:47:15 +05:30
Kuldeep c73c6b216e Merge branch 'exponential-backoff' into 'development'
Add exponential backoff utility

See merge request GeneralProtocols/xo/utils!12
2026-08-20 10:31:44 +00:00
HarvmasterandKuldeep 4e555c6ffe Add exponential backoff utility 2026-08-20 10:31:44 +00:00
Kuldeep 0cd615249e Merge branch 'event-emitter' into 'development'
Add Event Emitter

See merge request GeneralProtocols/xo/utils!13
2026-08-12 18:48:51 +00:00
HarvmasterandKuldeep 0fc1eb7aec Add Event Emitter 2026-08-12 18:48:50 +00:00
50 changed files with 8358 additions and 969 deletions
+20 -1
View File
@@ -1,6 +1,25 @@
{
"version": "0.1",
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
"words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue"],
"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
View File
@@ -2,7 +2,7 @@ import baseConfig from '@xo-cash/eslint-config';
export default [
{
ignores: [ 'scripts/**', 'docs/**' ],
ignores: [ 'scripts/**' ],
},
...baseConfig,
];
+527 -557
View File
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -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"
@@ -45,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",
@@ -66,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"
}
}
+93
View File
@@ -0,0 +1,93 @@
import { SSESession } from '../source/sse-session/index.ts';
// Because this is in a library, and we don't want to add the node types to this as it is intended to be used in a browser
// we will just declare the process object here locally so we don't get type errors from this script
declare const process: {
env: Record<string, string>;
stdout: {
write: (data: string) => void;
};
};
// Recommended URL: https://openrouter.ai/api/v1/chat/completions
// Recommended Model: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
// 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 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?';
const apiKey = process.env.API_KEY ?? '';
// Throw an error if the api key is not set
if (!apiKey) {
throw new Error('API key is required');
}
// Create a function to get the auth header
const getAuthHeader = (): string => {
return `Bearer ${apiKey}`;
};
// Create our sse session
const sseSession = new SSESession(url, {
// LLMs use requests
method: 'POST',
// Create the body of the request
body: JSON.stringify({
model: model,
messages: [
{
role: 'user',
content: prompt,
},
],
stream: true,
}),
// Create a custom request handler to set the headers
onRequest: async (requestInit): Promise<RequestInit> => {
requestInit.headers ??= {} as HeadersInit;
// Handle typescript annoyances
const headers = requestInit.headers as Record<string, string>;
// Set our headers - We could also do this using the `headers` property in the SSESession constructor
// Doing it here to demonstrate dynamic headers, for example a signed timestamp could be used to authenticate the request.
headers.Authorization = getAuthHeader();
headers['Content-Type'] = 'application/json';
return requestInit;
},
});
// Connect to the SSESession
await sseSession.connect();
// Loop over the message chunks using `for await`
for await (const message of sseSession.messages) {
// Handle `[DONE]` (this may be specific to OpenRouter)
if (message.data === '[DONE]') {
continue;
}
// First, we will parse the event to JSON
const responseJson = JSON.parse(message.data);
// 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
const firstChoice = choices[0];
// Then we will grab the next chunk of text
const messageContent = firstChoice.delta?.content;
// Then we will append the text to the console
process.stdout.write(messageContent || '');
}
// Just a terminal/node thing. If we dont put a new line, the console will overwrite the text with the `cwd` or next command input
process.stdout.write('\n');
+17
View File
@@ -0,0 +1,17 @@
import { SSESession, type SSEvent } from '../source/sse-session/index.ts';
// Command: npx tsx ./sandbox/sandbox-price-oracle.ts
// Use the GP Price Oracle API
const url = 'https://oracles.generalprotocols.com/sse/v1/messages';
// Create our sse session
const sseSession = await SSESession.create(url);
// Create a message event handler
sseSession.on('message', (message: SSEvent) => {
console.log(message);
});
// Connect to the SSESession
await sseSession.connect();
+3 -3
View File
@@ -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) {
+257
View File
@@ -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 ];
};
+35 -55
View File
@@ -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 = '$()';
+136 -6
View File
@@ -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
View File
@@ -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);
}
}
};
+4 -4
View File
@@ -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';
+52 -47
View File
@@ -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;
+272
View File
@@ -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;
}
};
+108
View File
@@ -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[];
};
+41
View File
@@ -0,0 +1,41 @@
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')}`;
};
/**
* Error thrown when a waitFor timeout is reached
*/
export class WaitForTimeoutError extends Error {
constructor(type: string) {
super(`Timeout waiting for event "${type}"`);
this.name = 'WaitForTimeoutError';
}
}
+278
View File
@@ -0,0 +1,278 @@
import type { DeeplyReadonly } from './types.ts';
import type { deepFreeze } from './misc.ts';
import { WaitForTimeoutError } from './errors.ts';
export type EventMap = Record<string, unknown>;
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: T): void;
}['bivarianceHack'];
/**
* A listener entry.
* @template T - The event payload type.
*/
interface ListenerEntry<T> {
listener: StoredListener<T>;
wrappedListener: StoredListener<T>;
cancel: () => void;
}
/**
* Callback returned by {@link on} and {@link once} for removing a listener.
*/
export type OffCallback = () => void;
/**
* A simple event emitter implementation.
* @template T - The event map type.
*/
export class EventEmitter<T extends EventMap> {
/**
* The listeners map.
* @private
*/
#listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
/**
* Add a listener for an event.
* @param type - The event type.
* @param listener - The listener function.
* @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events.
*/
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.#cancellable(listener);
// Create a wrapped listener so that the debounce can be applied.
const wrappedListener = debounceMilliseconds > 0 ? this.#debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener,
cancel,
};
// Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
/**
* Add a one-time listener for an event.
* @param type - The event type.
* @param listener - The listener function.
* @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events.
*/
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
this.off(type, listener);
listener(detail);
};
// Create a cancellable listener.
const { cancel, listener: cancellableListener } = this.#cancellable(wrappedListener);
// Create a debounced listener.
const debouncedListener = debounceMilliseconds > 0 ? this.#debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener: debouncedListener,
cancel,
};
// Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
/**
* Remove a listener for an event.
* @param type - The event type.
* @param listener - The listener function.
*/
off<K extends keyof T>(type: K, listener?: Listener<T[K]>): void {
// Get the listeners for the event type.
const listeners = this.#listeners.get(type);
if (!listeners) return;
// Find the listener entries (If a listener was provided, only 1 entry will be returned. Otherwise, all entries will be returned).
const listenerEntries = Array.from(listeners).filter((entry) => !listener || entry.listener === listener || entry.wrappedListener === listener);
// Remove the listener entries from the listeners set.
listenerEntries.forEach((entry) => {
// Set the wrapped listener to a no-op function to prevent it from being called by debounced events after it's been removed.
entry.cancel();
// Remove the listener entry from the listeners set.
listeners.delete(entry);
});
// If no listener was provided and no listeners are left for the event type, remove the listeners set from the listeners map.
if (!listener || this.#listeners.get(type)?.size === 0) {
this.#listeners.delete(type);
}
}
/**
* Emit an event.
*
* @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.
*/
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
// Get the listeners for the event type.
const listeners = this.#listeners.get(type);
if (!listeners) return false;
// Emit the event to all listeners.
listeners.forEach((entry) => {
try {
entry.wrappedListener(payload);
} catch (error) {
console.error(error);
}
});
// Return true if there are listeners for the event, false otherwise.
return listeners.size > 0;
}
/**
* Remove all listeners.
*/
removeAllListeners(): void {
for (const [ type, listeners ] of this.#listeners.entries()) {
listeners.forEach((entry) => {
this.off(type, entry.listener);
});
}
}
/**
* Wait for an event to be emitted that matches the provided predicate function's criteria.
* @param type - The event type.
* @param predicate - Predicate function to filter for whether the event payload matches the criteria.
* @param timeoutMs - The timeout in milliseconds.
* @returns The event payload.
*/
async waitFor<K extends keyof T>(type: K, predicate: (payload: 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;
// Create a cleanup function to remove the listener and clear the timeout if it is still pending.
const cleanup = (listener: Listener<T[K]>): void => {
// Remove the listener from the listeners map.
this.off(type, listener);
// Clear the timeout if it is still pending.
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};
// Create a listener function.
const listener = (payload: T[K]): void => {
try {
// If the event payload does not match the predicate condition, return.
if (!predicate(payload)) {
return;
}
cleanup(listener);
resolve(payload);
} catch (error) {
cleanup(listener);
reject(error);
}
};
// Set up timeout if specified
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener);
reject(new WaitForTimeoutError(String(type)));
}, timeoutMs);
}
// Add the listener to the listeners map.
this.on(type, listener);
});
}
/**
* Debounce a function.
*
* @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.
*/
#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: T[K]) => {
// If a debounce timer is already pending, clear it before scheduling the next one.
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);
};
}
/**
* 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: T[K]): void => {
if (cancelled) return;
func(detail);
},
};
}
}
+92
View File
@@ -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));
}
}
@@ -0,0 +1,364 @@
import {
ExponentialBackoffStoppedRetriesError,
ExponentialBackoffMaxRetriesHitError,
ExponentialBackoffNonIntegerError,
ExponentialBackoffNumberTooSmallError,
ExponentialBackoffNumberOutOfBoundsError,
ExponentialBackoffNumberNotFiniteError,
} from './errors.ts';
import { isWithinBounds } from '../misc.ts';
export type ExponentialBackoffOptions = {
/**
* The maximum delay between attempts in milliseconds
*/
maxDelay: number;
/**
* The maximum number of attempts. Passing 0 will result in infinite attempts.
*/
maxAttempts: number;
/**
* The base delay between attempts in milliseconds
*/
baseDelay: number;
/**
* The growth rate of the delay
*/
growthRate: number;
/**
* The jitter of the delay as a percentage of growthRate. The jitter is subtracted from the delay.
*/
jitter: number;
};
/**
* The function to call to stop the retries.
* This mimics the AbortSignal.abort function by taking in a reason for stopping
*
* @param reason - The reason for stopping the retries.
*/
export type ExponentialBackoffStopRetriesFunction = (reason: unknown) => void;
/**
* The parameters for the task function
*
* @param stopRetries - The function to call to stop the retries
*/
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.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
readonly #options: ExponentialBackoffOptions;
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Maximum proportional reduction subtracted from each delay (0–1). Default: `0.1`.
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.#options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
};
ExponentialBackoff.validateOptions(this.#options);
}
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*
* @returns The ExponentialBackoff instance
*/
public static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
/**
* Run the function with exponential backoff
*
* @param taskFn - The function to run
* @param 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
*
* @returns The result of the function
*/
public static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options: Partial<ExponentialBackoffStaticRunOptions> = {},
): Promise<T> {
// Grab onError and signal from the options
const { onError, signal, ...backoffOptions } = options;
const backoff = ExponentialBackoff.from(backoffOptions);
return backoff.run(taskFn, { onError, signal });
}
/**
* Validate the options for the exponential backoff
*
* @param options - The options to validate
*
* @throws {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
* @throws {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
/** Validate the value is finite, throwing an {@link ExponentialBackoffInvalidInfiniteIntegerError} if the value is infinite */
const assertIsFinite = (key: string, value: number): void => {
if (!Number.isFinite(value)) {
throw new ExponentialBackoffNumberNotFiniteError(key, value);
}
};
/** Validate the value is an integer, throwing a {@link ExponentialBackoffNonIntegerError} if it is not an integer */
const assertIsInteger = (key: string, value: number): void => {
if (!Number.isInteger(value)) {
throw new ExponentialBackoffNonIntegerError(key, value);
}
};
/** Validate the value is greater than the minimum, throwing a {@link ExponentialBackoffNumberTooSmallError} if it is not */
const assertIsHigherThan = (key: string, value: number, min: number): void => {
if (value < min) {
throw new ExponentialBackoffNumberTooSmallError(key, value, min);
}
};
/** Validate the value is within the bounds, throwing a {@link ExponentialBackoffNumberOutOfBoundsError} if it is not within the bounds */
const assertIsWithinBounds = (key: string, value: number, min: number, max: number): void => {
if (!isWithinBounds(value, min, max)) {
throw new ExponentialBackoffNumberOutOfBoundsError(key, value, min, max);
}
};
// Validate the max delay
assertIsFinite('maxDelay', options.maxDelay);
assertIsHigherThan('maxDelay', options.maxDelay, 0);
// Validate the max attempts
assertIsFinite('maxAttempts', options.maxAttempts);
assertIsInteger('maxAttempts', options.maxAttempts);
assertIsHigherThan('maxAttempts', options.maxAttempts, 0);
// Validate the base delay
assertIsFinite('baseDelay', options.baseDelay);
assertIsHigherThan('baseDelay', options.baseDelay, 0);
// Validate the growth rate
assertIsFinite('growthRate', options.growthRate);
assertIsHigherThan('growthRate', options.growthRate, 0);
// Validate the jitter
assertIsFinite('jitter', options.jitter);
assertIsWithinBounds('jitter', options.jitter, 0, 1);
}
/**
* Run the function with exponential backoff
*
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If 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
*
* @returns The result of the function
*/
public async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
options: ExponentialBackoffRunOptions = {},
): Promise<T> {
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[] = [];
// Initialize the attempt counter
let attempt = 0;
// If the max attempts is 0, we should continue indefinitely.
const unlimitedAttempts = this.#options.maxAttempts === 0;
// Loop until we succeed, hit the max attempts, or the abort signal is activated
while (true) {
try {
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await taskFn({ stopRetries });
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError?.(errorInstance);
// If we have unlimited attempts, don't append this to the errors array to prevent a memory leak.
if (!unlimitedAttempts) {
errors.push(errorInstance);
}
}
// Check if the abort signal has been activated
if (signal.aborted) {
// Throw an error if the abort signal has been activated
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.
const nextAttemptCount = attempt + 1;
const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts;
// If the next attempt exceeds the max attempts, break out of the loop
if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) {
break;
}
// Wait before going to the next attempt
const delay = this.#calculateDelay(this.#options, attempt);
// Wait for the delay or the abort signal
await new Promise<void>((resolve, reject) => {
// eslint-disable-next-line prefer-const
let timeout: ReturnType<typeof setTimeout>;
// Handle the abort signal
const abortHandler = (): void => {
clearTimeout(timeout);
reject(new ExponentialBackoffStoppedRetriesError(signal.reason));
};
// Handle the timeout
const timeoutHandler = (): void => {
signal.removeEventListener('abort', abortHandler);
resolve(undefined);
};
// Set the timeout
timeout = setTimeout(timeoutHandler, delay);
// Add the abort handler to the abort signal
signal.addEventListener('abort', abortHandler, { once: true });
});
attempt++;
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new ExponentialBackoffMaxRetriesHitError(errors);
}
/**
* Calculate the delay before we should attempt to retry
*
* @param options - The configuration for the exponential backoff
* @param attempt - The current attempt number
* @returns The time in milliseconds before another attempt should be made
*/
#calculateDelay(options: ExponentialBackoffOptions, attempt: number): number {
// Get the power of the growth rate
const power = options.growthRate ** attempt;
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power;
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay);
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random();
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay;
// Subtract the jitter from the delay
return cappedDelay - jitter;
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './errors.ts';
export * from './exponential-backoff.ts';
export * from './exponential-backoff-externally-aborted.ts';
+6 -9
View File
@@ -1,13 +1,10 @@
export * from './errors.ts';
export * from './event-emitter.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';
+46
View File
@@ -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;
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './errors.ts';
export * from './parser.ts';
export * from './schemas.ts';
export * from './serialization.ts';
+27
View File
@@ -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;
};
+241
View File
@@ -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();
+46
View File
@@ -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);
};
+51
View File
@@ -0,0 +1,51 @@
import type { DeeplyReadonly } from './types';
/**
* Recursively freezes an object by iterating over all properties and freezing them.
* @param obj - The object to freeze.
* @returns The frozen object.
*/
export const deepFreeze = <T>(value: T): DeeplyReadonly<T> => {
if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
for (const key of Reflect.ownKeys(value)) {
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
if (descriptor && 'value' in descriptor) {
deepFreeze(descriptor.value);
}
}
Object.freeze(value);
}
return value;
};
/**
* 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;
};
+23
View File
@@ -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;
+31
View File
@@ -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';
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from './async-push-iterator.ts';
export * from './types.ts';
export * from './sse-session.ts';
export * from './sse-event-parser.ts';
+10 -10
View File
@@ -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);
}
}
+635
View File
@@ -0,0 +1,635 @@
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, 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
* browser tab visibility handling.
*
* Each session maintains one HTTP streaming connection at a time. Incoming
* bytes are parsed into {@link SSEvent} objects and delivered through two
* surfaces:
*
* - **Events** — `"connected"`, `"message"`, `"disconnected"`, `"error"`,
* and `"closed"` on the session itself (extends {@link EventEmitter}).
* - **Messages** — {@link messages}, an async iterable for `for await...of`
* consumers.
*
* Typical usage:
*
* ```ts
* const session = await SSESession.create("/events");
*
* session.on("message", (event) => console.log(event.data));
*
* for await (const event of session.messages) {
* handle(event);
* }
* ```
*
* ## Lifecycle
*
* - {@link connect} opens (or reopens) the transport. It resolves once the
* HTTP stream is established; reading continues in the background.
* - {@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 close} aborts the transport, closes {@link messages}, and emits
* `"closed"`.
*
* Automatic reconnect is controlled by {@link SSESessionOptions.persistent}
* (server closed the stream) and
* {@link SSESessionOptions.attemptReconnect} (transport error).
*
* ## Connection supersession
*
* Each {@link connect} will create a new controller if one does not exist. Otherwise,
* it will return immediately.
*/
export class SSESession extends EventEmitter<SSESessionEventMap> {
/**
* Creates a session and waits until the first connection is established.
*
* @param url - The SSE endpoint URL.
* @param options - Configuration merged with instance defaults.
* @returns A connected session.
* @throws When the initial connection cannot be established.
*/
static async create(url: string, options: Partial<SSESessionOptions & SSESessionEventCallbacks> = {}): Promise<SSESession> {
const session = new SSESession(url, options);
await session.connect();
return session;
}
/**
* Enables SSE resume semantics by sending `Last-Event-ID` on reconnect.
*
* Listens for incoming `"message"` events and remembers the most recent
* {@link SSEvent.id}. On every subsequent connect or reconnect, the session's
* {@link onRequest} hook is wrapped so that header is attached when an id is
* known, allowing the server to replay only events the client has not yet
* received.
*
* The existing {@link onRequest} callback is preserved and runs after the
* header is applied, so auth or other header mutations continue to work.
*
* 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 = new SSESession(url);
* SSESession.addLastEventIdReconnect(session);
* await session.connect();
* // Reconnects send Last-Event-ID once an event with an id is received.
* ```
*
* @param session - The session to instrument.
* @returns The same session, for chaining.
*/
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;
// 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;
};
session.on('message', messageListener);
// 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);
};
// 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 };
}
/**
* Pauses and resumes a session based on browser tab visibility.
*
* Uses the Page Visibility API (`document.visibilitychange`):
*
* - **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.
*
* 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 session - The session to manage.
*/
static addBrowserVisibilityHandler(session: SSESession): { session: SSESession; removeListener?: OffCallback } {
if (typeof document === 'undefined') return { session };
// 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;
}
// 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);
// 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);
session.off('beforeDisconnect', disableOnExternalDisconnect);
// 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. */
readonly #url: string;
/**
* Per-instance configuration.
*
* Defaults live on the instance field (not a shared static) so each session
* gets its own {@link SSEEventParser} and {@link ExponentialBackoff}.
*/
public options: SSESessionOptions = {
// Fetch options
fetch: (...args) => fetch(...args),
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: new FormData(),
// callback to mutate the fetch options before the fetch is made.
onRequest: (request) => Promise.resolve(request),
// 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({
...SSE_SESSION_EXPONENTIAL_BACKOFF_DEFAULTS,
}),
// 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. */
#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 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 close} is called, or
* - a transport error occurs with
* {@link SSESessionOptions.attemptReconnect} disabled.
*
* A later {@link connect} replaces this with a new iterator when the
* previous one was closed. Consumers should read from `session.messages`
* rather than caching a reference across terminal disconnects.
*/
public messages: AsyncPushIterator<SSEvent> = new AsyncPushIterator<SSEvent>();
public constructor(url: string, options: Partial<SSESessionOptions & SSESessionEventCallbacks> = {}) {
super();
const { onConnected, onDisconnected, onError, ...restOptions } = options;
this.#url = url;
this.options = {
...this.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 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
* promise resolves without throwing).
*/
public async connect(): Promise<void> {
// If there is already a controller present, we are already connected.
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
// close ended the consumer's iteration loop.
this.#resetEventParser();
this.#ensureMessageStreamOpen();
const connectionController = new AbortController();
this.#connectionController = connectionController;
const { method, headers, body } = this.options;
// Create the fetch options.
const fetchOptions: RequestInit = {
method,
headers: headers || {},
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 {
// 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 `connect()` superseded this attempt — leave state to the winner.
if (this.#connectionController !== connectionController) return;
// Reset the controller to null to allow for reconnection if needed.
this.#connectionController = null;
// 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 disconnect during fetch).
if (this.#connectionController !== connectionController) {
await reader.cancel();
return;
}
this.emit('connected', undefined);
// Fire-and-forget: connect() resolves while the stream is consumed.
// Data is emitted as a `message` event and from the `messages` iterator.
this.#readStream(reader, connectionController).catch((error) => {
this.emit('error', error);
});
}
/**
* Disconnects only the currently active transport.
*
* `beforeDisconnect` is emitted on every invocation, even when no
* transport is currently active. This allows observers to react to
* an explicit disconnect operation.
*
* @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 disconnect(context: SSESessionOperationContext = {}): void {
this.emit('beforeDisconnect', context);
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.
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();
}
/**
* Terminates the session and disables attached visibility handling until
* the same instance is manually {@link connect connected} again.
*
* Closes {@link messages} and emits `"closed"`.
*/
public close(): void {
// Disconnect the session.
this.disconnect();
// Close the message stream so `for await...of` consumers resolve.
this.#closeMessageStream();
// Emit the closed event.
this.emit('closed', undefined);
}
/**
* Performs the HTTP request and returns a reader for the response body.
*
* {@link SSESessionOptions.onRequest} may mutate headers (for example auth
* tokens or `Last-Event-ID`) before the fetch runs.
*/
async #createReader(fetchOptions: RequestInit): Promise<ReadableStreamDefaultReader<Uint8Array>> {
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);
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();
this.emit('error', error);
throw error;
}
// Return the reader for the response body.
return response.body.getReader();
}
/**
* Reads bytes from an established stream until it ends, errors, or is
* superseded by a newer connection.
*/
async #readStream(reader: ReadableStreamDefaultReader<Uint8Array>, connectionController: AbortController): Promise<void> {
try {
while (this.#connectionController === connectionController) {
const { done, value } = await reader.read();
// `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) {
// Reset the controller to null to allow for reconnection if needed.
this.#connectionController = null;
// 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();
}
return;
}
// 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.
// 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.#connectionController = null;
const normalizedError = normalizeError(error);
this.emit('disconnected', { reason: 'error', error: normalizedError });
// Expected path for `disconnect()` — do not treat as an error or reconnect.
if (connectionController.signal.aborted) return;
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();
}
}
}
/** Clears partial SSE frames left over from an abandoned transport. */
#resetEventParser(): void {
this.options.eventParser.reset();
}
/**
* Creates a new {@link messages} iterator when the previous one was closed
* by a terminal disconnect or server stream end.
*/
#ensureMessageStreamOpen(): void {
if (!this.messages.closed) return;
this.messages = new AsyncPushIterator<SSEvent>();
}
/** Ends the message iteration loop for the current connection span. */
#closeMessageStream(): void {
if (this.messages.closed) return;
this.messages.close();
}
}
+170
View File
@@ -1,3 +1,173 @@
import type { ExponentialBackoffRunOptions } from '../exponential-backoff/exponential-backoff';
export type SSERequestInit = {
/**
* The HTTP method to use.
*/
method: 'GET' | 'POST';
/**
* Request headers sent on every connect and reconnect.
*/
headers?: Record<string, string>;
/**
* Request body for POST-based SSE endpoints.
*/
body?: string | FormData;
};
/**
* The fetch function to use.
*
* NOTE: This is compatible with Browser/Node's native "fetch" function.
* We use this in place of "typeof fetch" so that we can accept non-standard URLs ("url" is a "string" here).
* For example, a LibP2P adapter might not use a standardized URL format (and might only include "path").
* This would cause a type error as native fetch expects type "URL".
*/
export type SSERequestFunction = {
fetch: (url: string, options: RequestInit) => Promise<Response>;
};
/**
* Lifecycle hooks invoked by {@link SSESession} during connect, read, and teardown.
*/
export type SSESessionCallbacks = {
/**
* Called before each fetch so callers can attach auth headers, cookies, or
* 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.
*/
onConnected: () => void;
/**
* 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.disconnect} disconnects.
*/
onError: (error: Error) => void;
};
export type SSESessionRetryInterface = {
/**
* Retry policy used while establishing the HTTP connection in
* {@link SSESession.connect}. Defaults to {@link ExponentialBackoff} with
* unlimited attempts.
*/
retry: {
run<T>(fn: () => Promise<T>, options?: ExponentialBackoffRunOptions): Promise<T>;
};
};
export interface SSEParser {
/**
* Incremental SSE frame parser for the response body.
*
* {@link SSEEventParser.reset} is called by the session when abandoning a
* transport so partial frames do not carry over to the next connection.
*/
eventParser: {
parseEvents(buffer: Uint8Array): SSEvent[];
reset(): void;
};
}
export type SSELifecycleOptions = {
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* 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.disconnect}).
* - `"error"` — An unexpected fetch or read failure.
* - `"closed"` — {@link SSESession.close} was called.
*/
export type SSESessionEventMap = {
connected: void;
beforeDisconnect: SSESessionOperationContext;
disconnected: SSESessionDisconnectContext;
error: Error;
message: SSEvent;
closed: void;
};
/**
* Configuration for {@link SSESession}.
*/
export type SSESessionOptions = SSESessionCallbacks
& SSERequestInit
& SSERequestFunction
& SSESessionRetryInterface
& SSELifecycleOptions
& SSEParser;
/**
* Represents a Server-Sent Event.
*/
-32
View File
@@ -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.
*/
+7
View File
@@ -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';
+2 -1
View File
@@ -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
View File
@@ -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();
+8
View File
@@ -0,0 +1,8 @@
/**
* A deeply readonly type.
* @template T - The type to make deeply readonly.
* @returns The deeply readonly type.
*/
export type DeeplyReadonly<T> = {
readonly [K in keyof T]: T[K] extends (...args: never[]) => unknown ? T[K] : DeeplyReadonly<T[K]>;
};
File diff suppressed because it is too large Load Diff
+828
View File
@@ -0,0 +1,828 @@
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;
readonly: Readonly<{ value: number }>;
deeplyReadonly: DeeplyReadonly<{ nested: { value: number } }>;
};
/**
* Tests that EventEmitter invokes listeners when an event is emitted.
*/
const testEventEmitterCallsListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Register the listener and emit an event.
emitter.on('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been called with the emitted payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('hello');
};
/**
* Tests that EventEmitter invokes all listeners registered for the same event.
*/
const testEventEmitterCallsMultipleListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const firstListener = vi.fn();
const secondListener = vi.fn();
// Register two listeners for the same event type.
emitter.on('count', firstListener);
emitter.on('count', secondListener);
emitter.emit('count', 42);
// Expect both listeners to receive the same payload.
expect(firstListener).toHaveBeenCalledOnce();
expect(firstListener).toHaveBeenCalledWith(42);
expect(secondListener).toHaveBeenCalledOnce();
expect(secondListener).toHaveBeenCalledWith(42);
};
/**
* Tests that EventEmitter only invokes listeners registered for the emitted event type.
*/
const testEventEmitterCallsOnlyMatchingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
// Register listeners on different event types.
emitter.on('message', messageListener);
emitter.on('count', countListener);
// Emit only the message event.
emitter.emit('message', 'hello');
// Expect only the matching listener to have been called.
expect(messageListener).toHaveBeenCalledOnce();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that EventEmitter.emit returns false when no listeners are registered.
*/
const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that nobody was listening.
expect(hasListeners).toBe(false);
};
/**
* Tests that EventEmitter.emit returns true when listeners are registered.
*/
const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
emitter.on('message', vi.fn());
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that at least one listener was invoked.
expect(hasListeners).toBe(true);
};
/**
* Tests that EventEmitter.emit continues after a listener throws an error.
*/
const testEventEmitterEmitContinuesAfterListenerThrows = (): void => {
const emitter = new EventEmitter<TestEvents>();
const secondListener = vi.fn();
// Spy on the console.error method
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
emitter.on('message', (): void => {
throw new Error('listener failure');
});
emitter.on('message', secondListener);
emitter.emit('message', 'hello');
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 it supports Object.freeze() frozen payloads.
*/
const testEventEmitterSupportsObjectFreezeFrozenPayloads = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const payload = Object.freeze({ value: 42 });
emitter.on('readonly', listener, 100);
emitter.emit('readonly', payload);
// @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);
// Expect the listener to have been called with the frozen payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(payload);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that it supports deepFreeze() frozen payloads.
*/
const testEventEmitterSupportsDeepFreezeFrozenPayloads = async (): Promise<void> => {
vi.useFakeTimers();
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();
}
};
/**
* Tests that the off callback returned by on() removes the listener.
*/
const testEventEmitterOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// on() returns an off callback that removes the listener.
const off = emitter.on('message', listener);
emitter.emit('message', 'first');
// Unsubscribe before emitting again.
off();
emitter.emit('message', 'second');
// Expect the listener to have only received the first event.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that off() removes a listener when given the same function reference.
*/
const testEventEmitterOffRemovesListenerByReference = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
emitter.off('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been removed before the emit.
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that off() removes all listeners for an event type when no listener is provided.
*/
const testEventEmitterOffRemovesAllListenersForEventType = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
emitter.off('message');
expect(listener).not.toHaveBeenCalled();
expect(emitter.emit('message', 'hello')).toBe(false);
expect(emitter.emit('count', 42)).toBe(false);
};
/**
* Tests that off() does nothing when given an unknown listener reference.
*/
const testEventEmitterOffIgnoresUnknownListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
// Try to remove a different function reference.
emitter.off('message', vi.fn());
emitter.emit('message', 'hello');
// Expect the original listener to still receive the event.
expect(listener).toHaveBeenCalledOnce();
};
/**
* Tests that off() does nothing when called for an event type with no listeners.
*/
const testEventEmitterOffIgnoresUnregisteredEventType = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Call off without ever registering this listener.
emitter.off('message', listener);
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that once() listeners are invoked only one time.
*/
const testEventEmitterOnceListenerFiresOnce = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
// Expect the listener to auto-unsubscribe after the first emit.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that once() can be added when regular listeners already exist for the event type.
*/
const testEventEmitterOnceWorksWithExistingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const existingListener = vi.fn();
const onceListener = vi.fn();
// Register a regular listener first so the event type already exists in the map.
emitter.on('message', existingListener);
emitter.once('message', onceListener);
emitter.emit('message', 'hello');
expect(existingListener).toHaveBeenCalledOnce();
expect(onceListener).toHaveBeenCalledOnce();
};
/**
* Tests that the off callback returned by once() removes the listener before it fires.
*/
const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const off = emitter.once('message', listener);
// Unsubscribe before the event is ever emitted.
off();
emitter.emit('message', 'hello');
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that debounced listeners do not receive the debounced event if the listener is removed.
*/
const testEventEmitterOffCancelsPendingDebouncedCallback = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const off = emitter.on('message', listener, 100);
emitter.emit('message', 'first');
off();
emitter.emit('message', 'second');
await vi.advanceTimersByTimeAsync(100);
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that removeAllListeners() clears every registered listener.
*/
const testEventEmitterRemoveAllListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
emitter.on('message', messageListener);
emitter.on('count', countListener);
emitter.removeAllListeners();
// Emit on both event types after clearing all listeners.
emitter.emit('message', 'hello');
emitter.emit('count', 1);
expect(messageListener).not.toHaveBeenCalled();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that removeAllListeners() cancels a pending debounced callback.
*/
const testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Arm a debounce timer, then clear every listener before it expires.
emitter.on('message', listener, 100);
emitter.emit('message', 'should not arrive');
emitter.removeAllListeners();
await vi.advanceTimersByTimeAsync(100);
// Cleared listeners must not receive delayed debounced delivery.
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that 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.
*/
const testEventEmitterWaitForResolvesOnMatch = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
// Wait until an event matches the predicate.
const waitPromise = emitter.waitFor('count', (payload) => payload === 42);
// Emit a non-matching event first, then the matching one.
emitter.emit('count', 41);
emitter.emit('count', 42);
await expect(waitPromise).resolves.toBe(42);
};
/**
* Tests that waitFor() ignores non-matching events while other listeners still receive them.
*/
const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done');
// A regular listener should still receive every emit while waitFor filters.
emitter.on('message', listener);
emitter.emit('message', 'pending');
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
expect(listener).toHaveBeenCalledTimes(2);
};
/**
* Tests that waitFor() rejects when the timeout expires.
*/
const testEventEmitterWaitForRejectsOnTimeout = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true, 100);
// Attach the rejection handler before advancing timers so the rejection is handled.
const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"');
await vi.advanceTimersByTimeAsync(100);
await assertion;
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() clears its timeout when it resolves before expiry.
*/
const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
// Register waitFor with a timeout, then resolve it before the timer fires.
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100);
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
// If clearTimeout was not called, advancing past the timeout would reject the promise.
await vi.advanceTimersByTimeAsync(100);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() removes its listener after resolving.
*/
const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true);
emitter.emit('message', 'first');
await expect(waitPromise).resolves.toBe('first');
// Register a second waitFor so we can verify the first listener was cleaned up.
const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second');
// Emit a payload that only the second waitFor should accept.
emitter.emit('message', 'ignored');
// Track whether the second waitFor resolves too early.
let resolvedEarly = false;
/* eslint-disable-next-line */
secondWaitPromise.then(() => {
resolvedEarly = true;
});
// Yield so any premature resolution would have a chance to run.
await Promise.resolve();
expect(resolvedEarly).toBe(false);
emitter.emit('message', 'second');
await expect(secondWaitPromise).resolves.toBe('second');
};
/**
* Tests that the first debounced emit does not call clearTimeout.
*/
const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const listener = vi.fn();
emitter.on('message', listener, 100);
emitter.emit('message', 'first');
// The first emit starts the debounce timer; there is nothing to clear yet.
expect(clearTimeoutSpy).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced on() listeners receive only the last payload after the debounce window.
*/
const testEventEmitterDebouncedOnListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener, 100);
// Emit several events in quick succession.
emitter.emit('message', 'first');
emitter.emit('message', 'second');
emitter.emit('message', 'third');
// Expect the listener to not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance past the debounce window.
await vi.advanceTimersByTimeAsync(100);
// Expect only the last payload to have been delivered.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that repeated debounced emits reset the debounce timer.
*/
const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('count', listener, 100);
emitter.emit('count', 1);
// Advance halfway through the debounce window and emit again.
await vi.advanceTimersByTimeAsync(50);
emitter.emit('count', 2);
await vi.advanceTimersByTimeAsync(50);
// The timer was reset, so the listener should not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance the remaining time for the reset timer to expire.
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that a debounce time of zero behaves like a normal listener.
*/
const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// A debounce time of zero should behave like a normal listener.
emitter.on('message', listener, 0);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
expect(listener).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced once() listeners fire once with the last payload.
*/
const testEventEmitterDebouncedOnceListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener, 100);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
await vi.advanceTimersByTimeAsync(100);
// Expect the debounced once listener to fire once with the last payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('second');
// Emit again after the debounce window; the once listener should stay removed.
emitter.emit('message', 'third');
await vi.advanceTimersByTimeAsync(100);
expect(listener).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that the `waitFor` method rejects if the predicate function throws
*/
const testEventEmitterWaitForRejectsOnPredicateError = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const waitPromise = emitter.waitFor('message', () => {
throw new Error('predicate error');
});
emitter.emit('message', 'hello');
await expect(waitPromise).rejects.toThrow('predicate error');
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that waitFor() removes its listener after the predicate function throws an error.
*/
const testEventEmitterWaitForRemovesListenerAfterPredicateError = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
// Create a predicate function that throws an error.
const predicate = vi.fn().mockImplementation(() => {
throw new Error('predicate error');
});
// Wait for the predicate function to throw an error.
const waitPromise = emitter.waitFor('message', predicate);
// Emit an event, expecting the predicate function to throw an error.
emitter.emit('message', 'hello');
await expect(waitPromise).rejects.toThrow('predicate error');
// Expect the predicate function to have been called once.
expect(predicate).toHaveBeenCalledTimes(1);
// A later emit must not invoke the failed waitFor predicate again.
emitter.emit('message', 'again');
// Expect the predicate function to still have been called once.
expect(predicate).toHaveBeenCalledTimes(1);
};
/**
* Tests that waitFor() clears its timeout when the predicate function throws an error.
*/
const testEventEmitterWaitForClearsTimeoutAfterPredicateError = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const waitPromise = emitter.waitFor(
'message',
(): boolean => {
throw new Error('predicate error');
},
100,
);
emitter.emit('message', 'hello');
await expect(waitPromise).rejects.toThrow('predicate error');
// The timeout scheduled for waitFor must be cleared on predicate failure.
expect(clearTimeoutSpy).toHaveBeenCalled();
// Advancing past the original timeout must not produce a second rejection path.
await vi.advanceTimersByTimeAsync(100);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
const runTests = async (): Promise<void> => {
test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners);
test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners);
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
test('EventEmitter: continues after a listener throws an error', testEventEmitterEmitContinuesAfterListenerThrows);
test('EventEmitter: 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(
'EventEmitter: removes all listeners for an event type when off is called with no listener',
testEventEmitterOffRemovesAllListenersForEventType,
);
test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener);
test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType);
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener);
test(
'EventEmitter: debounced listeners do not receive the debounced event if the listener is removed',
testEventEmitterOffCancelsPendingDebouncedCallback,
);
test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
test(
'EventEmitter: cancels a pending debounced callback when removeAllListeners is called',
testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback,
);
test(
'EventEmitter: 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);
test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve);
test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve);
test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout);
test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener);
test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits);
test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce);
test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener);
test('EventEmitter: rejects waitFor when the predicate function throws', testEventEmitterWaitForRejectsOnPredicateError);
test(
'EventEmitter: removes the waitFor listener after it rejects due to predicate error',
testEventEmitterWaitForRemovesListenerAfterPredicateError,
);
test('EventEmitter: clears the timeout when waitFor rejects due to predicate error', testEventEmitterWaitForClearsTimeoutAfterPredicateError);
};
await runTests();
@@ -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();
+248
View File
@@ -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();
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from 'vitest';
import { normalizeError } from '../source/misc.ts';
/** 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 normalizeError converts non-Error throws to Error instances.
*/
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('normalizeError: converts non-Error throws to Error instances', testNormalizeError);
};
await runTests();
+65
View File
@@ -0,0 +1,65 @@
export type SseTestStreamOptions = {
/** Milliseconds to wait before enqueueing each chunk after the first. */
chunkDelayMs?: number;
/**
* When true, closes the body as soon as all initial chunks have been sent.
* Use this to simulate a server that sends events and then ends the stream.
*/
closeWhenDone?: boolean;
};
/**
* Test double for an SSE HTTP response body.
*
* Enqueues fixture chunks in order and stays open until {@link close} is called,
* matching real servers that keep the connection alive after each event's trailing
* `\n\n` frame boundary.
*/
export class SseTestStream {
readonly stream: ReadableStream<Uint8Array>;
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
private closed = false;
/**
* @param chunks - Fixture `raw` strings, whole or split, to simulate chunk boundaries.
* @param options - Delivery timing and optional auto-close after the initial chunks.
*/
constructor(chunks: string[], options: SseTestStreamOptions = {}) {
const { chunkDelayMs = 0, closeWhenDone = false } = options;
const encoder = new TextEncoder();
this.stream = new ReadableStream({
start: async (controller): Promise<void> => {
this.controller = controller;
for (let i = 0; i < chunks.length; i++) {
if (chunkDelayMs > 0 && i > 0) {
await new Promise((resolve) => setTimeout(resolve, chunkDelayMs));
}
if (this.closed) return;
controller.enqueue(encoder.encode(chunks[i]!));
}
if (closeWhenDone) {
this.close();
}
},
});
}
/**
* Ends the HTTP body the way a server closing the SSE connection would.
*/
close(): void {
if (this.closed) return;
this.closed = true;
this.controller?.close();
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"declaration": true,
"declarationMap": true