diff --git a/src/enricher.ts b/src/enricher.ts index 1c7960a..92a9028 100644 --- a/src/enricher.ts +++ b/src/enricher.ts @@ -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"; @@ -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 { + value: T; + expiresAt: number; +} + +/** + * Caches enriched metadata results with a configurable TTL. + */ +export class EnricherCache { + private _cache = new Map>(); + 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): Promise { + 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; + } +} diff --git a/src/index.ts b/src/index.ts index 35bb785..60b7128 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) @@ -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, @@ -248,6 +251,7 @@ export { getInvoiceMetadataCID, } from "./enricher.js"; export type { EnrichedInvoice, EnrichOptions } from "./enricher.js"; +export { EnricherCache } from "./enricher.js"; // IPFS functionality export { @@ -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 diff --git a/src/memoryProfiler.ts b/src/memoryProfiler.ts index 2db8f10..d907199 100644 --- a/src/memoryProfiler.ts +++ b/src/memoryProfiler.ts @@ -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; @@ -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 { + 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(); diff --git a/src/throttle/RateLimitParser.ts b/src/throttle/RateLimitParser.ts index 54890ca..4a055e4 100644 --- a/src/throttle/RateLimitParser.ts +++ b/src/throttle/RateLimitParser.ts @@ -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; +} diff --git a/src/validators/invoiceMetadataValidator.ts b/src/validators/invoiceMetadataValidator.ts index 706f622..51edb17 100644 --- a/src/validators/invoiceMetadataValidator.ts +++ b/src/validators/invoiceMetadataValidator.ts @@ -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 | 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; + 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 }; +} diff --git a/test/modules.test.ts b/test/modules.test.ts new file mode 100644 index 0000000..c631a24 --- /dev/null +++ b/test/modules.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { parseRetryAfter } from "../src/throttle/RateLimitParser.js"; +import { + validateMetadataKeys, + MAX_METADATA_KEY_LENGTH, +} from "../src/validators/invoiceMetadataValidator.js"; +import { + MemoryProfiler, + ProfilerNotInitializedError, +} from "../src/memoryProfiler.js"; +import { EnricherCache } from "../src/enricher.js"; + +describe("parseRetryAfter", () => { + it("parses an integer second value", () => { + expect(parseRetryAfter("3")).toBe(3000); + }); + + it("parses a fractional second value", () => { + expect(parseRetryAfter("1.5")).toBe(1500); + }); + + it("parses a fractional value with many decimals", () => { + expect(parseRetryAfter("0.25")).toBe(250); + }); + + it("returns 0 for '0'", () => { + expect(parseRetryAfter("0")).toBe(0); + }); + + it("parses an HTTP-date format value", () => { + const futureDate = new Date(Date.now() + 5000); + const httpDate = futureDate.toUTCString(); + const result = parseRetryAfter(httpDate); + expect(result).toBeGreaterThanOrEqual(4000); + expect(result).toBeLessThanOrEqual(6000); + }); + + it("returns null for empty string", () => { + expect(parseRetryAfter("")).toBeNull(); + }); + + it("returns null for unparseable value", () => { + expect(parseRetryAfter("unknown")).toBeNull(); + }); + + it("returns 0 for a past HTTP-date", () => { + const pastDate = new Date(Date.now() - 10000); + const httpDate = pastDate.toUTCString(); + expect(parseRetryAfter(httpDate)).toBe(0); + }); + + it("returns non-negative for negative numeric input", () => { + expect(parseRetryAfter("-5")).toBe(0); + }); +}); + +describe("validateMetadataKeys", () => { + it("returns valid for undefined input", () => { + expect(validateMetadataKeys(undefined)).toEqual({ valid: true }); + }); + + it("returns valid for empty object", () => { + expect(validateMetadataKeys({})).toEqual({ valid: true }); + }); + + it("returns valid for keys within the limit", () => { + const keys = { short: "value", another: "value2" }; + expect(validateMetadataKeys(keys)).toEqual({ valid: true }); + }); + + it("returns invalid for a key exceeding max length", () => { + const longKey = "a".repeat(MAX_METADATA_KEY_LENGTH + 1); + const result = validateMetadataKeys({ [longKey]: "value" }); + expect(result.valid).toBe(false); + expect(result.error).toContain(longKey); + expect(result.error).toContain(String(MAX_METADATA_KEY_LENGTH + 1)); + }); + + it("returns valid for a key exactly at max length", () => { + const exactKey = "a".repeat(MAX_METADATA_KEY_LENGTH); + expect(validateMetadataKeys({ [exactKey]: "value" })).toEqual({ + valid: true, + }); + }); + + it("validates nested custom keys", () => { + const longNestedKey = "b".repeat(MAX_METADATA_KEY_LENGTH + 1); + const input = { parent: { [longNestedKey]: "nested_value" } }; + const result = validateMetadataKeys(input); + expect(result.valid).toBe(false); + expect(result.error).toContain(longNestedKey); + }); + + it("returns valid for nested keys within limit", () => { + const input = { parent: { child: "value" } }; + expect(validateMetadataKeys(input)).toEqual({ valid: true }); + }); +}); + +describe("MemoryProfiler", () => { + let profiler: MemoryProfiler; + + beforeEach(() => { + profiler = new MemoryProfiler(); + }); + + it("throws ProfilerNotInitializedError before init", () => { + expect(() => profiler.snapshot()).toThrow(ProfilerNotInitializedError); + }); + + it("takes a snapshot after init", () => { + profiler.init(); + const snap = profiler.snapshot(); + expect(snap.heapUsed).toBeGreaterThan(0); + expect(snap.heapTotal).toBeGreaterThan(0); + expect(snap.rss).toBeGreaterThan(0); + expect(snap.timestamp).toBeGreaterThan(0); + }); + + it("records multiple snapshots", () => { + profiler.init(); + profiler.snapshot(); + profiler.snapshot(); + expect(profiler.getSnapshots()).toHaveLength(2); + }); + + it("returns a copy of snapshots", () => { + profiler.init(); + profiler.snapshot(); + const snaps = profiler.getSnapshots(); + snaps.pop(); + expect(profiler.getSnapshots()).toHaveLength(1); + }); + + it("exports a heap snapshot file", async () => { + profiler.init(); + const filePath = await profiler.exportHeapSnapshot( + "test-heap-export.heapsnapshot" + ); + expect(filePath).toBeTruthy(); + const fs = await import("node:fs/promises"); + const stat = await fs.stat(filePath); + expect(stat.size).toBeGreaterThan(0); + await fs.unlink(filePath); + }); + + it("exports to a directory with auto-generated name", async () => { + profiler.init(); + const filePath = await profiler.exportHeapSnapshot("."); + expect(filePath).toMatch(/heap-.*\.heapsnapshot$/); + const fs = await import("node:fs/promises"); + await fs.unlink(filePath); + }); + + it("reset clears snapshots", () => { + profiler.init(); + profiler.snapshot(); + profiler.reset(); + expect(profiler.getSnapshots()).toHaveLength(0); + }); + + it("reset clears snapshots but keeps profiler initialized", () => { + profiler.init(); + profiler.snapshot(); + profiler.reset(); + expect(profiler.getSnapshots()).toHaveLength(0); + const snap = profiler.snapshot(); + expect(snap.heapUsed).toBeGreaterThan(0); + }); +}); + +describe("EnricherCache", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("caches and returns the fetched value", async () => { + const cache = new EnricherCache(); + let callCount = 0; + const fetcher = async () => { + callCount++; + return "result"; + }; + + const result = await cache.getOrFetch("key1", fetcher); + expect(result).toBe("result"); + expect(callCount).toBe(1); + }); + + it("returns cached value on second call without re-fetching", async () => { + const cache = new EnricherCache(); + let callCount = 0; + const fetcher = async () => { + callCount++; + return "result"; + }; + + await cache.getOrFetch("key1", fetcher); + await cache.getOrFetch("key1", fetcher); + expect(callCount).toBe(1); + }); + + it("expires entries after TTL", async () => { + vi.useFakeTimers(); + const cache = new EnricherCache(1000); + let callCount = 0; + const fetcher = async () => { + callCount++; + return "result"; + }; + + await cache.getOrFetch("key1", fetcher); + expect(callCount).toBe(1); + + vi.advanceTimersByTime(1100); + await cache.getOrFetch("key1", fetcher); + expect(callCount).toBe(2); + }); + + it("clearCache empties the cache", async () => { + const cache = new EnricherCache(); + let callCount = 0; + const fetcher = async () => { + callCount++; + return "result"; + }; + + await cache.getOrFetch("key1", fetcher); + cache.clearCache(); + await cache.getOrFetch("key1", fetcher); + expect(callCount).toBe(2); + }); + + it("size counts non-expired entries", async () => { + vi.useFakeTimers(); + const cache = new EnricherCache(1000); + + await cache.getOrFetch("a", async () => "1"); + await cache.getOrFetch("b", async () => "2"); + expect(cache.size).toBe(2); + + vi.advanceTimersByTime(1100); + expect(cache.size).toBe(0); + }); + + it("uses default TTL of 60s", () => { + const cache = new EnricherCache(); + // Access private field for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((cache as any)._ttlMs).toBe(60_000); + }); +});