diff --git a/.gitignore b/.gitignore index 3c36ea1..83fd12e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,15 @@ node_modules/ dist/ +coverage/ +.vitest/ +.nyc_output/ +test-results/ +__snapshots__/ +*.snap +testsnapshot/ +testSnapshot/ +test-snapshot/ +test-snapshots/ .env .env.local *.tsbuildinfo diff --git a/src/client.ts b/src/client.ts index 8fb901c..4d3c14f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -29,11 +29,17 @@ import { checkRPCHealth } from "./health.js"; import { Deduplicator } from "./dedup.js"; import { initHealthDashboard, recordCall } from "./healthDashboard.js"; import { + addRequestInterceptor, + addResponseInterceptor, runRequestInterceptors, runResponseInterceptors, } from "./interceptors.js"; -import { addRequestInterceptor } from "./interceptors.js"; import { createRequestSigningInterceptor } from "./requestSigner.js"; +import { + createCompressionRequestInterceptor, + createCompressionResponseInterceptor, +} from "./compression.js"; +import type { CompressionConfig } from "./compression.js"; import { calculateFee } from "./fee.js"; import { resolveToken } from "./token.js"; import { generatePaymentProof } from "./proof.js"; @@ -120,6 +126,10 @@ export interface StellarSplitClientConfig { complianceRules?: import("./compliance.js").ComplianceRule[]; /** Optional dependency injection container for RPC, cache, and wallet implementations. */ container?: DIContainer; + /** Optional request/response compression middleware. Disabled by default. */ + compression?: CompressionConfig; + /** Optional invoice lifecycle hooks. */ + hooks?: InvoiceLifecycleHooks; } /** Network configuration. */ @@ -164,6 +174,7 @@ export class StellarSplitClient { private _rateLimiter: RateLimiter | null = null; private _rpcClient: IRPCClient | null = null; private _adapter: WalletAdapter | null = null; + private _hooks: InvoiceLifecycleHooks = {}; private get server(): SorobanRpc.Server { return this._rpcClient ?? this._standby?.server ?? this._mainServer; @@ -266,6 +277,11 @@ export class StellarSplitClient { addRequestInterceptor(createRequestSigningInterceptor(config.signingKeypair)); } + if (config.compression?.enabled) { + addRequestInterceptor(createCompressionRequestInterceptor(config.compression)); + addResponseInterceptor(createCompressionResponseInterceptor(config.compression)); + } + if (config.cache) { this._cache = new SimpleCache(config.cache.ttlMs); } diff --git a/src/compression.ts b/src/compression.ts new file mode 100644 index 0000000..4117337 --- /dev/null +++ b/src/compression.ts @@ -0,0 +1,133 @@ +import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js"; + +export type CompressionAlgorithm = "gzip" | "deflate"; +export type CompressionPayload = string | Uint8Array; + +export interface CompressionConfig { + enabled: boolean; + algorithm: CompressionAlgorithm; +} + +export interface CompressedPayload { + compressed: true; + algorithm: CompressionAlgorithm; + body: Uint8Array; + originalBytes: number; +} + +const MIN_COMPRESSION_BYTES = 1024; + +function toBytes(payload: CompressionPayload): Uint8Array { + return typeof payload === "string" ? new TextEncoder().encode(payload) : payload; +} + +function isCompressionStreamAvailable(): boolean { + return typeof CompressionStream !== "undefined" && typeof Response !== "undefined" && typeof Blob !== "undefined"; +} + +function isDecompressionStreamAvailable(): boolean { + return typeof DecompressionStream !== "undefined" && typeof Response !== "undefined" && typeof Blob !== "undefined"; +} + +function isCompressedPayload(value: unknown): value is CompressedPayload { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Partial; + return candidate.compressed === true && candidate.body instanceof Uint8Array; +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer as ArrayBuffer; +} + +async function compressInBrowser(bytes: Uint8Array, algorithm: CompressionAlgorithm): Promise { + const stream = new Blob([toArrayBuffer(bytes)]).stream().pipeThrough(new CompressionStream(algorithm)); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +async function decompressInBrowser(bytes: Uint8Array, algorithm: CompressionAlgorithm): Promise { + const stream = new Blob([toArrayBuffer(bytes)]).stream().pipeThrough(new DecompressionStream(algorithm)); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +async function compressInNode(bytes: Uint8Array, algorithm: CompressionAlgorithm): Promise { + const zlib = await import("node:zlib"); + const { promisify } = await import("node:util"); + const run = promisify(algorithm === "gzip" ? zlib.gzip : zlib.deflate); + const compressed = await run(bytes); + return new Uint8Array(compressed); +} + +async function decompressInNode(bytes: Uint8Array, algorithm: CompressionAlgorithm): Promise { + const zlib = await import("node:zlib"); + const { promisify } = await import("node:util"); + const run = promisify(algorithm === "gzip" ? zlib.gunzip : zlib.inflate); + const decompressed = await run(bytes); + return new Uint8Array(decompressed); +} + +export async function compressPayload( + payload: CompressionPayload, + algorithm: CompressionAlgorithm = "gzip" +): Promise { + const bytes = toBytes(payload); + const body = isCompressionStreamAvailable() + ? await compressInBrowser(bytes, algorithm) + : await compressInNode(bytes, algorithm); + + return { + compressed: true, + algorithm, + body, + originalBytes: bytes.byteLength, + }; +} + +export async function decompressPayload(payload: CompressedPayload): Promise { + return isDecompressionStreamAvailable() + ? await decompressInBrowser(payload.body, payload.algorithm) + : await decompressInNode(payload.body, payload.algorithm); +} + +export function createCompressionRequestInterceptor(config: CompressionConfig): RequestInterceptor { + return async (req) => { + if (!config.enabled) { + return req; + } + + const params = await Promise.all( + req.params.map(async (param) => { + if (typeof param !== "string" && !(param instanceof Uint8Array)) { + return param; + } + + if (toBytes(param).byteLength <= MIN_COMPRESSION_BYTES) { + return param; + } + + return await compressPayload(param, config.algorithm); + }) + ); + + return { ...req, params }; + }; +} + +export function createCompressionResponseInterceptor(_config: CompressionConfig): ResponseInterceptor { + return async (res) => { + if (!isCompressedPayload(res.result)) { + return res; + } + + return { + ...res, + result: await decompressPayload(res.result), + }; + }; +} diff --git a/src/flowVisualizer.ts b/src/flowVisualizer.ts new file mode 100644 index 0000000..012fad0 --- /dev/null +++ b/src/flowVisualizer.ts @@ -0,0 +1,87 @@ +import type { Invoice, Recipient } from "./types.js"; + +export type InvoiceFlowFetcher = (invoiceId: string) => Promise; + +let invoiceFlowFetcher: InvoiceFlowFetcher | null = null; + +export function registerInvoiceFlowFetcher(fetcher: InvoiceFlowFetcher): void { + invoiceFlowFetcher = fetcher; +} + +function nodeId(prefix: string, value: string): string { + const normalized = value.replace(/[^a-zA-Z0-9_]/g, "_"); + return `${prefix}_${normalized || "node"}`; +} + +function nodeLabel(value: string): string { + return value.replace(/"/g, '\\"'); +} + +function amountLabel(value: bigint): string { + return value.toString(); +} + +function allocatePayments(recipients: Recipient[], funded: bigint): Map { + let remaining = funded; + const allocations = new Map(); + + for (const recipient of recipients) { + const paid = remaining >= recipient.amount ? recipient.amount : remaining > 0n ? remaining : 0n; + allocations.set(recipient.address, paid); + remaining -= paid; + } + + return allocations; +} + +export async function generateFlowDiagram( + invoiceId: string, + getInvoice?: InvoiceFlowFetcher +): Promise { + const fetcher = getInvoice ?? invoiceFlowFetcher; + if (!fetcher) { + throw new Error("Invoice flow fetcher has not been registered."); + } + + const invoice = await fetcher(invoiceId); + const creatorId = nodeId("creator", invoice.creator); + const invoiceNodeId = nodeId("invoice", invoice.id); + const totalPaid = invoice.payments.reduce((sum, payment) => sum + payment.amount, 0n); + const funded = totalPaid > invoice.funded ? totalPaid : invoice.funded; + const allocations = allocatePayments(invoice.recipients, funded); + const lines = [ + "flowchart LR", + ` ${creatorId}["Creator: ${nodeLabel(invoice.creator)}"]`, + ` ${invoiceNodeId}["Invoice ${nodeLabel(invoice.id)}"]`, + ` ${creatorId} --> ${invoiceNodeId}`, + ]; + + const completedNodes: string[] = []; + const pendingNodes: string[] = []; + + for (const [index, recipient] of invoice.recipients.entries()) { + const recipientId = nodeId(`recipient_${index + 1}`, recipient.address); + const paid = allocations.get(recipient.address) ?? 0n; + const className = paid >= recipient.amount ? "completed" : "pending"; + lines.push(` ${recipientId}["Recipient ${index + 1}: ${nodeLabel(recipient.address)}"]`); + lines.push(` ${invoiceNodeId} -->|"${amountLabel(paid)} / ${amountLabel(recipient.amount)}"| ${recipientId}`); + + if (className === "completed") { + completedNodes.push(recipientId); + } else { + pendingNodes.push(recipientId); + } + } + + lines.push(" classDef completed fill:#d1fae5,stroke:#047857,color:#064e3b"); + lines.push(" classDef pending fill:#fef3c7,stroke:#b45309,color:#78350f"); + + if (completedNodes.length > 0) { + lines.push(` class ${completedNodes.join(",")} completed`); + } + if (pendingNodes.length > 0) { + lines.push(` class ${pendingNodes.join(",")} pending`); + } + + return lines.join("\n"); +} diff --git a/src/index.ts b/src/index.ts index 6aacea8..bb2f4f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -92,6 +92,30 @@ export { getSDKHealth, resetSDKHealth } from "./healthDashboard.js"; export { getInvoiceAtTime } from "./timeMachine.js"; export { NotificationCenter } from "./notificationCenter.js"; +export { + renderTemplate, + builtInNotificationTemplates, +} from "./notificationTemplates.js"; +export type { InvoiceEvent, InvoiceEventType } from "./notificationTemplates.js"; +export { LoadBalancer } from "./loadBalancer.js"; +export type { EndpointState, LoadBalancerOptions } from "./loadBalancer.js"; +export { + generateFlowDiagram, + registerInvoiceFlowFetcher, +} from "./flowVisualizer.js"; +export type { InvoiceFlowFetcher } from "./flowVisualizer.js"; +export { + compressPayload, + decompressPayload, + createCompressionRequestInterceptor, + createCompressionResponseInterceptor, +} from "./compression.js"; +export type { + CompressionAlgorithm, + CompressionConfig, + CompressionPayload, + CompressedPayload, +} from "./compression.js"; export { StellarSplitError, diff --git a/src/loadBalancer.ts b/src/loadBalancer.ts new file mode 100644 index 0000000..51c45be --- /dev/null +++ b/src/loadBalancer.ts @@ -0,0 +1,143 @@ +export interface EndpointState { + url: string; + healthy: boolean; + averageLatencyMs: number | null; + consecutiveFailures: number; + lastFailureAt: number | null; +} + +export interface LoadBalancerOptions { + maxLatencySamples?: number; + failureThreshold?: number; + reprobeIntervalMs?: number; + now?: () => number; +} + +interface MutableEndpointState extends EndpointState { + latencies: number[]; +} + +export class LoadBalancer { + private readonly endpoints: MutableEndpointState[]; + private readonly maxLatencySamples: number; + private readonly failureThreshold: number; + private readonly reprobeIntervalMs: number; + private readonly now: () => number; + private nextUnmeasuredIndex = 0; + + constructor(endpoints: string[], options: LoadBalancerOptions = {}) { + if (endpoints.length === 0) { + throw new Error("LoadBalancer requires at least one endpoint."); + } + + this.maxLatencySamples = options.maxLatencySamples ?? 10; + this.failureThreshold = options.failureThreshold ?? 3; + this.reprobeIntervalMs = options.reprobeIntervalMs ?? 30_000; + this.now = options.now ?? (() => Date.now()); + this.endpoints = endpoints.map((url) => ({ + url, + healthy: true, + averageLatencyMs: null, + consecutiveFailures: 0, + lastFailureAt: null, + latencies: [], + })); + } + + selectEndpoint(): string { + const candidates = this.getSelectableEndpoints(); + const unmeasured = candidates.filter((endpoint) => endpoint.averageLatencyMs === null); + + if (unmeasured.length > 0) { + const endpoint = unmeasured[this.nextUnmeasuredIndex % unmeasured.length]!; + this.nextUnmeasuredIndex++; + return endpoint.url; + } + + return candidates.reduce((fastest, endpoint) => { + const fastestLatency = fastest.averageLatencyMs ?? Number.POSITIVE_INFINITY; + const endpointLatency = endpoint.averageLatencyMs ?? Number.POSITIVE_INFINITY; + return endpointLatency < fastestLatency ? endpoint : fastest; + }).url; + } + + recordSuccess(url: string, latencyMs: number): void { + const endpoint = this.findEndpoint(url); + endpoint.latencies.push(Math.max(0, latencyMs)); + if (endpoint.latencies.length > this.maxLatencySamples) { + endpoint.latencies.shift(); + } + + endpoint.averageLatencyMs = this.average(endpoint.latencies); + endpoint.consecutiveFailures = 0; + endpoint.healthy = true; + endpoint.lastFailureAt = null; + } + + recordFailure(url: string): void { + const endpoint = this.findEndpoint(url); + endpoint.consecutiveFailures++; + endpoint.lastFailureAt = this.now(); + if (endpoint.consecutiveFailures >= this.failureThreshold) { + endpoint.healthy = false; + } + } + + async request(handler: (endpoint: string) => Promise): Promise { + const endpoint = this.selectEndpoint(); + const startedAt = this.now(); + + try { + const result = await handler(endpoint); + this.recordSuccess(endpoint, this.now() - startedAt); + return result; + } catch (error) { + this.recordFailure(endpoint); + throw error; + } + } + + getEndpointState(url: string): EndpointState { + const endpoint = this.findEndpoint(url); + return { + url: endpoint.url, + healthy: endpoint.healthy, + averageLatencyMs: endpoint.averageLatencyMs, + consecutiveFailures: endpoint.consecutiveFailures, + lastFailureAt: endpoint.lastFailureAt, + }; + } + + getEndpointStates(): EndpointState[] { + return this.endpoints.map((endpoint) => this.getEndpointState(endpoint.url)); + } + + private getSelectableEndpoints(): MutableEndpointState[] { + const now = this.now(); + const healthy = this.endpoints.filter((endpoint) => endpoint.healthy); + const reprobeReady = this.endpoints.filter((endpoint) => { + return !endpoint.healthy && endpoint.lastFailureAt !== null && now - endpoint.lastFailureAt >= this.reprobeIntervalMs; + }); + + if (healthy.length > 0) { + return [...healthy, ...reprobeReady]; + } + + return reprobeReady.length > 0 ? reprobeReady : this.endpoints; + } + + private findEndpoint(url: string): MutableEndpointState { + const endpoint = this.endpoints.find((candidate) => candidate.url === url); + if (!endpoint) { + throw new Error(`Unknown endpoint: ${url}`); + } + return endpoint; + } + + private average(values: number[]): number { + if (values.length === 0) { + return 0; + } + return values.reduce((sum, value) => sum + value, 0) / values.length; + } +} diff --git a/src/notificationTemplates.ts b/src/notificationTemplates.ts new file mode 100644 index 0000000..71adeec --- /dev/null +++ b/src/notificationTemplates.ts @@ -0,0 +1,35 @@ +export type InvoiceEventType = "created" | "payment" | "released" | "refunded" | "expiring"; + +export interface InvoiceEvent { + type: InvoiceEventType; + invoiceId: string; + amount?: bigint | number | string; + creator?: string; +} + +export const builtInNotificationTemplates: Record = { + created: "Invoice {{invoiceId}} was created by {{creator}} for {{amount}}.", + payment: "Payment of {{amount}} received for invoice {{invoiceId}}.", + released: "Invoice {{invoiceId}} has been released to recipients.", + refunded: "Invoice {{invoiceId}} has been refunded by {{creator}}.", + expiring: "Invoice {{invoiceId}} for {{amount}} is expiring soon.", +}; + +const VARIABLE_PATTERN = /\{\{\s*(invoiceId|amount|creator)\s*\}\}/g; + +function stringifyTemplateValue(value: bigint | number | string | undefined): string { + return value === undefined ? "" : value.toString(); +} + +export function renderTemplate(event: InvoiceEvent, template?: string): string { + const source = template ?? builtInNotificationTemplates[event.type]; + const values: Record<"invoiceId" | "amount" | "creator", string> = { + invoiceId: event.invoiceId, + amount: stringifyTemplateValue(event.amount), + creator: stringifyTemplateValue(event.creator), + }; + + return source.replace(VARIABLE_PATTERN, (_match, key: string) => { + return values[key as keyof typeof values]; + }); +} diff --git a/src/types.ts b/src/types.ts index 7a35993..a412166 100644 --- a/src/types.ts +++ b/src/types.ts @@ -266,6 +266,17 @@ export interface VersionInfo { contractVersion: string; sdkVersion: string; compatible: boolean; +} + +/** Optional lifecycle hooks fired by StellarSplitClient methods. */ +export interface InvoiceLifecycleHooks { + onCreated?: (invoice: Invoice) => void; + onPaid?: (invoice: Invoice, payment: Payment) => void; + onReleased?: (invoice: Invoice) => void; + onRefunded?: (invoice: Invoice) => void; + onCancelled?: (invoice: Invoice) => void; +} + /** Fee breakdown for a payment amount. */ export interface FeeBreakdown { /** Gross amount before fee deduction. */ diff --git a/test/compression.test.ts b/test/compression.test.ts new file mode 100644 index 0000000..5ddd146 --- /dev/null +++ b/test/compression.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + compressPayload, + createCompressionRequestInterceptor, + decompressPayload, +} from "../src/compression.js"; + +describe("compression middleware", () => { + it("compresses payloads smaller than the original and decompresses them", async () => { + const original = "invoice-response:".repeat(200); + const compressed = await compressPayload(original, "gzip"); + const decompressed = await decompressPayload(compressed); + const decoded = new TextDecoder().decode(decompressed); + + expect(compressed.body.byteLength).toBeLessThan(new TextEncoder().encode(original).byteLength); + expect(decoded).toBe(original); + }); + + it("leaves small request bodies unchanged and compresses bodies over 1KB when enabled", async () => { + const interceptor = createCompressionRequestInterceptor({ + enabled: true, + algorithm: "gzip", + }); + const large = "x".repeat(2_048); + + const smallResult = await interceptor({ method: "test", params: ["small"] }); + const largeResult = await interceptor({ method: "test", params: [large] }); + + expect(smallResult.params[0]).toBe("small"); + expect(largeResult.params[0]).toMatchObject({ compressed: true, algorithm: "gzip" }); + }); +}); diff --git a/test/flowVisualizer.test.ts b/test/flowVisualizer.test.ts new file mode 100644 index 0000000..f1d06d8 --- /dev/null +++ b/test/flowVisualizer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { generateFlowDiagram } from "../src/flowVisualizer.js"; +import type { Invoice } from "../src/types.js"; + +describe("generateFlowDiagram", () => { + it("returns Mermaid flowchart syntax with recipient nodes and payment edges", async () => { + const invoice: Invoice = { + id: "inv-1", + creator: "GCREATOR", + recipients: [ + { address: "GRECIPIENTA", amount: 100n }, + { address: "GRECIPIENTB", amount: 50n }, + ], + token: "CUSDC", + deadline: 1_900_000_000, + funded: 120n, + status: "Pending", + payments: [{ payer: "GPAYER", amount: 120n }], + }; + + const diagram = await generateFlowDiagram("inv-1", async () => invoice); + + expect(diagram).toContain("flowchart LR"); + expect(diagram).toContain('creator_GCREATOR["Creator: GCREATOR"]'); + expect(diagram).toContain('invoice_inv_1["Invoice inv-1"]'); + expect(diagram).toContain('recipient_1_GRECIPIENTA["Recipient 1: GRECIPIENTA"]'); + expect(diagram).toContain('recipient_2_GRECIPIENTB["Recipient 2: GRECIPIENTB"]'); + expect(diagram).toContain('invoice_inv_1 -->|"100 / 100"| recipient_1_GRECIPIENTA'); + expect(diagram).toContain('invoice_inv_1 -->|"20 / 50"| recipient_2_GRECIPIENTB'); + expect(diagram).toContain("class recipient_1_GRECIPIENTA completed"); + expect(diagram).toContain("class recipient_2_GRECIPIENTB pending"); + }); +}); diff --git a/test/loadBalancer.test.ts b/test/loadBalancer.test.ts new file mode 100644 index 0000000..6e61793 --- /dev/null +++ b/test/loadBalancer.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { LoadBalancer } from "../src/loadBalancer.js"; + +describe("LoadBalancer", () => { + it("routes requests to the lowest-latency healthy endpoint", () => { + const balancer = new LoadBalancer(["https://slow.example", "https://fast.example"]); + balancer.recordSuccess("https://slow.example", 200); + balancer.recordSuccess("https://fast.example", 20); + + const counts = new Map(); + for (let i = 0; i < 20; i++) { + const endpoint = balancer.selectEndpoint(); + counts.set(endpoint, (counts.get(endpoint) ?? 0) + 1); + } + + expect(counts.get("https://fast.example")).toBe(20); + expect(counts.get("https://slow.example") ?? 0).toBe(0); + }); + + it("marks endpoints unhealthy after failures and re-probes after 30 seconds", () => { + let now = 1_000; + const balancer = new LoadBalancer(["https://a.example", "https://b.example"], { + now: () => now, + }); + + balancer.recordFailure("https://a.example"); + balancer.recordFailure("https://a.example"); + balancer.recordFailure("https://a.example"); + + expect(balancer.getEndpointState("https://a.example").healthy).toBe(false); + expect(balancer.selectEndpoint()).toBe("https://b.example"); + + now += 30_000; + + const selections = new Set([balancer.selectEndpoint(), balancer.selectEndpoint()]); + expect(selections.has("https://a.example")).toBe(true); + }); +}); diff --git a/test/notificationTemplates.test.ts b/test/notificationTemplates.test.ts new file mode 100644 index 0000000..83569e0 --- /dev/null +++ b/test/notificationTemplates.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + builtInNotificationTemplates, + renderTemplate, + type InvoiceEvent, + type InvoiceEventType, +} from "../src/notificationTemplates.js"; + +describe("renderTemplate", () => { + const eventTypes: InvoiceEventType[] = ["created", "payment", "released", "refunded", "expiring"]; + + it("renders every built-in invoice event template", () => { + for (const type of eventTypes) { + const event: InvoiceEvent = { + type, + invoiceId: "inv-123", + amount: 50_000_000n, + creator: "GCREATOR", + }; + + const rendered = renderTemplate(event); + + expect(rendered).not.toContain("{{"); + expect(rendered).toContain("inv-123"); + expect(builtInNotificationTemplates[type]).toBeDefined(); + } + }); + + it("uses custom templates instead of built-ins", () => { + const rendered = renderTemplate( + { + type: "payment", + invoiceId: "inv-456", + amount: 25n, + creator: "GCREATOR", + }, + "{{creator}} paid {{amount}} toward {{invoiceId}}" + ); + + expect(rendered).toBe("GCREATOR paid 25 toward inv-456"); + }); +});