Format with prettier. Use screen mode for invitation import - dialog mode is broken.

This commit is contained in:
2026-03-23 10:15:48 +00:00
parent 7fd89c5663
commit b475b23beb
47 changed files with 1718 additions and 1098 deletions

View File

@@ -2,7 +2,7 @@
* Handles BCH Mnemonic parsing to/from URL form.
* Pulled directly from the old stack package.
*/
import { z } from 'zod';
import { z } from "zod";
export type BCHMnemonicURLRaw = {
entropy: Uint8Array;
@@ -17,7 +17,7 @@ export type BCHMnemonicURLRaw = {
* Handles BCHMnemonic URLs
*/
export class BCHMnemonicURL {
static PROTOCOL = 'bch-mnemonic';
static PROTOCOL = "bch-mnemonic";
/**
* Check if a URL is a valid wallet backup URL
@@ -48,7 +48,7 @@ export class BCHMnemonicURL {
}
// Decode the entropy.
const entropy = new Uint8Array(Buffer.from(url.pathname, 'base64'));
const entropy = new Uint8Array(Buffer.from(url.pathname, "base64"));
// Pick out our encoding keys from the URL
const params = BCHMnemonicURL.schema.parse(
@@ -74,7 +74,7 @@ export class BCHMnemonicURL {
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
// Add entropy validation
if (!raw.entropy || raw.entropy.length === 0) {
throw new Error('Invalid entropy: must be non-empty');
throw new Error("Invalid entropy: must be non-empty");
}
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
@@ -100,7 +100,7 @@ export class BCHMnemonicURL {
*/
toURL(): string {
// Conver the mnemonic words into the entropy used to derive the mnemonic words
const entropyBase64 = Buffer.from(this.raw.entropy).toString('base64');
const entropyBase64 = Buffer.from(this.raw.entropy).toString("base64");
// Create a new URL object with the prefix and the base64 encoded mnemonic
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
@@ -135,24 +135,24 @@ export class BCHMnemonicURL {
}
static ENCODING_KEYS = {
language: 'l',
passphrase: 'p',
comment: 'c',
path: 'd',
startHeight: 'h',
language: "l",
passphrase: "p",
comment: "c",
path: "d",
startHeight: "h",
} as const;
static SUPPORTED_LANGUAGES = [
'en',
'zh-CN',
'zh-TW',
'ja',
'es',
'pt',
'ko',
'fr',
'it',
'cs',
"en",
"zh-CN",
"zh-TW",
"ja",
"es",
"pt",
"ko",
"fr",
"it",
"cs",
] as const;
/**

View File

@@ -71,7 +71,7 @@ export class ExponentialBackoff {
fn: () => Promise<T>,
onError = (_error: Error) => {},
): Promise<T> {
let lastError: Error = new Error('Exponential backoff: Max retries hit');
let lastError: Error = new Error("Exponential backoff: Max retries hit");
let attempt = 0;

View File

@@ -1,13 +1,13 @@
/**
* Extended JSON encoding/decoding utilities.
* Handles BigInt and Uint8Array serialization for communication with sync-server.
*
*
* TODO: These are intended as temporary stand-ins until this functionality has been implemented directly in LibAuth.
* We are doing this so that we may better standardize with the rest of the BCH eco-system in future.
* See: https://github.com/bitauth/libauth/pull/108
*/
import { binToHex, hexToBin } from '@bitauth/libauth';
import { binToHex, hexToBin } from "@bitauth/libauth";
/**
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
@@ -15,7 +15,7 @@ import { binToHex, hexToBin } from '@bitauth/libauth';
* @returns The replaced value as an ExtJSON string, or the original value
*/
export const extendedJsonReplacer = function (value: unknown): unknown {
if (typeof value === 'bigint') {
if (typeof value === "bigint") {
return `<bigint: ${value.toString()}n>`;
} else if (value instanceof Uint8Array) {
return `<Uint8Array: ${binToHex(value)}>`;
@@ -36,7 +36,7 @@ export const extendedJsonReviver = function (value: unknown): unknown {
// Only perform a check if the value is a string.
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
if (typeof value === 'string') {
if (typeof value === "string") {
// Check if this value matches an Extended JSON encoded bigint.
const bigintMatch = value.match(bigIntRegex);
if (bigintMatch) {
@@ -70,7 +70,7 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
// If this is an object type (and it is not null - which is technically an "object")...
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
if (
typeof value === 'object' &&
typeof value === "object" &&
value !== null &&
!ArrayBuffer.isView(value)
) {
@@ -83,7 +83,9 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
const encodedObject: Record<string, unknown> = {};
// Iterate through each entry and encode it to extended JSON.
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
for (const [key, valueToEncode] of Object.entries(
value as Record<string, unknown>,
)) {
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
}
@@ -104,7 +106,7 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
// If this is an object type (and it is not null - which is technically an "object")...
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
if (
typeof value === 'object' &&
typeof value === "object" &&
value !== null &&
!ArrayBuffer.isView(value)
) {
@@ -117,7 +119,9 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
const decodedObject: Record<string, unknown> = {};
// Iterate through each entry and decode it from extended JSON.
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
for (const [key, valueToEncode] of Object.entries(
value as Record<string, unknown>,
)) {
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
}

View File

@@ -1,8 +1,22 @@
import type { HistoryItem, HistoryInvitationItem, HistoryUtxoItem } from '../services/history.js';
import type {
HistoryItem,
HistoryInvitationItem,
HistoryUtxoItem,
} from "../services/history.js";
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
export type HistoryColorName =
| "info"
| "warning"
| "success"
| "error"
| "muted"
| "text";
export type HistoryRowType = 'invitation' | 'invitation_input' | 'invitation_output' | 'utxo';
export type HistoryRowType =
| "invitation"
| "invitation_input"
| "invitation_output"
| "utxo";
export interface HistoryDisplayRow {
id: string;
@@ -20,14 +34,16 @@ export function formatHistoryDate(timestamp?: number): string | undefined {
return new Date(timestamp).toLocaleDateString();
}
export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow[] {
export function buildHistoryDisplayRows(
items: HistoryItem[],
): HistoryDisplayRow[] {
const rows: HistoryDisplayRow[] = [];
for (const item of items) {
if (item.kind === 'invitation') {
if (item.kind === "invitation") {
rows.push({
id: item.id,
type: 'invitation',
type: "invitation",
label: item.description,
timestamp: item.createdAtTimestamp,
isNested: false,
@@ -35,10 +51,13 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
});
for (const input of item.inputs) {
const satsPrefix = input.valueSatoshis !== undefined ? `${input.valueSatoshis.toLocaleString()} sats ` : '';
const satsPrefix =
input.valueSatoshis !== undefined
? `${input.valueSatoshis.toLocaleString()} sats `
: "";
rows.push({
id: `${item.id}-input-${input.id}`,
type: 'invitation_input',
type: "invitation_input",
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
description: input.description,
isNested: true,
@@ -50,8 +69,11 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
for (const output of item.outputs) {
rows.push({
id: `${item.id}-output-${output.id}`,
type: 'invitation_output',
label: output.valueSatoshis !== undefined ? `${output.valueSatoshis.toLocaleString()} sats` : 'Output',
type: "invitation_output",
label:
output.valueSatoshis !== undefined
? `${output.valueSatoshis.toLocaleString()} sats`
: "Output",
description: output.description,
isNested: true,
utxo: output,
@@ -64,8 +86,11 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
rows.push({
id: item.id,
type: 'utxo',
label: item.valueSatoshis !== undefined ? `${item.valueSatoshis.toLocaleString()} sats` : 'UTXO',
type: "utxo",
label:
item.valueSatoshis !== undefined
? `${item.valueSatoshis.toLocaleString()} sats`
: "UTXO",
description: item.description,
isNested: false,
utxo: item,
@@ -75,18 +100,21 @@ export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow
return rows;
}
export function getHistoryItemColorName(row: HistoryDisplayRow, isSelected: boolean = false): HistoryColorName {
if (isSelected) return 'info';
export function getHistoryItemColorName(
row: HistoryDisplayRow,
isSelected: boolean = false,
): HistoryColorName {
if (isSelected) return "info";
switch (row.type) {
case 'invitation':
return 'text';
case 'invitation_input':
return 'error';
case 'invitation_output':
return 'success';
case 'utxo':
return row.utxo?.reserved ? 'warning' : 'success';
case "invitation":
return "text";
case "invitation_input":
return "error";
case "invitation_output":
return "success";
case "utxo":
return row.utxo?.reserved ? "warning" : "success";
default:
return 'text';
return "text";
}
}

View File

@@ -1,5 +1,5 @@
import type { XOTemplate, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { Invitation } from '../services/invitation.js';
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
import type { Invitation } from "../services/invitation.js";
export interface SelectableUtxoLike {
outpointTransactionHash: string;
@@ -16,14 +16,17 @@ export const hasMissingRequirements = (missingRequirements: {
roles?: Record<string, unknown>;
}): boolean => {
return (
(missingRequirements.variables?.length ?? 0) > 0
|| (missingRequirements.inputs?.length ?? 0) > 0
|| (missingRequirements.outputs?.length ?? 0) > 0
|| (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0)
(missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0)
);
};
export const isInvitationRequirementsComplete = async (invitation: Invitation): Promise<boolean> => {
export const isInvitationRequirementsComplete = async (
invitation: Invitation,
): Promise<boolean> => {
const missingRequirements = await invitation.getMissingRequirements();
return !hasMissingRequirements(missingRequirements);
};
@@ -34,7 +37,7 @@ export const resolveActionRoles = (
rolesFromNavigation?: string[],
): string[] => {
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
return [ ...new Set(rolesFromNavigation) ];
return [...new Set(rolesFromNavigation)];
}
if (!template || !actionIdentifier) return [];
@@ -43,7 +46,7 @@ export const resolveActionRoles = (
.filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role);
return [ ...new Set(roleIds) ];
return [...new Set(roleIds)];
};
export const roleRequiresInputs = (
@@ -61,26 +64,38 @@ export const roleRequiresInputs = (
// Some templates specify slot/input requirements at action.requirements.roles
// instead of role.requirements. Respect those as well.
const roleRequirement = action.requirements?.roles?.find((requirement) => requirement.role === roleIdentifier);
const roleRequirement = action.requirements?.roles?.find(
(requirement) => requirement.role === roleIdentifier,
);
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
if (actionLevelSlotsMin > 0) return true;
const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier ? template.transactions?.[transactionIdentifier] : undefined;
const transaction = transactionIdentifier
? template.transactions?.[transactionIdentifier]
: undefined;
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
return (roleInputs?.length ?? 0) > 0;
};
export const getTransactionOutputIdentifier = (output: XOTemplateTransactionOutput): string | undefined => {
if (typeof output === 'string') return output;
if (output && typeof output === 'object' && 'output' in output && typeof output.output === 'string') {
export const getTransactionOutputIdentifier = (
output: XOTemplateTransactionOutput,
): string | undefined => {
if (typeof output === "string") return output;
if (
output &&
typeof output === "object" &&
"output" in output &&
typeof output.output === "string"
) {
return output.output;
}
return undefined;
};
export const normalizeLockingBytecodeHex = (value: string): string => value.trim().replace(/^0x/i, '');
export const normalizeLockingBytecodeHex = (value: string): string =>
value.trim().replace(/^0x/i, "");
export const resolveProvidedLockingBytecodeHex = (
template: XOTemplate,
@@ -88,18 +103,23 @@ export const resolveProvidedLockingBytecodeHex = (
variableValues: Record<string, string>,
): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockscript !== 'string') return undefined;
if (!outputDefinition || typeof outputDefinition.lockscript !== "string")
return undefined;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDefinition.lockscript] as
| { lockingScript?: string }
| undefined;
const lockingScriptDefinition = (
template.lockingScripts as Record<string, unknown> | undefined
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
if (!scriptIdentifier) return undefined;
const scriptExpression = (template.scripts as Record<string, unknown> | undefined)?.[scriptIdentifier];
if (typeof scriptExpression !== 'string') return undefined;
const scriptExpression = (
template.scripts as Record<string, unknown> | undefined
)?.[scriptIdentifier];
if (typeof scriptExpression !== "string") return undefined;
const directVariableMatch = scriptExpression.match(/^<\s*([A-Za-z0-9_]+)\s*>$/);
const directVariableMatch = scriptExpression.match(
/^<\s*([A-Za-z0-9_]+)\s*>$/,
);
if (!directVariableMatch) return undefined;
const variableIdentifier = directVariableMatch[1];
@@ -111,15 +131,17 @@ export const resolveProvidedLockingBytecodeHex = (
return normalizeLockingBytecodeHex(providedValue);
};
export const mapUnspentOutputsToSelectable = (unspentOutputs: any[]): SelectableUtxoLike[] => {
export const mapUnspentOutputsToSelectable = (
unspentOutputs: any[],
): SelectableUtxoLike[] => {
return unspentOutputs.map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? typeof utxo.lockingBytecode === "string"
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: Buffer.from(utxo.lockingBytecode).toString("hex")
: undefined,
selected: false,
}));
@@ -133,7 +155,10 @@ export const autoSelectGreedyUtxos = (
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
if (
utxo.lockingBytecode &&
seenLockingBytecodes.has(utxo.lockingBytecode)
) {
continue;
}
if (utxo.lockingBytecode) {

View File

@@ -1,20 +1,20 @@
/**
* Invitation utility functions.
*
*
* Pure functions for parsing and formatting invitation data.
* These functions have no React dependencies and can be used
* in both TUI and CLI contexts.
*/
import type { Invitation } from '../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types';
import type { Invitation } from "../services/invitation.js";
import type { XOTemplate } from "@xo-cash/types";
/**
* Color names for invitation states.
* These are semantic color names that can be mapped to actual colors
* by the consuming application (TUI or CLI).
*/
export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted';
export type StateColorName = "info" | "warning" | "success" | "error" | "muted";
/**
* Input data extracted from invitation commits.
@@ -61,7 +61,7 @@ export interface FormattedInvitationItem {
/**
* Get the current state/status of an invitation.
*
*
* @param invitation - The invitation to get state for
* @returns The status string
*/
@@ -71,34 +71,34 @@ export function getInvitationState(invitation: Invitation): string {
/**
* Get the semantic color name for an invitation state.
*
*
* @param state - The invitation state string
* @returns A semantic color name
*/
export function getStateColorName(state: string): StateColorName {
switch (state) {
case 'created':
case 'published':
return 'info';
case 'pending':
return 'warning';
case 'ready':
case 'signed':
case 'complete':
case 'broadcast':
case 'completed':
return 'success';
case 'expired':
case 'error':
return 'error';
case "created":
case "published":
return "info";
case "pending":
return "warning";
case "ready":
case "signed":
case "complete":
case "broadcast":
case "completed":
return "success";
case "expired":
case "error":
return "error";
default:
return 'muted';
return "muted";
}
}
/**
* Extract all inputs from invitation commits.
*
*
* @param invitation - The invitation to extract inputs from
* @returns Array of input data
*/
@@ -118,11 +118,13 @@ export function getInvitationInputs(invitation: Invitation): InvitationInput[] {
/**
* Extract all outputs from invitation commits.
*
*
* @param invitation - The invitation to extract outputs from
* @returns Array of output data
*/
export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] {
export function getInvitationOutputs(
invitation: Invitation,
): InvitationOutput[] {
const outputs: InvitationOutput[] = [];
for (const commit of invitation.data.commits || []) {
for (const output of commit.data?.outputs || []) {
@@ -139,11 +141,13 @@ export function getInvitationOutputs(invitation: Invitation): InvitationOutput[]
/**
* Extract all variables from invitation commits.
*
*
* @param invitation - The invitation to extract variables from
* @returns Array of variable data
*/
export function getInvitationVariables(invitation: Invitation): InvitationVariable[] {
export function getInvitationVariables(
invitation: Invitation,
): InvitationVariable[] {
const variables: InvitationVariable[] = [];
for (const commit of invitation.data.commits || []) {
for (const variable of commit.data?.variables || []) {
@@ -160,14 +164,17 @@ export function getInvitationVariables(invitation: Invitation): InvitationVariab
/**
* Get the user's role from commits (the role they have accepted).
*
*
* @param invitation - The invitation to check
* @param userEntityId - The user's entity identifier
* @returns The role identifier if found, null otherwise
*/
export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null {
export function getUserRole(
invitation: Invitation,
userEntityId: string | null,
): string | null {
if (!userEntityId) return null;
for (const commit of invitation.data.commits || []) {
if (commit.entityIdentifier === userEntityId) {
// Check inputs for role
@@ -189,32 +196,32 @@ export function getUserRole(invitation: Invitation, userEntityId: string | null)
/**
* Format an invitation for display in a list.
*
*
* @param invitation - The invitation to format
* @param template - Optional template for additional info (name)
* @returns Formatted item data for display
*/
export function formatInvitationListItem(
invitation: Invitation,
template?: XOTemplate | null
template?: XOTemplate | null,
): FormattedInvitationItem {
// Validate that we have the minimum required data
const invitationId = invitation?.data?.invitationIdentifier;
const actionId = invitation?.data?.actionIdentifier;
if (!invitationId || !actionId) {
return {
label: '',
status: 'error',
statusColor: 'error',
label: "",
status: "error",
statusColor: "error",
isValid: false,
};
}
const state = getInvitationState(invitation);
const templateName = template?.name ?? 'Unknown';
const templateName = template?.name ?? "Unknown";
const shortId = formatInvitationId(invitationId, 8);
return {
label: `[${state}] ${templateName}-${actionId} (${shortId})`,
status: state,
@@ -225,7 +232,7 @@ export function formatInvitationListItem(
/**
* Format an invitation ID for display (truncated).
*
*
* @param id - The full invitation ID
* @param maxLength - Maximum length for display
* @returns Truncated ID string
@@ -238,7 +245,7 @@ export function formatInvitationId(id: string, maxLength: number = 16): string {
/**
* Get all unique entity identifiers from an invitation's commits.
*
*
* @param invitation - The invitation to check
* @returns Array of unique entity identifiers
*/
@@ -254,12 +261,15 @@ export function getInvitationParticipants(invitation: Invitation): string[] {
/**
* Check if a user is a participant in an invitation.
*
*
* @param invitation - The invitation to check
* @param userEntityId - The user's entity identifier
* @returns True if the user has made at least one commit
*/
export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean {
export function isUserParticipant(
invitation: Invitation,
userEntityId: string | null,
): boolean {
if (!userEntityId) return false;
return getInvitationParticipants(invitation).includes(userEntityId);
}

View File

@@ -5,7 +5,11 @@ export class Logger {
private readonly path: string,
) {}
send(level: 'log' | 'error' | 'warn' | 'info', message: string, ...metadata: unknown[]) {
send(
level: "log" | "error" | "warn" | "info",
message: string,
...metadata: unknown[]
) {
const data = {
level,
message: `${this.path}: ${message}`,
@@ -13,34 +17,34 @@ export class Logger {
};
fetch(`${this.endpoint}`, {
method: 'POST',
method: "POST",
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
'x-api-key': this.token,
"Content-Type": "application/json",
"x-api-key": this.token,
},
}).catch(error => {
console.error('Failed to send log to logger:', error);
}).catch((error) => {
console.error("Failed to send log to logger:", error);
});
}
log(message: string, ...metadata: unknown[]) {
this.send('log', message, ...metadata);
this.send("log", message, ...metadata);
}
error(message: string, ...metadata: unknown[]) {
this.send('error', message, ...metadata);
this.send("error", message, ...metadata);
}
warn(message: string, ...metadata: unknown[]) {
this.send('warn', message, ...metadata);
this.send("warn", message, ...metadata);
}
info(message: string, ...metadata: unknown[]) {
this.send('info', message, ...metadata);
this.send("info", message, ...metadata);
}
child(path: string): Logger {
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
}
}
}

View File

@@ -1,11 +1,19 @@
import { ExponentialBackoff } from './exponential-backoff.js';
import { ExponentialBackoff } from "./exponential-backoff.js";
// Type declarations for browser environment (not available in Node.js)
declare const document: {
visibilityState: 'visible' | 'hidden';
addEventListener: (event: string, handler: (event: Event) => void) => void;
removeEventListener: (event: string, handler: (event: Event) => void) => void;
} | undefined;
declare const document:
| {
visibilityState: "visible" | "hidden";
addEventListener: (
event: string,
handler: (event: Event) => void,
) => void;
removeEventListener: (
event: string,
handler: (event: Event) => void,
) => void;
}
| undefined;
/**
* A Server-Sent Events client implementation using fetch API.
@@ -51,14 +59,14 @@ export class SSESession {
this.options = {
// Use default fetch function.
fetch: (...args) => fetch(...args),
method: 'GET',
method: "GET",
headers: {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
onConnected: () => {},
onMessage: () => {},
onError: (error) => console.error('SSESession error:', error),
onError: (error) => console.error("SSESession error:", error),
onDisconnected: () => {},
onReconnect: (options) => Promise.resolve(options),
@@ -71,10 +79,10 @@ export class SSESession {
this.controller = new AbortController();
// Set up visibility change handling if in mobile browser environment
if (typeof document !== 'undefined') {
if (typeof document !== "undefined") {
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
document.addEventListener(
'visibilitychange',
"visibilitychange",
this.visibilityChangeHandler,
);
}
@@ -85,16 +93,16 @@ export class SSESession {
*/
private async handleVisibilityChange(): Promise<void> {
// Guard for Node.js environment where document is undefined
if (typeof document === 'undefined') return;
if (typeof document === "undefined") return;
// When going to background, close the current connection cleanly
// This allows us to reconnect mobile devices when they come back after leaving the tab or browser app.
if (document.visibilityState === 'hidden') {
if (document.visibilityState === "hidden") {
this.controller.abort();
}
// When coming back to foreground, attempt to reconnect if not connected
if (document.visibilityState === 'visible' && !this.connected) {
if (document.visibilityState === "visible" && !this.connected) {
await this.connect();
}
}
@@ -115,7 +123,7 @@ export class SSESession {
headers: headers || {},
body: body || null,
signal: this.controller.signal,
cache: 'no-store',
cache: "no-store",
};
const exponentialBackoff = ExponentialBackoff.from({
@@ -144,7 +152,7 @@ export class SSESession {
}
if (!res.body) {
throw new Error('Response body is null');
throw new Error("Response body is null");
}
return res.body.getReader();
@@ -228,10 +236,10 @@ export class SSESession {
const line = lines[i];
// Empty line signals the end of an event
if (line === '') {
if (line === "") {
if (currentEvent.data) {
// Remove trailing newline if present
currentEvent.data = currentEvent.data.replace(/\n$/, '');
currentEvent.data = currentEvent.data.replace(/\n$/, "");
events.push(currentEvent as SSEvent);
currentEvent = {};
completeEventCount = i + 1;
@@ -242,24 +250,24 @@ export class SSESession {
if (!line) continue;
// Parse field: value format
const colonIndex = line.indexOf(':');
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const field = line.slice(0, colonIndex);
// Skip initial space after colon if present
const valueStartIndex =
colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0);
colonIndex + 1 + (line[colonIndex + 1] === " " ? 1 : 0);
const value = line.slice(valueStartIndex);
if (field === 'data') {
if (field === "data") {
currentEvent.data = currentEvent.data
? currentEvent.data + '\n' + value
? currentEvent.data + "\n" + value
: value;
} else if (field === 'event') {
} else if (field === "event") {
currentEvent.event = value;
} else if (field === 'id') {
} else if (field === "id") {
currentEvent.id = value;
} else if (field === 'retry') {
} else if (field === "retry") {
const retryMs = parseInt(value, 10);
if (!isNaN(retryMs)) {
currentEvent.retry = retryMs;
@@ -268,7 +276,7 @@ export class SSESession {
}
// Store the remainder of the buffer for the next chunk
const remainder = lines.slice(completeEventCount).join('\n');
const remainder = lines.slice(completeEventCount).join("\n");
this.messageBuffer = this.textEncoder.encode(remainder);
return events;
@@ -291,9 +299,9 @@ export class SSESession {
this.controller.abort();
// Remove the visibility handler (This is only required on browsers)
if (this.visibilityChangeHandler && typeof document !== 'undefined') {
if (this.visibilityChangeHandler && typeof document !== "undefined") {
document.removeEventListener(
'visibilitychange',
"visibilitychange",
this.visibilityChangeHandler,
);
this.visibilityChangeHandler = null;
@@ -348,7 +356,7 @@ export interface SSESessionOptions {
/**
* HTTP method to use (GET or POST).
*/
method: 'GET' | 'POST';
method: "GET" | "POST";
/**
* HTTP headers to send with the request.

View File

@@ -4,14 +4,17 @@ import { SSESession, type SSEvent } from "./sse-client.js";
import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
export type SyncServerEventMap = {
'connected': void;
'disconnected': void;
'error': Error;
'message': SSEvent;
}
connected: void;
disconnected: void;
error: Error;
message: SSEvent;
};
export class SyncServer extends EventEmitter<SyncServerEventMap> {
static async from(baseUrl: string, invitationIdentifier: string): Promise<SyncServer> {
static async from(
baseUrl: string,
invitationIdentifier: string,
): Promise<SyncServer> {
const server = new SyncServer(baseUrl, invitationIdentifier);
await server.connect();
return server;
@@ -19,22 +22,32 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
private sse: SSESession;
constructor(private readonly baseUrl: string, private readonly invitationIdentifier: string) {
constructor(
private readonly baseUrl: string,
private readonly invitationIdentifier: string,
) {
super();
// Create an SSE Session
this.sse = new SSESession(`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`, {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
},
this.sse = new SSESession(
`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`,
{
method: "GET",
headers: {
Accept: "text/event-stream",
},
// Create our event bubblers
onMessage: (event: SSEvent) => this.emit('message', event),
onError: (error: unknown) => this.emit('error', error instanceof Error ? error : new Error(String(error))),
onDisconnected: () => this.emit('disconnected', undefined),
onConnected: () => this.emit('connected', undefined),
});
// Create our event bubblers
onMessage: (event: SSEvent) => this.emit("message", event),
onError: (error: unknown) =>
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
),
onDisconnected: () => this.emit("disconnected", undefined),
onConnected: () => this.emit("connected", undefined),
},
);
}
/**
@@ -60,13 +73,17 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
*/
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
// Send a GET request to the sync server
const response = await fetch(`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`);
if(!response.ok) {
const response = await fetch(
`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`,
);
if (!response.ok) {
throw new Error(`Failed to get invitation: ${response.statusText}`);
}
const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
const invitation = decodeExtendedJson(await response.text()) as
| XOInvitation
| undefined;
return invitation;
}
@@ -78,10 +95,10 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
// Send a POST request to the sync server
const response = await fetch(`${this.baseUrl}/invitations`, {
method: 'POST',
method: "POST",
body: encodeExtendedJson(invitation),
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
});
@@ -96,4 +113,4 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
return data;
}
}
}

View File

@@ -1,12 +1,12 @@
/**
* Template utility functions.
*
*
* Pure functions for parsing and formatting template data.
* These functions have no React dependencies and can be used
* in both TUI and CLI contexts.
*/
import type { XOTemplate, XOTemplateAction } from '@xo-cash/types';
import type { XOTemplate, XOTemplateAction } from "@xo-cash/types";
/**
* Formatted template list item data.
@@ -57,25 +57,25 @@ export interface TemplateRole {
/**
* Format a template for display in a list.
*
*
* @param template - The template to format
* @param index - Optional index for numbered display
* @returns Formatted item data for display
*/
export function formatTemplateListItem(
template: XOTemplate | null | undefined,
index?: number
index?: number,
): FormattedTemplateItem {
if (!template) {
return {
label: '',
label: "",
description: undefined,
isValid: false,
};
}
const name = template.name || 'Unnamed Template';
const prefix = index !== undefined ? `${index + 1}. ` : '';
const name = template.name || "Unnamed Template";
const prefix = index !== undefined ? `${index + 1}. ` : "";
return {
label: `${prefix}${name}`,
@@ -86,7 +86,7 @@ export function formatTemplateListItem(
/**
* Format an action for display in a list.
*
*
* @param actionId - The action identifier
* @param action - The action definition from the template
* @param roleCount - Number of roles that can start this action
@@ -97,11 +97,11 @@ export function formatActionListItem(
actionId: string,
action: XOTemplateAction | null | undefined,
roleCount: number = 1,
index?: number
index?: number,
): FormattedActionItem {
if (!actionId) {
return {
label: '',
label: "",
description: undefined,
roleCount: 0,
isValid: false,
@@ -109,8 +109,8 @@ export function formatActionListItem(
}
const name = action?.name || actionId;
const prefix = index !== undefined ? `${index + 1}. ` : '';
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : '';
const prefix = index !== undefined ? `${index + 1}. ` : "";
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : "";
return {
label: `${prefix}${name}${roleSuffix}`,
@@ -124,14 +124,14 @@ export function formatActionListItem(
* Deduplicate starting actions from a template.
* Multiple roles that can start the same action are counted
* but returned as a single entry.
*
*
* @param template - The template to process
* @param startingActions - Array of { action, role } pairs
* @returns Array of unique starting actions with role counts
*/
export function deduplicateStartingActions(
template: XOTemplate,
startingActions: Array<{ action: string; role: string }>
startingActions: Array<{ action: string; role: string }>,
): UniqueStartingAction[] {
const actionMap = new Map<string, UniqueStartingAction>();
@@ -154,7 +154,7 @@ export function deduplicateStartingActions(
/**
* Get all roles from a template.
*
*
* @param template - The template to process
* @returns Array of role information
*/
@@ -163,7 +163,7 @@ export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
return Object.entries(template.roles).map(([roleId, role]) => {
// Handle case where role might be a string instead of object
const roleObj = typeof role === 'object' ? role : null;
const roleObj = typeof role === "object" ? role : null;
return {
roleId,
name: roleObj?.name || roleId,
@@ -174,21 +174,22 @@ export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
/**
* Get roles that can start a specific action.
*
*
* @param template - The template to check
* @param actionIdentifier - The action to check
* @returns Array of role information for roles that can start this action
*/
export function getRolesForAction(
template: XOTemplate,
actionIdentifier: string
actionIdentifier: string,
): TemplateRole[] {
const startEntries = (template.start ?? [])
.filter((s) => s.action === actionIdentifier);
const startEntries = (template.start ?? []).filter(
(s) => s.action === actionIdentifier,
);
return startEntries.map((entry) => {
const roleDef = template.roles?.[entry.role];
const roleObj = typeof roleDef === 'object' ? roleDef : null;
const roleObj = typeof roleDef === "object" ? roleDef : null;
return {
roleId: entry.role,
name: roleObj?.name || entry.role,
@@ -199,48 +200,52 @@ export function getRolesForAction(
/**
* Get template name safely.
*
*
* @param template - The template
* @returns The template name or a default
*/
export function getTemplateName(template: XOTemplate | null | undefined): string {
return template?.name || 'Unknown Template';
export function getTemplateName(
template: XOTemplate | null | undefined,
): string {
return template?.name || "Unknown Template";
}
/**
* Get template description safely.
*
*
* @param template - The template
* @returns The template description or undefined
*/
export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined {
export function getTemplateDescription(
template: XOTemplate | null | undefined,
): string | undefined {
return template?.description;
}
/**
* Get action name safely.
*
*
* @param template - The template containing the action
* @param actionId - The action identifier
* @returns The action name or the action ID as fallback
*/
export function getActionName(
template: XOTemplate | null | undefined,
actionId: string
actionId: string,
): string {
return template?.actions?.[actionId]?.name || actionId;
}
/**
* Get action description safely.
*
*
* @param template - The template containing the action
* @param actionId - The action identifier
* @returns The action description or undefined
*/
export function getActionDescription(
template: XOTemplate | null | undefined,
actionId: string
actionId: string,
): string | undefined {
return template?.actions?.[actionId]?.description;
}

View File

@@ -2,19 +2,19 @@ import type { XOTemplate } from "@xo-cash/types";
/**
* List of helper functions to make templates easy to develop with.
*
*
* Most of these are centered around the fact that the templates are very disjointed and sporadic in where the information lies.
*
*
* I.e. required variables ** names ** are stored in actions.roles.requirements.variables, but the variable definitions are stored in the template.variables object.
* so to make a UI out of that, you first need to iterate over the actions.roles.requirements.variables and then lookup the variable definition in the template.variables object.
* this is a pain, so these functions are here to help.
*
*
* Simiarly for inputs, outputs, locking scripts, etc. The get referenced in the actions, but then the actual lookup of what is actually is becomes a pain.
*/
/**
* Deepens a templates object by append the actual definitions for the objects as they are referenced in the template.
* NOTE: Whether this is better as part of the template or not can be debated endlessly.
* NOTE: Whether this is better as part of the template or not can be debated endlessly.
* The decision for separating the defintions from where they are used is likely to reduce template size...
* This could be fruitless though, as its easily compressible (gzip, msgpack, etc) will yield similar results to the separated approach.
*/
@@ -222,7 +222,14 @@ export function resolveTemplateReferences(
const resolved = structuredClone(template);
for (const rule of RESOLUTION_RULES) {
applyRule(resolved, resolved, rule.path.split("."), 0, rule.from, rule.mode);
applyRule(
resolved,
resolved,
rule.path.split("."),
0,
rule.from,
rule.mode,
);
}
return resolved as unknown as ResolvedXOTemplate;
@@ -357,20 +364,14 @@ interface ResolvedStartEntry {
// ─── The full resolved template ──────────────────────────────────
interface ResolvedXOTemplate
extends Omit<
XOTemplate,
| "actions"
| "transactions"
| "outputs"
| "inputs"
| "lockingScripts"
| "start"
> {
interface ResolvedXOTemplate extends Omit<
XOTemplate,
"actions" | "transactions" | "outputs" | "inputs" | "lockingScripts" | "start"
> {
start: ResolvedStartEntry[];
actions: Record<string, ResolvedActionDefinition>;
transactions: Record<string, ResolvedTransactionDefinition>;
outputs: Record<string, ResolvedOutputDefinition>;
inputs: Record<string, ResolvedInputDefinition>;
lockingScripts: Record<string, ResolvedLockingScriptDefinition>;
}
}