diff --git a/packages/util/src/lib/cloudinary.ts b/packages/util/src/lib/cloudinary.ts index de9d05c..f8473a5 100644 --- a/packages/util/src/lib/cloudinary.ts +++ b/packages/util/src/lib/cloudinary.ts @@ -9,7 +9,7 @@ const CLOUDINARY_DEFAULT_HOST = "res.cloudinary.com"; /** * parseUrl - * @description + * @description Parses a Cloudinary URL into its component parts. */ export interface ParseUrl { @@ -26,7 +26,59 @@ export interface ParseUrl { version?: number; } +// Bound the cache so a long-lived process can't grow it without limit; evict LRU once full. +const PARSE_URL_CACHE_LIMIT = 5000; +const parseUrlCache = new Map(); + +// Deep-copy the nested `transformations`/`queryParams` so a caller mutating the returned +// object can't poison the shared cache entry. Every other field is a primitive. +function copyParts(parts: ParseUrl): ParseUrl { + return { + ...parts, + transformations: Array.isArray(parts.transformations) + ? [...parts.transformations] + : parts.transformations, + queryParams: + parts.queryParams && typeof parts.queryParams === "object" + ? { ...parts.queryParams } + : parts.queryParams, + }; +} + +/** + * Memoizes {@link parseUrlUncached} by `src`. Parsing runs a large regex and the same + * `src` is commonly parsed many times (e.g. once per `srcset` width, every render), so a + * bounded LRU collapses repeats to one parse per distinct URL. Each call returns a fresh + * copy, and invalid input throws as before (failures are never cached). + */ export function parseUrl(src: string): ParseUrl | undefined { + // Non-string inputs hit the uncached throw path; don't cache them. + if (typeof src !== "string") return parseUrlUncached(src); + + const cached = parseUrlCache.get(src); + if (cached !== undefined) { + // LRU bump: re-insert so this entry becomes most-recently-used. + parseUrlCache.delete(src); + parseUrlCache.set(src, cached); + return copyParts(cached); + } + + // Let throws (invalid src) propagate without caching, so error behavior is identical + // to an uncached parse. + const result = parseUrlUncached(src); + + // `parseUrlUncached` is typed `| undefined`; guard so we never cache/copy a non-object. + if (result === undefined) return undefined; + + parseUrlCache.set(src, result); + if (parseUrlCache.size > PARSE_URL_CACHE_LIMIT) { + // Map iterates in insertion order, so the first key is the least-recently-used. + parseUrlCache.delete(parseUrlCache.keys().next().value as string); + } + return copyParts(result); +} + +function parseUrlUncached(src: string): ParseUrl | undefined { if (typeof src !== "string") { throw new Error(`Failed to parse URL - Invalid src: Is not a string`); } diff --git a/packages/util/tests/lib/cloudinary.spec.js b/packages/util/tests/lib/cloudinary.spec.js index ab9cf4d..d1e72d8 100644 --- a/packages/util/tests/lib/cloudinary.spec.js +++ b/packages/util/tests/lib/cloudinary.spec.js @@ -336,6 +336,54 @@ describe('Cloudinary', () => { }); }); + describe('parseUrl memoization', () => { + // parseUrl memoizes by `src`. `decodeURIComponent` is a global called once per + // successful parse (on the public ID), so spying on it is a clean way to observe + // whether a given call actually ran the parse pipeline or was served from cache. + + it('parses a given src only once and serves repeated calls from cache', () => { + const src = `https://res.cloudinary.com/test-cloud/image/upload/c_limit,w_960/v1234/memoize-once`; + const decodeSpy = vi.spyOn(globalThis, 'decodeURIComponent'); + + const first = parseUrl(src); // cache miss -> runs the parse + const callsAfterMiss = decodeSpy.mock.calls.length; + expect(callsAfterMiss).toBeGreaterThan(0); + + const second = parseUrl(src); // cache hit -> must not re-run the parse + expect(decodeSpy).toHaveBeenCalledTimes(callsAfterMiss); + + // The cached result still matches a fresh parse of the same URL. + expect(second).toEqual(first); + }); + + it('returns deeply-independent copies so callers cannot mutate cached state', () => { + const src = `https://res.cloudinary.com/test-cloud/video/upload/f_auto,q_auto/c_limit,w_960/v1234/nested/folder/turtle.mp4?_i=AA&_a=BB`; + + const first = parseUrl(src); + const second = parseUrl(src); + + expect(second).toEqual(first); + // Nested values must be fresh per call, not shared with the cache entry. + expect(second.transformations).not.toBe(first.transformations); + expect(second.queryParams).not.toBe(first.queryParams); + + // Mutating a returned copy must not leak into the cache / later calls. + first.transformations.push('x_injected'); + first.queryParams.injected = true; + + const third = parseUrl(src); + expect(third.transformations).not.toContain('x_injected'); + expect(third.queryParams).not.toHaveProperty('injected'); + }); + + it('does not cache failures - invalid src throws on every call', () => { + const src = `https://res.cloudinary.com/test-cloud/image/upload/c_limit,w_960/memoize-no-version`; + expect(() => parseUrl(src)).toThrow('Invalid src: Does not include version'); + // A second call must throw the same error (the failure was not memoized). + expect(() => parseUrl(src)).toThrow('Invalid src: Does not include version'); + }); + }); + describe('getPublicId', () => { it('should throw an error on a Cloudinary URL without a version', () => { const publicId = 'turtle';