Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 17 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Invoice>(config.cache.ttlMs);
}
Expand Down
133 changes: 133 additions & 0 deletions src/compression.ts
Original file line number Diff line number Diff line change
@@ -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<CompressedPayload>;
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<CompressedPayload> {
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<Uint8Array> {
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),
};
};
}
87 changes: 87 additions & 0 deletions src/flowVisualizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Invoice, Recipient } from "./types.js";

export type InvoiceFlowFetcher = (invoiceId: string) => Promise<Invoice>;

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<string, bigint> {
let remaining = funded;
const allocations = new Map<string, bigint>();

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<string> {
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");
}
24 changes: 24 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading