Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ef5acd3f | ||
|
|
0c95b9b2b1 | ||
|
|
d907946804 | ||
|
|
4a5d6486a4 | ||
|
|
04db9f4c0e | ||
|
|
752cda0706 | ||
|
|
e96ab6457b | ||
|
|
c3dd0162d9 | ||
|
|
bd2f1044e7 | ||
|
|
a9d96d7a0d | ||
|
|
32eca1e016 | ||
|
|
947cbb2a7c | ||
|
|
c73c6b216e | ||
|
|
4e555c6ffe | ||
|
|
0cd615249e | ||
|
|
0fc1eb7aec |
+20
-1
@@ -1,6 +1,25 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"import": ["@generalprotocols/cspell-dictionary/cspell.json"],
|
||||
"words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue"],
|
||||
"words": [
|
||||
"nonfungible",
|
||||
"lockscript",
|
||||
"cashassembly",
|
||||
"checksigverify",
|
||||
"inputindex",
|
||||
"utxovalue",
|
||||
"bivariance",
|
||||
"bytecodes",
|
||||
"outputbytecode",
|
||||
"utxobytecode",
|
||||
"outputtokencategory",
|
||||
"utxotokencategory",
|
||||
"utxotokenamount",
|
||||
"outputtokenamount",
|
||||
"outputvalue",
|
||||
"numequal",
|
||||
"Prefault",
|
||||
"cashtoken"
|
||||
],
|
||||
"ignorePaths": ["source/template/xo-template.schema.json"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import baseConfig from '@xo-cash/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [ 'scripts/**', 'docs/**' ],
|
||||
ignores: [ 'scripts/**' ],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
|
||||
Generated
+527
-557
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -6,9 +6,9 @@ import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueType
|
||||
*
|
||||
* @param {unknown} value - Value to encode, should be one of: Uint8Array, bigint, boolean, string, or a safe integer number.
|
||||
* @param {string} valueIdentifier - Identifier used in error messages.
|
||||
* @returns {Uint8Array} Bytes representation of the value.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number is not a safe integer.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the value type cannot be resolved.
|
||||
* @returns {Uint8Array} - Bytes representation of the value.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number is not a safe integer.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When the value type cannot be resolved.
|
||||
*/
|
||||
export const convertValueToBytes = (value: unknown, valueIdentifier: string): Uint8Array => {
|
||||
if (value instanceof Uint8Array) {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describeExpectedInput, parseScript } from '@bitauth/libauth';
|
||||
import type { CashAssemblyScriptSegment } from '@bitauth/libauth';
|
||||
import { CashAssemblyCompilationFailedError } from './errors.ts';
|
||||
import type { CashAssemblyTemplateScripts } from './types.ts';
|
||||
|
||||
/**
|
||||
* Shared state while scanning a parse tree and visiting nested template scripts.
|
||||
*/
|
||||
type CollectFromParseTreeContext = {
|
||||
|
||||
/**
|
||||
* Script ids present in the template scripts map.
|
||||
*/
|
||||
knownScriptIdentifiers: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Script ids whose sources were already visited.
|
||||
*/
|
||||
visitedScriptIdentifiers: ReadonlySet<string>;
|
||||
|
||||
/**
|
||||
* Pending script ids queued to visit.
|
||||
*/
|
||||
scriptIdentifiersToVisit: string[];
|
||||
|
||||
/**
|
||||
* WalletData variable names.
|
||||
*/
|
||||
variableNames: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for scanning one parsed Script node.
|
||||
*/
|
||||
type CollectFromParsedScriptParameters = CollectFromParseTreeContext & {
|
||||
|
||||
/**
|
||||
* Parsed Script whose children are Push, Evaluation, Identifier, or literals.
|
||||
*/
|
||||
scriptSegment: CashAssemblyScriptSegment;
|
||||
|
||||
/**
|
||||
* True when this Script is the value of a Push.
|
||||
* Direct Identifier children are WalletData unless they are template script ids.
|
||||
*/
|
||||
isDirectPushContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for handling one Identifier node.
|
||||
*/
|
||||
type CollectFromParsedIdentifierParameters = CollectFromParseTreeContext & {
|
||||
|
||||
/**
|
||||
* Identifier text from parseScript, which may include `.` and `_`.
|
||||
*/
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* True when this Identifier is a direct child of a Push Script.
|
||||
*/
|
||||
isDirectPushContent: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enqueues a template script id so its source can be visited when it exists in the scripts map.
|
||||
*
|
||||
* @param {string} scriptIdentifier - Script id from an evaluation or nested script source.
|
||||
* @param {ReadonlySet<string>} knownScriptIdentifiers - Script ids present in the template scripts map.
|
||||
* @param {ReadonlySet<string>} visitedScriptIdentifiers - Script ids whose sources were already visited.
|
||||
* @param {string[]} scriptIdentifiersToVisit - Pending script ids queued to visit.
|
||||
*/
|
||||
const enqueueReachableTemplateScriptIdentifier = (
|
||||
scriptIdentifier: string,
|
||||
knownScriptIdentifiers: ReadonlySet<string>,
|
||||
visitedScriptIdentifiers: ReadonlySet<string>,
|
||||
scriptIdentifiersToVisit: string[],
|
||||
): void => {
|
||||
// Return when this id is not a template script.
|
||||
if (knownScriptIdentifiers.has(scriptIdentifier) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip ids whose sources were already visited.
|
||||
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid duplicate queue entries when the same id is referenced more than once.
|
||||
if (scriptIdentifiersToVisit.includes(scriptIdentifier) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Queue this id so its source is parsed after the current scan finishes.
|
||||
scriptIdentifiersToVisit.push(scriptIdentifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses CashAssembly source with parseScript and throws when the parse fails.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Evaluation or script source to parse.
|
||||
* @returns {CashAssemblyScriptSegment} - Parsed Script root.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the source.
|
||||
*/
|
||||
const parseCashAssemblySource = (cashAssemblyText: string): CashAssemblyScriptSegment => {
|
||||
// Parse so Push and Identifier nodes are available for collection.
|
||||
const parseResult = parseScript(cashAssemblyText);
|
||||
|
||||
// parseScript returns a union. status false is the failure arm and must not be read as a Script.
|
||||
if (parseResult.status === false) {
|
||||
// describeExpectedInput turns the expected input list into readable text for the typed error.
|
||||
const expectedInputDescription = describeExpectedInput(parseResult.expected);
|
||||
|
||||
throw new CashAssemblyCompilationFailedError(`${expectedInputDescription} Line ${String(parseResult.index.line)}, column ${String(parseResult.index.column)}.`);
|
||||
}
|
||||
|
||||
return parseResult.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identifier visitor for the parseScript tree.
|
||||
* Records WalletData or queues a nested script from one Identifier node.
|
||||
*
|
||||
* @param {CollectFromParsedIdentifierParameters} parameters - Identifier text and the parse tree context.
|
||||
*/
|
||||
const collectFromParsedIdentifier = (parameters: CollectFromParsedIdentifierParameters): void => {
|
||||
const { identifier, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } = parameters;
|
||||
|
||||
// Script ids are visited whether they appear in a Push or beside opcodes.
|
||||
if (knownScriptIdentifiers.has(identifier) === true) {
|
||||
enqueueReachableTemplateScriptIdentifier(identifier, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct Push names that are not script ids are WalletData, including dotted names.
|
||||
if (isDirectPushContent === true) {
|
||||
variableNames.add(identifier);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Visitor for one Script node in the parseScript tree.
|
||||
* Recurses into Push and Evaluation children and dispatches Identifier nodes.
|
||||
*
|
||||
* @param {CollectFromParsedScriptParameters} parameters - Parsed Script, Push depth flag, and parse tree context.
|
||||
*/
|
||||
const collectFromParsedScript = (parameters: CollectFromParsedScriptParameters): void => {
|
||||
const { scriptSegment, isDirectPushContent, knownScriptIdentifiers, visitedScriptIdentifiers, scriptIdentifiersToVisit, variableNames } =
|
||||
parameters;
|
||||
|
||||
for (const child of scriptSegment.value) {
|
||||
if (child.name === 'Push') {
|
||||
// Inner Script of a Push is where `<name>` WalletData lives, including nested angle brackets.
|
||||
collectFromParsedScript({
|
||||
scriptSegment: child.value,
|
||||
isDirectPushContent: true,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.name === 'Evaluation') {
|
||||
// Evaluation contents are opcodes and nested Pushes, not WalletData by themselves.
|
||||
collectFromParsedScript({
|
||||
scriptSegment: child.value,
|
||||
isDirectPushContent: false,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.name === 'Identifier') {
|
||||
collectFromParsedIdentifier({
|
||||
identifier: child.value,
|
||||
isDirectPushContent,
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects WalletData names from the parseScript tree using the visitor pattern.
|
||||
*
|
||||
* parseScript returns a tree of Script, Push, Evaluation, and Identifier nodes. Nested Push and
|
||||
* Evaluation nodes are entered immediately. A template script id is queued whether it appears inside a Push or next to
|
||||
* opcodes. Queued script sources are visited after the current tree finishes, in the order they were
|
||||
* first seen.
|
||||
* A visited id is skipped on later encounters. Opcodes and literals are ignored. Unrelated scripts
|
||||
* in the map are not visited. An Identifier inside a nested Push is collected from that inner Push,
|
||||
* for example `<$(<someKey.public_key> OP_HASH160)>` collects `someKey.public_key`.
|
||||
*
|
||||
* @param {string} evaluation - CashAssembly evaluation or script id text to scan.
|
||||
* @param {CashAssemblyTemplateScripts} [templateScripts] - Optional template scripts map used to visit nested script ids.
|
||||
* @returns {string[]} - WalletData names from reachable Pushes. Script ids are omitted when
|
||||
* `templateScripts` is provided.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When parseScript rejects the starting text or a visited script source.
|
||||
*/
|
||||
export const collectVariablesUsingParseScript = (evaluation: string, templateScripts: CashAssemblyTemplateScripts = {}): string[] => {
|
||||
// Create a set of the script identifiers from the template scripts map.
|
||||
const knownScriptIdentifiers = new Set(Object.keys(templateScripts));
|
||||
|
||||
const visitedScriptIdentifiers = new Set<string>();
|
||||
const scriptIdentifiersToVisit: string[] = [];
|
||||
const variableNames = new Set<string>();
|
||||
|
||||
// Share by reference context.
|
||||
const parseTreeContext: CollectFromParseTreeContext = {
|
||||
knownScriptIdentifiers,
|
||||
visitedScriptIdentifiers,
|
||||
scriptIdentifiersToVisit,
|
||||
variableNames,
|
||||
};
|
||||
|
||||
// Parse the starting text so nested Pushes are visible before visiting nested script sources.
|
||||
const startingScriptSegment = parseCashAssemblySource(evaluation);
|
||||
|
||||
// Scan the starting parse tree before visiting nested script sources.
|
||||
collectFromParsedScript({
|
||||
...parseTreeContext,
|
||||
scriptSegment: startingScriptSegment,
|
||||
isDirectPushContent: false,
|
||||
});
|
||||
|
||||
for (const scriptIdentifier of scriptIdentifiersToVisit) {
|
||||
if (visitedScriptIdentifiers.has(scriptIdentifier) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedScriptIdentifiers.add(scriptIdentifier);
|
||||
|
||||
// Get the script definition from the template scripts map.
|
||||
const scriptDefinition = templateScripts[scriptIdentifier];
|
||||
|
||||
const nestedScriptSegment = parseCashAssemblySource(scriptDefinition);
|
||||
|
||||
collectFromParsedScript({
|
||||
...parseTreeContext,
|
||||
scriptSegment: nestedScriptSegment,
|
||||
isDirectPushContent: false,
|
||||
});
|
||||
}
|
||||
|
||||
return [ ...variableNames ];
|
||||
};
|
||||
@@ -1,61 +1,41 @@
|
||||
/**
|
||||
* Detects whether a string is a pure CashAssembly expression.
|
||||
*
|
||||
* CashAssembly expressions look like `$(<variable>)` or `$(<a> <b>)`. This pattern checks
|
||||
* that the entire string is one such expression and nothing else. It will not match if
|
||||
* there is other text surrounding the expression.
|
||||
*
|
||||
* For example:
|
||||
* `$(<fee>)` matches (a full expression)
|
||||
* `OP_DUP $(<fee>)` does not match (extra text before it)
|
||||
* `$()` does not match (empty expression)
|
||||
*/
|
||||
export const CASHASSEMBLY_EXPRESSION_PATTERN = /^\$\([^)]+\)$/;
|
||||
|
||||
/**
|
||||
* Finds all CashAssembly evaluations embedded in a larger string.
|
||||
*
|
||||
* An evaluation looks like `$(...)`, for example `$(<fee>)` or `$(<a> <b>)`. This pattern
|
||||
* locates every occurrence in the input and returns them all (global flag `g`).
|
||||
* Empty evaluations `$()` are intentionally excluded because they reference no variables.
|
||||
*
|
||||
* For example, scanning `"OP_DUP <$(<pubkeyHash>)> OP_HASH160 $(<fee>)"` would return
|
||||
* `['$(<pubkeyHash>)', '$(<fee>)']`.
|
||||
*/
|
||||
export const CASHASSEMBLY_EVALUATION_PATTERN = /\$\([^)]+\)/g;
|
||||
|
||||
/**
|
||||
* Extracts variable names from angle-bracket references inside a CashAssembly evaluation.
|
||||
*
|
||||
* Inside an evaluation like `$(<pubkeyHash> <fee>)`, variables are referenced as `<name>`.
|
||||
* This pattern captures the name between the brackets. The global flag `g` allows iterating
|
||||
* over every variable reference in a single evaluation string.
|
||||
*
|
||||
* For example, running this against `$(<pubkeyHash> <fee>)` would return the variable names
|
||||
* `["pubkeyHash", "fee"]`.
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_PATTERN = /<([^>]+)>/g;
|
||||
|
||||
/**
|
||||
* Identifies CashAssembly literal tokens that appear inside angle-bracket push statements.
|
||||
*
|
||||
* Inside an evaluation, not everything between `<` and `>` is a variable name. Literals are
|
||||
* also valid push contents: numeric literals (e.g. `<0>`, `<32>`), hex literals (`<0x02>`),
|
||||
* binary literals (`<0b1010>`), and string literals (`<"minting">`, `<'hello'>`). This pattern
|
||||
* matches any captured token that starts with a digit or a quote character.
|
||||
*/
|
||||
export const CASHASSEMBLY_LITERAL_TOKEN_PATTERN = /^[0-9"']/;
|
||||
|
||||
/**
|
||||
* Matches a single dot variable method reference inside an angle-bracket identifier.
|
||||
* Matches a single dot variable method reference inside an angle bracket identifier.
|
||||
*
|
||||
* Used to detect primitive method references such as `expiry.toIso8601`.
|
||||
*
|
||||
* For example:
|
||||
* `expiry.toIso8601` matches (base `expiry`, method `toIso8601`)
|
||||
* `requestedSatoshis` does not match (no method)
|
||||
* `key.schnorr_signature.all_outputs` does not match (more than one dot)
|
||||
* `key.public_key` matches the pattern shape but it is only resolved
|
||||
* when `hint` maps to a primitive
|
||||
* For example `expiry.toIso8601` matches with base expiry and method toIso8601.
|
||||
* `requestedSatoshis` does not match because it has no method.
|
||||
* `key.schnorr_signature.all_outputs` does not match because it has more than one dot.
|
||||
*/
|
||||
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
|
||||
|
||||
/**
|
||||
* Character count of the `$(` evaluation opener, which is two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so scanning
|
||||
* starts on the evaluation contents rather than on the opening parenthesis.
|
||||
*/
|
||||
export const CASHASSEMBLY_EVALUATION_START_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Character count of the `//` and `/*` comment openers, which are both two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so the '*'
|
||||
* of a block comment opener cannot be reused as the '*' of its closer.
|
||||
*/
|
||||
export const CASHASSEMBLY_COMMENT_START_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Character count of the block comment closer, which is two characters long.
|
||||
*
|
||||
* Adding this length moves the read position to the first character after that sequence so the
|
||||
* closing slash cannot pair with the next character and open another comment.
|
||||
*/
|
||||
export const CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* `$()` with nothing between the parentheses.
|
||||
*
|
||||
* Callers use this to detect an evaluation that has no contents to compile.
|
||||
*/
|
||||
export const EMPTY_CASHASSEMBLY_EVALUATION = '$()';
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import type { IdentifierResolutionType } from '@bitauth/libauth';
|
||||
|
||||
/**
|
||||
* Error thrown when a required variable is missing.
|
||||
*/
|
||||
export class CashAssemblyRequiredVariableMissingError extends Error {
|
||||
constructor(variableNames?: string[]) {
|
||||
/**
|
||||
* Variable names that were required but absent from the variables map.
|
||||
*/
|
||||
readonly variableNames: string[];
|
||||
|
||||
constructor(variableNames: string[] = []) {
|
||||
const defaultMessage = 'Missing required variable';
|
||||
if (variableNames !== undefined && variableNames.length > 0) {
|
||||
if (variableNames.length > 0) {
|
||||
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
|
||||
} else {
|
||||
super(defaultMessage);
|
||||
}
|
||||
|
||||
this.variableNames = variableNames;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +32,104 @@ export class CashAssemblyCompilationFailedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a quoted string inside `$()` never closes.
|
||||
*/
|
||||
export class CashAssemblyQuotedLiteralUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the opening quote that never found a closer.
|
||||
*/
|
||||
readonly openingQuoteIndex: number;
|
||||
|
||||
/**
|
||||
* Quote character that opened the literal.
|
||||
*/
|
||||
readonly quoteCharacter: string;
|
||||
|
||||
/**
|
||||
* Text from the opening quote through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(openingQuoteIndex: number, quoteCharacter: string, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(openingQuoteIndex);
|
||||
const defaultMessage = 'Quoted literal in a CashAssembly evaluation is unclosed';
|
||||
const details = [
|
||||
`quoteCharacter ${JSON.stringify(quoteCharacter)}`,
|
||||
`openingQuoteIndex ${String(openingQuoteIndex)}`,
|
||||
`unclosedText ${JSON.stringify(unclosedText)}`,
|
||||
].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.openingQuoteIndex = openingQuoteIndex;
|
||||
this.quoteCharacter = quoteCharacter;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a block comment inside `$()` never closes.
|
||||
*/
|
||||
export class CashAssemblyBlockCommentUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the first slash of the block comment opener.
|
||||
*/
|
||||
readonly commentStartIndex: number;
|
||||
|
||||
/**
|
||||
* Text from the block comment opener through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(commentStartIndex: number, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(commentStartIndex);
|
||||
const defaultMessage = 'Block comment in a CashAssembly evaluation is unclosed';
|
||||
const details = [ `commentStartIndex ${String(commentStartIndex)}`, `unclosedText ${JSON.stringify(unclosedText)}` ].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.commentStartIndex = commentStartIndex;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a `$()` evaluation never finds its matching closer.
|
||||
*/
|
||||
export class CashAssemblyEvaluationUnclosedError extends Error {
|
||||
/**
|
||||
* Index of the `$` that opened the evaluation.
|
||||
*/
|
||||
readonly evaluationStartIndex: number;
|
||||
|
||||
/**
|
||||
* How many `$()` remain open at the end of the string, including nested evaluations.
|
||||
*/
|
||||
readonly remainingOpenEvaluations: number;
|
||||
|
||||
/**
|
||||
* Text from the opening `$` through the end of the scanned string.
|
||||
*/
|
||||
readonly unclosedText: string;
|
||||
|
||||
constructor(evaluationStartIndex: number, remainingOpenEvaluations: number, cashAssemblyText: string) {
|
||||
const unclosedText = cashAssemblyText.slice(evaluationStartIndex);
|
||||
const defaultMessage = 'CashAssembly evaluation is unclosed';
|
||||
const details = [
|
||||
`evaluationStartIndex ${String(evaluationStartIndex)}`,
|
||||
`remainingOpenEvaluations ${String(remainingOpenEvaluations)}`,
|
||||
`unclosedText ${JSON.stringify(unclosedText)}`,
|
||||
].join(', ');
|
||||
|
||||
super(`${defaultMessage}: ${details}`);
|
||||
|
||||
this.evaluationStartIndex = evaluationStartIndex;
|
||||
this.remainingOpenEvaluations = remainingOpenEvaluations;
|
||||
this.unclosedText = unclosedText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a variable's runtime type does not match the type required for compilation.
|
||||
*/
|
||||
@@ -35,12 +141,12 @@ export class CashAssemblyVariableTypeMismatchError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a supported primitive hint does not expose the requested method.
|
||||
* Error thrown when a method resolvable primitive type does not expose the requested method.
|
||||
*/
|
||||
export class CashAssemblyPrimitiveMethodMissingError extends Error {
|
||||
constructor(identifier: string, methodName: string, hint: string) {
|
||||
constructor(identifier: string, methodName: string, type: string) {
|
||||
const defaultMessage = 'CashAssembly primitive method does not exist';
|
||||
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", hint "${hint}"`);
|
||||
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", type "${type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +171,7 @@ export class CashAssemblyNumberNotSafeIntegerError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a supported primitive is selected but its value is missing when provided in the variables map.
|
||||
* Error thrown when a method resolvable primitive is selected but its value is missing from the variables map.
|
||||
*/
|
||||
export class CashAssemblyPrimitiveVariableMissingError extends Error {
|
||||
constructor(identifier: string, variableName: string) {
|
||||
@@ -74,6 +180,30 @@ export class CashAssemblyPrimitiveVariableMissingError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when one identifier would resolve as more than one {@link IdentifierResolutionType}.
|
||||
*/
|
||||
export class CashAssemblyIdentifierCollisionError extends Error {
|
||||
/**
|
||||
* Identifier that more than one resolution type would match.
|
||||
*/
|
||||
readonly identifier: string;
|
||||
|
||||
/**
|
||||
* The list of resolution types that the identifier matches.
|
||||
*/
|
||||
readonly resolutionTypes: IdentifierResolutionType[];
|
||||
|
||||
constructor(identifier: string, resolutionTypes: IdentifierResolutionType[]) {
|
||||
const defaultMessage = 'CashAssembly identifier exists for more than one resolution type';
|
||||
|
||||
super(`${defaultMessage}: identifier "${identifier}", resolutionTypes [${resolutionTypes.join(', ')}]`);
|
||||
|
||||
this.identifier = identifier;
|
||||
this.resolutionTypes = resolutionTypes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
|
||||
*/
|
||||
|
||||
+259
-175
@@ -7,26 +7,26 @@
|
||||
* ## Syntax (CashAssembly)
|
||||
*
|
||||
* `<expression>` is a push statement. Compiles the contents and pushes the result onto the VM stack.
|
||||
* `<someKey.public_key>` pushes the 33 byte compressed public key. `<1>` pushes the integer 1.
|
||||
* For example `<someKey.public_key>` pushes the 33 byte compressed public key and `<1>` pushes the integer 1.
|
||||
*
|
||||
* `$(<expression>)` is an evaluation. Runs the inner script in the VM and inserts the top stack
|
||||
* item as VM bytecode.
|
||||
* `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
|
||||
* For example `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
|
||||
*
|
||||
* `<$(<expression>)>` is a push of an evaluation result. It evaluates first then pushes.
|
||||
* For a P2PKH locking script example see
|
||||
* For example a P2PKH locking script looks like
|
||||
* `OP_DUP OP_HASH160 <$(<someKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG`.
|
||||
*
|
||||
* `variableId.operation` is a variable with a compiler resolved operation. `someKey.public_key`
|
||||
* produces the public key bytes. `someKey.schnorr_signature.all_outputs` produces a Schnorr
|
||||
* signature.
|
||||
* `variableId.operation` is a variable with a compiler resolved operation.
|
||||
* For example `someKey.public_key` produces the public key bytes and
|
||||
* `someKey.schnorr_signature.all_outputs` produces a Schnorr signature.
|
||||
*
|
||||
* Opcodes (`OP_DUP`, `OP_HASH160`, and similar) are inserted as their bytecode equivalent directly.
|
||||
*
|
||||
* ## Name resolution priority (CashAssembly)
|
||||
*
|
||||
* When the compiler encounters an identifier it resolves it in this order.
|
||||
* 1. Opcode always wins. Naming a variable or script `OP_ADD` will not shadow it.
|
||||
* 1. Opcode always wins. For example naming a variable or script `OP_ADD` will not shadow it.
|
||||
* 2. Variable shadows scripts of the same name.
|
||||
* 3. Script is the script's bytecode.
|
||||
*
|
||||
@@ -34,80 +34,40 @@
|
||||
*
|
||||
* Supported `<base.method>` pushes are resolved to bytes before CashAssembly compiles.
|
||||
* Inside CashAssembly the order is Opcode then Variable then Script.
|
||||
*
|
||||
* ## This compile path
|
||||
*
|
||||
* This file is a layer on CashAssembly. Text outside `$()` is kept. Only `$()` is compiled.
|
||||
* For example `Paid $(scriptA) of $(scriptB)` keeps the words Paid and of.
|
||||
*
|
||||
* `$()` is found by scanning left to right with parenthesis matching by depth count. Quoted strings
|
||||
* and comments are skipped so a `)` there cannot end `$()`. Nested `$()` stay inside the outer
|
||||
* evaluation. That text is never passed to parseScript.
|
||||
*
|
||||
* Empty `$()` still uses this mixed path. When the string contains no `$()` at all, the entire
|
||||
* string is compiled as CashAssembly so push opcodes remain.
|
||||
*/
|
||||
import type { CompilerBch } from '@bitauth/libauth';
|
||||
import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth';
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
import {
|
||||
CashAssemblyCompilationFailedError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyRequiredVariableMissingError,
|
||||
CashAssemblyVariableTypeMismatchError,
|
||||
CashAssemblyVmNumberDecodeError,
|
||||
} from './errors.ts';
|
||||
import {
|
||||
CASHASSEMBLY_EVALUATION_PATTERN,
|
||||
CASHASSEMBLY_EXPRESSION_PATTERN,
|
||||
CASHASSEMBLY_LITERAL_TOKEN_PATTERN,
|
||||
CASHASSEMBLY_VARIABLE_PATTERN,
|
||||
} from './defaults.ts';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
import { resolvePrimitiveMethodBytes } from './primitive-evaluations.ts';
|
||||
|
||||
/**
|
||||
* Supported decode modes for compiled CashAssembly evaluation bytes.
|
||||
*/
|
||||
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
|
||||
|
||||
/**
|
||||
* Parameters for compiling CashAssembly string.
|
||||
*/
|
||||
export type CompileCashAssemblyStringParameters = {
|
||||
|
||||
/**
|
||||
* Text that may embed CashAssembly evaluations such as `$(<fee>)` or `$(<expiry.toIso8601>)`.
|
||||
*/
|
||||
cashAssemblyText: string;
|
||||
|
||||
/**
|
||||
* Used for both primitive method evaluations and normal CashAssembly compilation.
|
||||
*/
|
||||
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
|
||||
|
||||
/**
|
||||
* The mode to decode compiled evaluation bytes into a string.
|
||||
*/
|
||||
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
|
||||
|
||||
/**
|
||||
* Optional template variable definitions. When provided, each `<name.method>` push whose `hint`
|
||||
* maps to a supported primitive class is resolved to bytes before CashAssembly compilation.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the expression is a CashAssembly expression.
|
||||
*
|
||||
* @param {unknown} expression - The expression to check.
|
||||
* @returns {boolean} True if the expression is a CashAssembly expression, false otherwise.
|
||||
*/
|
||||
export const isCashAssemblyExpression = (expression: unknown): boolean => {
|
||||
return typeof expression === 'string' && CASHASSEMBLY_EXPRESSION_PATTERN.test(expression);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts all CashAssembly evaluations (i.e., substrings like $(...)) from the input text.
|
||||
*
|
||||
* @param {string} text - The input string to scan for CashAssembly evaluations.
|
||||
* @returns {string[]} An array of evaluation strings found in the input.
|
||||
*
|
||||
* @example
|
||||
* extractCashAssemblyEvaluations("OP_DUP <$(<foo>)> OP_HASH160 $(<bar>)");
|
||||
* // returns ['$(<foo>)', '$(<bar>)']
|
||||
*/
|
||||
export const extractCashAssemblyEvaluations = (text: string): string[] => {
|
||||
return text.match(CASHASSEMBLY_EVALUATION_PATTERN) ?? [];
|
||||
};
|
||||
import { assertNoIdentifierCollisions } from './identifier-collisions.ts';
|
||||
import { collectVariablesUsingParseScript } from './collect-evaluations.ts';
|
||||
import { EMPTY_CASHASSEMBLY_EVALUATION } from './defaults.ts';
|
||||
import { scanCashAssemblyEvaluations } from './scan-evaluations.ts';
|
||||
import type {
|
||||
CompileCashAssemblyEvaluationsParameters,
|
||||
CompileCashAssemblySourceToBytesParameters,
|
||||
CompileCashAssemblySourceToDecodedTextParameters,
|
||||
CompileCashAssemblyStringParameters,
|
||||
CompiledCashAssemblyDecodeMode,
|
||||
} from './types.ts';
|
||||
|
||||
/**
|
||||
* Returns the segment of `identifier` before the first `.`.
|
||||
@@ -116,7 +76,7 @@ export const extractCashAssemblyEvaluations = (text: string): string[] => {
|
||||
* Multi segment identifiers such as `foo.bar.baz` resolve to `foo`.
|
||||
*
|
||||
* @param {string} identifier - Identifier that may contain a dot.
|
||||
* @returns {string} The base name before the first `.`.
|
||||
* @returns {string} - The base name before the first `.`.
|
||||
*/
|
||||
const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
const firstDotIndex = identifier.indexOf('.');
|
||||
@@ -128,31 +88,6 @@ const resolveIdentifierBaseName = (identifier: string): string => {
|
||||
return identifier.slice(0, firstDotIndex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts unique variable identifiers enclosed in angle brackets from each evaluation string.
|
||||
*
|
||||
* CashAssembly literal tokens such as hex bytes, numbers, and quoted strings are excluded via
|
||||
* {@link CASHASSEMBLY_LITERAL_TOKEN_PATTERN}. For example, `<0x02>` and `<"minting">` are not returned.
|
||||
*
|
||||
* @param {string[]} evaluations - An array of evaluation strings from which to extract variable names.
|
||||
* @returns {string[]} An array of variable names.
|
||||
*/
|
||||
export const extractVariablesFromEvaluations = (evaluations: string[]): string[] => {
|
||||
const uniqueVariables = new Set<string>();
|
||||
|
||||
for (const evaluation of evaluations) {
|
||||
for (const [ , extractedIdentifier ] of evaluation.matchAll(CASHASSEMBLY_VARIABLE_PATTERN)) {
|
||||
if (CASHASSEMBLY_LITERAL_TOKEN_PATTERN.test(extractedIdentifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uniqueVariables.add(extractedIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return [ ...uniqueVariables ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes compiled CashAssembly evaluation bytes into a string representation.
|
||||
*
|
||||
@@ -161,10 +96,9 @@ export const extractVariablesFromEvaluations = (evaluations: string[]): string[]
|
||||
* such as hashes, and `boolean` to represent boolean values.
|
||||
*
|
||||
* @param {Uint8Array} compiledResult - The compiled evaluation bytecode.
|
||||
* @param {CompiledCashAssemblyDecodeMode} [evaluationDecodeMode='utf8'] - The decode mode used to convert
|
||||
* bytes to text.
|
||||
* @returns {string} The decoded value as a string suitable for inline replacement.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
|
||||
* @param {CompiledCashAssemblyDecodeMode} evaluationDecodeMode - The decode mode used to convert bytes to text.
|
||||
* @returns {string} - The decoded value as a string suitable for inline replacement.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} - When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
|
||||
*/
|
||||
export const decodeCompiledCashAssemblyEvaluation = (
|
||||
compiledResult: Uint8Array,
|
||||
@@ -206,43 +140,52 @@ export const decodeCompiledCashAssemblyEvaluation = (
|
||||
* @param {CompilerBch} compiler - The libauth compiler from {@link compileCashAssemblyEvaluations}.
|
||||
* @param {string} evaluation - The specific evaluation string to compile.
|
||||
* @param {Record<string, Uint8Array>} variables - A record mapping variable names to their values.
|
||||
* @returns {Uint8Array} The compiled bytecode.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} If a required variable is not present.
|
||||
* @throws {@link CashAssemblyVariableTypeMismatchError} If a variable value is not a Uint8Array.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} If libauth compilation fails.
|
||||
* @param {string[]} requiredVariableNames - WalletData names from {@link collectVariablesUsingParseScript} that must be present.
|
||||
* @returns {Uint8Array} - The compiled bytecode.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - If a required variable is not present.
|
||||
* @throws {@link CashAssemblyVariableTypeMismatchError} - If a variable value is not a Uint8Array.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - If libauth compilation fails.
|
||||
*/
|
||||
export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation: string, variables: Record<string, Uint8Array>): Uint8Array => {
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
export const generateCashAssemblyBytecode = (
|
||||
compiler: CompilerBch,
|
||||
evaluation: string,
|
||||
variables: Record<string, Uint8Array>,
|
||||
requiredVariableNames: string[],
|
||||
): Uint8Array => {
|
||||
// Missing WalletData must fail here. Libauth would otherwise compile as if the value were empty bytes.
|
||||
const missingVariables = requiredVariableNames.filter((name: string) => Object.hasOwn(variables, name) === false);
|
||||
|
||||
// Validate that all required variables are provided
|
||||
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
|
||||
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
}
|
||||
|
||||
// Construct the bytecode object using the keys in variableNames, mapping to the values in variables
|
||||
const bytecode: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
|
||||
// Pass only required names so extra keys in `variables` are not sent to Libauth.
|
||||
for (const variableName of requiredVariableNames) {
|
||||
const value = variables[variableName];
|
||||
|
||||
// By the time execution reaches this point, the value should be a Uint8Array.
|
||||
if (!(value instanceof Uint8Array)) {
|
||||
// Libauth WalletData is always bytes. A number would compile incorrectly if we trusted TypeScript alone.
|
||||
if (value instanceof Uint8Array === false) {
|
||||
throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value);
|
||||
}
|
||||
|
||||
bytecode[variableName] = value;
|
||||
}
|
||||
|
||||
// `scriptId` is the source string registered in compileCashAssemblyEvaluations.
|
||||
const compiledBytecode = compiler.generateBytecode({
|
||||
data: { bytecode },
|
||||
scriptId: evaluation,
|
||||
});
|
||||
|
||||
if (!compiledBytecode.success) {
|
||||
// Collapse libauth's full errors list into one string because
|
||||
// CashAssemblyCompilationFailedError only carries a single message.
|
||||
// A failed compile must not return partial bytecode.
|
||||
if (compiledBytecode.success === false) {
|
||||
// CashAssemblyCompilationFailedError carries one message. Libauth may report several errors.
|
||||
let compilationFailureMessage = 'unknown compilation failure';
|
||||
|
||||
// Prefer Libauth's own error text when the failure object includes an errors list.
|
||||
if ('errors' in compiledBytecode && compiledBytecode.errors.length > 0) {
|
||||
compilationFailureMessage = compiledBytecode.errors.map((compilationError) => compilationError.error).join('; ');
|
||||
}
|
||||
@@ -254,28 +197,50 @@ export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation:
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
|
||||
* Prepares a compiler for the provided CashAssembly source strings, setting required variables as 'WalletData'.
|
||||
*
|
||||
* @param {string[]} evaluations - Array of evaluation strings (e.g., ['$(<var1>)', '$(<var2> <var3>)']).
|
||||
* @returns {CompilerBch} A Libauth compiler instance for use with these evaluations.
|
||||
* Each evaluation string is registered as a script whose id and source are that string. Template
|
||||
* scripts are then copied onto the same table. If a source is a template id such as `scriptA`, that
|
||||
* id must compile the template source. Leaving the id as its own source would compile the text
|
||||
* `scriptA` and include itself.
|
||||
*
|
||||
* @param {CompileCashAssemblyEvaluationsParameters} parameters - Source strings, optional template scripts, and already collected WalletData names.
|
||||
* @returns {CompilerBch} - A Libauth compiler instance for use with these source strings.
|
||||
*/
|
||||
export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerBch => {
|
||||
// Create a scripts object where each key is the evaluation and the value is also the evaluation
|
||||
export const compileCashAssemblyEvaluations = (parameters: CompileCashAssemblyEvaluationsParameters): CompilerBch => {
|
||||
const { evaluations, templateScripts, variableNames } = parameters;
|
||||
const scripts: Record<string, string> = {};
|
||||
|
||||
// Libauth looks up scripts by id. Using the source string as the id lets generateBytecode request that exact source.
|
||||
for (const evaluation of evaluations) {
|
||||
scripts[evaluation] = evaluation;
|
||||
}
|
||||
|
||||
// Get the variable names from the evaluations.
|
||||
const variableNames = extractVariablesFromEvaluations(evaluations);
|
||||
const knownScriptIdentifiers = new Set<string>();
|
||||
|
||||
if (templateScripts !== undefined) {
|
||||
for (const [ scriptIdentifier, scriptDefinition ] of Object.entries(templateScripts)) {
|
||||
// Compiling `scriptA` must run the template source. Otherwise the id compiles as the text `scriptA` and includes itself.
|
||||
scripts[scriptIdentifier] = scriptDefinition;
|
||||
|
||||
// Template ids compile as scripts. They must not also be registered as WalletData.
|
||||
knownScriptIdentifiers.add(scriptIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
|
||||
const variables: Record<string, { type: 'WalletData' }> = {};
|
||||
|
||||
// Register caller collected names. This function does not parse or collect again.
|
||||
for (const variableName of variableNames) {
|
||||
// A name that is a template script id is already a compiler script, not WalletData.
|
||||
if (knownScriptIdentifiers.has(variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Libauth WalletData is the name before the first `.`. `someKey.public_key` registers as `someKey`.
|
||||
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
|
||||
}
|
||||
|
||||
// Create the libauth compiler.
|
||||
const compiler = createCompilerBch({
|
||||
scripts,
|
||||
variables,
|
||||
@@ -285,69 +250,188 @@ export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerB
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
|
||||
* with a decoded string representation.
|
||||
* Compiles one CashAssembly source string to bytecode.
|
||||
*
|
||||
* The source may be an evaluation such as `$(scriptA)` or CashAssembly such as
|
||||
* `scriptA`. WalletData names come from {@link collectVariablesUsingParseScript}.
|
||||
*
|
||||
* @param {CompileCashAssemblySourceToBytesParameters} parameters - Source text and compilation context.
|
||||
* @returns {Uint8Array} - Compiled bytecode for that source.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} - When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} - When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
*/
|
||||
export const compileCashAssemblySourceToBytes = (parameters: CompileCashAssemblySourceToBytesParameters): Uint8Array => {
|
||||
const { cashAssemblySource, variables, templateVariables, templateScripts } = parameters;
|
||||
|
||||
// Throw if there is a identifier collision between opcodes, variable, or template script.
|
||||
assertNoIdentifierCollisions({ variables, templateScripts });
|
||||
|
||||
// Collect WalletData names from the source and every reachable nested script source.
|
||||
const variableNames = collectVariablesUsingParseScript(cashAssemblySource, templateScripts);
|
||||
|
||||
// Resolve supported primitive methods to bytes before treating remaining names as WalletData.
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
variableNames,
|
||||
templateVariables,
|
||||
variables,
|
||||
});
|
||||
|
||||
// Collect names that still need a caller supplied value after primitive resolution.
|
||||
const missingVariables: string[] = [];
|
||||
for (const variableName of variableNames) {
|
||||
// Primitive methods already produced bytes under the full name.
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A present runtime value will be converted to bytes below.
|
||||
if (Object.hasOwn(variables, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missingVariables.push(variableName);
|
||||
}
|
||||
|
||||
// Refuse invented defaults so a missing value cannot compile into incorrect bytecode.
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
}
|
||||
|
||||
// Convert each variable to its bytes before compilation.
|
||||
const variableBytes: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
// Prefer the primitive method result so CashAssembly sees bytes under `base.method`.
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
variableBytes[variableName] = primitiveMethodBytes[variableName];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remaining names are caller WalletData that still needs a byte encoding.
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
|
||||
// Reuse the names collected above so this compiler is not built from a second parse.
|
||||
const compiler = compileCashAssemblyEvaluations({
|
||||
evaluations: [ cashAssemblySource ],
|
||||
templateScripts,
|
||||
variableNames,
|
||||
});
|
||||
|
||||
return generateCashAssemblyBytecode(compiler, cashAssemblySource, variableBytes, variableNames);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles one CashAssembly source to bytes and decodes those bytes to text.
|
||||
*
|
||||
* @param {CompileCashAssemblySourceToDecodedTextParameters} parameters - Source text, WalletData, optional template context, and decode mode.
|
||||
* @returns {string} - Decoded compilation result for that source.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} - When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} - When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} - When `evaluationDecodeMode` is `bigint` and compiled bytes are not a VM number.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
*/
|
||||
const compileCashAssemblySourceToDecodedText = (parameters: CompileCashAssemblySourceToDecodedTextParameters): string => {
|
||||
const { cashAssemblySource, variables, templateVariables, templateScripts, evaluationDecodeMode } = parameters;
|
||||
|
||||
const compilationResult = compileCashAssemblySourceToBytes({
|
||||
cashAssemblySource,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
});
|
||||
|
||||
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compiles CashAssembly text. WalletData names come from parseScript. `$()` evaluations in text
|
||||
* are found by scanning the string and matching parentheses.
|
||||
*
|
||||
* When `cashAssemblyText` contains `$()`, each non empty evaluation is compiled and
|
||||
* replaced in place. Empty `$()` is copied through. Surrounding text is kept. Evaluation drops
|
||||
* push opcodes and keeps the stack payload.
|
||||
*
|
||||
* When `cashAssemblyText` contains no `$()` at all, the entire string is compiled as CashAssembly.
|
||||
* Push opcodes from `<...>` remain. Pass a template script id such as `scriptA` or
|
||||
* concatenated ids such as `scriptA scriptB`.
|
||||
*
|
||||
* Required WalletData names come from {@link collectVariablesUsingParseScript}.
|
||||
*
|
||||
* @param {CompileCashAssemblyStringParameters} parameters - Parameters for compiling the CashAssembly string.
|
||||
* @param {string} parameters.cashAssemblyText - The string with CashAssembly evaluations.
|
||||
* @param {Record<string, XOInvitationVariableValue | Uint8Array>} parameters.variables - Object mapping
|
||||
* variable names to values for compilation.
|
||||
* @param {CompiledCashAssemblyDecodeMode} [parameters.evaluationDecodeMode='utf8'] - The decode mode used
|
||||
* after each evaluation is compiled. See {@link decodeCompiledCashAssemblyEvaluation}.
|
||||
* @param {XOTemplate['variables']} [parameters.templateVariables] - Optional template variable definitions
|
||||
* used to resolve supported `<name.method>` pushes via each variable's `hint`.
|
||||
* @returns {string} Compiled text with all evaluations replaced by decoded string values.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and an evaluation is not a VM number.
|
||||
* @returns {string} - Compiled text with evaluations replaced, or decoded bytecode when the whole string is compiled as CashAssembly.
|
||||
* @throws {@link CashAssemblyRequiredVariableMissingError} - When a required variable is not present in the variables map.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} - When a supported primitive hint has an unknown method.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} - When a supported primitive method is missing its runtime value.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} - When a primitive method return type cannot be embedded as bytes.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} - When a number variable is not a safe integer.
|
||||
* @throws {@link CashAssemblyVmNumberDecodeError} - When `evaluationDecodeMode` is `bigint` and compiled bytes are not a VM number.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches two resolution types.
|
||||
* @throws {@link CashAssemblyCompilationFailedError} - When Libauth compilation fails.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => {
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables } = parameters;
|
||||
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables, templateScripts } = parameters;
|
||||
|
||||
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
|
||||
// Extract variable identifiers required by the current evaluation.
|
||||
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
|
||||
// Scan once so empty `$()` still copies text around `$()` instead of compiling as CashAssembly.
|
||||
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
|
||||
|
||||
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
|
||||
identifiers: variableNames,
|
||||
templateVariables,
|
||||
if (scannedEvaluations.length === 0) {
|
||||
return compileCashAssemblySourceToDecodedText({
|
||||
cashAssemblySource: cashAssemblyText,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
evaluationDecodeMode,
|
||||
});
|
||||
}
|
||||
|
||||
// Prefer resolved method bytes. Fall back to converting the raw variable value.
|
||||
const missingVariables = variableNames.filter((variableName) => {
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
return false;
|
||||
}
|
||||
// Copy text outside `$()` and compile each `$()`.
|
||||
let textWithCompiledEvaluations = '';
|
||||
|
||||
return Object.hasOwn(variables, variableName) === false;
|
||||
});
|
||||
// Start of the next original text slice in cashAssemblyText. Gaps between evaluations must stay intact.
|
||||
let nextTextStartIndex = 0;
|
||||
|
||||
if (missingVariables.length > 0) {
|
||||
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
|
||||
// When every `$()` is empty, return cashAssemblyText so the original text is unchanged.
|
||||
let hasCompiledNonEmptyEvaluation = false;
|
||||
|
||||
for (const scannedEvaluation of scannedEvaluations) {
|
||||
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex, scannedEvaluation.startIndex);
|
||||
|
||||
// Empty `$()` has no contents to compile.
|
||||
if (scannedEvaluation.evaluationText === EMPTY_CASHASSEMBLY_EVALUATION) {
|
||||
textWithCompiledEvaluations += scannedEvaluation.evaluationText;
|
||||
} else {
|
||||
hasCompiledNonEmptyEvaluation = true;
|
||||
textWithCompiledEvaluations += compileCashAssemblySourceToDecodedText({
|
||||
cashAssemblySource: scannedEvaluation.evaluationText,
|
||||
variables,
|
||||
templateVariables,
|
||||
templateScripts,
|
||||
evaluationDecodeMode,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert each variable to its bytes before compilation.
|
||||
const variableBytes: Record<string, Uint8Array> = {};
|
||||
for (const variableName of variableNames) {
|
||||
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
|
||||
variableBytes[variableName] = primitiveMethodBytes[variableName];
|
||||
continue;
|
||||
}
|
||||
// Advance past this `$()` so nested evaluations inside it are not copied again.
|
||||
nextTextStartIndex = scannedEvaluation.closeIndex + 1;
|
||||
}
|
||||
|
||||
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
|
||||
}
|
||||
// Copy text after the last `$()` so the suffix is kept.
|
||||
textWithCompiledEvaluations += cashAssemblyText.slice(nextTextStartIndex);
|
||||
|
||||
// Compile the evaluation in isolation.
|
||||
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
|
||||
if (hasCompiledNonEmptyEvaluation === false) {
|
||||
return cashAssemblyText;
|
||||
}
|
||||
|
||||
// Generate the bytes for the evaluation.
|
||||
const compilationResult: Uint8Array = generateCashAssemblyBytecode(compiler, evaluation, variableBytes);
|
||||
|
||||
// Decode the bytes into a string as per the decode mode.
|
||||
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
|
||||
});
|
||||
return textWithCompiledEvaluations;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { generateBytecodeMap, IdentifierResolutionType, OpcodesBchSpec } from '@bitauth/libauth';
|
||||
import { CashAssemblyIdentifierCollisionError } from './errors.ts';
|
||||
import type { CompileCashAssemblyContext } from './types.ts';
|
||||
|
||||
/**
|
||||
* Set of opcode names that the compiler recognizes.
|
||||
*/
|
||||
const COMPILER_OPCODE_NAMES: ReadonlySet<string> = new Set(Object.keys(generateBytecodeMap(OpcodesBchSpec)));
|
||||
|
||||
/**
|
||||
* Parameters for {@link assertNoIdentifierCollisions} function.
|
||||
*/
|
||||
export type AssertNoIdentifierCollisionsParameters = Pick<CompileCashAssemblyContext, 'variables' | 'templateScripts'>;
|
||||
|
||||
/**
|
||||
* Libauth resolves opcodes, then variables, then scripts, and the first match silently hides the
|
||||
* rest, leading to unexpected compilation results and no errors. This function throws when one identifier matches more than one resolution type.
|
||||
*
|
||||
* @param {AssertNoIdentifierCollisionsParameters} parameters - Provided variables and optional template scripts.
|
||||
* @throws {@link CashAssemblyIdentifierCollisionError} - When one identifier matches more than one resolution type.
|
||||
*/
|
||||
export const assertNoIdentifierCollisions = (parameters: AssertNoIdentifierCollisionsParameters): void => {
|
||||
const { variables, templateScripts } = parameters;
|
||||
|
||||
// Create a set of the script identifiers from the template scripts map.
|
||||
const scriptIdentifiers = new Set(Object.keys(templateScripts ?? {}));
|
||||
|
||||
const variableNames = new Set(Object.keys(variables));
|
||||
|
||||
// Combine both, the variableNames and the scriptIdentifiers, to get a set of all identifiers.
|
||||
const declaredIdentifiers = new Set([ ...variableNames, ...scriptIdentifiers ]);
|
||||
|
||||
// This loop will check for more than one occurrence of an identifier in OP_CODES, variables, and scripts
|
||||
for (const identifier of declaredIdentifiers) {
|
||||
// Resolution types is a native construct from libauth.
|
||||
const resolutionTypes: IdentifierResolutionType[] = [];
|
||||
|
||||
// If the identifier is an opcode, then push it to the resolution types array, this will also be
|
||||
// detected by either or both of the variableNames and scriptIdentifiers checks.
|
||||
if (COMPILER_OPCODE_NAMES.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.opcode);
|
||||
}
|
||||
|
||||
// If the identifier is in variables, then push it to the resolution types array.
|
||||
if (variableNames.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.variable);
|
||||
}
|
||||
|
||||
// If the identifier is a script identifier, then push it to the resolution types array.
|
||||
if (scriptIdentifiers.has(identifier) === true) {
|
||||
resolutionTypes.push(IdentifierResolutionType.script);
|
||||
}
|
||||
|
||||
// If there are more than one resolution types, then throw an error.
|
||||
if (resolutionTypes.length > 1) {
|
||||
throw new CashAssemblyIdentifierCollisionError(identifier, resolutionTypes);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from './evaluations.ts';
|
||||
export * from './primitive-evaluations.ts';
|
||||
export * from './bytes.ts';
|
||||
export * from './defaults.ts';
|
||||
export { collectVariablesUsingParseScript } from './collect-evaluations.ts';
|
||||
export * from './errors.ts';
|
||||
export { compileCashAssemblyString } from './evaluations.ts';
|
||||
export { extractCashAssemblyEvaluations, isCashAssemblyExpression } from './scan-evaluations.ts';
|
||||
export type { CompiledCashAssemblyDecodeMode, CompileCashAssemblyStringParameters } from './types.ts';
|
||||
|
||||
@@ -16,10 +16,10 @@ import { CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN } from './defaults.ts';
|
||||
import { convertValueToBytes } from './bytes.ts';
|
||||
|
||||
/**
|
||||
* Template hint values mapped to a primitive class for resolving primitive method evaluations.
|
||||
* Keys are values from XOTemplatePrimitiveTypes.
|
||||
* Maps method resolvable template types to `@xo-cash/primitives` classes for `base.method` resolution.
|
||||
* Keys are a subset of {@link XOTemplatePrimitiveTypes}.
|
||||
*/
|
||||
const PRIMITIVE_BY_TEMPLATE_HINT = {
|
||||
const RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE = {
|
||||
[XOTemplatePrimitiveTypes.FUNGIBLE_TOKEN_AMOUNT]: FungibleTokenAmount,
|
||||
[XOTemplatePrimitiveTypes.NFT_COMMITMENT]: NFTCommitment,
|
||||
[XOTemplatePrimitiveTypes.PUBLIC_KEY]: PublicKey,
|
||||
@@ -31,7 +31,11 @@ const PRIMITIVE_BY_TEMPLATE_HINT = {
|
||||
[XOTemplatePrimitiveTypes.TRANSACTION_HASH]: TransactionHash,
|
||||
} as const;
|
||||
|
||||
type SupportedPrimitiveHint = keyof typeof PRIMITIVE_BY_TEMPLATE_HINT;
|
||||
/**
|
||||
* Template primitive types that map to an `@xo-cash/primitives` class for `base.method` resolution.
|
||||
* This is a subset of {@link XOTemplatePrimitiveType}, not every declared template type.
|
||||
*/
|
||||
type ResolvablePrimitiveType = keyof typeof RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE;
|
||||
|
||||
/**
|
||||
* Inputs needed to call a primitive method for a `name.method` push.
|
||||
@@ -54,21 +58,21 @@ type CallPrimitiveMethodParameters = {
|
||||
value: unknown;
|
||||
|
||||
/**
|
||||
* Supported template hint that selects the primitive class.
|
||||
* Method resolvable template type that selects the primitive class.
|
||||
*/
|
||||
hint: SupportedPrimitiveHint;
|
||||
type: ResolvablePrimitiveType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
|
||||
* Inputs needed to resolve primitive method pushes from collected CashAssembly variable names.
|
||||
*/
|
||||
export type ResolvePrimitiveMethodBytesParameters = {
|
||||
|
||||
/**
|
||||
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
|
||||
* Variable names from {@link collectVariablesUsingParseScript}, for example
|
||||
* `['amount.toSatoshis', 'fee.toSatoshis']`.
|
||||
*/
|
||||
identifiers: string[];
|
||||
variableNames: string[];
|
||||
|
||||
/**
|
||||
* Variable names and values object.
|
||||
@@ -77,30 +81,30 @@ export type ResolvePrimitiveMethodBytesParameters = {
|
||||
|
||||
/**
|
||||
* Template variable definitions. When omitted, no primitive methods are resolved.
|
||||
* The `hint` on each entry selects the primitive class.
|
||||
* The `type` on each entry selects the primitive class.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when `hint` maps to a supported primitive class in `PRIMITIVE_BY_TEMPLATE_HINT`.
|
||||
* Returns true when `type` maps to a primitive class in `RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE` for method resolution.
|
||||
*
|
||||
* @param {XOTemplatePrimitiveType | undefined} hint - Template variable hint.
|
||||
* @returns {boolean} True when the hint selects a supported primitive class.
|
||||
* @param {XOTemplatePrimitiveType | undefined} type - Template variable type.
|
||||
* @returns {boolean} True when the type can resolve `base.method` through a primitive class.
|
||||
*/
|
||||
const isSupportedPrimitiveHint = (hint: XOTemplatePrimitiveType | undefined): hint is SupportedPrimitiveHint => {
|
||||
return hint !== undefined && Object.hasOwn(PRIMITIVE_BY_TEMPLATE_HINT, hint) === true;
|
||||
const isResolvablePrimitiveType = (type: XOTemplatePrimitiveType | undefined): type is ResolvablePrimitiveType => {
|
||||
return type !== undefined && Object.hasOwn(RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE, type) === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when `methodName` is an own function on the primitive class for `hint`.
|
||||
* Returns true when `methodName` is an own function on the primitive class for `type`.
|
||||
*
|
||||
* @param {SupportedPrimitiveHint} hint - Supported template hint.
|
||||
* @param {ResolvablePrimitiveType} type - Method resolvable template type.
|
||||
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
|
||||
* @returns {boolean} True when that class exposes the named method.
|
||||
* @returns {boolean} - True when that class exposes the named method.
|
||||
*/
|
||||
const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: string): boolean => {
|
||||
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
|
||||
const canResolvePrimitiveMethod = (type: ResolvablePrimitiveType, methodName: string): boolean => {
|
||||
const PrimitiveClass = RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE[type];
|
||||
|
||||
// Check own properties only so inherited Object.prototype names are rejected without needing a value.
|
||||
if (Object.hasOwn(PrimitiveClass.prototype, methodName) === false) {
|
||||
@@ -113,17 +117,17 @@ const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: str
|
||||
/**
|
||||
* Constructs a primitive from a raw value and calls one instance method on it.
|
||||
*
|
||||
* Call only after `canResolvePrimitiveMethod` is true for the same hint and method.
|
||||
* Call only after `canResolvePrimitiveMethod` is true for the same type and method.
|
||||
*
|
||||
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and supported hint.
|
||||
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and resolvable type.
|
||||
* @returns {unknown} Method return value, later encoded as CashAssembly push bytes.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the prototype member is not a function.
|
||||
* @throws When the primitive constructor rejects the raw value (validation errors from `@xo-cash/primitives`).
|
||||
*/
|
||||
const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown => {
|
||||
const { identifier, methodName, value, hint } = parameters;
|
||||
const { identifier, methodName, value, type } = parameters;
|
||||
|
||||
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
|
||||
const PrimitiveClass = RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE[type];
|
||||
|
||||
// Constructing runs each primitive's own input validation (range checks, hex length, etc).
|
||||
// `as never` satisfies TypeScript across constructors that accept different input shapes.
|
||||
@@ -133,45 +137,45 @@ const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown
|
||||
const primitiveMethod = Reflect.get(PrimitiveClass.prototype, methodName);
|
||||
|
||||
if (typeof primitiveMethod !== 'function') {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, type);
|
||||
}
|
||||
|
||||
return primitiveMethod.call(primitiveInstance);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves supported `base.method` identifiers to CashAssembly variable bytes.
|
||||
* Resolves method resolvable `base.method` identifiers to CashAssembly variable bytes.
|
||||
*
|
||||
* Each single dot identifier whose `hint` maps to a supported primitive is resolved and stored under
|
||||
* the full identifier (`base.method`). Unsupported or multi dot identifiers are left for CashAssembly.
|
||||
* Each single dot identifier whose `type` is in `RESOLVABLE_PRIMITIVE_CLASS_BY_TYPE` is resolved and stored under
|
||||
* the full identifier (`base.method`). Other template types or multi dot identifiers are left for CashAssembly.
|
||||
*
|
||||
* When `templateVariables` is omitted, returns an empty map.
|
||||
*
|
||||
* @param {ResolvePrimitiveMethodBytesParameters} parameters - Identifiers, values, and optional template metadata.
|
||||
* @returns {Record<string, Uint8Array>} Resolved method identifiers mapped to bytes for CashAssembly pushes.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the hint is a supported primitive but the method is missing.
|
||||
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the type is method resolvable but the method is missing.
|
||||
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When the runtime value is missing from the variables map.
|
||||
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the method return type cannot be embedded.
|
||||
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a method return value is a number that is not a safe integer.
|
||||
*/
|
||||
export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBytesParameters): Record<string, Uint8Array> => {
|
||||
const { identifiers, templateVariables, variables } = parameters;
|
||||
const { variableNames, templateVariables, variables } = parameters;
|
||||
|
||||
// Without template metadata there is no hint to select a primitive class.
|
||||
// Without template metadata there is no type to select a primitive class.
|
||||
if (templateVariables === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const resolvedBytes: Record<string, Uint8Array> = {};
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
// The same identifier can appear more than once. Resolve it only once.
|
||||
if (Object.hasOwn(resolvedBytes, identifier) === true) {
|
||||
for (const variableName of variableNames) {
|
||||
// The same name can appear more than once. Resolve it only once.
|
||||
if (Object.hasOwn(resolvedBytes, variableName) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the primitive method reference in the identifier.
|
||||
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
// Find the primitive method reference in the name.
|
||||
const methodReferenceMatch = variableName.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
|
||||
|
||||
if (methodReferenceMatch === null) {
|
||||
continue;
|
||||
@@ -179,37 +183,38 @@ export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBy
|
||||
|
||||
const [ , baseName, methodName ] = methodReferenceMatch;
|
||||
|
||||
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
|
||||
// Unknown names and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
|
||||
// must be left for CashAssembly rather than treated as primitive failures.
|
||||
if (Object.hasOwn(templateVariables, baseName) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hint = templateVariables[baseName].hint;
|
||||
const type = templateVariables[baseName].type;
|
||||
|
||||
if (isSupportedPrimitiveHint(hint) === false) {
|
||||
// Template types without a mapped primitive class are left for CashAssembly.
|
||||
if (isResolvablePrimitiveType(type) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Supported hint with an unknown method should be thrown as an error.
|
||||
if (canResolvePrimitiveMethod(hint, methodName) === false) {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
|
||||
// Method resolvable type with an unknown method should throw.
|
||||
if (canResolvePrimitiveMethod(type, methodName) === false) {
|
||||
throw new CashAssemblyPrimitiveMethodMissingError(variableName, methodName, type);
|
||||
}
|
||||
|
||||
// If the method is known but the runtime value is missing, throw an error.
|
||||
if (Object.hasOwn(variables, baseName) === false) {
|
||||
throw new CashAssemblyPrimitiveVariableMissingError(identifier, baseName);
|
||||
throw new CashAssemblyPrimitiveVariableMissingError(variableName, baseName);
|
||||
}
|
||||
|
||||
const methodResult = callPrimitiveMethod({
|
||||
identifier,
|
||||
identifier: variableName,
|
||||
methodName,
|
||||
value: variables[baseName],
|
||||
hint,
|
||||
type,
|
||||
});
|
||||
|
||||
// Store the result under identifier.
|
||||
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
|
||||
// CashAssembly looks up the full `base.method` name as WalletData bytes.
|
||||
resolvedBytes[variableName] = convertValueToBytes(methodResult, variableName);
|
||||
}
|
||||
|
||||
return resolvedBytes;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH,
|
||||
CASHASSEMBLY_COMMENT_START_LENGTH,
|
||||
CASHASSEMBLY_EVALUATION_START_LENGTH,
|
||||
EMPTY_CASHASSEMBLY_EVALUATION,
|
||||
} from './defaults.ts';
|
||||
import { CashAssemblyBlockCommentUnclosedError, CashAssemblyEvaluationUnclosedError, CashAssemblyQuotedLiteralUnclosedError } from './errors.ts';
|
||||
|
||||
/**
|
||||
* Skips a quoted CashAssembly string so a `)` inside it cannot end `$()`.
|
||||
*
|
||||
* CashAssembly UTF8 literals use `"..."` or `'...'`. Everything between the quotes is payload,
|
||||
* including parentheses.
|
||||
* For example `$(<"hello)world">)` must close after the literal, not at the `)` inside the quotes.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the quoted literal.
|
||||
* @param {number} openingQuoteIndex - Index of the opening `"` or `'`.
|
||||
* @returns {number} - Index of the character after the closing quote.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When the quoted literal never closes.
|
||||
*/
|
||||
const skipQuotedCashAssemblyLiteral = (cashAssemblyText: string, openingQuoteIndex: number): number => {
|
||||
const quoteCharacter = cashAssemblyText[openingQuoteIndex];
|
||||
let currentIndex = openingQuoteIndex + 1;
|
||||
|
||||
// CashAssembly puts payload `)` inside the quotes. Stop only at the matching quote.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === quoteCharacter) {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
throw new CashAssemblyQuotedLiteralUnclosedError(openingQuoteIndex, quoteCharacter, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function skips a `//` comment so a `)` written in the comment cannot end `$()`.
|
||||
* A line comment runs from `//` to the newline, or to the end of the string when there is no newline.
|
||||
* A `)` in that span is comment text, not the closer of `$()`.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the line comment.
|
||||
* @param {number} commentStartIndex - Index of the first `/` of `//`.
|
||||
* @returns {number} - Index after the newline that ends the comment, or the end of the string if there is no newline.
|
||||
*/
|
||||
const skipSingleLineCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
|
||||
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
|
||||
|
||||
// CashAssembly line comments hide `)` until the newline. End of string also ends them.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === '\n') {
|
||||
return currentIndex + 1;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return currentIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function skips a block comment so a `)` written in the comment cannot end `$()`.
|
||||
* A `)` inside a block comment is comment text, not the closer of `$()`.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the block comment.
|
||||
* @param {number} commentStartIndex - Index of the first slash of the block comment opener.
|
||||
* @returns {number} - Index of the character after the comment closer.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When the block comment never closes.
|
||||
*/
|
||||
const skipBlockCashAssemblyComment = (cashAssemblyText: string, commentStartIndex: number): number => {
|
||||
let currentIndex = commentStartIndex + CASHASSEMBLY_COMMENT_START_LENGTH;
|
||||
|
||||
// CashAssembly block comments hide `)` until the closer is found.
|
||||
while (currentIndex + 1 < cashAssemblyText.length) {
|
||||
if (cashAssemblyText[currentIndex] === '*' && cashAssemblyText[currentIndex + 1] === '/') {
|
||||
return currentIndex + CASHASSEMBLY_BLOCK_COMMENT_END_LENGTH;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
throw new CashAssemblyBlockCommentUnclosedError(commentStartIndex, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parenthesis matching with a depth count.
|
||||
*
|
||||
* Returns the index of the `)` that closes the `$()` starting at `evaluationStartIndex`.
|
||||
* Nested `$()` raise the depth so an inner closer cannot finish the outer evaluation.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that contains the evaluation.
|
||||
* @param {number} evaluationStartIndex - Index of the `$` that opens this evaluation.
|
||||
* @returns {number} - Index of the matching closing parenthesis.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When the evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal inside the evaluation never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment inside the evaluation never closes.
|
||||
*/
|
||||
const findCashAssemblyEvaluationCloseIndex = (cashAssemblyText: string, evaluationStartIndex: number): number => {
|
||||
let currentIndex = evaluationStartIndex + CASHASSEMBLY_EVALUATION_START_LENGTH;
|
||||
let remainingOpenEvaluations = 1;
|
||||
|
||||
// Scan the evaluation contents until the matching closer at depth zero.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
const currentCharacter = cashAssemblyText[currentIndex];
|
||||
const nextCharacter = cashAssemblyText[currentIndex + 1];
|
||||
|
||||
// CashAssembly allows `)` inside `"..."` and `'...'`. Skip the literal so that closer is not structural.
|
||||
if (currentCharacter === '"' || currentCharacter === "'") {
|
||||
currentIndex = skipQuotedCashAssemblyLiteral(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CashAssembly `//` comments may mention `)`. Skip them so that `)` is not treated as a closer.
|
||||
if (currentCharacter === '/' && nextCharacter === '/') {
|
||||
currentIndex = skipSingleLineCashAssemblyComment(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// CashAssembly block comments may mention `)`. Skip them so that `)` is not treated as a closer.
|
||||
if (currentCharacter === '/' && nextCharacter === '*') {
|
||||
currentIndex = skipBlockCashAssemblyComment(cashAssemblyText, currentIndex);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nested `$()` must close before this evaluation can close.
|
||||
if (currentCharacter === '$' && nextCharacter === '(') {
|
||||
// Since all previous checks have passed then it means this is start of another evaluation, it we must
|
||||
// increment the remainingOpenEvaluations counter and move the currentIndex to the next character.
|
||||
remainingOpenEvaluations += 1;
|
||||
currentIndex += CASHASSEMBLY_EVALUATION_START_LENGTH;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A closer at depth one ends this evaluation. Inner closers only reduce nested depth.
|
||||
if (currentCharacter === ')') {
|
||||
// Since all previous checks have passed then it means this is a closer for the current evaluation, we must
|
||||
// decrement the remainingOpenEvaluations counter and check if it is now zero. If it is then we have found the
|
||||
// matching closer and we can return the currentIndex.
|
||||
remainingOpenEvaluations -= 1;
|
||||
|
||||
if (remainingOpenEvaluations === 0) {
|
||||
return currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currentIndex to the next character.
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
// The matching closer was never found.
|
||||
throw new CashAssemblyEvaluationUnclosedError(evaluationStartIndex, remainingOpenEvaluations, cashAssemblyText);
|
||||
};
|
||||
|
||||
/**
|
||||
* One `$()` found in text that is not nested inside another `$()`.
|
||||
*/
|
||||
type CashAssemblyEvaluationScanMatch = {
|
||||
|
||||
/**
|
||||
* Complete `$()` text including the opening `$` and the matching closer.
|
||||
*/
|
||||
evaluationText: string;
|
||||
|
||||
/**
|
||||
* Index of the `$` that opens this evaluation.
|
||||
*/
|
||||
startIndex: number;
|
||||
|
||||
/**
|
||||
* Index of the matching closing parenthesis.
|
||||
*/
|
||||
closeIndex: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scans text and returns every `$()` that starts in this string, including empty `$()`.
|
||||
*
|
||||
* Nested `$()` stay inside the outer evaluation.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that may contain `$()`.
|
||||
* @returns {CashAssemblyEvaluationScanMatch[]} - Evaluations in left to right order.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const scanCashAssemblyEvaluations = (cashAssemblyText: string): CashAssemblyEvaluationScanMatch[] => {
|
||||
const scannedEvaluations: CashAssemblyEvaluationScanMatch[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
// Find `$()` that starts in this string. Nested evaluations stay inside the outer evaluation.
|
||||
while (currentIndex < cashAssemblyText.length) {
|
||||
const currentCharacter = cashAssemblyText[currentIndex];
|
||||
const nextCharacter = cashAssemblyText[currentIndex + 1];
|
||||
|
||||
// A `$` that is followed by `(`.
|
||||
if (currentCharacter === '$' && nextCharacter === '(') {
|
||||
// Count parentheses so quotes, comments, and nested `$()` cannot close this evaluation early.
|
||||
const closeIndex = findCashAssemblyEvaluationCloseIndex(cashAssemblyText, currentIndex);
|
||||
// Slice the complete `$()` so later steps do not re-scan these characters.
|
||||
const evaluationText = cashAssemblyText.slice(currentIndex, closeIndex + 1);
|
||||
|
||||
// Store the outer `$()`. Nested evaluations are already inside evaluationText.
|
||||
scannedEvaluations.push({
|
||||
closeIndex,
|
||||
evaluationText,
|
||||
startIndex: currentIndex,
|
||||
});
|
||||
|
||||
// Skip to after this `$()` so nested evaluations are not scanned as their own matches.
|
||||
currentIndex = closeIndex + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
currentIndex += 1;
|
||||
}
|
||||
|
||||
return scannedEvaluations;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns each `$()` that starts in this string.
|
||||
* Empty `$()` is omitted.
|
||||
*
|
||||
* @param {string} cashAssemblyText - Text that may contain `$()`.
|
||||
* @returns {string[]} - Complete non empty evaluation strings in left to right order.
|
||||
* @throws {@link CashAssemblyEvaluationUnclosedError} - When an evaluation never closes.
|
||||
* @throws {@link CashAssemblyQuotedLiteralUnclosedError} - When a quoted literal never closes.
|
||||
* @throws {@link CashAssemblyBlockCommentUnclosedError} - When a block comment never closes.
|
||||
*/
|
||||
export const extractCashAssemblyEvaluations = (cashAssemblyText: string): string[] => {
|
||||
const scannedEvaluations = scanCashAssemblyEvaluations(cashAssemblyText);
|
||||
const evaluations: string[] = [];
|
||||
|
||||
// Omit empty `$()` so callers that compile extracted text do not treat it as an evaluation.
|
||||
for (const scannedEvaluation of scannedEvaluations) {
|
||||
if (scannedEvaluation.evaluationText !== EMPTY_CASHASSEMBLY_EVALUATION) {
|
||||
evaluations.push(scannedEvaluation.evaluationText);
|
||||
}
|
||||
}
|
||||
|
||||
return evaluations;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns true when the value is exactly one CashAssembly `$()`.
|
||||
* Text with characters outside `$()`, empty `$()`, or a failed scan returns false.
|
||||
*
|
||||
* @param {unknown} expression - Value to test.
|
||||
* @returns {boolean} - True when the value is a string that is one complete evaluation and nothing else.
|
||||
*/
|
||||
export const isCashAssemblyExpression = (expression: unknown): boolean => {
|
||||
// Numbers and other types are never CashAssembly evaluations.
|
||||
if (typeof expression !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const evaluations = extractCashAssemblyEvaluations(expression);
|
||||
|
||||
// If there is only one evaluation extracted and the returned value matches to that of the expression passed in the parameter,
|
||||
// then return true.
|
||||
return evaluations.length === 1 && evaluations[0] === expression;
|
||||
} catch {
|
||||
// A failed scan is not a complete evaluation.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Supported decode modes for compiled CashAssembly evaluation bytes.
|
||||
*/
|
||||
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
|
||||
|
||||
/**
|
||||
* Template script map passed into CashAssembly compilation so evaluations can resolve bare script ids.
|
||||
*/
|
||||
export type CashAssemblyTemplateScripts = NonNullable<XOTemplate['scripts']>;
|
||||
|
||||
/**
|
||||
* WalletData and template context shared by CashAssembly compilation.
|
||||
*/
|
||||
export type CompileCashAssemblyContext = {
|
||||
|
||||
/**
|
||||
* Runtime WalletData keyed by variable name.
|
||||
* {@link compileCashAssemblySourceToBytes} collects required names with
|
||||
* {@link collectVariablesUsingParseScript} then looks them up here for
|
||||
* primitive method resolution and CashAssembly compilation. A collected name that is absent throws
|
||||
* {@link CashAssemblyRequiredVariableMissingError}. Missing keys are never replaced with defaults.
|
||||
*/
|
||||
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
|
||||
|
||||
/**
|
||||
* Template variable definitions. When provided, each `<name.method>` push whose `hint` maps to
|
||||
* a supported primitive class is resolved to bytes before CashAssembly compilation.
|
||||
*/
|
||||
templateVariables?: XOTemplate['variables'] | undefined;
|
||||
|
||||
/**
|
||||
* Named CashAssembly scripts from {@link XOTemplate.scripts}.
|
||||
* Each key is a script id such as `scriptA`. Each value is that script's source.
|
||||
* Pass this when compilation refers to those ids, for example `$(scriptA)`, `scriptA`, or
|
||||
* `scriptA scriptB OP_ADD`. The compiler looks up those ids here. The visitor collects
|
||||
* WalletData from those sources and from nested ids.
|
||||
* When omitted, named scripts are not given to the compiler and only WalletData pushes in the
|
||||
* starting text are collected.
|
||||
*/
|
||||
templateScripts?: CashAssemblyTemplateScripts | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling CashAssembly text.
|
||||
*/
|
||||
export type CompileCashAssemblyStringParameters = CompileCashAssemblyContext & {
|
||||
|
||||
/**
|
||||
* Text to compile, which may contain `$()`.
|
||||
*
|
||||
* When this text contains `$()`, each CashAssembly evaluation is compiled and replaced with its
|
||||
* decoded payload. Surrounding text is kept.
|
||||
* When this text contains no `$()`, the entire string is compiled as CashAssembly so push
|
||||
* opcodes remain.
|
||||
*/
|
||||
cashAssemblyText: string;
|
||||
|
||||
/**
|
||||
* The mode to decode compiled bytecode into a string.
|
||||
*/
|
||||
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling one CashAssembly source string to bytecode.
|
||||
*/
|
||||
export type CompileCashAssemblySourceToBytesParameters = CompileCashAssemblyContext & {
|
||||
|
||||
/**
|
||||
* Evaluation or CashAssembly source passed to the compiler as a script id.
|
||||
*/
|
||||
cashAssemblySource: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for compiling one CashAssembly source string to decoded text.
|
||||
*/
|
||||
export type CompileCashAssemblySourceToDecodedTextParameters = CompileCashAssemblySourceToBytesParameters & {
|
||||
|
||||
/**
|
||||
* The mode to decode compiled bytecode into a string.
|
||||
*/
|
||||
evaluationDecodeMode: CompiledCashAssemblyDecodeMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parameters for preparing a Libauth compiler for CashAssembly source strings.
|
||||
*/
|
||||
export type CompileCashAssemblyEvaluationsParameters = {
|
||||
|
||||
/**
|
||||
* CashAssembly source strings such as `$(<var1>)` or `scriptA`.
|
||||
*/
|
||||
evaluations: string[];
|
||||
|
||||
/**
|
||||
* Optional template scripts so nested ids compile to the template sources.
|
||||
*/
|
||||
templateScripts?: CashAssemblyTemplateScripts | undefined;
|
||||
|
||||
/**
|
||||
* WalletData names already collected with {@link collectVariablesUsingParseScript}.
|
||||
* This function does not collect again.
|
||||
*/
|
||||
variableNames: string[];
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './errors.ts';
|
||||
export * from './exponential-backoff.ts';
|
||||
export * from './exponential-backoff-externally-aborted.ts';
|
||||
+6
-9
@@ -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';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Thrown when a value does not satisfy the XOInvitation schema.
|
||||
*/
|
||||
export class InvitationInvalidError extends Error {
|
||||
constructor(details: string) {
|
||||
const defaultMessage = `Invitation invalid, expected a value matching the XOInvitation type, but got: ${details}`;
|
||||
super(defaultMessage);
|
||||
this.name = 'InvitationInvalidError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when invitation share JSON cannot be parsed.
|
||||
*/
|
||||
export class InvitationJsonMalformedError extends Error {
|
||||
/**
|
||||
* Parser failure forwarded from JSON parsing.
|
||||
*/
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string) {
|
||||
const defaultMessage = `Invitation JSON malformed: expected a valid JSON string, but got: ${reason}`;
|
||||
super(defaultMessage);
|
||||
this.name = 'InvitationJsonMalformedError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when invitation serialization fails.
|
||||
*/
|
||||
export class InvitationSerializationFailedError extends Error {
|
||||
/**
|
||||
* Serializer failure forwarded from the serialization path.
|
||||
*/
|
||||
readonly reason: string;
|
||||
|
||||
constructor(reason: string) {
|
||||
const defaultMessage = `Invitation serialization failed: ${reason}`;
|
||||
super(defaultMessage);
|
||||
this.name = 'InvitationSerializationFailedError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './errors.ts';
|
||||
export * from './parser.ts';
|
||||
export * from './schemas.ts';
|
||||
export * from './serialization.ts';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { buildErrorDescription } from '../errors.ts';
|
||||
import { InvitationInvalidError } from './errors.ts';
|
||||
import { xoInvitationSchema } from './schemas.ts';
|
||||
|
||||
/**
|
||||
* Validates an untyped value against the XOInvitation schema and returns it as a typed invitation.
|
||||
*
|
||||
* The schema parser returns a rebuilt copy whose keys follow the schema order. Commit signatures cover the JSON text of
|
||||
* each commit's data, and that text depends on key order, so a rebuilt copy no longer matches its signatures. Only the
|
||||
* value that was received keeps the order it was signed with.
|
||||
*
|
||||
* The received value itself is returned, not a copy, so the key order its commit signatures were made over is kept.
|
||||
*
|
||||
* @param {unknown} invitationValue - Value to validate, such as the output of Extended JSON parsing.
|
||||
* @returns {XOInvitation} - The validated invitation, as the same object that was passed in.
|
||||
* @throws {@link InvitationInvalidError} - When any field is missing, has the wrong type, or is not part of the invitation type.
|
||||
*/
|
||||
export const parseInvitation = (invitationValue: unknown): XOInvitation => {
|
||||
const parseResult = xoInvitationSchema.safeParse(invitationValue);
|
||||
|
||||
if (parseResult.success === false) {
|
||||
throw new InvitationInvalidError(buildErrorDescription(parseResult.error.issues));
|
||||
}
|
||||
|
||||
return invitationValue as XOInvitation;
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
/* eslint-disable @stylistic/newline-per-chained-call */
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { z } from 'zod';
|
||||
import { satoshisSchema, uint8ArraySchema, xoTemplateNftCapabilitySchema } from '../template/schemas.ts';
|
||||
|
||||
/**
|
||||
* Validation schema for a reference to an input or output in an earlier commit that a later item extends.
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* "inputs|outputs": [
|
||||
* {
|
||||
* "mergesWith": { "commitIdentifier": "...", "itemIndex": 0 } ← this schema
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const xoInvitationMergesWithReferenceSchema = z
|
||||
.object({
|
||||
commitIdentifier: z.string().describe('The commit that holds the item being extended.'),
|
||||
itemIndex: z.number().int().nonnegative().describe('The position of the extended item inside that commit.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for the template action a commit is resolved against when it differs from the invitation root.
|
||||
*/
|
||||
export const xoInvitationActionReferenceSchema = z
|
||||
.object({
|
||||
templateIdentifier: z.string().describe('The template that defines the action.'),
|
||||
actionIdentifier: z.string().describe('The action inside that template.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ============================================================
|
||||
// Commit data
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validation schema for a variable value. Matches every value type an invitation variable can hold.
|
||||
*/
|
||||
export const xoInvitationVariableValueSchema = z
|
||||
.union([ z.bigint(), z.boolean(), z.number(), z.string() ])
|
||||
.describe('A variable value expressed as a bigint, boolean, number, or string.');
|
||||
|
||||
/**
|
||||
* Validation schema for a variable supplied by a commit.
|
||||
*/
|
||||
export const xoInvitationVariableSchema = z
|
||||
.object({
|
||||
variableIdentifier: z.string().describe('The template variable this value is for.'),
|
||||
value: xoInvitationVariableValueSchema,
|
||||
roleIdentifier: z.string().exactOptional().describe('The role this variable belongs to.'),
|
||||
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a cashtoken.
|
||||
*/
|
||||
export const outputTokenSchema = z
|
||||
.object({
|
||||
category: uint8ArraySchema.describe('The token category.'),
|
||||
amount: z.bigint().describe('The fungible token amount.'),
|
||||
nft: z
|
||||
.object({
|
||||
capability: xoTemplateNftCapabilitySchema,
|
||||
commitment: uint8ArraySchema.describe('The NFT commitment.'),
|
||||
})
|
||||
.strict()
|
||||
.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a cashtoken supplied by a commit output. Every field is optional because a later commit may
|
||||
* supply the fields an earlier commit left unset.
|
||||
*/
|
||||
export const xoInvitationTokenSchema = z
|
||||
.object({
|
||||
amount: z.bigint().exactOptional().describe('The fungible token amount.'),
|
||||
category: uint8ArraySchema.exactOptional().describe('The token category.'),
|
||||
nft: z
|
||||
.object({
|
||||
capability: xoTemplateNftCapabilitySchema.exactOptional(),
|
||||
commitment: uint8ArraySchema.exactOptional().describe('The NFT commitment.'),
|
||||
})
|
||||
.strict()
|
||||
.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a value one participant resolved from its own template key, such as a public key or a signature.
|
||||
*/
|
||||
export const xoInvitationResolutionSchema = z
|
||||
.object({
|
||||
resolutionIdentifier: z.string().describe('The key operation this value resolves, such as someKey.public_key.'),
|
||||
value: uint8ArraySchema.describe('The resolved bytes.'),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role whose key this value was resolved from.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction input supplied by a commit.
|
||||
*/
|
||||
export const xoInvitationInputSchema = z
|
||||
.object({
|
||||
outpointIndex: z.number().int().nonnegative().exactOptional().describe('The index of the spent output in its transaction.'),
|
||||
outpointTransactionHash: uint8ArraySchema.exactOptional().describe('The hash of the transaction that holds the spent output.'),
|
||||
sequenceNumber: z.number().int().nonnegative().exactOptional().describe('The sequence number of this input.'),
|
||||
unlockingBytecode: uint8ArraySchema.exactOptional().describe('The script that unlocks the spent output.'),
|
||||
inputIdentifier: z.string().exactOptional().describe('The template input this input fills.'),
|
||||
transactionIndex: z.number().int().nonnegative().exactOptional().describe('The position this input must take in the final transaction.'),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role this input belongs to.'),
|
||||
resolutions: z.array(xoInvitationResolutionSchema).exactOptional(),
|
||||
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
|
||||
valueSatoshis: satoshisSchema.exactOptional(),
|
||||
token: outputTokenSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction output supplied by a commit.
|
||||
*/
|
||||
export const xoInvitationOutputSchema = z
|
||||
.object({
|
||||
lockingBytecode: uint8ArraySchema.exactOptional().describe('The locking bytecode of this output.'),
|
||||
valueSatoshis: satoshisSchema.exactOptional(),
|
||||
token: xoInvitationTokenSchema.exactOptional(),
|
||||
outputIdentifier: z.string().exactOptional().describe('The template output this output fills.'),
|
||||
transactionIndex: z.number().int().nonnegative().exactOptional().describe('The position this output must take in the final transaction.'),
|
||||
secretIdentifier: z.string().exactOptional().describe('The secret used to derive this output.'),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role this output belongs to.'),
|
||||
mergesWith: xoInvitationMergesWithReferenceSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for the data a commit supplies.
|
||||
*/
|
||||
export const xoInvitationCommitDataSchema = z
|
||||
.object({
|
||||
transactionVersion: z.number().int().nonnegative().exactOptional().describe('The transaction version.'),
|
||||
transactionLocktime: z.number().int().nonnegative().exactOptional().describe('The transaction locktime.'),
|
||||
variables: z.array(xoInvitationVariableSchema).exactOptional(),
|
||||
inputs: z.array(xoInvitationInputSchema).exactOptional(),
|
||||
outputs: z.array(xoInvitationOutputSchema).exactOptional(),
|
||||
resolutions: z.array(xoInvitationResolutionSchema).exactOptional(),
|
||||
roleIdentifier: z.string().exactOptional().describe('The role the committing entity takes in the action with this commit.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for one signed commit in an invitation.
|
||||
*/
|
||||
export const xoInvitationCommitSchema = z
|
||||
.object({
|
||||
commitIdentifier: z.string().describe('The identifier of this commit.'),
|
||||
// The type declares this key on every commit, but serialization drops it on the first commit where it is undefined.
|
||||
// Prefault restores the missing key, in this case it's undefined.
|
||||
previousCommitIdentifier: z.string().optional().prefault(undefined).describe('The commit this one follows. Undefined on the first commit.'),
|
||||
entityIdentifier: z.string().describe('The entity that made this commit.'),
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When this commit was created, as a Unix timestamp in milliseconds.'),
|
||||
actionReference: xoInvitationActionReferenceSchema.exactOptional(),
|
||||
data: xoInvitationCommitDataSchema,
|
||||
signature: z.string().describe('The signature over this commit.'),
|
||||
expiresAtTimestamp: z.number().int().nonnegative().describe('When this commit expires, as a Unix timestamp in milliseconds.'),
|
||||
memo: z.string().exactOptional().describe('A free text note attached to this commit.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ============================================================
|
||||
// Results
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction result that has been created but not broadcast.
|
||||
*/
|
||||
export const xoInvitationTransactionCreatedResultSchema = z
|
||||
.object({
|
||||
transactionHash: z.string().describe('The hash of the created transaction.'),
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When the transaction was created, as a Unix timestamp in milliseconds.'),
|
||||
createdByEntityIdentifier: z.string().describe('The entity that created the transaction.'),
|
||||
createdBySignature: z.string().describe('The signature over the created result.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a transaction result that has been created and broadcast.
|
||||
*/
|
||||
export const xoInvitationTransactionBroadcastedResultSchema = xoInvitationTransactionCreatedResultSchema
|
||||
.extend({
|
||||
broadcastedAtTimestamp: z.number().int().nonnegative().describe('When the transaction was broadcasted, as a Unix timestamp in milliseconds.'),
|
||||
broadcastedByEntityIdentifier: z.string().describe('The entity that broadcasted the transaction.'),
|
||||
broadcastedBySignature: z.string().describe('The signature over the broadcasted result.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for a data result created on an invitation.
|
||||
*/
|
||||
export const xoInvitationCreatedResultSchema = z
|
||||
.object({
|
||||
value: xoInvitationVariableValueSchema,
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When the value was created, as a Unix timestamp in milliseconds.'),
|
||||
createdByEntityIdentifier: z.string().describe('The entity that created the value.'),
|
||||
createdBySignature: z.string().describe('The signature over the created value.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Validation schema for the results created on an invitation.
|
||||
*/
|
||||
export const xoInvitationResultSchema = z
|
||||
.object({
|
||||
transactions: z
|
||||
.record(z.string(), z.union([ xoInvitationTransactionBroadcastedResultSchema, xoInvitationTransactionCreatedResultSchema ]))
|
||||
.exactOptional(),
|
||||
data: z.record(z.string(), xoInvitationCreatedResultSchema).exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ============================================================
|
||||
// Invitation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validation schema for an invitation.
|
||||
*/
|
||||
export const xoInvitationSchema: z.ZodType<XOInvitation> = z
|
||||
.object({
|
||||
invitationIdentifier: z.string().describe('The identifier of this invitation.'),
|
||||
createdAtTimestamp: z.number().int().nonnegative().describe('When this invitation was created, as a Unix timestamp in milliseconds.'),
|
||||
templateIdentifier: z.string().describe('The template this invitation uses.'),
|
||||
actionIdentifier: z.string().describe('The template action this invitation performs.'),
|
||||
commits: z.array(xoInvitationCommitSchema),
|
||||
result: xoInvitationResultSchema.exactOptional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
import { fromExtendedJson, toExtendedJson } from '../extended-json.ts';
|
||||
import { InvitationJsonMalformedError, InvitationSerializationFailedError } from './errors.ts';
|
||||
import { parseInvitation } from './parser.ts';
|
||||
|
||||
/**
|
||||
* Serializes an invitation for sharing. Encodes bigint and Uint8Array fields using Extended JSON.
|
||||
*
|
||||
* @param invitation Shared invitation to export.
|
||||
* @returns JSON string that {@link deserializeInvitation} can restore.
|
||||
* @throws {InvitationSerializationFailedError} When JSON serialization fails.
|
||||
*/
|
||||
export const serializeInvitation = (invitation: XOInvitation): string => {
|
||||
try {
|
||||
return toExtendedJson(invitation);
|
||||
} catch (serializationError) {
|
||||
const reason = serializationError instanceof Error ? serializationError.message : 'unknown error while serializing invitation';
|
||||
|
||||
throw new InvitationSerializationFailedError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deserializes a shared invitation JSON string into an invitation.
|
||||
*
|
||||
* Every field is checked against its declared type, so the returned invitation can be trusted to match
|
||||
* {@link XOInvitation}.
|
||||
*
|
||||
* @param serializedInvitation JSON produced by {@link serializeInvitation}.
|
||||
* @returns Parsed invitation whose fields match their declared types.
|
||||
* @throws {InvitationJsonMalformedError} When the string is not valid JSON.
|
||||
* @throws {InvitationInvalidError} When the parsed value does not match the XOInvitation type.
|
||||
*/
|
||||
export const deserializeInvitation = (serializedInvitation: string): XOInvitation => {
|
||||
let parsedValue: unknown;
|
||||
|
||||
try {
|
||||
parsedValue = fromExtendedJson(serializedInvitation);
|
||||
} catch (parsingError) {
|
||||
const reason = parsingError instanceof Error ? parsingError.message : 'unknown error while deserializing invitation';
|
||||
|
||||
throw new InvitationJsonMalformedError(reason);
|
||||
}
|
||||
|
||||
return parseInvitation(parsedValue);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -32,3 +32,26 @@ export const SSE_TRAILING_NEWLINE_REGEX = /\n$/;
|
||||
* buffered partial lines between streamed chunks before the next parse call.
|
||||
*/
|
||||
export const NEW_LINE = '\n';
|
||||
|
||||
/**
|
||||
* Default exponential backoff options for the SSE session.
|
||||
*/
|
||||
export const SSE_SESSION_EXPONENTIAL_BACKOFF_DEFAULTS = {
|
||||
baseDelay: 250,
|
||||
maxDelay: 10000,
|
||||
maxAttempts: 0,
|
||||
growthRate: 1.3,
|
||||
jitter: 0.3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default attempt reconnect flag for the SSE session.
|
||||
* @default true
|
||||
*/
|
||||
export const SSE_SESSION_ATTEMPT_RECONNECT_DEFAULT = true;
|
||||
|
||||
/**
|
||||
* Default persistent flag for the SSE session.
|
||||
* @default false
|
||||
*/
|
||||
export const SSE_SESSION_PERSISTENT_DEFAULT = false;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Error thrown when a response body is null
|
||||
*/
|
||||
export class ResponseBodyNullError extends Error {
|
||||
constructor() {
|
||||
super('Response body is null');
|
||||
this.name = 'ResponseBodyNullError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when an HTTP error occurs
|
||||
*/
|
||||
export class HTTPError extends Error {
|
||||
constructor(status: number, message: string) {
|
||||
super(`HTTP error! Status: ${status} - ${message}`);
|
||||
this.name = 'HTTPError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a plugin's unsubscribe function is not a function
|
||||
*/
|
||||
export class SSESessionUnsubscribePluginNotAFunctionError extends Error {
|
||||
constructor(pluginName: string) {
|
||||
super(`${pluginName}'s unsubscribe function is not a function`);
|
||||
this.name = 'SSESessionUnsubscribePluginNotAFunctionError';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import type { $ZodIssue } from 'zod/v4/core';
|
||||
|
||||
/**
|
||||
* Formats the Zod validation failures into a single string with one line each: "- <field>: <message>" and top level failures
|
||||
* with no field path show as "(root)" for better readability.
|
||||
*
|
||||
* @param issues The Zod validation failures to format.
|
||||
* @returns A human readable error string for better debugging.
|
||||
*/
|
||||
export const buildErrorDescription = (issues: $ZodIssue[]): string => {
|
||||
// Initialize an empty array to store the formatted lines.
|
||||
const lines: string[] = [];
|
||||
|
||||
// Iterate over the issues and format them into a string.
|
||||
for (const issue of issues) {
|
||||
// Get the issue path.
|
||||
const issuePath = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
||||
|
||||
// The prefix that Zod adds to messages.
|
||||
const messagePrefix = 'Invalid input: ';
|
||||
|
||||
// Remove the prefix for better readability.
|
||||
const issueMessage = issue.message.startsWith(messagePrefix) ? issue.message.slice(messagePrefix.length) : issue.message;
|
||||
|
||||
// Add the formatted line to the array.
|
||||
lines.push(`- ${issuePath}: ${issueMessage}`);
|
||||
}
|
||||
|
||||
// Return the formatted string.
|
||||
return `\n${lines.join('\n')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when the provided template does not satisfy the XOTemplate schema.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './errors.ts';
|
||||
export * from './identifier.ts';
|
||||
export * from './parser.ts';
|
||||
export * from './schemas.ts';
|
||||
|
||||
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
|
||||
export { serializeTemplate } from './serialization.ts';
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import { xoTemplateSchema } from './schemas.ts';
|
||||
import { TemplateInvalidError, buildErrorDescription } from './errors.ts';
|
||||
import { buildErrorDescription } from '../errors.ts';
|
||||
import { TemplateInvalidError } from './errors.ts';
|
||||
import { deserializeTemplate, serializeTemplate } from './serialization.ts';
|
||||
|
||||
/**
|
||||
|
||||
+16
-13
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @stylistic/newline-per-chained-call */
|
||||
import { BchVmVersions, XOTemplateBaseTypes, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
|
||||
import { BchVmVersions, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================
|
||||
@@ -58,12 +58,6 @@ export const xoTemplateNftCapabilitySchema = z.enum(XOTemplateNftCapabilities);
|
||||
*/
|
||||
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
|
||||
|
||||
/**
|
||||
* Validation schema for a base type identifier.
|
||||
* Accepts values from XOTemplateBaseTypes for the `type` field on constants, variables, and data.
|
||||
*/
|
||||
export const xoTemplateBaseTypeSchema = z.enum(XOTemplateBaseTypes);
|
||||
|
||||
/**
|
||||
* Validation schema for a primitive type identifier.
|
||||
* Accepts values from XOTemplatePrimitiveTypes for the `hint` field on constants, variables, and data.
|
||||
@@ -521,6 +515,11 @@ export const xoTemplateLockingScriptRoleSchema = xoTemplateViewPropertiesSchema
|
||||
actions: z.array(xoTemplateActionIntentSchema).optional().describe('List of action references available to this role.'),
|
||||
balance: xoTemplateAssetAmountsSchema.partial().optional().describe('Estimated ownership in the optional set of asset amounts specified.'),
|
||||
selectable: z.boolean().optional().describe('Whether outputs locked to this script should be available for coin selection.'),
|
||||
relevant: z
|
||||
.union([ z.boolean(), z.string() ])
|
||||
.optional()
|
||||
.describe('Whether this output or locking script should be tracked by the engine, When omitted, the engine treats this field as true.'
|
||||
+ 'Accepts true, false, or a CashASM expression that evaluates to a boolean relevance value.'),
|
||||
privacy: z
|
||||
.union([ z.number(), z.string() ])
|
||||
.optional()
|
||||
@@ -738,9 +737,9 @@ export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
|
||||
*/
|
||||
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||
.extend({
|
||||
type: xoTemplateBaseTypeSchema.describe('The data type of this constant.'),
|
||||
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this constant.'),
|
||||
value: z.unknown().describe('The value of this constant.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand what this constant represents.'),
|
||||
hint: z.unknown().optional().describe('An optional hint to help apps and users understand what this constant represents.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -757,9 +756,9 @@ export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
|
||||
*/
|
||||
export const xoTemplateDataSchema = z
|
||||
.object({
|
||||
type: xoTemplateBaseTypeSchema.describe('The data type of this data field.'),
|
||||
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this data field.'),
|
||||
value: z.unknown().describe('The value for this data field.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand this data field.'),
|
||||
hint: z.unknown().optional().describe('An optional hint to help apps and users understand this data field.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -795,8 +794,8 @@ export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
|
||||
*/
|
||||
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
|
||||
.extend({
|
||||
type: xoTemplateBaseTypeSchema.optional().describe('The data type of this variable.'),
|
||||
hint: xoTemplatePrimitiveTypeSchema.optional().describe('A hint to help users understand what value to provide.'),
|
||||
type: xoTemplatePrimitiveTypeSchema.optional().describe('The data type of this variable.'),
|
||||
hint: z.unknown().optional().describe('A hint to help users understand what value to provide.'),
|
||||
|
||||
// A neutral intent that the engine uses to populate the default value for this variable.
|
||||
// View properties (name, description, icon) may contain CashASM expressions that the
|
||||
@@ -864,6 +863,10 @@ export const xoTemplateIconSchema = xoTemplateViewPropertiesSchema
|
||||
export const xoTemplateDefaultsSchema = z
|
||||
.object({
|
||||
change: xoTemplateOutputIntentSchema.optional().describe('Instructions for how to construct automated change output.'),
|
||||
address: xoTemplateLockingScriptIntentSchema
|
||||
.optional()
|
||||
.describe('Configure the default intent structure for creating receiving addresses / locking scripts.'),
|
||||
receive: xoTemplateOutputIntentSchema.optional().describe('Configure the default intent structure for creating receive outputs.'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -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]>;
|
||||
};
|
||||
+837
-48
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -7,6 +7,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noEmit": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
|
||||
Reference in New Issue
Block a user