Author SHA1 Message Date
Harvmaster fb3362783c fixes 2026-08-12 03:32:24 +00:00
Harvmaster 31ac7dcc0b Merge branch 'sse-and-backoff' into HEAD 2026-08-12 03:24:35 +00:00
Harvmaster c31b51a1b6 Merge branch 'event-emitter' into sse-and-backoff 2026-08-12 03:18:21 +00:00
Harvmaster 1995fb9235 Merge branch 'exponential-backoff' into sse-and-backoff 2026-08-12 03:17:13 +00:00
Harvmaster 59c387e3e1 Merge branch 'development' into exponential-backoff 2026-08-11 14:30:08 +00:00
Harvmaster 04e61b7208 formatting 2026-08-10 04:02:41 +00:00
Harvmaster 6f6bddf560 Abort during delay 2026-08-10 03:52:49 +00:00
Harvmaster f07b65511e Move calculateDelay back to private. Move abort signal check 2026-08-10 02:48:39 +00:00
Harvmaster f793655cd9 Merge development 2026-08-10 02:48:12 +00:00
Harvmaster f8941dab01 Audit 2026-08-10 02:35:12 +00:00
Harvmaster c5b0d484d4 Formatting 2026-08-10 02:32:32 +00:00
Harvmaster 95dc998b93 Ignore errors thrown by listeners 2026-08-10 02:32:28 +00:00
Harvmaster ef797a5f20 Fix removeAllListeners 2026-08-10 02:32:25 +00:00
Harvmaster 57e809157b Dont emit to debounced listener if off was called 2026-08-10 02:32:22 +00:00
Harvmaster bc28a7e96a Clean up listener and timeout after waitfor reject 2026-08-10 02:32:17 +00:00
Harvmaster 296ab4aadf Custom waitFor timeout error 2026-08-10 02:32:08 +00:00
Harvmaster 78311487a4 Deeply freeze object 2026-08-10 02:30:55 +00:00
Harvmaster ce3d79181e private calculateDelay 2026-08-10 02:28:06 +00:00
Harvmaster f06ac63d0d Formatting 2026-08-10 02:04:51 +00:00
Harvmaster 26602dc156 Ignore errors thrown by listeners 2026-08-10 02:04:30 +00:00
Harvmaster e782cace36 Fix removeAllListeners 2026-08-10 02:03:58 +00:00
Harvmaster 099c392dd3 Dont emit to debounced listener if off was called 2026-08-10 02:02:44 +00:00
Harvmaster da412e4ad8 Clean up listener and timeout after waitfor reject 2026-08-10 02:00:12 +00:00
Harvmaster 91649558a8 Custom waitFor timeout error 2026-08-10 01:58:37 +00:00
Harvmaster 576a78022c Deeply freeze object 2026-08-10 01:58:04 +00:00
Harvmaster 6693964f5b Merge branch 'event-emitter' into sse-and-backoff 2026-08-09 05:09:33 +00:00
Harvmaster a075594683 checkout development package-lock 2026-08-07 13:07:24 +00:00
Harvmaster 6a630d0e9c Fix lock file 2026-08-07 13:02:51 +00:00
Harvmaster 7c3e4947d3 Fix lock-file 2026-08-07 13:00:16 +00:00
Harvmaster e663bc3df0 Fix lock file 2026-08-07 12:47:04 +00:00
Harvmaster 21d3971e8a Merge branch 'development' into event-emitter 2026-08-07 12:45:03 +00:00
Harvmaster 2e4f4bbfa1 Off mutes debounced events 2026-08-07 12:44:18 +00:00
Harvmaster a799299633 Use hash privatE 2026-08-07 06:02:17 +00:00
Harvmaster 20a6c328fa Merge branch 'development' into sse-and-backoff 2026-08-07 05:56:22 +00:00
Harvmaster 4edf51ebb4 Fix type issue in requestInit 2026-08-07 05:52:33 +00:00
Harvmaster 79dacae153 Merge branch 'exponential-backoff' into sse-and-backoff 2026-08-07 05:48:29 +00:00
Harvmaster 70d7be1be3 Merge branch 'event-emitter' into sse-and-backoff 2026-08-07 05:47:18 +00:00
Harvmaster e981bf8095 Added bivariance to allowed words 2026-08-07 02:29:58 +00:00
Harvmaster da6afe4107 Audit Fix 2026-08-07 02:25:42 +00:00
Harvmaster dc57495a86 Formatting 2026-08-07 02:17:58 +00:00
Harvmaster c919728fb3 Handle errors in Predicate function 2026-08-07 02:12:09 +00:00
Harvmaster ede4c54252 Improve waitFor docs 2026-08-07 02:11:55 +00:00
Harvmaster 8b47dd2d8b Add general off handling 2026-08-07 02:11:40 +00:00
Harvmaster a948671251 Use variable for debounce addtion 2026-08-07 01:44:20 +00:00
Harvmaster 3664464903 Use hash private 2026-08-07 01:43:49 +00:00
Harvmaster c7f8637ea0 Add off callback docs 2026-08-07 01:43:06 +00:00
Harvmaster 2b6f4e2f8e Freeze object when emitting 2026-08-07 01:42:55 +00:00
Harvmaster abd7cc619e Remove type assertion: 2026-08-07 01:41:50 +00:00
Harvmaster 941719e4e6 Audit fix 2026-08-06 13:44:20 +00:00
Harvmaster 6306cc3380 Formatting 2026-08-06 13:32:16 +00:00
Harvmaster f1bdb1c9ed Export errors 2026-08-06 13:31:47 +00:00
Kuldeep 6a1313c113 Merge branch 'cashassembly' into 'development'
Cash Assembly: Support for native cash assembly evaluations and primitive method resolution

