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
61 changes: 61 additions & 0 deletions src/enricher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* - Enrich invoices with IPFS metadata
* - Parse IPFS CIDs from invoice memos
* - Merge on-chain invoice data with off-chain metadata
* - Cache enriched results with a configurable TTL
*/

import type { Invoice, InvoiceMetadata, IPFSConfig } from "./types.js";
Expand Down Expand Up @@ -195,3 +196,63 @@ export function hasIPFSMetadata(invoice: Invoice): boolean {
export function getInvoiceMetadataCID(invoice: Invoice): string | null {
return parseIpfsCid(invoice.memo);
}

// ---------------------------------------------------------------------------
// EnricherCache — in-memory TTL cache for metadata lookups
// ---------------------------------------------------------------------------

/** Default TTL for cache entries in milliseconds. */
const DEFAULT_TTL_MS = 60_000;

interface CacheEntry<T> {
value: T;
expiresAt: number;
}

/**
* Caches enriched metadata results with a configurable TTL.
*/
export class EnricherCache<T = unknown> {
private _cache = new Map<string, CacheEntry<T>>();
private _ttlMs: number;

constructor(ttlMs: number = DEFAULT_TTL_MS) {
this._ttlMs = ttlMs;
}

/**
* Get a cached value or compute and cache it.
*
* @param key - Cache key (enrichment identifier).
* @param fetch - Async function to compute the value on cache miss.
* @returns The cached or freshly computed value.
*/
async getOrFetch(key: string, fetch: () => Promise<T>): Promise<T> {
const cached = this._cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.value;
}

const value = await fetch();
this._cache.set(key, {
value,
expiresAt: Date.now() + this._ttlMs,
});
return value;
}

/** Clear all cache entries. */
clearCache(): void {
this._cache.clear();
}

/** Return the number of non-expired entries. */
get size(): number {
let count = 0;
const now = Date.now();
for (const entry of Array.from(this._cache.values())) {
if (now < entry.expiresAt) count++;
}
return count;
}
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export {
// Invoice metadata JSON Schema validator (issue #533)
export { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator.js";
export type { MetadataValidationResult } from "./validators/invoiceMetadataValidator.js";
export { validateMetadataKeys, MAX_METADATA_KEY_LENGTH } from "./validators/invoiceMetadataValidator.js";

// ---------------------------------------------------------------------------
// Lifecycle management (graceful shutdown)
Expand All @@ -240,6 +241,8 @@ export type {
SpeedscopeEvent,
ProfilerSessionOptions,
} from "./profiler.js";
export { MemoryProfiler, memoryProfiler, ProfilerNotInitializedError } from "./memoryProfiler.js";
export type { MemorySnapshot } from "./memoryProfiler.js";
export {
enrichInvoice,
enrichInvoices,
Expand All @@ -248,6 +251,7 @@ export {
getInvoiceMetadataCID,
} from "./enricher.js";
export type { EnrichedInvoice, EnrichOptions } from "./enricher.js";
export { EnricherCache } from "./enricher.js";

// IPFS functionality
export {
Expand Down Expand Up @@ -736,6 +740,7 @@ export {
export type { AdaptiveThrottleConfig, ThrottleStats } from "./throttle/AdaptiveThrottle.js";
export { parseRateLimitHeaders } from "./throttle/RateLimitParser.js";
export type { HeadersLike, RateLimitInfo } from "./throttle/RateLimitParser.js";
export { parseRetryAfter } from "./throttle/RateLimitParser.js";

// Receipt chain — SHA-256-linked, tamper-evident payment receipt history
// per invoice. `PaymentReceipt` is aliased to `ChainPaymentReceipt` here to
Expand Down
95 changes: 95 additions & 0 deletions src/memoryProfiler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { MemoryReport } from "./types.js";

import * as v8 from "node:v8";
import * as fs from "node:fs/promises";
import * as path from "node:path";

let _cacheEntries = 0;
let _listenerCount = 0;

Expand Down Expand Up @@ -35,3 +39,94 @@ export function trackMemoryUsage(): MemoryReport {
);
return { cacheEntries: _cacheEntries, listenerCount: _listenerCount, estimatedKB, warnings };
}

/** Error thrown when the profiler is used before initialization. */
export class ProfilerNotInitializedError extends Error {
constructor() {
super("MemoryProfiler has not been initialized. Call init() first.");
this.name = "ProfilerNotInitializedError";
}
}

/** Memory usage snapshot. */
export interface MemorySnapshot {
heapUsed: number;
heapTotal: number;
rss: number;
external: number;
timestamp: number;
}

/**
* Tracks V8 memory usage metrics and supports heap snapshot export.
*/
export class MemoryProfiler {
private _initialized = false;
private _snapshots: MemorySnapshot[] = [];

/** Initialize the profiler. */
init(): void {
this._initialized = true;
this._snapshots = [];
}

/** Take a memory usage snapshot and return it. */
snapshot(): MemorySnapshot {
this._ensureInitialized();
const mem = process.memoryUsage();
const entry: MemorySnapshot = {
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
rss: mem.rss,
external: mem.external,
timestamp: Date.now(),
};
this._snapshots.push(entry);
return entry;
}

/** Return all recorded snapshots. */
getSnapshots(): MemorySnapshot[] {
this._ensureInitialized();
return [...this._snapshots];
}

/**
* Export a V8 heap snapshot to a `.heapsnapshot` file for offline analysis.
*
* @param outputPath - Directory or full file path where the snapshot will be written.
* @returns The full path of the written file.
*/
async exportHeapSnapshot(outputPath: string): Promise<string> {
this._ensureInitialized();

let filePath = outputPath;
try {
const stat = await fs.stat(filePath);
if (stat.isDirectory()) {
filePath = path.join(filePath, `heap-${Date.now()}.heapsnapshot`);
}
} catch {
if (!filePath.endsWith(".heapsnapshot")) {
filePath = `${filePath}.heapsnapshot`;
}
}

const snapshot = v8.writeHeapSnapshot(filePath);
return snapshot;
}

/** Reset the profiler, clearing all recorded snapshots. */
reset(): void {
this._snapshots = [];
}

private _ensureInitialized(): void {
if (!this._initialized) {
throw new ProfilerNotInitializedError();
}
}
}

/** Default singleton profiler instance. */
export const memoryProfiler = new MemoryProfiler();
31 changes: 31 additions & 0 deletions src/throttle/RateLimitParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,34 @@ export function parseRateLimitHeaders(headers: HeadersLike): RateLimitInfo {
resetAt: resetSeconds !== undefined ? resetSeconds * 1000 : 0,
};
}

/**
* Parse a Retry-After header value into a delay in milliseconds.
*
* - Integer or fractional seconds (e.g. "3", "1.5") are converted to ms.
* - HTTP-date values (e.g. "Wed, 21 Oct 2015 07:28:00 GMT") are converted
* to the delay between now and that date.
* - Unparseable values return `null`.
*/
export function parseRetryAfter(value: string): number | null {
if (!value || value.trim().length === 0) return null;

const trimmed = value.trim();

const numeric = Number.parseFloat(trimmed);
if (!Number.isNaN(numeric) && trimmed === String(numeric)) {
return Math.max(0, Math.round(numeric * 1000));
}

if (!Number.isNaN(numeric) && /^[\d.]+\s*$/.test(trimmed)) {
return Math.max(0, Math.round(numeric * 1000));
}

const dateMs = Date.parse(trimmed);
if (!Number.isNaN(dateMs)) {
const delay = dateMs - Date.now();
return Math.max(0, Math.round(delay));
}

return null;
}
39 changes: 39 additions & 0 deletions src/validators/invoiceMetadataValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,42 @@ export class InvoiceMetadataValidator {
return { valid: false, errors };
}
}

/** Maximum allowed length for custom metadata keys. */
export const MAX_METADATA_KEY_LENGTH = 64;

/**
* Validate that all custom metadata keys are within the allowed length.
*
* @param customKeys - Record of custom metadata key-value pairs.
* @returns An object with `valid` boolean and optional `error` message.
*/
export function validateMetadataKeys(
customKeys: Record<string, unknown> | undefined
): { valid: boolean; error?: string } {
if (!customKeys) return { valid: true };

for (const key of Object.keys(customKeys)) {
if (key.length > MAX_METADATA_KEY_LENGTH) {
return {
valid: false,
error: `Custom metadata key "${key}" exceeds maximum length of ${MAX_METADATA_KEY_LENGTH} characters (got ${key.length})`,
};
}

const value = customKeys[key];
if (value && typeof value === "object" && !Array.isArray(value)) {
const nested = value as Record<string, unknown>;
for (const nestedKey of Object.keys(nested)) {
if (nestedKey.length > MAX_METADATA_KEY_LENGTH) {
return {
valid: false,
error: `Custom metadata key "${nestedKey}" exceeds maximum length of ${MAX_METADATA_KEY_LENGTH} characters (got ${nestedKey.length})`,
};
}
}
}
}

return { valid: true };
}
Loading
Loading
You are viewing a condensed version of this merge commit. You can view the full changes here.