See merge request GeneralProtocols/xo/utils!17
2026-08-06 10:40:02 +00:00
Kuldeep e76ff01192 Cash Assembly: Support for native cash assembly evaluations and primitive method resolution 2026-08-06 10:40:02 +00:00
Harvmaster 11d3aca520 Add tests. Use exactOptionalPropertyTypes in tsconfig 2026-08-06 10:09:34 +00:00
Harvmaster 254aee021e Add public to statics 2026-08-06 10:08:56 +00:00
Harvmaster 44981839f8 Add TS Docs throws to constructor 2026-08-06 10:08:13 +00:00
Harvmaster 67c0239106 Move Types 2026-08-06 10:08:00 +00:00
Harvmaster 9f020df754 Fix errors 2026-08-06 10:07:38 +00:00
Harvmaster 604cd36334 Improve validation code. Add throws tsdocs to validateOptions 2026-08-06 10:06:04 +00:00
Harvmaster bc6f9a8c7f Add prepare script 2026-08-03 09:23:27 +00:00
Harvmaster c6ce99605f Formatting 2026-07-29 11:51:16 +00:00
Harvmaster ba495065de Replace event-emitter with eventEmitter3 2026-07-29 11:50:50 +00:00
Harvmaster 5e8b0a1ea8 Change comment: aborted -> activated 2026-07-26 04:24:11 +00:00
Harvmaster 011b0391a5 Remove bad merge 2026-07-26 04:02:51 +00:00
Harvmaster 236386ced4 Merge branch 'development' into sse-and-backoff 2026-07-26 04:00:35 +00:00
Harvmaster a2d7c723ad Audit Fix 2026-07-23 08:00:53 +00:00
Harvmaster 205fb18785 Use Custom Error Classes for Validation 2026-07-23 07:56:50 +00:00
Harvmaster a28b142ce6 Merge branch 'development' into sse-and-backoff 2026-07-20 10:21:33 +00:00
Harvmaster 948a885987 Add sandbox and examples for SSE Session 2026-07-20 03:45:11 +00:00
Harvmaster 65be9c7dee Add SSE Session class 2026-07-19 19:24:07 +00:00
Harvmaster 2189e9c4f5 Fix parser linting 2026-07-19 19:15:32 +00:00
Harvmaster 047563f6fa Add try-async method 2026-07-19 19:15:22 +00:00
Harvmaster 9f0acc1fce Merge branch 'composed-push-iterator' into sse-branc 2026-07-19 19:09:11 +00:00
Harvmaster b25ebc14bc Merge branch 'event-emitter' into sse-branc 2026-07-19 19:07:44 +00:00
Harvmaster 2754ebe122 Merge branch 'exponential-backoff' into sse-branc 2026-07-19 19:07:07 +00:00
Harvmaster 7cb9a29e77 Add tests for abort fn in exponential backoff 2026-07-19 17:15:31 +00:00
Harvmaster f178b2e6fd Fix fn comment 2026-07-19 16:59:20 +00:00
Harvmaster a395596883 Fix formatting 2026-07-19 14:03:09 +00:00
Harvmaster 3dd1766ef0 Merge branch 'development' into composed-push-iterator 2026-07-19 13:44:02 +00:00
Harvmaster 67fd28a01f Merge branch 'development' into event-emitter 2026-07-19 13:43:27 +00:00
Harvmaster ae64fc1acf Merge branch 'development' into exponential-backoff 2026-07-19 13:42:43 +00:00
Harvmaster 96483cf445 Documentation and spelling 2026-07-18 16:26:07 +00:00
Harvmaster 23ecfbd032 Validate options on Exponential Backoff 2026-07-18 16:24:00 +00:00
Harvmaster 448ef5ca61 Rename mock functions in Vi 2026-07-18 16:12:17 +00:00
Harvmaster 7d40a40ea9 use js private 2026-07-18 13:56:43 +00:00
Harvmaster 798ccdf32c Relocate statics below constructor 2026-07-18 13:53:04 +00:00
Harvmaster 873a075329 Add abort signal to exponential backoff 2026-07-18 13:18:01 +00:00
Harvmaster bce4c1552e Make jitter subtract only 2026-07-13 22:07:40 +10:00
Harvmaster f346a1fc4f Actually simplify the while look condition 2026-07-13 02:21:37 +00:00
Harvmaster 452f5acdd5 Formatting 2026-07-13 01:49:36 +00:00
Harvmaster e726d1c25a Document await in exponential backoff run function 2026-07-13 01:48:26 +00:00
Harvmaster 06cc8eff25 Throw Exponential Backoff error containing all execution errors 2026-07-13 01:48:14 +00:00
Harvmaster 869b025e08 Document option in exponential backoff constructor 2026-07-13 01:48:10 +00:00
Harvmaster 09d732ab41 simplify while loop condition 2026-07-13 01:48:05 +00:00
Harvmaster 6f3eeb4079 Update barrel file 2026-07-13 01:44:02 +00:00
Harvmaster 24d1c94b74 Use readable stream to simplify async-push-iterator 2026-07-13 01:43:23 +00:00
Harvmaster 6fe4a64562 Add exponential backoff utility 2026-06-29 07:18:20 +00:00
Harvmaster d9a14769cb Add Event Emitter 2026-06-29 07:16:48 +00:00
30 changed files with 5977 additions and 746 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": "0.1", "version": "0.1",
"import": ["@generalprotocols/cspell-dictionary/cspell.json"], "import": ["@generalprotocols/cspell-dictionary/cspell.json"],
"words": ["nonfungible", "lockscript"], "words": ["nonfungible", "lockscript", "cashassembly", "checksigverify", "inputindex", "utxovalue", "bivariance"],
"ignorePaths": ["source/template/xo-template.schema.json"] "ignorePaths": ["source/template/xo-template.schema.json"]
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import baseConfig from '@xo-cash/eslint-config';
export default [ export default [
{ {
ignores: [ 'scripts/**', 'docs/**' ], ignores: [ 'scripts/**', 'docs/**', 'source/parser' ],
}, },
...baseConfig, ...baseConfig,
]; ];
+726 -723
View File
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -1,6 +1,6 @@
{ {
"name": "@xo-cash/utils", "name": "@xo-cash/utils",
"version": "0.0.2", "version": "0.0.3",
"description": "XO Cash utilities", "description": "XO Cash utilities",
"type": "module", "type": "module",
"types": "./dist/index.d.mts", "types": "./dist/index.d.mts",
@@ -23,7 +23,8 @@
"spellcheck": "cspell 'source/**' 'test/**' 'playground/**'", "spellcheck": "cspell 'source/**' 'test/**' 'playground/**'",
"style": "eslint", "style": "eslint",
"syntax": "tsc --noEmit", "syntax": "tsc --noEmit",
"test": "vitest --dir test/ --test-timeout=15000 --passWithNoTests --run --coverage" "test": "vitest --dir test/ --test-timeout=15000 --passWithNoTests --run --coverage",
"prepare": "npm run build"
}, },
"files": [ "files": [
"dist" "dist"
@@ -44,23 +45,21 @@
], ],
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.1.0-next.8", "@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/types": "0.0.3", "@xo-cash/primitives": "0.0.2",
"@xo-cash/types": "0.0.4",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"overrides": { "overrides": {
"echarts": "6.1.0" "echarts": "6.1.0",
"minimatch": "10.2.6"
}, },
"devDependencies": { "devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1", "@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0", "@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1", "@xo-cash/eslint-config": "1.0.2",
"@xo-cash/templates": "0.0.1", "@xo-cash/templates": "0.0.3",
"cspell": "^9.6.0", "cspell": "^9.6.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"prettier": "^3.6.2", "prettier": "^3.6.2",
@@ -68,7 +67,6 @@
"typedoc": "^0.28.16", "typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2", "typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17" "vitest": "^4.0.17"
} }
} }
+93
View File
@@ -0,0 +1,93 @@
import { SSESession } from '../source/sse-session/index.ts';
// Because this is in a library, and we don't want to add the node types to this as it is intended to be used in a browser
// we will just declare the process object here locally so we don't get type errors from this script
declare const process: {
env: Record<string, string>;
stdout: {
write: (data: string) => void;
};
};
// Recommended URL: https://openrouter.ai/api/v1/chat/completions
// Recommended Model: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free
// Exmaple Command: API_KEY="your-api-key" URL="https://openrouter.ai/api/v1/chat/completions" MODEL="ibm-granite/granite-4.1-8b" PROMPT="Hello, Tell me a joke about robots?" npx tsx ./sandbox/sandbox-llm.ts
// Read the Environemt 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 relavent part of the response (we want to first grab the choices array)
const choices = responseJson.choices;
// Then we will grab the first choice
const firstChoice = choices[0];
// Then we will grab the next chunk of text
const messageContent = firstChoice.delta?.content;
// Then we will append the text to the console
process.stdout.write(messageContent || '');
}
// Just a terminal/node thing. If we dont put a new line, the console will overwrite the text with the `cwd` or next command input
process.stdout.write('\n');
+17
View File
@@ -0,0 +1,17 @@
import { SSESession, type SSEvent } from '../source/sse-session/index.ts';
// Command: npx tsx ./sandbox/sandbox-price-oracle.ts
// Use the GP Price Oracle API
const url = 'https://oracles.generalprotocols.com/sse/v1/messages';
// Create our sse session
const sseSession = await SSESession.create(url);
// Create a message event handler
sseSession.on('message', (message: SSEvent) => {
console.log(message);
});
// Connect to the SSESession
await sseSession.connect();
+40
View File
@@ -0,0 +1,40 @@
import { bigIntToVmNumber, utf8ToBin } from '@bitauth/libauth';
import { CashAssemblyNumberNotSafeIntegerError, CashAssemblyUnsupportedValueTypeError } from './errors.ts';
/**
* Converts a value into bytes representation.
*
* @param {unknown} value - Value to encode, should be one of: Uint8Array, bigint, boolean, string, or a safe integer number.
* @param {string} valueIdentifier - Identifier used in error messages.
* @returns {Uint8Array} Bytes representation of the value.
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number is not a safe integer.
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the value type cannot be resolved.
*/
export const convertValueToBytes = (value: unknown, valueIdentifier: string): Uint8Array => {
if (value instanceof Uint8Array) {
return value;
}
if (typeof value === 'bigint') {
return bigIntToVmNumber(value);
}
if (typeof value === 'boolean') {
// The BCH VM treats an empty byte array as false and any nonempty byte array as true.
return new Uint8Array(value ? [ 1 ] : []);
}
if (typeof value === 'string') {
return utf8ToBin(value);
}
if (typeof value === 'number') {
if (Number.isSafeInteger(value) === true) {
return bigIntToVmNumber(BigInt(value));
}
throw new CashAssemblyNumberNotSafeIntegerError(valueIdentifier, value);
}
throw new CashAssemblyUnsupportedValueTypeError(valueIdentifier, typeof value);
};
+61
View File
@@ -0,0 +1,61 @@
/**
* Detects whether a string is a pure CashAssembly expression.
*
* CashAssembly expressions look like `$(<variable>)` or `$(<a> <b>)`. This pattern checks
* that the entire string is one such expression and nothing else. It will not match if
* there is other text surrounding the expression.
*
* For example:
* `$(<fee>)` matches (a full expression)
* `OP_DUP $(<fee>)` does not match (extra text before it)
* `$()` does not match (empty expression)
*/
export const CASHASSEMBLY_EXPRESSION_PATTERN = /^\$\([^)]+\)$/;
/**
* Finds all CashAssembly evaluations embedded in a larger string.
*
* An evaluation looks like `$(...)`, for example `$(<fee>)` or `$(<a> <b>)`. This pattern
* locates every occurrence in the input and returns them all (global flag `g`).
* Empty evaluations `$()` are intentionally excluded because they reference no variables.
*
* For example, scanning `"OP_DUP <$(<pubkeyHash>)> OP_HASH160 $(<fee>)"` would return
* `['$(<pubkeyHash>)', '$(<fee>)']`.
*/
export const CASHASSEMBLY_EVALUATION_PATTERN = /\$\([^)]+\)/g;
/**
* Extracts variable names from angle-bracket references inside a CashAssembly evaluation.
*
* Inside an evaluation like `$(<pubkeyHash> <fee>)`, variables are referenced as `<name>`.
* This pattern captures the name between the brackets. The global flag `g` allows iterating
* over every variable reference in a single evaluation string.
*
* For example, running this against `$(<pubkeyHash> <fee>)` would return the variable names
* `["pubkeyHash", "fee"]`.
*/
export const CASHASSEMBLY_VARIABLE_PATTERN = /<([^>]+)>/g;
/**
* Identifies CashAssembly literal tokens that appear inside angle-bracket push statements.
*
* Inside an evaluation, not everything between `<` and `>` is a variable name. Literals are
* also valid push contents: numeric literals (e.g. `<0>`, `<32>`), hex literals (`<0x02>`),
* binary literals (`<0b1010>`), and string literals (`<"minting">`, `<'hello'>`). This pattern
* matches any captured token that starts with a digit or a quote character.
*/
export const CASHASSEMBLY_LITERAL_TOKEN_PATTERN = /^[0-9"']/;
/**
* Matches a single dot variable method reference inside an angle-bracket identifier.
*
* Used to detect primitive method references such as `expiry.toIso8601`.
*
* For example:
* `expiry.toIso8601` matches (base `expiry`, method `toIso8601`)
* `requestedSatoshis` does not match (no method)
* `key.schnorr_signature.all_outputs` does not match (more than one dot)
* `key.public_key` matches the pattern shape but it is only resolved
* when `hint` maps to a primitive
*/
export const CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN = /^([^.]+)\.([^.]+)$/;
+85
View File
@@ -0,0 +1,85 @@
/* eslint-disable max-classes-per-file */
/**
* Error thrown when a required variable is missing.
*/
export class CashAssemblyRequiredVariableMissingError extends Error {
constructor(variableNames?: string[]) {
const defaultMessage = 'Missing required variable';
if (variableNames !== undefined && variableNames.length > 0) {
super(`${defaultMessage}: variableNames [${variableNames.join(', ')}]`);
} else {
super(defaultMessage);
}
}
}
/**
* Error thrown when cash assembly compilation fails.
*/
export class CashAssemblyCompilationFailedError extends Error {
constructor(message?: string) {
const defaultMessage = 'Cash assembly compilation failed';
super(message ? `${defaultMessage}: ${message}` : defaultMessage);
}
}
/**
* Error thrown when a variable's runtime type does not match the type required for compilation.
*/
export class CashAssemblyVariableTypeMismatchError extends Error {
constructor(variableKey: string, expectedType: string, actualType: string) {
const defaultMessage = 'Variable type mismatch';
super(`${defaultMessage}: variableKey "${variableKey}", expected ${expectedType}, got ${actualType}`);
}
}
/**
* Error thrown when a supported primitive hint does not expose the requested method.
*/
export class CashAssemblyPrimitiveMethodMissingError extends Error {
constructor(identifier: string, methodName: string, hint: string) {
const defaultMessage = 'CashAssembly primitive method does not exist';
super(`${defaultMessage}: identifier "${identifier}", methodName "${methodName}", hint "${hint}"`);
}
}
/**
* Error thrown when a value cannot be resolved as bytes.
*/
export class CashAssemblyUnsupportedValueTypeError extends Error {
constructor(identifier: string, returnedType: string) {
const defaultMessage = 'CashAssembly value type is unsupported for byte resolution';
super(`${defaultMessage}: identifier "${identifier}", returnedType "${returnedType}"`);
}
}
/**
* Error thrown when a number cannot be safely encoded as a CashAssembly VM number.
*/
export class CashAssemblyNumberNotSafeIntegerError extends Error {
constructor(identifier: string, value: number) {
const defaultMessage = 'CashAssembly number is not a safe integer';
super(`${defaultMessage}: identifier "${identifier}", got ${String(value)}`);
}
}
/**
* Error thrown when a supported primitive is selected but its value is missing when provided in the variables map.
*/
export class CashAssemblyPrimitiveVariableMissingError extends Error {
constructor(identifier: string, variableName: string) {
const defaultMessage = 'CashAssembly primitive variable is missing from the variables map';
super(`${defaultMessage}: identifier "${identifier}", variableName "${variableName}"`);
}
}
/**
* Error thrown when compiled evaluation bytes cannot be decoded as a VM number.
*/
export class CashAssemblyVmNumberDecodeError extends Error {
constructor(reason: string) {
const defaultMessage = 'CashAssembly evaluation could not be decoded as a VM number';
super(`${defaultMessage}: ${reason}`);
}
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Utilities for parsing, extracting, and compiling CashAssembly expressions.
*
* CashAssembly is the scripting language used by Bitauth templates to describe Bitcoin Cash
* locking and unlocking scripts.
*
* ## Syntax (CashAssembly)
*
* `<expression>` is a push statement. Compiles the contents and pushes the result onto the VM stack.
* `<someKey.public_key>` pushes the 33 byte compressed public key. `<1>` pushes the integer 1.
*
* `$(<expression>)` is an evaluation. Runs the inner script in the VM and inserts the top stack
* item as VM bytecode.
* `$(<someKey.public_key> OP_HASH160)` inserts the HASH160 of the public key.
*
* `<$(<expression>)>` is a push of an evaluation result. It evaluates first then pushes.
* For a P2PKH locking script example see
* `OP_DUP OP_HASH160 <$(<someKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG`.
*
* `variableId.operation` is a variable with a compiler resolved operation. `someKey.public_key`
* produces the public key bytes. `someKey.schnorr_signature.all_outputs` produces a Schnorr
* signature.
*
* Opcodes (`OP_DUP`, `OP_HASH160`, and similar) are inserted as their bytecode equivalent directly.
*
* ## Name resolution priority (CashAssembly)
*
* When the compiler encounters an identifier it resolves it in this order.
* 1. Opcode always wins. Naming a variable or script `OP_ADD` will not shadow it.
* 2. Variable shadows scripts of the same name.
* 3. Script is the script's bytecode.
*
* ## Resolution Order (CashAssembly + Primitive Method Resolution)
*
* Supported `<base.method>` pushes are resolved to bytes before CashAssembly compiles.
* Inside CashAssembly the order is Opcode then Variable then Script.
*/
import type { CompilerBch } from '@bitauth/libauth';
import { binToHex, binToUtf8, createCompilerBch, vmNumberToBigInt } from '@bitauth/libauth';
import type { XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import {
CashAssemblyCompilationFailedError,
CashAssemblyVariableTypeMismatchError,
CashAssemblyRequiredVariableMissingError,
CashAssemblyVmNumberDecodeError,
} from './errors.ts';
import {
CASHASSEMBLY_EVALUATION_PATTERN,
CASHASSEMBLY_EXPRESSION_PATTERN,
CASHASSEMBLY_LITERAL_TOKEN_PATTERN,
CASHASSEMBLY_VARIABLE_PATTERN,
} from './defaults.ts';
import { convertValueToBytes } from './bytes.ts';
import { resolvePrimitiveMethodBytes } from './primitive-evaluations.ts';
/**
* Supported decode modes for compiled CashAssembly evaluation bytes.
*/
export type CompiledCashAssemblyDecodeMode = 'utf8' | 'hex' | 'boolean' | 'bigint' | 'uint8array';
/**
* Parameters for compiling CashAssembly string.
*/
export type CompileCashAssemblyStringParameters = {
/**
* Text that may embed CashAssembly evaluations such as `$(<fee>)` or `$(<expiry.toIso8601>)`.
*/
cashAssemblyText: string;
/**
* Used for both primitive method evaluations and normal CashAssembly compilation.
*/
variables: Record<string, XOInvitationVariableValue | Uint8Array>;
/**
* The mode to decode compiled evaluation bytes into a string.
*/
evaluationDecodeMode?: CompiledCashAssemblyDecodeMode;
/**
* Optional template variable definitions. When provided, each `<name.method>` push whose `hint`
* maps to a supported primitive class is resolved to bytes before CashAssembly compilation.
*/
templateVariables?: XOTemplate['variables'];
};
/**
* Checks if the expression is a CashAssembly expression.
*
* @param {unknown} expression - The expression to check.
* @returns {boolean} True if the expression is a CashAssembly expression, false otherwise.
*/
export const isCashAssemblyExpression = (expression: unknown): boolean => {
return typeof expression === 'string' && CASHASSEMBLY_EXPRESSION_PATTERN.test(expression);
};
/**
* Extracts all CashAssembly evaluations (i.e., substrings like $(...)) from the input text.
*
* @param {string} text - The input string to scan for CashAssembly evaluations.
* @returns {string[]} An array of evaluation strings found in the input.
*
* @example
* extractCashAssemblyEvaluations("OP_DUP <$(<foo>)> OP_HASH160 $(<bar>)");
* // returns ['$(<foo>)', '$(<bar>)']
*/
export const extractCashAssemblyEvaluations = (text: string): string[] => {
return text.match(CASHASSEMBLY_EVALUATION_PATTERN) ?? [];
};
/**
* Returns the segment of `identifier` before the first `.`.
*
* When there is no `.`, returns `identifier` unchanged.
* Multi segment identifiers such as `foo.bar.baz` resolve to `foo`.
*
* @param {string} identifier - Identifier that may contain a dot.
* @returns {string} The base name before the first `.`.
*/
const resolveIdentifierBaseName = (identifier: string): string => {
const firstDotIndex = identifier.indexOf('.');
if (firstDotIndex === -1) {
return identifier;
}
return identifier.slice(0, firstDotIndex);
};
/**
* Extracts unique variable identifiers enclosed in angle brackets from each evaluation string.
*
* CashAssembly literal tokens such as hex bytes, numbers, and quoted strings are excluded via
* {@link CASHASSEMBLY_LITERAL_TOKEN_PATTERN}. For example, `<0x02>` and `<"minting">` are not returned.
*
* @param {string[]} evaluations - An array of evaluation strings from which to extract variable names.
* @returns {string[]} An array of variable names.
*/
export const extractVariablesFromEvaluations = (evaluations: string[]): string[] => {
const uniqueVariables = new Set<string>();
for (const evaluation of evaluations) {
for (const [ , extractedIdentifier ] of evaluation.matchAll(CASHASSEMBLY_VARIABLE_PATTERN)) {
if (CASHASSEMBLY_LITERAL_TOKEN_PATTERN.test(extractedIdentifier)) {
continue;
}
uniqueVariables.add(extractedIdentifier);
}
}
return [ ...uniqueVariables ];
};
/**
* Decodes compiled CashAssembly evaluation bytes into a string representation.
*
* 'evaluationDecodeMode' determines how the evaluation bytes are interpreted and presented.
* Use `bigint` for numeric values like satoshis, `utf8` for text labels, `hex` for binary data
* such as hashes, and `boolean` to represent boolean values.
*
* @param {Uint8Array} compiledResult - The compiled evaluation bytecode.
* @param {CompiledCashAssemblyDecodeMode} [evaluationDecodeMode='utf8'] - The decode mode used to convert
* bytes to text.
* @returns {string} The decoded value as a string suitable for inline replacement.
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and the bytes are not a VM number.
*/
export const decodeCompiledCashAssemblyEvaluation = (
compiledResult: Uint8Array,
evaluationDecodeMode: CompiledCashAssemblyDecodeMode = 'utf8',
): string => {
// Converts the byte array to a string.
if (evaluationDecodeMode === 'uint8array') {
return String(compiledResult);
}
// Converts the byte array to a boolean string, converting the evaluation result into a true or a false.
if (evaluationDecodeMode === 'boolean') {
return compiledResult.length === 0 ? 'false' : 'true';
}
// Converts the byte array to a hex string.
if (evaluationDecodeMode === 'hex') {
return binToHex(compiledResult);
}
// Converts the byte array to a bigint string.
if (evaluationDecodeMode === 'bigint') {
const vmNumberResult = vmNumberToBigInt(compiledResult);
if (typeof vmNumberResult === 'bigint') {
return vmNumberResult.toString();
}
throw new CashAssemblyVmNumberDecodeError(vmNumberResult);
}
// Converts the byte array to a utf8 string.
return binToUtf8(compiledResult);
};
/**
* Generates bytecode for a specific CashAssembly evaluation using given variable values and a prepared compiler.
*
* @param {CompilerBch} compiler - The libauth compiler from {@link compileCashAssemblyEvaluations}.
* @param {string} evaluation - The specific evaluation string to compile.
* @param {Record<string, Uint8Array>} variables - A record mapping variable names to their values.
* @returns {Uint8Array} The compiled bytecode.
* @throws {@link CashAssemblyRequiredVariableMissingError} If a required variable is not present.
* @throws {@link CashAssemblyVariableTypeMismatchError} If a variable value is not a Uint8Array.
* @throws {@link CashAssemblyCompilationFailedError} If libauth compilation fails.
*/
export const generateCashAssemblyBytecode = (compiler: CompilerBch, evaluation: string, variables: Record<string, Uint8Array>): Uint8Array => {
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
// Validate that all required variables are provided
const missingVariables = variableNames.filter((name: string) => !Object.hasOwn(variables, name));
if (missingVariables.length > 0) {
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
}
// Construct the bytecode object using the keys in variableNames, mapping to the values in variables
const bytecode: Record<string, Uint8Array> = {};
for (const variableName of variableNames) {
const value = variables[variableName];
// By the time execution reaches this point, the value should be a Uint8Array.
if (!(value instanceof Uint8Array)) {
throw new CashAssemblyVariableTypeMismatchError(variableName, 'Uint8Array', typeof value);
}
bytecode[variableName] = value;
}
const compiledBytecode = compiler.generateBytecode({
data: { bytecode },
scriptId: evaluation,
});
if (!compiledBytecode.success) {
// Collapse libauth's full errors list into one string because
// CashAssemblyCompilationFailedError only carries a single message.
let compilationFailureMessage = 'unknown compilation failure';
if ('errors' in compiledBytecode && compiledBytecode.errors.length > 0) {
compilationFailureMessage = compiledBytecode.errors.map((compilationError) => compilationError.error).join('; ');
}
throw new CashAssemblyCompilationFailedError(compilationFailureMessage);
}
return compiledBytecode.bytecode;
};
/**
* Prepares a compiler for the provided CashAssembly evaluations, setting required variables as 'WalletData'.
*
* @param {string[]} evaluations - Array of evaluation strings (e.g., ['$(<var1>)', '$(<var2> <var3>)']).
* @returns {CompilerBch} A Libauth compiler instance for use with these evaluations.
*/
export const compileCashAssemblyEvaluations = (evaluations: string[]): CompilerBch => {
// Create a scripts object where each key is the evaluation and the value is also the evaluation
const scripts: Record<string, string> = {};
for (const evaluation of evaluations) {
scripts[evaluation] = evaluation;
}
// Get the variable names from the evaluations.
const variableNames = extractVariablesFromEvaluations(evaluations);
// Register each base name once. Dotted pushes share one WalletData entry under the base name.
const variables: Record<string, { type: 'WalletData' }> = {};
for (const variableName of variableNames) {
variables[resolveIdentifierBaseName(variableName)] = { type: 'WalletData' as const };
}
// Create the libauth compiler.
const compiler = createCompilerBch({
scripts,
variables,
});
return compiler;
};
/**
* Compiles all CashAssembly evaluations in a text string and replaces each evaluation
* with a decoded string representation.
*
* @param {CompileCashAssemblyStringParameters} parameters - Parameters for compiling the CashAssembly string.
* @param {string} parameters.cashAssemblyText - The string with CashAssembly evaluations.
* @param {Record<string, XOInvitationVariableValue | Uint8Array>} parameters.variables - Object mapping
* variable names to values for compilation.
* @param {CompiledCashAssemblyDecodeMode} [parameters.evaluationDecodeMode='utf8'] - The decode mode used
* after each evaluation is compiled. See {@link decodeCompiledCashAssemblyEvaluation}.
* @param {XOTemplate['variables']} [parameters.templateVariables] - Optional template variable definitions
* used to resolve supported `<name.method>` pushes via each variable's `hint`.
* @returns {string} Compiled text with all evaluations replaced by decoded string values.
* @throws {@link CashAssemblyRequiredVariableMissingError} When a required variable is not present in the variables map.
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When a supported primitive hint has an unknown method.
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When a supported primitive method is missing its runtime value.
* @throws {@link CashAssemblyUnsupportedValueTypeError} When a primitive method return type cannot be embedded as bytes.
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a number variable is not a safe integer.
* @throws {@link CashAssemblyVmNumberDecodeError} When `evaluationDecodeMode` is `bigint` and an evaluation is not a VM number.
*/
export const compileCashAssemblyString = (parameters: CompileCashAssemblyStringParameters): string => {
const { cashAssemblyText, variables, evaluationDecodeMode = 'utf8', templateVariables } = parameters;
return cashAssemblyText.replace(CASHASSEMBLY_EVALUATION_PATTERN, (evaluation) => {
// Extract variable identifiers required by the current evaluation.
const variableNames = extractVariablesFromEvaluations([ evaluation ]);
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
identifiers: variableNames,
templateVariables,
variables,
});
// Prefer resolved method bytes. Fall back to converting the raw variable value.
const missingVariables = variableNames.filter((variableName) => {
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
return false;
}
return Object.hasOwn(variables, variableName) === false;
});
if (missingVariables.length > 0) {
throw new CashAssemblyRequiredVariableMissingError(missingVariables);
}
// Convert each variable to its bytes before compilation.
const variableBytes: Record<string, Uint8Array> = {};
for (const variableName of variableNames) {
if (Object.hasOwn(primitiveMethodBytes, variableName) === true) {
variableBytes[variableName] = primitiveMethodBytes[variableName];
continue;
}
variableBytes[variableName] = convertValueToBytes(variables[variableName], variableName);
}
// Compile the evaluation in isolation.
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
// Generate the bytes for the evaluation.
const compilationResult: Uint8Array = generateCashAssemblyBytecode(compiler, evaluation, variableBytes);
// Decode the bytes into a string as per the decode mode.
return decodeCompiledCashAssemblyEvaluation(compilationResult, evaluationDecodeMode);
});
};
+5
View File
@@ -0,0 +1,5 @@
export * from './evaluations.ts';
export * from './primitive-evaluations.ts';
export * from './bytes.ts';
export * from './defaults.ts';
export * from './errors.ts';
@@ -0,0 +1,216 @@
import {
FungibleTokenAmount,
NFTCommitment,
PublicKey,
Satoshis,
SchnorrSignature,
TemplateIdentifier,
Timestamp,
TokenCategory,
TransactionHash,
} from '@xo-cash/primitives';
import { XOTemplatePrimitiveTypes } from '@xo-cash/types';
import type { XOTemplate, XOTemplatePrimitiveType } from '@xo-cash/types';
import { CashAssemblyPrimitiveMethodMissingError, CashAssemblyPrimitiveVariableMissingError } from './errors.ts';
import { CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN } from './defaults.ts';
import { convertValueToBytes } from './bytes.ts';
/**
* Template hint values mapped to a primitive class for resolving primitive method evaluations.
* Keys are values from XOTemplatePrimitiveTypes.
*/
const PRIMITIVE_BY_TEMPLATE_HINT = {
[XOTemplatePrimitiveTypes.FUNGIBLE_TOKEN_AMOUNT]: FungibleTokenAmount,
[XOTemplatePrimitiveTypes.NFT_COMMITMENT]: NFTCommitment,
[XOTemplatePrimitiveTypes.PUBLIC_KEY]: PublicKey,
[XOTemplatePrimitiveTypes.SATOSHIS]: Satoshis,
[XOTemplatePrimitiveTypes.SCHNORR_SIGNATURE]: SchnorrSignature,
[XOTemplatePrimitiveTypes.TEMPLATE_IDENTIFIER]: TemplateIdentifier,
[XOTemplatePrimitiveTypes.TIMESTAMP]: Timestamp,
[XOTemplatePrimitiveTypes.TOKEN_CATEGORY]: TokenCategory,
[XOTemplatePrimitiveTypes.TRANSACTION_HASH]: TransactionHash,
} as const;
type SupportedPrimitiveHint = keyof typeof PRIMITIVE_BY_TEMPLATE_HINT;
/**
* Inputs needed to call a primitive method for a `name.method` push.
*/
type CallPrimitiveMethodParameters = {
/**
* Full push identifier from the evaluation, for example `amount.toSatoshis`.
*/
identifier: string;
/**
* Method name to call on the constructed primitive, for example `toIso8601`.
*/
methodName: string;
/**
* Value for the variable.
*/
value: unknown;
/**
* Supported template hint that selects the primitive class.
*/
hint: SupportedPrimitiveHint;
};
/**
* Inputs needed to resolve primitive method pushes from extracted CashAssembly identifiers.
*/
export type ResolvePrimitiveMethodBytesParameters = {
/**
* Variable identifiers from {@link extractVariablesFromEvaluations}, for example
* `['amount.toSatoshis', 'fee.toSatoshis']`.
*/
identifiers: string[];
/**
* Variable names and values object.
*/
variables: Record<string, unknown>;
/**
* Template variable definitions. When omitted, no primitive methods are resolved.
* The `hint` on each entry selects the primitive class.
*/
templateVariables?: XOTemplate['variables'];
};
/**
* Returns true when `hint` maps to a supported primitive class in `PRIMITIVE_BY_TEMPLATE_HINT`.
*
* @param {XOTemplatePrimitiveType | undefined} hint - Template variable hint.
* @returns {boolean} True when the hint selects a supported primitive class.
*/
const isSupportedPrimitiveHint = (hint: XOTemplatePrimitiveType | undefined): hint is SupportedPrimitiveHint => {
return hint !== undefined && Object.hasOwn(PRIMITIVE_BY_TEMPLATE_HINT, hint) === true;
};
/**
* Returns true when `methodName` is an own function on the primitive class for `hint`.
*
* @param {SupportedPrimitiveHint} hint - Supported template hint.
* @param {string} methodName - Method name from the evaluation text, for example `toSatoshis`.
* @returns {boolean} True when that class exposes the named method.
*/
const canResolvePrimitiveMethod = (hint: SupportedPrimitiveHint, methodName: string): boolean => {
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
// Check own properties only so inherited Object.prototype names are rejected without needing a value.
if (Object.hasOwn(PrimitiveClass.prototype, methodName) === false) {
return false;
}
return typeof Reflect.get(PrimitiveClass.prototype, methodName) === 'function';
};
/**
* Constructs a primitive from a raw value and calls one instance method on it.
*
* Call only after `canResolvePrimitiveMethod` is true for the same hint and method.
*
* @param {CallPrimitiveMethodParameters} parameters - Identifier, method, value, and supported hint.
* @returns {unknown} Method return value, later encoded as CashAssembly push bytes.
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the prototype member is not a function.
* @throws When the primitive constructor rejects the raw value (validation errors from `@xo-cash/primitives`).
*/
const callPrimitiveMethod = (parameters: CallPrimitiveMethodParameters): unknown => {
const { identifier, methodName, value, hint } = parameters;
const PrimitiveClass = PRIMITIVE_BY_TEMPLATE_HINT[hint];
// Constructing runs each primitive's own input validation (range checks, hex length, etc).
// `as never` satisfies TypeScript across constructors that accept different input shapes.
const primitiveInstance = new PrimitiveClass(value as never);
// Same prototype member canResolvePrimitiveMethod already verified as an own function.
const primitiveMethod = Reflect.get(PrimitiveClass.prototype, methodName);
if (typeof primitiveMethod !== 'function') {
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
}
return primitiveMethod.call(primitiveInstance);
};
/**
* Resolves supported `base.method` identifiers to CashAssembly variable bytes.
*
* Each single dot identifier whose `hint` maps to a supported primitive is resolved and stored under
* the full identifier (`base.method`). Unsupported or multi dot identifiers are left for CashAssembly.
*
* When `templateVariables` is omitted, returns an empty map.
*
* @param {ResolvePrimitiveMethodBytesParameters} parameters - Identifiers, values, and optional template metadata.
* @returns {Record<string, Uint8Array>} Resolved method identifiers mapped to bytes for CashAssembly pushes.
* @throws {@link CashAssemblyPrimitiveMethodMissingError} When the hint is a supported primitive but the method is missing.
* @throws {@link CashAssemblyPrimitiveVariableMissingError} When the runtime value is missing from the variables map.
* @throws {@link CashAssemblyUnsupportedValueTypeError} When the method return type cannot be embedded.
* @throws {@link CashAssemblyNumberNotSafeIntegerError} When a method return value is a number that is not a safe integer.
*/
export const resolvePrimitiveMethodBytes = (parameters: ResolvePrimitiveMethodBytesParameters): Record<string, Uint8Array> => {
const { identifiers, templateVariables, variables } = parameters;
// Without template metadata there is no hint to select a primitive class.
if (templateVariables === undefined) {
return {};
}
const resolvedBytes: Record<string, Uint8Array> = {};
for (const identifier of identifiers) {
// The same identifier can appear more than once. Resolve it only once.
if (Object.hasOwn(resolvedBytes, identifier) === true) {
continue;
}
// Find the primitive method reference in the identifier.
const methodReferenceMatch = identifier.match(CASHASSEMBLY_VARIABLE_METHOD_REFERENCE_PATTERN);
if (methodReferenceMatch === null) {
continue;
}
const [ , baseName, methodName ] = methodReferenceMatch;
// Unknown identifiers and CashAssembly native operations such as someKey.schnorr_signature.all_outputs
// must be left for CashAssembly rather than treated as primitive failures.
if (Object.hasOwn(templateVariables, baseName) === false) {
continue;
}
const hint = templateVariables[baseName].hint;
if (isSupportedPrimitiveHint(hint) === false) {
continue;
}
// Supported hint with an unknown method should be thrown as an error.
if (canResolvePrimitiveMethod(hint, methodName) === false) {
throw new CashAssemblyPrimitiveMethodMissingError(identifier, methodName, hint);
}
// If the method is known but the runtime value is missing, throw an error.
if (Object.hasOwn(variables, baseName) === false) {
throw new CashAssemblyPrimitiveVariableMissingError(identifier, baseName);
}
const methodResult = callPrimitiveMethod({
identifier,
methodName,
value: variables[baseName],
hint,
});
// Store the result under identifier.
resolvedBytes[identifier] = convertValueToBytes(methodResult, identifier);
}
return resolvedBytes;
};
+92
View File
@@ -0,0 +1,92 @@
/**
* 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 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 a waitFor timeout is reached
*/
export class WaitForTimeoutError extends Error {
constructor(type: string) {
super(`Timeout waiting for event "${type}"`);
this.name = 'WaitForTimeoutError';
}
}
+272
View File
@@ -0,0 +1,272 @@
import type { DeeplyReadonly } from './types.ts';
import { WaitForTimeoutError } from './errors.ts';
import { deepFreeze } from './misc.ts';
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: DeeplyReadonly<T>) => void;
/**
* Internally permits listeners for individual event payloads to be stored
* in a collection typed with the union of all event payloads.
*/
type StoredListener<T> = {
bivarianceHack(detail: DeeplyReadonly<T>): void;
}['bivarianceHack'];
/**
* A listener entry.
* @template T - The event payload type.
*/
interface ListenerEntry<T> {
listener: StoredListener<T>;
wrappedListener: StoredListener<T>;
cancel: () => void;
}
/**
* Callback returned by {@link on} and {@link once} for removing a listener.
*/
export type OffCallback = () => void;
/**
* A simple event emitter implementation.
* @template T - The event map type.
*/
export class EventEmitter<T extends EventMap> {
/**
* The listeners map.
* @private
*/
#listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
/**
* Add a listener for an event.
* @param type - The event type.
* @param listener - The listener function.
* @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events.
*/
on<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const { cancel, listener: cancellableListener } = this.cancellable(listener);
// Create a wrapped listener so that the debounce can be applied.
const wrappedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener,
cancel,
};
// Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
/**
* Add a one-time listener for an event.
* @param type - The event type.
* @param listener - The listener function.
* @param debounceMilliseconds - The debounce time in milliseconds.
* @returns An off callback that can be called to stop listening for events.
*/
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds: number = 0): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: DeeplyReadonly<T[K]>) => {
this.off(type, listener);
listener(detail);
};
// Create a cancellable listener.
const { cancel, listener: cancellableListener } = this.cancellable(wrappedListener);
// Create a debounced listener.
const debouncedListener = debounceMilliseconds > 0 ? this.debounce(cancellableListener, debounceMilliseconds) : cancellableListener;
// If the listeners map does not have the event type, create a new set.
if (!this.#listeners.has(type)) {
this.#listeners.set(type, new Set());
}
// Create a listener entry.
const listenerEntry: ListenerEntry<T[K]> = {
listener,
wrappedListener: debouncedListener,
cancel,
};
// Add the listener entry to the listeners map.
this.#listeners.get(type)?.add(listenerEntry);
// Return an "off" callback that can be called to stop listening for events.
return () => this.off(type, listener);
}
/**
* Remove a listener for an event.
* @param type - The event type.
* @param listener - The listener function.
*/
off<K extends keyof T>(type: K, listener?: Listener<T[K]>): void {
// Get the listeners for the event type.
const listeners = this.#listeners.get(type);
if (!listeners) return;
// Find the listener entries (If a listener was provided, only 1 entry will be returned. Otherwise, all entries will be returned).
const listenerEntries = Array.from(listeners).filter((entry) => !listener || entry.listener === listener || entry.wrappedListener === listener);
// Remove the listener entries from the listeners set.
listenerEntries.forEach((entry) => {
// Set the wrapped listener to a no-op function to prevent it from being called by debounced events after it's been removed.
entry.cancel();
// Remove the listener entry from the listeners set.
listeners.delete(entry);
});
// If no listener was provided and no listeners are left for the event type, remove the listeners set from the listeners map.
if (!listener || this.#listeners.get(type)?.size === 0) {
this.#listeners.delete(type);
}
}
/**
* Emit an event.
* @param type - The event type.
* @param payload - The event payload.
* @returns True if there are listeners for the event, false otherwise.
*/
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
// Get the listeners for the event type.
const listeners = this.#listeners.get(type);
if (!listeners) return false;
// Clone the payload to avoid freezing the original object.
const payloadClone = structuredClone(payload);
// Freeze the cloned payload to make it readonly.
const readonlyPayload = deepFreeze(payloadClone);
// Emit the event to all listeners.
listeners.forEach((entry) => {
try {
entry.wrappedListener(readonlyPayload);
} catch (error) {
console.error(error);
}
});
// Return true if there are listeners for the event, false otherwise.
return listeners.size > 0;
}
/**
* Remove all listeners.
*/
removeAllListeners(): void {
for (const [ type, listeners ] of this.#listeners.entries()) {
listeners.forEach((entry) => {
this.off(type, entry.listener);
});
}
}
/**
* Wait for an event to be emitted that matches the provided predicate function's criteria.
* @param type - The event type.
* @param predicate - Predicate function to filter for whether the event payload matches the criteria.
* @param timeoutMs - The timeout in milliseconds.
* @returns The event payload.
*/
async waitFor<K extends keyof T>(
type: K,
predicate: (payload: DeeplyReadonly<T[K]>) => boolean,
timeoutMs?: number,
): Promise<DeeplyReadonly<T[K]>> {
// Create a promise to wait for the event to be emitted.
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
// Create a cleanup function to remove the listener and clear the timeout if it is still pending.
const cleanup = (listener: Listener<T[K]>): void => {
// Remove the listener from the listeners map.
this.off(type, listener);
// Clear the timeout if it is still pending.
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};
// Create a listener function.
const listener = (payload: DeeplyReadonly<T[K]>): void => {
try {
// If the event payload does not match the predicate condition, return.
if (!predicate(payload)) {
return;
}
cleanup(listener);
resolve(payload);
} catch (error) {
cleanup(listener);
reject(error);
}
};
// Set up timeout if specified
if (timeoutMs !== undefined) {
timeoutId = setTimeout(() => {
this.off(type, listener);
reject(new WaitForTimeoutError(String(type)));
}, timeoutMs);
}
// Add the listener to the listeners map.
this.on(type, listener);
});
}
/**
* Debounce a function.
* @param func - The function to debounce.
* @param wait - The wait time in milliseconds.
* @returns The debounced function.
*/
private debounce<K extends keyof T>(func: Listener<T[K]>, wait: number): Listener<T[K]> {
// Create a timeout variable.
let timeout: ReturnType<typeof setTimeout>;
return (detail: DeeplyReadonly<T[K]>) => {
// If a debounce timer is already pending, clear it before scheduling the next one.
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(detail);
}, wait);
};
}
private cancellable<K extends keyof T>(func: Listener<T[K]>): { cancel: () => void; listener: Listener<T[K]> } {
let cancelled = false;
return {
cancel: (): boolean => (cancelled = true),
listener: (detail: DeeplyReadonly<T[K]>): void => {
if (cancelled) return;
func(detail);
},
};
}
}
+326
View File
@@ -0,0 +1,326 @@
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;
};
/**
* Exponential backoff is a technique used to retry a function after a delay.
*
* The delay increases exponentially with each attempt, up to a maximum delay.
*
* The jitter is a random amount of time subtracted from the delay to prevent thundering herd problems.
*
* The growth rate is the factor by which the delay increases with each attempt.
*/
export class ExponentialBackoff {
readonly #options: ExponentialBackoffOptions;
/**
* Creates a new exponential-backoff instance.
*
* Unspecified options use the defaults listed below.
*
* @param options - Exponential-backoff configuration overrides.
* @param options.maxDelay - Maximum delay between retries. Default: `10_000` ms.
* @param options.maxAttempts - Maximum number of attempts; `0` retries indefinitely. Default: `10`.
* @param options.baseDelay - Delay used as the basis for the first retry. Default: `1_000` ms.
* @param options.growthRate - Multiplier applied to the delay after each attempt. Default: `2`.
* @param options.jitter - Maximum proportional reduction subtracted from each delay (01). Default: `0.1`.
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*/
constructor(options: Partial<ExponentialBackoffOptions> = {}) {
this.#options = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
...options,
};
ExponentialBackoff.validateOptions(this.#options);
}
/**
* Create a new ExponentialBackoff instance
*
* @param config - The configuration for the exponential backoff
*
* @throws An {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws An {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws An {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
* @throws An {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
*
* @returns The ExponentialBackoff instance
*/
public static from(config?: Partial<ExponentialBackoffOptions>): ExponentialBackoff {
const backoff = new ExponentialBackoff(config);
return backoff;
}
/**
* Run the function with exponential backoff
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
* @param options - The configuration for the exponential backoff
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
public static run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
options?: Partial<ExponentialBackoffOptions>,
): Promise<T> {
const backoff = ExponentialBackoff.from(options);
return backoff.run(taskFn, onError);
}
/**
* Validate the options for the exponential backoff
*
* @param options - The options to validate
*
* @throws {@link ExponentialBackoffNumberNotFiniteError} if a provided option is not a finite number
* @throws {@link ExponentialBackoffNonIntegerError} if a provided option is not an integer
* @throws {@link ExponentialBackoffNumberOutOfBoundsError} if a provided option is out of bounds
* @throws {@link ExponentialBackoffNumberTooSmallError} if a provided option is too small
*/
public static validateOptions(options: ExponentialBackoffOptions): void {
/** Validate the value is finite, throwing an {@link ExponentialBackoffInvalidInfiniteIntegerError} if the value is infinite */
const assertIsFinite = (key: string, value: number): void => {
if (!Number.isFinite(value)) {
throw new ExponentialBackoffNumberNotFiniteError(key, value);
}
};
/** Validate the value is an integer, throwing a {@link ExponentialBackoffNonIntegerError} if it is not an integer */
const assertIsInteger = (key: string, value: number): void => {
if (!Number.isInteger(value)) {
throw new ExponentialBackoffNonIntegerError(key, value);
}
};
/** Validate the value is greater than the minimum, throwing a {@link ExponentialBackoffNumberTooSmallError} if it is not */
const assertIsHigherThan = (key: string, value: number, min: number): void => {
if (value < min) {
throw new ExponentialBackoffNumberTooSmallError(key, value, min);
}
};
/** Validate the value is within the bounds, throwing a {@link ExponentialBackoffNumberOutOfBoundsError} if it is not within the bounds */
const assertIsWithinBounds = (key: string, value: number, min: number, max: number): void => {
if (!isWithinBounds(value, min, max)) {
throw new ExponentialBackoffNumberOutOfBoundsError(key, value, min, max);
}
};
// Validate the max delay
assertIsFinite('maxDelay', options.maxDelay);
assertIsHigherThan('maxDelay', options.maxDelay, 0);
// Validate the max attempts
assertIsFinite('maxAttempts', options.maxAttempts);
assertIsInteger('maxAttempts', options.maxAttempts);
assertIsHigherThan('maxAttempts', options.maxAttempts, -1);
// Validate the base delay
assertIsFinite('baseDelay', options.baseDelay);
assertIsHigherThan('baseDelay', options.baseDelay, 0);
// Validate the growth rate
assertIsFinite('growthRate', options.growthRate);
assertIsHigherThan('growthRate', options.growthRate, 0);
// Validate the jitter
assertIsFinite('jitter', options.jitter);
assertIsWithinBounds('jitter', options.jitter, 0, 1);
}
/**
* Run the function with exponential backoff
*
* If the function fails but we have not hit the max attempts, the error will be passed to the onError callback
* and the function will be retried with an exponential delay
*
* If the function fails and we have hit the max attempts, an ExponentialBackoffMaxRetriesHitError will be thrown with all the errors that were thrown by the task function
*
* @param taskFn - The function to run
* @param onError - The callback to call when an error occurs
*
* @throws An {@link ExponentialBackoffMaxRetriesHitError} with all the errors that were thrown by the task function
* @throws An {@link ExponentialBackoffStoppedRetriesError} if the abort signal is activated
*
* @returns The result of the function
*/
public async run<T>(
taskFn: (callbackParameters: ExponentialBackoffCallbackParameters) => Promise<T>,
onError = (_error: Error): void => {},
): Promise<T> {
// Initialize an abort signal to allow the task function to be aborted
const abortController = new AbortController();
const stopRetries = abortController.abort.bind(abortController);
// Initialize an empty array to store the errors
const errors: Error[] = [];
// Initialize the attempt counter
let attempt = 0;
// If the max attempts is 0, we should continue indefinitely.
const unlimitedAttempts = this.#options.maxAttempts === 0;
// Loop until we succeed, hit the max attempts, or the abort signal is activated
while (true) {
try {
// Await the promise before returning so its execution context remains in the try-catch
// If we didn't await, this `run` function would successfully return and any errors would not be caught here.
return await taskFn({ stopRetries });
} catch (error) {
// Store the error in case we fail every attempt
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError(errorInstance);
// If we have unlimited attempts, don't append this to the errors array to prevent a memory leak.
if (!unlimitedAttempts) {
errors.push(errorInstance);
}
}
// Check if the abort signal has been activated
if (abortController.signal.aborted) {
// Throw an error if the abort signal has been activated
throw new ExponentialBackoffStoppedRetriesError(abortController.signal.reason);
}
// Calculate the count for next attempt. Do this now so we can exit before waiting and before running the next attempt.
const nextAttemptCount = attempt + 1;
const nextAttemptExceedsMaxAttempts = nextAttemptCount >= this.#options.maxAttempts;
// If the next attempt exceeds the max attempts, break out of the loop
if (!unlimitedAttempts && nextAttemptExceedsMaxAttempts) {
break;
}
// Wait before going to the next attempt
const delay = this.#calculateDelay(this.#options, attempt);
// Wait for the delay or the abort signal
await new Promise((resolve, reject) => {
// Set a timeout to resolve the promise after the delay
// eslint-disable-next-line prefer-const
let timeout: ReturnType<typeof setTimeout>;
// Handle the abort signal
const abortHandler = (): void => {
clearTimeout(timeout);
abortController.signal.removeEventListener('abort', abortHandler);
reject(new ExponentialBackoffStoppedRetriesError(abortController.signal.reason));
};
// Handle the timeout
const timeoutHandler = (): void => {
abortController.signal.removeEventListener('abort', abortHandler);
resolve(undefined);
};
// Set the timeout
timeout = setTimeout(timeoutHandler, delay);
// Add the abort handler to the abort signal
abortController.signal.addEventListener('abort', abortHandler);
});
attempt++;
}
// We completed the loop without ever succeeding. Throw an ExponentialBackoffMaxRetriesHitError with all the errors we got
throw new ExponentialBackoffMaxRetriesHitError(errors);
}
/**
* Calculate the delay before we should attempt to retry
*
* @param options - The configuration for the exponential backoff
* @param attempt - The current attempt number
* @returns The time in milliseconds before another attempt should be made
*/
#calculateDelay(options: ExponentialBackoffOptions, attempt: number): number {
// Get the power of the growth rate
const power = options.growthRate ** attempt;
// Get the delay before jitter or limit
const rawDelay = options.baseDelay * power;
// Cap the delay to the maximum. Do this before the jitter so jitter does not become larger than delay
const cappedDelay = Math.min(rawDelay, options.maxDelay);
// Get a random number for the amount to "jitter" the delay by
const jitterAmount = Math.random();
// Calculate the jitter
const jitter = jitterAmount * options.jitter * cappedDelay;
// Subtract the jitter from the delay
return cappedDelay - jitter;
}
}
+7
View File
@@ -1,10 +1,17 @@
export * from './errors.ts';
export * from './event-emitter.ts';
export * from './exponential-backoff.ts';
export * from './extended-json.ts'; export * from './extended-json.ts';
export * from './misc.ts';
export * from './script.ts'; export * from './script.ts';
export * from './sse-session/index.ts'; export * from './sse-session/index.ts';
export * from './template/errors.ts'; export * from './template/errors.ts';
export * from './template/identifier.ts'; export * from './template/identifier.ts';
export * from './template/parser.ts'; export * from './template/parser.ts';
export * from './template/schemas.ts'; export * from './template/schemas.ts';
export * from './cash-assembly/index.ts';
export * from './cash-assembly/errors.ts';
export * from './cash-assembly/defaults.ts';
// Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead. // Only exporting serializeTemplate as deserializeTemplate is only used internally and parseTemplate should be used instead.
export { serializeTemplate } from './template/serialization.ts'; export { serializeTemplate } from './template/serialization.ts';
+55
View File
@@ -0,0 +1,55 @@
import type { DeeplyReadonly } from './types';
/**
* Validate the value is within the bounds, returning true if it is within the bounds, false otherwise
*
* @param value - The value to validate
* @param min - The minimum value
* @param max - The maximum value
*
* @returns True if the value is within the bounds, false otherwise
*/
export const isWithinBounds = (value: number, min: number, max: number): boolean => {
if (value < min || value > max) {
return false;
}
return true;
};
/**
* Tries to execute an async function and handles any errors that occur.
* @param fn - The function to execute.
* @param onError - The callback to call if the function fails.
* @returns The result of the function.
*/
export const tryAsync = async (fn: () => unknown, onError?: (error: Error) => void): Promise<void> => {
try {
await fn();
} catch (error) {
const errorInstance = error instanceof Error ? error : new Error(`${error}`);
onError?.(errorInstance);
}
};
/**
* Recursively freezes an object by iterating over all properties and freezing them.
* @param obj - The object to freeze.
* @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;
};
+2
View File
@@ -1,2 +1,4 @@
export * from './async-push-iterator.ts'; export * from './async-push-iterator.ts';
export * from './types.ts';
export * from './sse-session.ts';
export * from './sse-event-parser.ts'; export * from './sse-event-parser.ts';
+478
View File
@@ -0,0 +1,478 @@
import type { SSESessionOptions, SSESessionEventMap, SSEvent } from './types.ts';
import { EventEmitter } from '../event-emitter.ts';
import { HTTPError, ResponseBodyNullError } from '../errors.ts';
import { tryAsync } from '../misc.ts';
import { ExponentialBackoff } from '../exponential-backoff.ts';
import { SSEEventParser } from './sse-event-parser.ts';
import { AsyncPushIterator } from './async-push-iterator.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 abort} stops the in-flight fetch without ending the session.
* Used internally for tab visibility. The {@link messages} iterator stays
* open so an existing consumer resumes when the tab becomes visible again.
* - {@link disconnect} aborts the transport, closes {@link messages}, emits
* `"closed"`, and disables attached visibility handlers until the next
* manual {@link connect}.
*
* Automatic reconnect is controlled by {@link SSESessionOptions.persistent}
* (server closed the stream) and
* {@link SSESessionOptions.attemptReconnect} (transport error).
*
* ## Connection supersession
*
* Each {@link connect} or {@link abort} bumps an internal `connectionId`.
* Background read loops capture their id at start and exit quietly when a
* newer connection supersedes them, avoiding duplicate events or errors from
* stale transports.
*/
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> = {}): Promise<SSESession> {
const client = new SSESession(url, options);
await client.connect();
return client;
}
/**
* 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.
*
* Attach as early in the session lifetime as possible. When added after
* {@link create}, the initial connection omits the header (no id yet);
* all later reconnects include it. To instrument before the first connect,
* call this on the session returned from {@link withBrowserVisibility}
* before awaiting a separate {@link connect} when the tab starts hidden.
*
* ```ts
* const session = await SSESession.create(url);
* await SSESession.addLastEventIdReconnect(session);
* // Reconnects send Last-Event-ID once an event with an id is received.
* ```
*
* @param client - The session to instrument.
* @returns The same session, for chaining.
*/
static async addLastEventIdReconnect(client: SSESession): Promise<SSESession> {
let lastEventId: string | undefined;
client.on('message', (event) => {
lastEventId = event.id;
});
const originalOnRequest = client.options.onRequest;
client.options.onRequest = async (request: RequestInit): Promise<RequestInit> => {
if (lastEventId) {
request.headers = { ...request.headers, 'Last-Event-ID': lastEventId };
}
return originalOnRequest(request);
};
return client;
}
/**
* Pauses and resumes a session based on browser tab visibility.
*
* Uses the Page Visibility API (`document.visibilitychange`):
*
* - **hidden** — {@link abort} stops the active fetch. {@link messages}
* stays open; `"disconnected"` fires but `"closed"` does not.
* - **visible** — {@link connect} re-establishes the stream if needed.
*
* The listener is removed when {@link disconnect} emits `"closed"`, and
* re-attached automatically on the next `"connected"` event.
*
* No-op in non-browser environments where `document` is undefined.
*
* @param client - The session to manage.
*/
static addBrowserVisibilityHandler(client: SSESession): SSESession {
if (typeof document === 'undefined') return client;
const handleVisibilityChange = (): void => {
if (document.visibilityState === 'hidden') {
void client.abort();
return;
}
client.connect().catch(() => {
// connect() reports failures via onError and the "error" event.
});
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Stop managing visibility after an explicit disconnect; re-register
// when the same instance is manually connected again.
client.once('closed', () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
client.once('connected', () => {
SSESession.addBrowserVisibilityHandler(client);
});
});
return client;
}
/** 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: (...args) => fetch(...args),
method: 'GET',
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
},
body: new FormData(),
onRequest: (request) => Promise.resolve(request),
onConnected: () => {},
onDisconnected: () => {},
onError: (error) => console.error('SSEClient error:', error),
// Retry the initial fetch until it succeeds (maxAttempts: 0 = unlimited).
retry: new ExponentialBackoff({
baseDelay: 1000,
maxDelay: 10000,
maxAttempts: 0,
growthRate: 1.3,
jitter: 0.3,
}),
attemptReconnect: true,
persistent: false,
eventParser: new SSEEventParser(),
};
/** AbortController for the currently active fetch, if any. */
#controller: AbortController | null = null;
/**
* Asynchronous stream of parsed SSE events for the active connection.
*
* Stays open across {@link abort} and automatic reconnects so an existing
* `for await` consumer keeps receiving events after visibility resumes.
*
* Closes when:
* - the server ends the stream and {@link SSESessionOptions.persistent}
* is false,
* - {@link disconnect} is called, or
* - 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> = {}) {
super();
this.#url = url;
this.options = {
...this.options,
...options,
// Shallow merge would drop default headers when options.headers is set.
headers: { ...this.options.headers, ...options.headers },
};
}
/**
* Connects or reconnects to the SSE endpoint.
*
* Resolves once the HTTP stream is established and `"connected"` has been
* emitted. Body reading continues asynchronously in the background via
* {@link #readStream}.
*
* @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.#controller) return;
// Prepare for a fresh transport. Parser state from an abandoned connection
// must not bleed into the next one; reopen messages if a prior terminal
// close ended the consumer's iteration loop.
this.#resetEventParser();
this.#ensureMessageStreamOpen();
const controller = new AbortController();
this.#controller = controller;
const { method, headers, body } = this.options;
const fetchBody = method === 'POST' ? body : null;
const fetchOptions: RequestInit = {
method,
headers: headers || {},
body: fetchBody ?? null,
signal: controller.signal,
cache: 'no-store',
};
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = await this.options.retry.run(() => this.#createReader(fetchOptions));
} catch (error) {
// A newer abort/connect superseded this attempt — leave state to the winner.
if (this.#controller !== controller) return;
this.#controller = null;
await this.#notifyDisconnected();
await this.#notifyError(error);
this.#closeMessageStream();
throw error;
}
// Connection succeeded but was already replaced (for example abort during fetch).
if (this.#controller !== controller) {
await reader.cancel();
return;
}
await tryAsync(
() => this.options.onConnected(),
(error) => this.options.onError(error),
);
this.emit('connected', undefined);
// Fire-and-forget: connect() resolves while the stream is consumed.
this.#readStream(reader, controller).catch((error) => {
this.options.onError(error);
});
}
/**
* Aborts only the currently active transport.
*
* The session remains reusable: {@link messages} stays open, visibility
* handling stays attached, and {@link connect} can reopen the stream.
* Partial parser state from the abandoned transport is discarded.
*
* Emits `"disconnected"` but not `"closed"`.
*/
public async abort(): Promise<void> {
if (!this.#controller) return;
// Grab the current controller to ensure we are aborting the correct one.
const controller = this.#controller;
this.#controller = null;
// Invalidate any in-flight read loop and fetch for this transport.
controller.abort();
this.#resetEventParser();
await this.#notifyDisconnected();
}
/**
* 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 async disconnect(): Promise<void> {
this.#closeMessageStream();
this.emit('closed', undefined);
if (this.#controller) {
await this.abort();
} else {
this.#resetEventParser();
}
}
/**
* 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);
if (!response.ok) {
const responseCode = response.status;
const responseText = await response.text();
const error = new HTTPError(responseCode, responseText);
void this.#notifyError(error);
throw error;
}
if (!response.body) {
const error = new ResponseBodyNullError();
void this.#notifyError(error);
throw error;
}
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>, controller: AbortController): Promise<void> {
try {
while (this.#controller === controller) {
const { done, value } = await reader.read();
// abort() or a newer connect() may have landed while we were awaiting.
if (this.#controller !== controller) return;
if (done) {
this.#controller = null;
await this.#notifyDisconnected();
if (this.options.persistent) {
// Server closed gracefully — reopen unless the consumer opted out.
await this.connect();
} else {
this.#closeMessageStream();
}
return;
}
// Some environments yield `{ done: false, value: undefined }`.
if (!value) continue;
for (const event of this.options.eventParser.parseEvents(value)) {
this.emit('message', event);
this.messages.push(event);
}
}
} catch (error) {
// If the controller is different, we already started a new connection and it would be confusing to handle this error.
if (controller !== this.#controller) return;
// Invalidate the current controller to allow for reconnection if needed
this.#controller = null;
await this.#notifyDisconnected();
// Expected path for abort() — do not treat as an error or reconnect.
if (controller.signal.aborted) return;
await this.#notifyError(error);
if (this.options.attemptReconnect) {
await this.connect();
} else {
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();
}
/** Invokes {@link SSESessionOptions.onDisconnected} and emits `"disconnected"`. */
async #notifyDisconnected(): Promise<void> {
await tryAsync(
() => this.options.onDisconnected(),
(error) => this.options.onError(error),
);
this.emit('disconnected', undefined);
}
/** Invokes {@link SSESessionOptions.onError} and emits `"error"`. */
async #notifyError(error: unknown): Promise<void> {
const errorInstance = error instanceof Error ? error : new Error(String(error));
await tryAsync(
() => this.options.onError(errorInstance),
(callbackError) => console.error('SSESession error:', callbackError),
);
this.emit('error', errorInstance);
}
}
+128
View File
@@ -1,3 +1,131 @@
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>;
/**
* Called after the HTTP stream is established and before body reading begins.
*/
onConnected: () => void;
/**
* Called when the active transport ends — including {@link SSESession.abort},
* server stream completion, and errors. Not paired with {@link SSESessionCallbacks.onConnected}
* when the initial connect never succeeds.
*/
onDisconnected: () => void;
/**
* Called on fetch or read failures. Not invoked for intentional
* {@link SSESession.abort} aborts.
*/
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>, onError?: (error: Error) => void): 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 abort).
*/
attemptReconnect: boolean;
/**
* When true, {@link SSESession} calls {@link SSESession.connect} again after
* the **server** closes the stream normally (`done`).
*/
persistent: boolean;
};
/**
* Events emitted by {@link SSESession}.
*
* - `"connected"` — HTTP stream established.
* - `"message"` — A complete SSE event was parsed.
* - `"disconnected"` — The active transport ended (including {@link SSESession.abort}).
* - `"error"` — An unexpected fetch or read failure.
* - `"closed"` — {@link SSESession.disconnect} was called; visibility handling is detached.
*/
export type SSESessionEventMap = {
connected: void;
disconnected: void;
error: Error;
message: SSEvent;
closed: void;
};
/**
* Configuration for {@link SSESession}.
*/
export type SSESessionOptions = SSESessionCallbacks
& SSERequestInit
& SSERequestFunction
& SSESessionRetryInterface
& SSELifecycleOptions
& SSEParser;
/** /**
* Represents a Server-Sent Event. * Represents a Server-Sent Event.
*/ */
+15 -9
View File
@@ -1,5 +1,5 @@
/* eslint-disable @stylistic/newline-per-chained-call */ /* eslint-disable @stylistic/newline-per-chained-call */
import { BchVmVersions, XOTemplateLockingTypes, XOTemplateNftCapabilities, XOTemplatePrimitiveTypes } from '@xo-cash/types'; import { BchVmVersions, XOTemplateBaseTypes, XOTemplatePrimitiveTypes, XOTemplateLockingTypes, XOTemplateNftCapabilities } from '@xo-cash/types';
import { z } from 'zod'; import { z } from 'zod';
// ============================================================ // ============================================================
@@ -59,8 +59,14 @@ export const xoTemplateNftCapabilitySchema = z.enum(XOTemplateNftCapabilities);
export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes); export const xoTemplateLockingTypeSchema = z.enum(XOTemplateLockingTypes);
/** /**
* Validation schema for a primitive type identifier. Defines the set of primitive types * Validation schema for a base type identifier.
* that can be declared in an XO template. Used by constants, variables, and data fields. * Accepts values from XOTemplateBaseTypes for the `type` field on constants, variables, and data.
*/
export const xoTemplateBaseTypeSchema = z.enum(XOTemplateBaseTypes);
/**
* Validation schema for a primitive type identifier.
* Accepts values from XOTemplatePrimitiveTypes for the `hint` field on constants, variables, and data.
*/ */
export const xoTemplatePrimitiveTypeSchema = z.enum(XOTemplatePrimitiveTypes); export const xoTemplatePrimitiveTypeSchema = z.enum(XOTemplatePrimitiveTypes);
@@ -732,9 +738,9 @@ export const xoTemplateTransactionSchema = xoTemplateViewPropertiesSchema
*/ */
export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
.extend({ .extend({
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this constant.'), type: xoTemplateBaseTypeSchema.describe('The data type of this constant.'),
value: z.unknown().describe('The value of this constant.'), value: z.unknown().describe('The value of this constant.'),
hint: z.string().optional().describe('An optional hint to help apps and users understand what this constant represents.'), hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand what this constant represents.'),
}) })
.strict(); .strict();
@@ -751,9 +757,9 @@ export const xoTemplateConstantSchema = xoTemplateViewPropertiesSchema
*/ */
export const xoTemplateDataSchema = z export const xoTemplateDataSchema = z
.object({ .object({
type: xoTemplatePrimitiveTypeSchema.describe('The data type of this data field.'), type: xoTemplateBaseTypeSchema.describe('The data type of this data field.'),
value: z.unknown().describe('The value for this data field.'), value: z.unknown().describe('The value for this data field.'),
hint: z.string().optional().describe('An optional hint to help apps and users understand this data field.'), hint: xoTemplatePrimitiveTypeSchema.optional().describe('An optional hint to help apps and users understand this data field.'),
}) })
.strict(); .strict();
@@ -789,8 +795,8 @@ export const xoTemplateImportDefaultValueSchema = xoTemplateIntentSchema
*/ */
export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema export const xoTemplateVariableSchema = xoTemplateViewPropertiesSchema
.extend({ .extend({
type: xoTemplatePrimitiveTypeSchema.optional().describe('The data type of this variable.'), type: xoTemplateBaseTypeSchema.optional().describe('The data type of this variable.'),
hint: z.string().optional().describe('A hint to help users understand what value to provide.'), hint: xoTemplatePrimitiveTypeSchema.optional().describe('A hint to help users understand what value to provide.'),
// A neutral intent that the engine uses to populate the default value for this variable. // A neutral intent that the engine uses to populate the default value for this variable.
// View properties (name, description, icon) may contain CashASM expressions that the // View properties (name, description, icon) may contain CashASM expressions that the
+8
View File
@@ -0,0 +1,8 @@
/**
* A deeply readonly type.
* @template T - The type to make deeply readonly.
* @returns The deeply readonly type.
*/
export type DeeplyReadonly<T> = {
readonly [K in keyof T]: T[K] extends (...args: never[]) => unknown ? T[K] : DeeplyReadonly<T[K]>;
};
+813
View File
@@ -0,0 +1,813 @@
import { expect, test } from 'vitest';
import {
bigIntToVmNumber,
binToHex,
createCompilerBch,
createVirtualMachineBch,
encodeDataPush,
generatePrivateKey,
hash256,
secp256k1,
utf8ToBin,
} from '@bitauth/libauth';
import { XOTemplateBaseTypes, XOTemplatePrimitiveTypes } from '@xo-cash/types';
import {
isCashAssemblyExpression,
extractCashAssemblyEvaluations,
extractVariablesFromEvaluations,
decodeCompiledCashAssemblyEvaluation,
compileCashAssemblyString,
generateCashAssemblyBytecode,
compileCashAssemblyEvaluations,
resolvePrimitiveMethodBytes,
} from '../source/cash-assembly/index.ts';
import {
CashAssemblyRequiredVariableMissingError,
CashAssemblyCompilationFailedError,
CashAssemblyVariableTypeMismatchError,
CashAssemblyPrimitiveMethodMissingError,
CashAssemblyPrimitiveVariableMissingError,
CashAssemblyVmNumberDecodeError,
CashAssemblyNumberNotSafeIntegerError,
} from '../source/cash-assembly/errors.ts';
/**
* Tests that isCashAssemblyExpression recognizes a string made up entirely of one evaluation.
*/
const testIsCashAssemblyExpressionMatchesFullExpression = (): void => {
expect(isCashAssemblyExpression('$(<requestedSatoshis>)')).toBe(true);
};
/**
* Tests that isCashAssemblyExpression rejects a string that contains an evaluation plus other text.
*/
const testIsCashAssemblyExpressionRejectsSurroundingText = (): void => {
expect(isCashAssemblyExpression('Received $(<requestedSatoshis>)')).toBe(false);
};
/**
* Tests that isCashAssemblyExpression rejects plain text with no evaluation at all.
*/
const testIsCashAssemblyExpressionRejectsPlainText = (): void => {
expect(isCashAssemblyExpression('Received funds')).toBe(false);
};
/**
* Tests that isCashAssemblyExpression rejects an empty evaluation, since it references no variables.
*/
const testIsCashAssemblyExpressionRejectsEmptyEvaluation = (): void => {
expect(isCashAssemblyExpression('$()')).toBe(false);
};
/**
* Tests that isCashAssemblyExpression rejects non-string input rather than coercing it.
*/
const testIsCashAssemblyExpressionRejectsNonStringInput = (): void => {
// A number can never be a CashAssembly expression, regardless of its value
expect(isCashAssemblyExpression(5000)).toBe(false);
};
/**
* Tests that extractCashAssemblyEvaluations finds a single evaluation embedded in a larger string.
*/
const testExtractCashAssemblyEvaluationsFindsSingleEvaluation = (): void => {
const satoshisDescription = 'Received $(<requestedSatoshis>) satoshis from sender.';
// Only the evaluation substring is returned, not the surrounding text
expect(extractCashAssemblyEvaluations(satoshisDescription)).toStrictEqual([ '$(<requestedSatoshis>)' ]);
};
/**
* Tests that extractCashAssemblyEvaluations returns an empty array when the text has no evaluations.
*/
const testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound = (): void => {
expect(extractCashAssemblyEvaluations('Received funds')).toStrictEqual([]);
};
/**
* Tests that extractVariablesFromEvaluations returns each referenced variable name once,
* even when it appears in more than one evaluation.
*/
const testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations = (): void => {
const evaluations = [ '$(<requestedTokenAmount> <decimalsFactor> OP_DIV)', '$(<requestedTokenAmount> <decimalsFactor> OP_MOD)' ];
// requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
};
/**
* Tests that extractVariablesFromEvaluations excludes numeric and quoted-string literal tokens,
* returning only the true variable reference.
*/
const testExtractVariablesFromEvaluationsExcludesLiteralTokens = (): void => {
const evaluation = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
// <0x02>, <"minting">, and <"immutable"> are literals, not variables, so only tokenCapability is returned
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'tokenCapability' ]);
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation converts VM number bytes to their decimal string
* when decode mode is bigint.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesBigint = (): void => {
const compiledResult = bigIntToVmNumber(1234n);
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'bigint')).toBe('1234');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation throws when bigint mode receives bytes that are not a VM number.
*/
const testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber = (): void => {
const decodeNonMinimalZero = (): string => decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 0x00 ]), 'bigint');
const expectedMessage =
'CashAssembly evaluation could not be decoded as a VM number: Failed to decode VM Number: the number is not minimally-encoded.';
expect(decodeNonMinimalZero).toThrow(CashAssemblyVmNumberDecodeError);
expect(decodeNonMinimalZero).toThrow(expectedMessage);
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation converts bytes to a hex string when decode mode is hex.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesHex = (): void => {
const compiledResult = new Uint8Array([ 0xab, 0xcd ]);
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'hex')).toBe('abcd');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation reports an empty byte array as 'false' and any
* non-empty byte array as 'true' when decode mode is boolean.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesBoolean = (): void => {
// An empty byte array is falsy on the BCH VM
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array(0), 'boolean')).toBe('false');
// Any non-empty byte array is truthy on the BCH VM
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 1 ]), 'boolean')).toBe('true');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation defaults to utf8 decoding when no mode is given.
*/
const testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8 = (): void => {
const compiledResult = utf8ToBin('hello');
expect(decodeCompiledCashAssemblyEvaluation(compiledResult)).toBe('hello');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation returns the comma-separated decimal byte values
* when decode mode is uint8array.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array = (): void => {
const compiledResult = new Uint8Array([ 1, 2, 255 ]);
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'uint8array')).toBe('1,2,255');
};
/**
* Tests that compileCashAssemblyString compiles a single evaluation and decodes it as a bigint.
*/
const testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint = (): void => {
const compiledSatoshisText = compileCashAssemblyString({
cashAssemblyText: '$(<requestedSatoshis>)',
variables: { requestedSatoshis: 5000n },
evaluationDecodeMode: 'bigint',
});
expect(compiledSatoshisText).toBe('5000');
};
/**
* Tests that compileCashAssemblyString compiles multiple evaluations embedded in the same string.
*
*/
const testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString = (): void => {
const tokenDescription =
'Transferred $(<requestedTokenAmount> <decimalsFactor> OP_DIV).$(<requestedTokenAmount> <decimalsFactor> OP_MOD) tokens.';
expect(extractCashAssemblyEvaluations(tokenDescription)).toStrictEqual([
'$(<requestedTokenAmount> <decimalsFactor> OP_DIV)',
'$(<requestedTokenAmount> <decimalsFactor> OP_MOD)',
]);
const compiledTokenText = compileCashAssemblyString({
cashAssemblyText: tokenDescription,
variables: { requestedTokenAmount: 1050n, decimalsFactor: 100n },
evaluationDecodeMode: 'bigint',
});
expect(compiledTokenText).toBe('Transferred 10.50 tokens.');
};
/**
* Tests that a variable referenced twice in one evaluation uses the same supplied value both times.
*/
const testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation = (): void => {
const evaluation = '$(<amount> <amount> OP_ADD)';
// The name appears twice in the script body but is required only once in the variables map.
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'amount' ]);
const compiledText = compileCashAssemblyString({
cashAssemblyText: evaluation,
variables: { amount: 21n },
evaluationDecodeMode: 'bigint',
});
expect(compiledText).toBe('42');
};
/**
* Tests that compileCashAssemblyString respects OP_IF/OP_ELSE branching driven by a variable.
*/
const testCompileCashAssemblyStringEvaluatesConditionalLiterals = (): void => {
const conditionalExpression = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
// tokenCapability equal to 2 takes the OP_IF branch
const mintingResult = compileCashAssemblyString({
cashAssemblyText: conditionalExpression,
variables: { tokenCapability: 2n },
evaluationDecodeMode: 'utf8',
});
expect(mintingResult).toBe('minting');
// tokenCapability not equal to 2 takes the OP_ELSE branch
const immutableResult = compileCashAssemblyString({
cashAssemblyText: conditionalExpression,
variables: { tokenCapability: 0n },
evaluationDecodeMode: 'utf8',
});
expect(immutableResult).toBe('immutable');
};
/**
* Tests that compileCashAssemblyString defaults to utf8 decoding when evaluationDecodeMode is omitted.
*/
const testCompileCashAssemblyStringDefaultsToUtf8DecodeMode = (): void => {
const compiledLabel = compileCashAssemblyString({
cashAssemblyText: '$(<label>)',
variables: { label: 'hello' },
});
expect(compiledLabel).toBe('hello');
};
/**
* Tests that compileCashAssemblyString accepts a Uint8Array variable value directly,
* and decodes the result as hex.
*/
const testCompileCashAssemblyStringAcceptsUint8ArrayVariable = (): void => {
const compiledHash = compileCashAssemblyString({
cashAssemblyText: '$(<hashBytes>)',
variables: { hashBytes: new Uint8Array([ 0xde, 0xad, 0xbe, 0xef ]) },
evaluationDecodeMode: 'hex',
});
expect(compiledHash).toBe('deadbeef');
};
/**
* Tests that compileCashAssemblyString throws CashAssemblyRequiredVariableMissingError, naming the missing
* variable, when the variables map does not contain a variable referenced by the text.
*/
const testCompileCashAssemblyStringThrowsForMissingVariable = (): void => {
const compileWithMissingVariable = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<requestedSatoshis>)',
variables: {},
evaluationDecodeMode: 'bigint',
});
expect(compileWithMissingVariable).toThrow(CashAssemblyRequiredVariableMissingError);
// The message states the reason (missing from the variables map) and names the specific variable
expect(compileWithMissingVariable).toThrow('Missing required variable: variableNames [requestedSatoshis]');
};
/**
* Tests that compileCashAssemblyString throws for a non integer number, and that the same display text
* can be produced with integer DIV and MOD instead of a float variable.
*/
const testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod = (): void => {
const compileWithNonInteger = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: 12.5 },
evaluationDecodeMode: 'utf8',
});
const expectedMessage = 'CashAssembly number is not a safe integer: identifier "amount", got 12.5';
expect(compileWithNonInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithNonInteger).toThrow(expectedMessage);
// Floats are rejected. Format decimals from integer quantity and scale with OP_DIV and OP_MOD.
const compiledText = compileCashAssemblyString({
cashAssemblyText: '$(<amount> <base> OP_DIV).$(<amount> <base> OP_MOD)',
variables: { amount: 125n, base: 10n },
evaluationDecodeMode: 'bigint',
});
expect(compiledText).toBe('12.5');
};
/**
* Tests that compileCashAssemblyString throws when a number variable is outside the safe integer range.
*/
const testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber = (): void => {
const unsafeInteger = Number.MAX_SAFE_INTEGER + 2;
const compileWithUnsafeInteger = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: unsafeInteger },
evaluationDecodeMode: 'utf8',
});
const expectedMessage = `CashAssembly number is not a safe integer: identifier "amount", got ${String(unsafeInteger)}`;
expect(compileWithUnsafeInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithUnsafeInteger).toThrow(expectedMessage);
};
/**
* Tests that compileCashAssemblyString throws for non finite number values.
*/
const testCompileCashAssemblyStringThrowsForNonFiniteNumber = (): void => {
const compileWithNaN = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: Number.NaN },
evaluationDecodeMode: 'utf8',
});
const compileWithInfinity = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: Number.POSITIVE_INFINITY },
evaluationDecodeMode: 'utf8',
});
expect(compileWithNaN).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithNaN).toThrow('CashAssembly number is not a safe integer: identifier "amount", got NaN');
expect(compileWithInfinity).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithInfinity).toThrow('CashAssembly number is not a safe integer: identifier "amount", got Infinity');
};
/**
* Tests that a decoded variable value which happens to contain text shaped like a CashAssembly
* evaluation (e.g. "$(<real>)") is left alone as plain output text
*/
const testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText = (): void => {
const compiledText = compileCashAssemblyString({
cashAssemblyText: '$(<label>)$(<real>)',
variables: { label: '$(<real>)', real: '999' },
evaluationDecodeMode: 'utf8',
});
expect(compiledText).toBe('$(<real>)999');
};
/**
* Tests that omitting templateVariables leaves method shaped evaluations for CashAssembly.
*/
const testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables = (): void => {
const compileWithoutTemplateVariables = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<expiry.toIso8601>)',
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
});
expect(compileWithoutTemplateVariables).toThrow(CashAssemblyRequiredVariableMissingError);
expect(compileWithoutTemplateVariables).toThrow('Missing required variable: variableNames [expiry.toIso8601]');
};
/**
* Tests that an unsupported or missing hint leaves the evaluation for CashAssembly instead of throwing.
*/
const testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive = (): void => {
const compileWithNonPrimitiveHint = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<label.toIso8601>)',
variables: { label: 'hello' },
templateVariables: {
label: {
name: 'Label',
description: 'A text label',
type: XOTemplateBaseTypes.STRING,
},
},
});
expect(compileWithNonPrimitiveHint).toThrow(CashAssemblyRequiredVariableMissingError);
expect(compileWithNonPrimitiveHint).toThrow('Missing required variable: variableNames [label.toIso8601]');
};
/**
* Tests that a supported hint with an unknown method throws CashAssemblyPrimitiveMethodMissingError.
*
* Once the hint maps to a supported primitive class, a missing method is a primitive resolution
* failure rather than a CashAssembly missing variable.
*/
const testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive = (): void => {
const compileWithUnknownMethod = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<expiry.notARealMethod>)',
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
templateVariables: {
expiry: {
name: 'Expiry',
description: 'Invitation expiry time',
type: XOTemplateBaseTypes.INTEGER,
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
},
},
});
expect(compileWithUnknownMethod).toThrow(CashAssemblyPrimitiveMethodMissingError);
expect(compileWithUnknownMethod).toThrow('CashAssembly primitive method does not exist: identifier "expiry.notARealMethod", methodName "notARealMethod", hint "timestamp"');
};
/**
* Tests that a committed supported transform throws when the runtime value is missing.
*/
const testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue = (): void => {
const compileWithMissingValue = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<expiry.toIso8601>)',
variables: {},
templateVariables: {
expiry: {
name: 'Expiry',
description: 'Invitation expiry time',
type: XOTemplateBaseTypes.INTEGER,
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
},
},
});
expect(compileWithMissingValue).toThrow(CashAssemblyPrimitiveVariableMissingError);
expect(compileWithMissingValue).toThrow('CashAssembly primitive variable is missing from the variables map: identifier "expiry.toIso8601", variableName "expiry"');
};
/**
* Tests that multiple supported `<base.method>` pushes inside one evaluation.
*/
const testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation = (): void => {
const compiledText = compileCashAssemblyString({
cashAssemblyText: '$(<amount.toSatoshis> <fee.toSatoshis> OP_SUB)',
variables: {
amount: 5000n,
fee: 1000n,
},
templateVariables: {
amount: {
name: 'Amount',
description: 'Payment in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
fee: {
name: 'Fee',
description: 'Network fee in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
},
evaluationDecodeMode: 'bigint',
});
expect(compiledText).toBe('4000');
};
/**
* Tests that lock and display evaluations are extracted from one string and that the lock
* hash evaluation compiles to the HASH256 of the redeem script.
*/
const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText = (): void => {
// Test only key material. Never use with real funds.
const privateKeyBytes = generatePrivateKey();
const publicKeyResult = secp256k1.derivePublicKeyCompressed(privateKeyBytes);
expect(typeof publicKeyResult).not.toBe('string');
const publicKeyBytes = publicKeyResult as Uint8Array;
// Minimal P2S redeem body. Push the compressed public key, then OP_CHECKSIG.
const redeemScript = Uint8Array.from([ ...encodeDataPush(publicKeyBytes), 0xac ]);
const expectedRedeemHashHex = binToHex(hash256(redeemScript));
const lockText = 'Lock OP_HASH256 <$(<redeemScript> OP_HASH256)> OP_EQUAL. ';
const displayTextSource = 'Expires $(<expiry.toIso8601>). Amount $(<amount.toBCH>) BCH ($(<amount.toString>) satoshis).';
const cashAssemblyText = `${lockText}${displayTextSource}`;
const evaluations = extractCashAssemblyEvaluations(cashAssemblyText);
expect(evaluations).toStrictEqual([ '$(<redeemScript> OP_HASH256)', '$(<expiry.toIso8601>)', '$(<amount.toBCH>)', '$(<amount.toString>)' ]);
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'redeemScript', 'expiry.toIso8601', 'amount.toBCH', 'amount.toString' ]);
const redeemHashHex = compileCashAssemblyString({
cashAssemblyText: evaluations[0],
variables: { redeemScript },
evaluationDecodeMode: 'hex',
});
expect(redeemHashHex).toBe(expectedRedeemHashHex);
};
/**
* Tests a P2SH32 spend whose redeem script mixes primitive method evaluations with a real Schnorr checksig.
*
* Redeem requires:
* 1. A P2PKH style signature check against owner.public_key.
* 2. UTXO value equal to price + fee, where price and fee are bound via Satoshis.toSatoshis then OP_ADD.
*/
const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = (): void => {
// Test only key material. Never use with real funds.
const privateKeyBytes = generatePrivateKey();
const priceSatoshis = 5000n;
const feeSatoshis = 1000n;
const totalSatoshis = 6000n;
const redeemScriptSource =
'OP_DUP OP_HASH160 <$(<owner.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIGVERIFY '
+ 'OP_INPUTINDEX OP_UTXOVALUE <$(<price.toSatoshis> <fee.toSatoshis> OP_ADD)> OP_EQUAL';
// Resolve primitive method pushes so CashAssembly receives VM number bytes under the full identifiers.
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
identifiers: [ 'price.toSatoshis', 'fee.toSatoshis' ],
variables: {
price: priceSatoshis,
fee: feeSatoshis,
},
templateVariables: {
price: {
name: 'Price',
description: 'Base payment in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
fee: {
name: 'Fee',
description: 'Network fee in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
},
});
expect(Object.keys(primitiveMethodBytes)).toStrictEqual([ 'price.toSatoshis', 'fee.toSatoshis' ]);
const redeemCompiler = createCompilerBch({
scripts: {
redeem: redeemScriptSource,
},
variables: {
owner: { type: 'Key' },
price: { type: 'WalletData' },
fee: { type: 'WalletData' },
},
});
const redeemResult = redeemCompiler.generateBytecode({
data: {
keys: { privateKeys: { owner: privateKeyBytes } },
bytecode: primitiveMethodBytes,
},
scriptId: 'redeem',
});
expect(redeemResult.success).toBe(true);
if (redeemResult.success !== true) {
return;
}
const redeemBytecode = redeemResult.bytecode;
// P2SH32 lock is OP_HASH256 <hash256(redeem)> OP_EQUAL.
const lockingBytecode = Uint8Array.from([ 0xaa, ...encodeDataPush(hash256(redeemBytecode)), 0x87 ]);
// Unlock compiler signs against the same redeem body used as the covered bytecode for P2SH.
const unlockCompiler = createCompilerBch({
scripts: {
lock: redeemScriptSource,
unlock: '<owner.schnorr_signature.all_outputs> <owner.public_key>',
},
unlockingScripts: {
unlock: 'lock',
},
variables: {
owner: { type: 'Key' },
price: { type: 'WalletData' },
fee: { type: 'WalletData' },
},
});
const program = {
inputIndex: 0,
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis }],
transaction: {
inputs: [
{
outpointIndex: 0,
outpointTransactionHash: new Uint8Array(32),
sequenceNumber: 0,
unlockingBytecode: new Uint8Array(),
},
],
locktime: 0,
outputs: [{ lockingBytecode: new Uint8Array(), valueSatoshis: totalSatoshis }],
version: 2,
},
};
const unlockResult = unlockCompiler.generateBytecode({
data: {
keys: { privateKeys: { owner: privateKeyBytes } },
bytecode: primitiveMethodBytes,
compilationContext: program,
},
scriptId: 'unlock',
});
expect(unlockResult.success).toBe(true);
if (unlockResult.success !== true) {
return;
}
// P2SH unlock pushes signature and public key, then the redeem script itself.
program.transaction.inputs[0].unlockingBytecode = Uint8Array.from([ ...unlockResult.bytecode, ...encodeDataPush(redeemBytecode) ]);
const virtualMachine = createVirtualMachineBch();
const evaluationResult = virtualMachine.evaluate(program);
expect(virtualMachine.stateSuccess(evaluationResult)).toBe(true);
// Wrong UTXO value must fail the baked price + fee equality check.
const wrongValueProgram = {
...program,
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis - 1n }],
transaction: {
...program.transaction,
inputs: [
{
...program.transaction.inputs[0],
unlockingBytecode: new Uint8Array(),
},
],
},
};
const wrongValueUnlockResult = unlockCompiler.generateBytecode({
data: {
keys: { privateKeys: { owner: privateKeyBytes } },
bytecode: primitiveMethodBytes,
compilationContext: wrongValueProgram,
},
scriptId: 'unlock',
});
expect(wrongValueUnlockResult.success).toBe(true);
if (wrongValueUnlockResult.success !== true) {
return;
}
wrongValueProgram.transaction.inputs[0].unlockingBytecode = Uint8Array.from([
...wrongValueUnlockResult.bytecode,
...encodeDataPush(redeemBytecode),
]);
expect(virtualMachine.stateSuccess(virtualMachine.evaluate(wrongValueProgram))).not.toBe(true);
};
/**
* Tests that generateCashAssemblyBytecode produces the pushed bytes for a variable evaluation.
*/
const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void => {
const evaluation = '$(<inputValue>)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) });
expect(bytecode).toStrictEqual(new Uint8Array([ 4 ]));
};
/**
* Tests that generateCashAssemblyBytecode throws CashAssemblyVariableTypeMismatchError when a variable value is not a Uint8Array.
*/
const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void => {
const evaluation = '$(<inputValue>)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const generateWithTypeMismatch = (): Uint8Array =>
generateCashAssemblyBytecode(
compiler,
evaluation,
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
{ inputValue: 4 },
);
expect(generateWithTypeMismatch).toThrow(CashAssemblyVariableTypeMismatchError);
expect(generateWithTypeMismatch).toThrow('Variable type mismatch: variableKey "inputValue", expected Uint8Array, got number');
};
/**
* Tests that generateCashAssemblyBytecode throws CashAssemblyCompilationFailedError when the
* evaluation references an identifier the compiler cannot resolve as an opcode, variable, or script.
*/
const testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier = (): void => {
const evaluation = '$(<inputValue> unresolvedIdentifier)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const variables = { inputValue: new Uint8Array([ 1 ]) };
const generateWithUnresolvedIdentifier = (): Uint8Array => generateCashAssemblyBytecode(compiler, evaluation, variables);
expect(generateWithUnresolvedIdentifier).toThrow(CashAssemblyCompilationFailedError);
expect(generateWithUnresolvedIdentifier).toThrow('Cash assembly compilation failed: Unknown identifier "unresolvedIdentifier".');
};
const runTests = async (): Promise<void> => {
test('isCashAssemblyExpression: matches a full expression', testIsCashAssemblyExpressionMatchesFullExpression);
test('isCashAssemblyExpression: rejects surrounding text', testIsCashAssemblyExpressionRejectsSurroundingText);
test('isCashAssemblyExpression: rejects plain text', testIsCashAssemblyExpressionRejectsPlainText);
test('isCashAssemblyExpression: rejects an empty evaluation', testIsCashAssemblyExpressionRejectsEmptyEvaluation);
test('isCashAssemblyExpression: rejects non-string input', testIsCashAssemblyExpressionRejectsNonStringInput);
test('extractCashAssemblyEvaluations: finds a single evaluation', testExtractCashAssemblyEvaluationsFindsSingleEvaluation);
test(
'extractCashAssemblyEvaluations: returns an empty array when none are found',
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
);
test('extractVariablesFromEvaluations: deduplicates across evaluations', testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations);
test('extractVariablesFromEvaluations: excludes literal tokens', testExtractVariablesFromEvaluationsExcludesLiteralTokens);
test('decodeCompiledCashAssemblyEvaluation: decodes bigint', testDecodeCompiledCashAssemblyEvaluationDecodesBigint);
test(
'decodeCompiledCashAssemblyEvaluation: throws when bigint bytes are not a VM number',
testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber,
);
test('decodeCompiledCashAssemblyEvaluation: decodes hex', testDecodeCompiledCashAssemblyEvaluationDecodesHex);
test('decodeCompiledCashAssemblyEvaluation: decodes boolean', testDecodeCompiledCashAssemblyEvaluationDecodesBoolean);
test('decodeCompiledCashAssemblyEvaluation: defaults to utf8', testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8);
test('decodeCompiledCashAssemblyEvaluation: decodes uint8array', testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array);
test('compileCashAssemblyString: decodes a single evaluation as bigint', testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint);
test(
'compileCashAssemblyString: compiles multiple evaluations in one string',
testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString,
);
test(
'compileCashAssemblyString: reuses the same variable twice in one evaluation',
testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation,
);
test('compileCashAssemblyString: evaluates conditional literals', testCompileCashAssemblyStringEvaluatesConditionalLiterals);
test('compileCashAssemblyString: defaults to utf8 decode mode', testCompileCashAssemblyStringDefaultsToUtf8DecodeMode);
test('compileCashAssemblyString: accepts a Uint8Array variable', testCompileCashAssemblyStringAcceptsUint8ArrayVariable);
test('compileCashAssemblyString: throws for a missing variable', testCompileCashAssemblyStringThrowsForMissingVariable);
test(
'compileCashAssemblyString: throws for a non-integer number and produces the same display with integer DIV and MOD',
testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod,
);
test('compileCashAssemblyString: throws for an unsafe integer number', testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber);
test('compileCashAssemblyString: throws for a non-finite number', testCompileCashAssemblyStringThrowsForNonFiniteNumber);
test(
'compileCashAssemblyString: does not reinterpret injected evaluation-looking text',
testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText,
);
test(
'compileCashAssemblyString: leaves method evaluations for CashAssembly without templateVariables',
testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables,
);
test(
'compileCashAssemblyString: skips when hint is not a supported primitive',
testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive,
);
test(
'compileCashAssemblyString: throws when method does not exist on the primitive',
testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive,
);
test(
'compileCashAssemblyString: throws when a supported transform is missing its runtime value',
testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue,
);
test(
'compileCashAssemblyString: resolves multiple primitive method pushes in one evaluation',
testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation,
);
test(
'compileCashAssemblyString: hex-compiles a P2S lock hash extracted from mixed lock and display text',
testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText,
);
test(
'P2SH32 redeem: primitive OP_ADD and Schnorr signature spend successfully',
testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully,
);
test('generateCashAssemblyBytecode: produces raw bytes for a variable', testGenerateCashAssemblyBytecodeProducesRawBytesForVariable);
test('generateCashAssemblyBytecode: throws for a non-Uint8Array variable', testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable);
test('generateCashAssemblyBytecode: throws for an unresolved identifier', testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier);
};
await runTests();
+728
View File
@@ -0,0 +1,728 @@
import { expect, test, vi } from 'vitest';
import { EventEmitter } from '../source/event-emitter.ts';
/** Simple event map used across these tests. */
type TestEvents = {
message: string;
count: number;
nested: { nested: { value: number } };
};
/**
* Tests that EventEmitter invokes listeners when an event is emitted.
*/
const testEventEmitterCallsListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Register the listener and emit an event.
emitter.on('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been called with the emitted payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('hello');
};
/**
* Tests that EventEmitter invokes all listeners registered for the same event.
*/
const testEventEmitterCallsMultipleListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const firstListener = vi.fn();
const secondListener = vi.fn();
// Register two listeners for the same event type.
emitter.on('count', firstListener);
emitter.on('count', secondListener);
emitter.emit('count', 42);
// Expect both listeners to receive the same payload.
expect(firstListener).toHaveBeenCalledOnce();
expect(firstListener).toHaveBeenCalledWith(42);
expect(secondListener).toHaveBeenCalledOnce();
expect(secondListener).toHaveBeenCalledWith(42);
};
/**
* Tests that EventEmitter only invokes listeners registered for the emitted event type.
*/
const testEventEmitterCallsOnlyMatchingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
// Register listeners on different event types.
emitter.on('message', messageListener);
emitter.on('count', countListener);
// Emit only the message event.
emitter.emit('message', 'hello');
// Expect only the matching listener to have been called.
expect(messageListener).toHaveBeenCalledOnce();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that EventEmitter.emit returns false when no listeners are registered.
*/
const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that nobody was listening.
expect(hasListeners).toBe(false);
};
/**
* Tests that EventEmitter.emit returns true when listeners are registered.
*/
const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
emitter.on('message', vi.fn());
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that at least one listener was invoked.
expect(hasListeners).toBe(true);
};
/**
* Tests that EventEmitter.emit continues after a listener throws an error.
*/
const testEventEmitterEmitContinuesAfterListenerThrows = (): void => {
const emitter = new EventEmitter<TestEvents>();
const secondListener = vi.fn();
emitter.on('message', (): void => {
throw new Error('listener failure');
});
emitter.on('message', secondListener);
emitter.emit('message', 'hello');
expect(secondListener).toHaveBeenCalledOnce();
expect(secondListener).toHaveBeenCalledWith('hello');
};
/**
* Tests that emitted events cannot be mutated.
*/
const testEventEmitterEmittedEventsCannotBeMutated = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const payload = { nested: { value: 1 } };
emitter.on('nested', listener, 100);
// Arm the debounce timer with the current payload.
emitter.emit('nested', payload);
// Mutate the original object while the timer is still pending.
payload.nested.value = 999;
await vi.advanceTimersByTimeAsync(100);
// The listener must receive a snapshot from emit time, not the mutated value.
expect(listener).toHaveBeenCalledOnce();
// Expect the listener to have received the original payload object without any mutations
expect(listener).toHaveBeenCalledWith({
nested: {
value: 1,
},
});
// Expect the payload object to have been mutated
expect(payload).toStrictEqual({
nested: {
value: 999,
},
});
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that the off callback returned by on() removes the listener.
*/
const testEventEmitterOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// on() returns an off callback that removes the listener.
const off = emitter.on('message', listener);
emitter.emit('message', 'first');
// Unsubscribe before emitting again.
off();
emitter.emit('message', 'second');
// Expect the listener to have only received the first event.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that off() removes a listener when given the same function reference.
*/
const testEventEmitterOffRemovesListenerByReference = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
emitter.off('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been removed before the emit.
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that off() removes all listeners for an event type when no listener is provided.
*/
const testEventEmitterOffRemovesAllListenersForEventType = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
emitter.off('message');
expect(listener).not.toHaveBeenCalled();
expect(emitter.emit('message', 'hello')).toBe(false);
expect(emitter.emit('count', 42)).toBe(false);
};
/**
* Tests that off() does nothing when given an unknown listener reference.
*/
const testEventEmitterOffIgnoresUnknownListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
// Try to remove a different function reference.
emitter.off('message', vi.fn());
emitter.emit('message', 'hello');
// Expect the original listener to still receive the event.
expect(listener).toHaveBeenCalledOnce();
};
/**
* Tests that off() does nothing when called for an event type with no listeners.
*/
const testEventEmitterOffIgnoresUnregisteredEventType = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Call off without ever registering this listener.
emitter.off('message', listener);
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that once() listeners are invoked only one time.
*/
const testEventEmitterOnceListenerFiresOnce = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
// Expect the listener to auto-unsubscribe after the first emit.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that once() can be added when regular listeners already exist for the event type.
*/
const testEventEmitterOnceWorksWithExistingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const existingListener = vi.fn();
const onceListener = vi.fn();
// Register a regular listener first so the event type already exists in the map.
emitter.on('message', existingListener);
emitter.once('message', onceListener);
emitter.emit('message', 'hello');
expect(existingListener).toHaveBeenCalledOnce();
expect(onceListener).toHaveBeenCalledOnce();
};
/**
* Tests that the off callback returned by once() removes the listener before it fires.
*/
const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const off = emitter.once('message', listener);
// Unsubscribe before the event is ever emitted.
off();
emitter.emit('message', 'hello');
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that debounced listeners do not receive the debounced event if the listener is removed.
*/
const testEventEmitterOffCancelsPendingDebouncedCallback = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const off = emitter.on('message', listener, 100);
emitter.emit('message', 'first');
off();
emitter.emit('message', 'second');
await vi.advanceTimersByTimeAsync(100);
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that removeAllListeners() clears every registered listener.
*/
const testEventEmitterRemoveAllListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
emitter.on('message', messageListener);
emitter.on('count', countListener);
emitter.removeAllListeners();
// Emit on both event types after clearing all listeners.
emitter.emit('message', 'hello');
emitter.emit('count', 1);
expect(messageListener).not.toHaveBeenCalled();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that removeAllListeners() cancels a pending debounced callback.
*/
const testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Arm a debounce timer, then clear every listener before it expires.
emitter.on('message', listener, 100);
emitter.emit('message', 'should not arrive');
emitter.removeAllListeners();
await vi.advanceTimersByTimeAsync(100);
// Cleared listeners must not receive delayed debounced delivery.
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() resolves when a matching event is emitted.
*/
const testEventEmitterWaitForResolvesOnMatch = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
// Wait until an event matches the predicate.
const waitPromise = emitter.waitFor('count', (payload) => payload === 42);
// Emit a non-matching event first, then the matching one.
emitter.emit('count', 41);
emitter.emit('count', 42);
await expect(waitPromise).resolves.toBe(42);
};
/**
* Tests that waitFor() ignores non-matching events while other listeners still receive them.
*/
const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done');
// A regular listener should still receive every emit while waitFor filters.
emitter.on('message', listener);
emitter.emit('message', 'pending');
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
expect(listener).toHaveBeenCalledTimes(2);
};
/**
* Tests that waitFor() rejects when the timeout expires.
*/
const testEventEmitterWaitForRejectsOnTimeout = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true, 100);
// Attach the rejection handler before advancing timers so the rejection is handled.
const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"');
await vi.advanceTimersByTimeAsync(100);
await assertion;
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() clears its timeout when it resolves before expiry.
*/
const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
// Register waitFor with a timeout, then resolve it before the timer fires.
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100);
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
// If clearTimeout was not called, advancing past the timeout would reject the promise.
await vi.advanceTimersByTimeAsync(100);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() removes its listener after resolving.
*/
const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true);
emitter.emit('message', 'first');
await expect(waitPromise).resolves.toBe('first');
// Register a second waitFor so we can verify the first listener was cleaned up.
const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second');
// Emit a payload that only the second waitFor should accept.
emitter.emit('message', 'ignored');
// Track whether the second waitFor resolves too early.
let resolvedEarly = false;
/* eslint-disable-next-line */
secondWaitPromise.then(() => {
resolvedEarly = true;
});
// Yield so any premature resolution would have a chance to run.
await Promise.resolve();
expect(resolvedEarly).toBe(false);
emitter.emit('message', 'second');
await expect(secondWaitPromise).resolves.toBe('second');
};
/**
* Tests that the first debounced emit does not call clearTimeout.
*/
const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const listener = vi.fn();
emitter.on('message', listener, 100);
emitter.emit('message', 'first');
// The first emit starts the debounce timer; there is nothing to clear yet.
expect(clearTimeoutSpy).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced on() listeners receive only the last payload after the debounce window.
*/
const testEventEmitterDebouncedOnListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener, 100);
// Emit several events in quick succession.
emitter.emit('message', 'first');
emitter.emit('message', 'second');
emitter.emit('message', 'third');
// Expect the listener to not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance past the debounce window.
await vi.advanceTimersByTimeAsync(100);
// Expect only the last payload to have been delivered.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that repeated debounced emits reset the debounce timer.
*/
const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('count', listener, 100);
emitter.emit('count', 1);
// Advance halfway through the debounce window and emit again.
await vi.advanceTimersByTimeAsync(50);
emitter.emit('count', 2);
await vi.advanceTimersByTimeAsync(50);
// The timer was reset, so the listener should not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance the remaining time for the reset timer to expire.
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that a debounce time of zero behaves like a normal listener.
*/
const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// A debounce time of zero should behave like a normal listener.
emitter.on('message', listener, 0);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
expect(listener).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced once() listeners fire once with the last payload.
*/
const testEventEmitterDebouncedOnceListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener, 100);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
await vi.advanceTimersByTimeAsync(100);
// Expect the debounced once listener to fire once with the last payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('second');
// Emit again after the debounce window; the once listener should stay removed.
emitter.emit('message', 'third');
await vi.advanceTimersByTimeAsync(100);
expect(listener).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that the `waitFor` method rejects if the predicate function throws
*/
const testEventEmitterWaitForRejectsOnPredicateError = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const waitPromise = emitter.waitFor('message', () => {
throw new Error('predicate error');
});
emitter.emit('message', 'hello');
await expect(waitPromise).rejects.toThrow('predicate error');
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that waitFor() removes its listener after the predicate function throws an error.
*/
const testEventEmitterWaitForRemovesListenerAfterPredicateError = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
// Create a predicate function that throws an error.
const predicate = vi.fn().mockImplementation(() => {
throw new Error('predicate error');
});
// Wait for the predicate function to throw an error.
const waitPromise = emitter.waitFor('message', predicate);
// Emit an event, expecting the predicate function to throw an error.
emitter.emit('message', 'hello');
await expect(waitPromise).rejects.toThrow('predicate error');
// Expect the predicate function to have been called once.
expect(predicate).toHaveBeenCalledTimes(1);
// A later emit must not invoke the failed waitFor predicate again.
emitter.emit('message', 'again');
// Expect the predicate function to still have been called once.
expect(predicate).toHaveBeenCalledTimes(1);
};
/**
* Tests that waitFor() clears its timeout when the predicate function throws an error.
*/
const testEventEmitterWaitForClearsTimeoutAfterPredicateError = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const waitPromise = emitter.waitFor(
'message',
(): boolean => {
throw new Error('predicate error');
},
100,
);
emitter.emit('message', 'hello');
await expect(waitPromise).rejects.toThrow('predicate error');
// The timeout scheduled for waitFor must be cleared on predicate failure.
expect(clearTimeoutSpy).toHaveBeenCalled();
// Advancing past the original timeout must not produce a second rejection path.
await vi.advanceTimersByTimeAsync(100);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
const runTests = async (): Promise<void> => {
test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners);
test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners);
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
test('EventEmitter: continues after a listener throws an error', testEventEmitterEmitContinuesAfterListenerThrows);
test('EventEmitter: emitted events cannot be mutated', testEventEmitterEmittedEventsCannotBeMutated);
test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener);
test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference);
test(
'EventEmitter: removes all listeners for an event type when off is called with no listener',
testEventEmitterOffRemovesAllListenersForEventType,
);
test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener);
test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType);
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener);
test(
'EventEmitter: debounced listeners do not receive the debounced event if the listener is removed',
testEventEmitterOffCancelsPendingDebouncedCallback,
);
test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
test(
'EventEmitter: cancels a pending debounced callback when removeAllListeners is called',
testEventEmitterRemoveAllListenersCancelsPendingDebouncedCallback,
);
test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch);
test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);
test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout);
test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve);
test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve);
test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout);
test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener);
test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits);
test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce);
test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener);
test('EventEmitter: rejects waitFor when the predicate function throws', testEventEmitterWaitForRejectsOnPredicateError);
test(
'EventEmitter: removes the waitFor listener after it rejects due to predicate error',
testEventEmitterWaitForRemovesListenerAfterPredicateError,
);
test('EventEmitter: clears the timeout when waitFor rejects due to predicate error', testEventEmitterWaitForClearsTimeoutAfterPredicateError);
};
await runTests();
+676
View File
@@ -0,0 +1,676 @@
import { expect, test, vi } from 'vitest';
import { ExponentialBackoff } from '../source/exponential-backoff.ts';
import {
ExponentialBackoffMaxRetriesHitError,
ExponentialBackoffNumberNotFiniteError,
ExponentialBackoffStoppedRetriesError,
} from '../source/errors.ts';
/**
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
*/
const validExponentialBackoffOptions = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
};
/**
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
* with library defaults (including the default 1000ms base delay) when no options are passed.
*/
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
// Fake timers let us advance time without waiting real seconds between retries.
vi.useFakeTimers();
// Pin Math.random to 0 so jitter does not reduce the default delay.
vi.spyOn(Math, 'random').mockReturnValue(0);
try {
// The wrapped function fails on its first invocation and succeeds on the second.
// That forces ExponentialBackoff.run down the retry path using default options.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('static-result');
// Call the static helper with no onError and no options — defaults apply entirely.
const promise = ExponentialBackoff.run(rejectThenResolveFn);
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
await vi.advanceTimersByTimeAsync(1_000);
// The retry should have succeeded and returned the resolved value from the mock.
await expect(promise).resolves.toBe('static-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
* with defaults, still retrying when only some fields are overridden.
*/
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('done');
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
// onError is explicitly undefined to verify the default no-op handler is used.
const result = await ExponentialBackoff.run(rejectThenResolveFn, undefined, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
});
expect(result).toBe('done');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
* the instance's stored options when no per-run options are supplied.
*/
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('instance-result');
// Options live on the instance; run(fn) should read them instead of static defaults.
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
const result = await backoff.run(rejectThenResolveFn);
expect(result).toBe('instance-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
*/
const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise<void> => {
// Always resolves — never enters the catch/retry branch.
const resolveFn = vi.fn(async () => 'success');
const onError = vi.fn();
const result = await ExponentialBackoff.run(resolveFn, onError, {
baseDelay: 0,
jitter: 0,
});
expect(result).toBe('success');
expect(resolveFn).toHaveBeenCalledOnce();
expect(onError).not.toHaveBeenCalled();
};
/**
* Tests that retries continue across multiple failures until the function eventually resolves.
*/
const testExponentialBackoffRetriesUntilSuccess = async (): Promise<void> => {
// Three invocations: two rejections then a success on the third call.
const tripleRejectFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 5,
});
expect(result).toBe('success');
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
};
/**
* Tests that the onError callback is invoked once for every failed attempt, including the last one
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
*/
const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void> => {
const error = new Error('temporary failure');
// Always rejects with the same error — we will exhaust all attempts.
const rejectFn = vi.fn().mockRejectedValue(error);
const onError = vi.fn();
// maxAttempts: 3 means three tries total, all of which will fail.
await expect(ExponentialBackoff.run(rejectFn, onError, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
})).rejects.toThrow(ExponentialBackoffMaxRetriesHitError);
expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledWith(error);
};
/**
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
* with every task error preserved in order on the cause.
*/
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise<void> => {
const firstError = new Error('first');
const lastError = new Error('last');
// Two distinct errors so we can prove both are collected, not just the last one.
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(lastError);
try {
await ExponentialBackoff.run(doubleRejectFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 2,
});
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
expect((error as ExponentialBackoffMaxRetriesHitError).cause).toEqual([ firstError, lastError ]);
}
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
* so callers always observe a consistent error type in the callback.
*/
const testExponentialBackoffWrapsNonErrorThrows = async (): Promise<void> => {
// Reject with a plain string — not an Error subclass.
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
const onError = vi.fn();
// Single attempt — we fail fast and inspect what onError received.
try {
await ExponentialBackoff.run(rejectedFn, onError, {
baseDelay: 0,
jitter: 0,
maxAttempts: 1,
});
expect.fail('Expected ExponentialBackoffMaxRetriesHitError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ExponentialBackoffMaxRetriesHitError);
const [ wrappedError ] = (error as ExponentialBackoffMaxRetriesHitError).cause as Error[];
expect(wrappedError).toBeInstanceOf(Error);
expect(wrappedError.message).toBe('not-an-error');
}
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onError.mock.calls[0][0].message).toBe('not-an-error');
};
/**
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
* and the onError callback is not called.
*/
const testExponentialBackoffRunSuccessAndAbortSignal = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and succeeds
const abortAndSucceedFn = vi.fn(({ stopRetries }) => {
stopRetries(new Error('retry me'));
return Promise.resolve('success');
});
const onErrorFn = vi.fn();
// Run the exponential backoff with the function and the onError callback
const result = await ExponentialBackoff.run(abortAndSucceedFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
});
// Expect the result to be the success message
expect(result).toBe('success');
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
// Expect the onError callback to not have been called
expect(onErrorFn).not.toHaveBeenCalled();
};
/**
* Tests that when the abort signal is aborted with an error, an ExponentialBackoffStoppedRetriesError is thrown
* with the error as the message.
*/
const testExponentialBackoffRunWithAbortSignal = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowFn = vi.fn(({ stopRetries }) => {
stopRetries(new Error('exponential backoff aborted'));
throw new Error('error message');
});
const onErrorFn = vi.fn();
// Define the expected error
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
await expect(ExponentialBackoff.run(abortAndThrowFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
})).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowFn).toHaveBeenCalledOnce();
expect(abortAndThrowFn).not.toHaveResolved();
};
/**
* Tests that when the abort signal is aborted with a string, an ExponentialBackoffStoppedRetriesError is thrown
* with the string as the message.
*/
const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowStringFn = vi.fn(({ stopRetries }) => {
stopRetries('exponential backoff aborted');
// eslint-disable-next-line
throw 'error message';
});
const onErrorFn = vi.fn();
// Define the expected error, Note that we "stopRetries" with just a string, not an error. They are treated equivalently.
const expectedError = new ExponentialBackoffStoppedRetriesError(new Error('exponential backoff aborted'));
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
await expect(ExponentialBackoff.run(abortAndThrowStringFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
})).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls[0][0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls[0][0].message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
expect(abortAndThrowStringFn).not.toHaveResolved();
};
/**
* Tests that the delay is aborted when the abort signal is activated.
*/
const testExponentialBackoffRunDelayAbortedWhenAbortSignal = async (): Promise<void> => {
vi.useFakeTimers();
try {
let abort: (reason: unknown) => void;
const taskFn = vi.fn(async ({ stopRetries }) => {
abort = stopRetries;
throw new Error('error message');
});
// Start the exponential backoff and wait for it to complete
const result = ExponentialBackoff.run(taskFn, () => {}, {
baseDelay: 1000,
jitter: 0,
maxAttempts: 0,
});
// Advance the timer by 500 (mid delay)
await vi.advanceTimersByTimeAsync(500);
// Make sure the abort function is defined (That the taskFn was called)
if (!abort!) {
throw new Error('abort is not defined');
}
// Check that the abort function is defined
expect(abort).toBeDefined();
// Abort the exponential backoff
abort?.(new Error('exponential backoff aborted'));
// Expect the result to be rejected with an ExponentialBackoffStoppedRetriesError
await expect(result).rejects.toThrow(ExponentialBackoffStoppedRetriesError);
} finally {
vi.useRealTimers();
}
};
/**
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
* as an alternative to the static helper.
*/
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
const successfullyResolve = vi.fn(async () => 42);
// from() is a convenience constructor; run() on the result should behave like the static path.
const backoff = ExponentialBackoff.from({
baseDelay: 0,
jitter: 0,
});
const result = await backoff.run(successfullyResolve);
expect(result).toBe(42);
expect(successfullyResolve).toHaveBeenCalledOnce();
};
/**
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
*/
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise<void> => {
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
const tripleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockRejectedValueOnce(new Error('attempt 3'))
.mockResolvedValueOnce('eventually');
const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 0,
});
expect(result).toBe('eventually');
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
};
/**
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
* (with jitter disabled so the math is exact).
*/
const testExponentialBackoffIncreasesDelayExponentially = async (): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
baseDelay: 100,
growthRate: 2,
jitter: 0,
maxDelay: 10_000,
maxAttempts: 5,
});
// Attempt 0 fires synchronously on the first microtask tick.
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
await vi.advanceTimersByTimeAsync(100);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
await vi.advanceTimersByTimeAsync(200);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
*/
const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
baseDelay: 1_000,
growthRate: 4,
jitter: 0,
maxDelay: 2_000,
maxAttempts: 5,
});
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
await vi.advanceTimersByTimeAsync(1_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
await vi.advanceTimersByTimeAsync(2_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
*/
const testExponentialBackoffAppliesJitter = async (): Promise<void> => {
vi.useFakeTimers();
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
vi.spyOn(Math, 'random').mockReturnValue(1);
try {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, {
baseDelay: 1_000,
growthRate: 1,
jitter: 0.1,
maxDelay: 10_000,
maxAttempts: 3,
});
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
await vi.advanceTimersByTimeAsync(899);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
*/
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
const validCases = [
validExponentialBackoffOptions,
{
...validExponentialBackoffOptions,
maxDelay: 0,
maxAttempts: 0,
baseDelay: 0,
growthRate: 0,
jitter: 0,
},
{
...validExponentialBackoffOptions,
jitter: 1,
},
] as const;
for (const options of validCases) {
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
*/
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
// Define our test cases with each value being less than 0
const negativeCases = [
{ field: 'maxDelay', value: -1 },
{ field: 'maxAttempts', value: -1 },
{ field: 'baseDelay', value: -1 },
{ field: 'growthRate', value: -1 },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of negativeCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Exponential backoff option "${field}" is too small. Must be at least 0`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
*/
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
// Define our test cases with each value being less than 0 or greater than 1
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
// Iterate through the test cases and expect an error to be thrown
for (const { value } of invalidJitterCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
jitter: value,
})).toThrow('Exponential backoff option "jitter" is out of bounds. Must be between 0 and 1');
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
*/
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
// Define our test cases with each value being Infinity
const nonFiniteCases = [
{ field: 'maxDelay', value: Infinity },
{ field: 'maxAttempts', value: Infinity },
{ field: 'baseDelay', value: Infinity },
{ field: 'growthRate', value: Infinity },
{ field: 'jitter', value: Infinity },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nonFiniteCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-integer values.
*/
const testExponentialBackoffValidateOptionsRejectsNonIntegerValues = (): void => {
// Define our test cases with each value being a non-integer
const nonIntegerCases = [{ field: 'maxAttempts', value: 1.5 }] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nonIntegerCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be an integer`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
*/
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
// Define our test cases with each value being NaN
const nanCases = [
{ field: 'maxDelay', value: Number.NaN },
{ field: 'maxAttempts', value: Number.NaN },
{ field: 'baseDelay', value: Number.NaN },
{ field: 'growthRate', value: Number.NaN },
{ field: 'jitter', value: Number.NaN },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nanCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Exponential backoff option "${field}" is invalid. Must be a finite number`);
}
};
/** Tests that passing undefined into the constructor does not cause an error during spread */
const testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread = async (): Promise<void> => {
const options = {
baseDelay: undefined,
growthRate: undefined,
jitter: undefined,
maxDelay: undefined,
maxAttempts: undefined,
};
// We expect an error during validation as undefined is not a finite number, not an issue with the spread operator
// @ts-expect-error - Passing undefined is allowed if the exactOptionalPropertyTypes option is set to false in TS Compiler options.
expect(() => new ExponentialBackoff(options)).toThrow(ExponentialBackoffNumberNotFiniteError);
};
const runTests = async (): Promise<void> => {
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
test('ExponentialBackoff.run: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt);
test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess);
test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure);
test(
'ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted',
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted,
);
test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows);
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError);
test('ExponentialBackoff: aborts with abort signal, skipping delay', testExponentialBackoffRunDelayAbortedWhenAbortSignal);
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay);
test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter);
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
test('ExponentialBackoff.validateOptions: rejects non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues);
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
test('ExponentialBackoff: constructor does not cause an error during spread', testExponentialBackoffConstructorDoesNotCauseErrorDuringSpread);
};
await runTests();
+70
View File
@@ -0,0 +1,70 @@
import { expect, test, vi } from 'vitest';
import { tryAsync } from '../source/misc.ts';
/** Spy used to confirm the wrapped async function ran successfully. */
const successFlagFn = vi.fn();
/** Spy used to confirm the error callback was invoked on failure. */
const errorFlagFn = vi.fn();
/**
* Tests that tryAsync invokes the function and skips the error callback on success.
*/
const testTryAsyncCallsFunctionOnSuccess = async (): Promise<void> => {
// Reset spies so prior test runs do not affect call counts.
vi.clearAllMocks();
const successFn = async (): Promise<void> => {
successFlagFn();
};
await tryAsync(successFn);
// The wrapped function should run and no error handler should be called.
expect(successFlagFn).toHaveBeenCalledOnce();
expect(errorFlagFn).not.toHaveBeenCalled();
};
/**
* Tests that tryAsync invokes the error callback when the function throws.
*/
const testTryAsyncCallsErrorCallbackOnFailure = async (): Promise<void> => {
vi.clearAllMocks();
const errorFn = async (): Promise<void> => {
throw new Error('test');
};
await tryAsync(errorFn, errorFlagFn);
// The success path should not run; the error callback should receive the failure.
expect(successFlagFn).not.toHaveBeenCalled();
expect(errorFlagFn).toHaveBeenCalledOnce();
};
/**
* Tests that tryAsync wraps non-Error throws in Error instances before calling the error callback.
*/
const testTryAsyncConvertsNonErrorThrows = async (): Promise<void> => {
vi.clearAllMocks();
const errorFn = async (): Promise<void> => {
/* eslint-disable-next-line */
throw 'test';
};
await tryAsync(errorFn, errorFlagFn);
// Non-Error throws must be normalized to Error before onError is called.
expect(successFlagFn).not.toHaveBeenCalled();
expect(errorFlagFn).toHaveBeenCalledOnce();
expect(errorFlagFn).toHaveBeenCalledWith(new Error('test'));
};
const runTests = async (): Promise<void> => {
test('tryAsync: calls the function and skips the error callback on success', testTryAsyncCallsFunctionOnSuccess);
test('tryAsync: calls the error callback when the function fails', testTryAsyncCallsErrorCallbackOnFailure);
test('tryAsync: converts non-Error throws to Error instances', testTryAsyncConvertsNonErrorThrows);
};
await runTests();
+1 -1
View File
@@ -99,7 +99,7 @@ const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
/* eslint-disable-next-line */ /* eslint-disable-next-line */
for await (const _value of iterator) { for await (const _value of iterator) {
} }
} catch (error) { } catch {
failureFlag(); failureFlag();
} }
}; };
+65
View File
@@ -0,0 +1,65 @@
export type SseTestStreamOptions = {
/** Milliseconds to wait before enqueueing each chunk after the first. */
chunkDelayMs?: number;
/**
* When true, closes the body as soon as all initial chunks have been sent.
* Use this to simulate a server that sends events and then ends the stream.
*/
closeWhenDone?: boolean;
};
/**
* Test double for an SSE HTTP response body.
*
* Enqueues fixture chunks in order and stays open until {@link close} is called,
* matching real servers that keep the connection alive after each event's trailing
* `\n\n` frame boundary.
*/
export class SseTestStream {
readonly stream: ReadableStream<Uint8Array>;
private controller: ReadableStreamDefaultController<Uint8Array> | null = null;
private closed = false;
/**
* @param chunks - Fixture `raw` strings, whole or split, to simulate chunk boundaries.
* @param options - Delivery timing and optional auto-close after the initial chunks.
*/
constructor(chunks: string[], options: SseTestStreamOptions = {}) {
const { chunkDelayMs = 0, closeWhenDone = false } = options;
const encoder = new TextEncoder();
this.stream = new ReadableStream({
start: async (controller): Promise<void> => {
this.controller = controller;
for (let i = 0; i < chunks.length; i++) {
if (chunkDelayMs > 0 && i > 0) {
await new Promise((resolve) => setTimeout(resolve, chunkDelayMs));
}
if (this.closed) return;
controller.enqueue(encoder.encode(chunks[i]!));
}
if (closeWhenDone) {
this.close();
}
},
});
}
/**
* Ends the HTTP body the way a server closing the SSE connection would.
*/
close(): void {
if (this.closed) return;
this.closed = true;
this.controller?.close();
}
}
+633
View File
@@ -0,0 +1,633 @@
import { expect, test, vi, type Mock } from 'vitest';
import { SSESession } from '../../source/sse-session/sse-session.ts';
import { ExponentialBackoff } from '../../source/exponential-backoff.ts';
import type { SSESessionOptions, SSEvent } from '../../source/sse-session/types.ts';
import { SseTestStream, type SseTestStreamOptions } from './helpers/sse-stream.ts';
import { priceOracleEvents, storageEvents } from './fixtures/events.fixtures.ts';
import { ExponentialBackoffMaxRetriesHitError, HTTPError, ResponseBodyNullError } from '../../source/errors.ts';
/** URL passed to every session under test. */
const EVENTS_URL = '/events';
/** Headers required for a valid SSE response in these tests. */
const SSE_HEADERS = { 'Content-Type': 'text/event-stream' };
type FetchFn = SSESessionOptions['fetch'];
/**
* Builds a minimal ExponentialBackoff so reconnect and retry paths finish quickly in tests.
* Real production delays would make vi.waitFor-based assertions time out.
*
* @param maxAttempts - Maximum retry attempts; defaults to 1.
*/
const testRetry = (maxAttempts = 1): ExponentialBackoff => {
return new ExponentialBackoff({
baseDelay: 1,
maxDelay: 1,
maxAttempts,
growthRate: 1,
jitter: 0,
});
};
/**
* Wraps fixture chunks in a Response backed by {@link SseTestStream}.
* SseTestStream simulates a real HTTP body: chunks arrive over time and the stream
* can optionally close itself when all chunks are sent.
*
* @param chunks - Raw SSE payload strings to stream.
* @param options - Optional stream timing and close behavior.
*/
const sseFetchResponse = (chunks: string[], options: SseTestStreamOptions = {}): Response => {
return new Response(new SseTestStream(chunks, options).stream, {
status: 200,
headers: SSE_HEADERS,
});
};
/**
* Creates a vitest mock fetch that delegates to the given responder.
* SSESession requires fetch injection so tests never hit the network.
*
* @param responder - Function that returns the Response for each fetch call.
*/
const createFetchMock = (responder: (url: string, init: RequestInit) => Response | Promise<Response>): Mock<FetchFn> => {
return vi.fn(async (url: string, init: RequestInit) => responder(url, init));
};
/**
* Session defaults that disable reconnect noise unless a test opts in.
* attemptReconnect: false — transport errors should not auto-retry by default.
* persistent: false — server closing the stream should close the message iterator.
*/
const defaultSessionOptions: Partial<SSESessionOptions> = {
attemptReconnect: false,
persistent: false,
retry: testRetry(),
};
/**
* Creates an SSESession wired to the injected fetch mock.
* SSESession.create immediately calls connect(), so fetch is invoked during creation.
*
* @param fetch - Mock fetch implementation.
* @param options - Per-test session overrides merged on top of defaults.
*/
const createSession = async (fetch: FetchFn, options: Partial<SSESessionOptions> = {}): Promise<SSESession> => {
return SSESession.create(EVENTS_URL, {
...defaultSessionOptions,
onError: vi.fn(),
...options,
fetch,
});
};
/**
* Runs a callback against a session and always disconnects afterward.
* Most tests use this so session lifecycle (connect on create, disconnect on exit)
* is consistent and resources are not leaked between cases.
*
* @param fetch - Mock fetch for the session.
* @param options - Session options.
* @param run - Test body receiving the connected session.
*/
const withSession = async <T>(fetch: FetchFn, options: Partial<SSESessionOptions>, run: (session: SSESession) => Promise<T>): Promise<T> => {
const session = await createSession(fetch, options);
try {
return await run(session);
} finally {
await session.disconnect();
}
};
/**
* Reads up to `count` messages from the session's async iterator.
* Breaking out of the for-await loop early leaves the underlying stream open,
* which is intentional for tests that inspect post-read session state.
*
* @param session - Connected SSE session.
* @param count - Number of events to collect before stopping.
*/
const readMessages = async (session: SSESession, count: number): Promise<SSEvent[]> => {
const events: SSEvent[] = [];
for await (const event of session.messages) {
events.push(event);
if (events.length >= count) {
break;
}
}
return events;
};
/**
* Builds a ReadableStream that emits one chunk then errors after a delay.
* Simulates a mid-stream network failure: the client receives partial data,
* then the connection drops before the server finishes sending.
*
* @param raw - SSE payload to enqueue before the error.
* @param delayMs - Milliseconds to wait before erroring (gives the parser time to process the chunk).
*/
const failingStreamAfter = (raw: string, delayMs = 50): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller): Promise<void> {
controller.enqueue(encoder.encode(raw));
await new Promise((resolve) => setTimeout(resolve, delayMs));
controller.error(new Error('network failure'));
},
});
};
/**
* Scenario: a client opens a standard GET SSE connection.
* Verifies the session passes the correct fetch options for a spec-compliant SSE request.
*/
const testSseSessionConnectCallsFetchWithExpectedOptions = async (): Promise<void> => {
// Return one valid storage fixture event so connect succeeds and the stream stays open briefly.
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
// withSession → createSession → SSESession.create → connect → fetchMock is called once.
await withSession(fetchMock, {}, async () => {
expect(fetchMock).toHaveBeenCalledWith(
EVENTS_URL,
expect.objectContaining({
method: 'GET',
cache: 'no-store',
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
}),
}),
);
// While the stream is active the abort signal must not yet be triggered.
expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(false);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: a client connects and the application wants a lifecycle callback when the stream is ready.
* Verifies onConnected fires after the transport is established.
*/
const testSseSessionConnectInvokesOnConnected = async (): Promise<void> => {
const onConnected = vi.fn();
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, { onConnected }, async () => {
expect(onConnected).toHaveBeenCalledOnce();
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: a client is already connected and something calls connect() again (e.g. a duplicate init).
* Verifies the session does not open a second HTTP transport.
*/
const testSseSessionConnectDoesNotOpenSecondTransport = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, {}, async (session) => {
// Pull one event so the first transport is fully established and reading.
await readMessages(session, 1);
// Idempotent connect — should be a no-op at the fetch layer.
await session.connect();
expect(fetchMock).toHaveBeenCalledOnce();
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the application attaches auth or other headers via onRequest before each fetch.
* Verifies mutations from onRequest reach the actual fetch call.
*/
const testSseSessionConnectPassesOnRequestMutations = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(
fetchMock,
{
// onRequest runs during connect and can rewrite headers/body before fetch sees them.
onRequest: async (request) => ({
...request,
headers: { ...request.headers, Authorization: 'Bearer test-token' },
}),
},
async () => {
expect(fetchMock).toHaveBeenCalledWith(
EVENTS_URL,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token',
}),
}),
);
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: some SSE endpoints require POST with a form body instead of a plain GET.
* Verifies method and body are forwarded to fetch.
*/
const testSseSessionConnectSendsPostBody = async (): Promise<void> => {
const body = new FormData();
body.set('topic', 'prices');
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(
fetchMock,
{
method: 'POST',
body,
},
async () => {
expect(fetchMock).toHaveBeenCalledWith(
EVENTS_URL,
expect.objectContaining({
method: 'POST',
body,
}),
);
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: a single long-lived connection delivers many events from different domains (LLM, oracle, storage).
* Verifies the parser and session deliver every fixture event in order through one stream.
*/
const testSseSessionDeliversMultipleFixtureEvents = async (): Promise<void> => {
const fixtures = [ ...priceOracleEvents, ...storageEvents ];
const expected = fixtures.flatMap(({ parsed }) => parsed ?? []);
// All raw payloads are concatenated into one SseTestStream response.
const fetchMock = createFetchMock(() => sseFetchResponse(fixtures.map(({ raw }) => raw)));
try {
await withSession(fetchMock, {}, async (session) => {
const received = await readMessages(session, expected.length);
expect(received).toEqual(expected);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: application code listens via session.on('message') instead of the async iterator.
* Verifies the EventEmitter path receives the same parsed events as the iterator.
*/
const testSseSessionEmitsMessageEvents = async (): Promise<void> => {
const { raw, parsed } = storageEvents[0]!;
const emitted: SSEvent[] = [];
// ':\n\n' is an SSE comment/heartbeat; chunkDelayMs forces it to arrive as a separate chunk
// so incremental parsing is exercised before the real data frame.
const fetchMock = createFetchMock(() => sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 50 }));
// Use createSession directly (not withSession) so we control disconnect timing in finally.
const session = await createSession(fetchMock, {});
try {
session.on('message', (event) => emitted.push(event));
await readMessages(session, 1);
expect(emitted).toEqual([ parsed![0] ]);
} finally {
await session.disconnect();
vi.restoreAllMocks();
}
};
/**
* Scenario: network chunks split an SSE frame at an arbitrary byte boundary.
* Verifies the internal parser buffers partial data and still emits a complete event.
*/
const testSseSessionParsesEventSplitAcrossChunks = async (): Promise<void> => {
const { raw, parsed } = storageEvents[0]!;
const mid = Math.floor(raw.length / 2);
// SseTestStream sends each array element as a separate chunk — the event is cut in half.
const fetchMock = createFetchMock(() => sseFetchResponse([ raw.slice(0, mid), raw.slice(mid) ]));
try {
await withSession(fetchMock, {}, async (session) => {
const [ event ] = await readMessages(session, 1);
expect(event).toEqual(parsed![0]);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: server closes the stream after one event and the client is not in persistent mode.
* Verifies the message iterator closes and onDisconnected fires.
*/
const testSseSessionClosesOnServerCloseWhenNotPersistent = async (): Promise<void> => {
// closeWhenDone: true makes SseTestStream end the body after sending the chunk.
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }));
const disconnected = vi.fn();
try {
// persistent defaults to false — terminal server close should shut down messages.
await withSession(fetchMock, { onDisconnected: disconnected }, async (session) => {
await readMessages(session, 1);
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
expect(disconnected).toHaveBeenCalled();
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: server closes the stream, the message iterator closes, then the app manually reconnects.
* Verifies a second fetch opens and events flow again (non-persistent manual reconnect path).
*/
const testSseSessionReconnectsAfterTerminalClose = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }));
// Override the default responder: first fetch closes after one event, second stays open.
fetchMock
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ], { closeWhenDone: true }))
.mockResolvedValueOnce(sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, {}, async (session) => {
await readMessages(session, 1);
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
// App-initiated reconnect after the first stream ended.
await session.connect();
const [ event ] = await readMessages(session, 1);
expect(event).toEqual(storageEvents[0]!.parsed![0]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: persistent client — server closes the stream but the session should auto-reconnect.
* Verifies two fetch calls and events from both streams arrive on the same message iterator.
*/
const testSseSessionPersistentReconnectsOnServerClose = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }));
fetchMock
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[0]!.raw ], { closeWhenDone: true }))
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ]));
try {
await withSession(
fetchMock,
{
persistent: true,
retry: testRetry(2),
},
async (session) => {
const [ first, second ] = await readMessages(session, 2);
expect(first).toEqual(priceOracleEvents[0]!.parsed![0]);
expect(second).toEqual(priceOracleEvents[1]!.parsed![0]);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: mid-stream network error with attemptReconnect enabled.
* Verifies the session fetches again and the iterator continues delivering events.
*/
const testSseSessionReconnectsOnTransportError = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ priceOracleEvents[0]!.raw ]));
fetchMock
// First connection: delivers one event then the stream errors (failingStreamAfter).
.mockResolvedValueOnce(new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS }))
// Second connection: clean stream with the next fixture event.
.mockResolvedValueOnce(sseFetchResponse([ priceOracleEvents[1]!.raw ]));
try {
await withSession(
fetchMock,
{
attemptReconnect: true,
retry: testRetry(2),
},
async (session) => {
const [ beforeError, recovered ] = await readMessages(session, 2);
expect(beforeError).toEqual(priceOracleEvents[0]!.parsed![0]);
expect(recovered).toEqual(priceOracleEvents[1]!.parsed![0]);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
},
);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: mid-stream network error with attemptReconnect disabled (default).
* Verifies the session terminates: messages close, onError runs, and an error event is emitted.
*/
const testSseSessionClosesOnTransportErrorWhenNotReconnecting = async (): Promise<void> => {
const onError = vi.fn();
const response = new Response(failingStreamAfter(priceOracleEvents[0]!.raw), { status: 200, headers: SSE_HEADERS });
const fetchMock = createFetchMock(() => response);
try {
await withSession(fetchMock, { onError }, async (session) => {
const errorEvent = new Promise<Error>((resolve) => session.once('error', resolve));
await vi.waitFor(() => expect(session.messages.closed).toBe(true));
expect(onError).toHaveBeenCalled();
await expect(errorEvent).resolves.toBeInstanceOf(Error);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the user aborts an active connection (e.g. navigation away) but may reconnect later.
* Verifies onDisconnected fires, fetch is aborted, but the message iterator stays open.
*/
const testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen = async (): Promise<void> => {
const onDisconnected = vi.fn();
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
try {
await withSession(fetchMock, { onDisconnected }, async (session) => {
await readMessages(session, 1);
await session.abort();
expect(onDisconnected).toHaveBeenCalled();
expect(session.messages.closed).toBe(false);
});
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the application fully tears down the session (logout, component unmount, etc.).
* Verifies disconnect closes the message iterator and emits the closed event.
*/
const testSseSessionDisconnectClosesMessagesAndEmitsClosed = async (): Promise<void> => {
const fetchMock = createFetchMock(() => sseFetchResponse([ storageEvents[0]!.raw ]));
const session = await createSession(fetchMock, {});
const closed = new Promise<void>((resolve) => session.once('closed', () => resolve()));
try {
await session.disconnect();
expect(session.messages.closed).toBe(true);
await expect(closed).resolves.toBeUndefined();
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: the server returns a non-2xx HTTP status (500).
* Verifies create rejects, onError is invoked, and the error is a real Error instance.
*/
const testSseSessionHttpErrorCallsOnErrorAndThrows = async (): Promise<void> => {
const onError = vi.fn();
const fetchMock = createFetchMock(() =>
new Response('nope', {
status: 500,
statusText: 'Internal Server Error',
}));
const expectedError = new ExponentialBackoffMaxRetriesHitError([ new HTTPError(500, 'nope') ]);
try {
await expect(createSession(fetchMock, { onError })).rejects.toThrow(expectedError);
expect(onError).toHaveBeenCalled();
expect(onError.mock.calls[0]?.[0]).toBeInstanceOf(Error);
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: fetch returns 200 but with a null body (misconfigured proxy or server bug).
* Verifies create rejects because SSE requires a readable stream body.
*/
const testSseSessionRejectsWhenResponseBodyIsNull = async (): Promise<void> => {
const onError = vi.fn();
const fetchMock = createFetchMock(() => new Response(null, { status: 200, headers: SSE_HEADERS }));
const expectedError = new ExponentialBackoffMaxRetriesHitError([ new ResponseBodyNullError() ]);
try {
await expect(createSession(fetchMock, { onError, retry: testRetry() })).rejects.toThrow(expectedError);
expect(onError).toHaveBeenCalled();
} finally {
vi.restoreAllMocks();
}
};
/**
* Scenario: SSE spec resume — after receiving an event with an id, reconnect should send Last-Event-ID.
* Verifies the second fetch includes the id from the first event (1234 in storageEvents[0]).
*/
const testSseSessionSendsLastEventIdOnReconnect = async (): Promise<void> => {
const { raw } = storageEvents[0]!;
let reconnectHeaders: Record<string, string> | undefined;
const fetchMock = createFetchMock(() => sseFetchResponse([ raw ]));
fetchMock
// First connection: heartbeat chunk, then the event (with id: 1234), then server closes.
.mockResolvedValueOnce(sseFetchResponse([ ':\n\n', raw ], { chunkDelayMs: 10, closeWhenDone: true }))
// Second connection: capture whatever headers the reconnect logic attached.
.mockImplementationOnce(async (_url, init) => {
reconnectHeaders = init.headers as Record<string, string>;
return sseFetchResponse([]);
});
const session = await createSession(fetchMock, { persistent: true, retry: testRetry(2) });
try {
// Registers an onRequest hook that copies the last seen event id into reconnect headers.
await SSESession.addLastEventIdReconnect(session);
await readMessages(session, 1);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(reconnectHeaders?.['Last-Event-ID']).toBe('1234');
} finally {
await session.disconnect();
vi.restoreAllMocks();
}
};
const runTests = async (): Promise<void> => {
test('SSESession.connect: calls injected fetch with method, headers, and abort signal', testSseSessionConnectCallsFetchWithExpectedOptions);
test('SSESession.connect: invokes onConnected when the stream is established', testSseSessionConnectInvokesOnConnected);
test('SSESession.connect: does not open a second transport when connect is called again', testSseSessionConnectDoesNotOpenSecondTransport);
test('SSESession.connect: passes request mutations from onRequest to fetch', testSseSessionConnectPassesOnRequestMutations);
test('SSESession.connect: sends POST bodies for POST-based SSE endpoints', testSseSessionConnectSendsPostBody);
test('SSESession: delivers multiple fixture events through a single session', testSseSessionDeliversMultipleFixtureEvents);
test('SSESession: emits message events for incoming SSE frames', testSseSessionEmitsMessageEvents);
test('SSESession: parses a fixture event split across chunk boundaries', testSseSessionParsesEventSplitAcrossChunks);
test('SSESession: closes messages and emits disconnected when persistent is false', testSseSessionClosesOnServerCloseWhenNotPersistent);
test('SSESession: opens a new message stream after reconnecting following a terminal close', testSseSessionReconnectsAfterTerminalClose);
test('SSESession: reconnects when the server closes the stream and persistent is true', testSseSessionPersistentReconnectsOnServerClose);
test('SSESession: reconnects when attemptReconnect is true', testSseSessionReconnectsOnTransportError);
test('SSESession: closes messages and emits error when attemptReconnect is false', testSseSessionClosesOnTransportErrorWhenNotReconnecting);
test('SSESession.abort: aborts fetch, emits disconnected, and keeps messages open', testSseSessionAbortEmitsDisconnectedAndKeepsMessagesOpen);
test('SSESession.disconnect: closes messages and emits closed', testSseSessionDisconnectClosesMessagesAndEmitsClosed);
test('SSESession: calls onError, emits error, and throws from create on HTTP error', testSseSessionHttpErrorCallsOnErrorAndThrows);
test('SSESession: rejects when the response body is null', testSseSessionRejectsWhenResponseBodyIsNull);
test('SSESession: sends Last-Event-ID on reconnect after receiving an event with an id', testSseSessionSendsLastEventIdOnReconnect);
};
await runTests();
+1
View File
@@ -7,6 +7,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"exactOptionalPropertyTypes": true,
"noEmit": true, "noEmit": true,
"declaration": true, "declaration": true,
"declarationMap": true "declarationMap": true