Skip to content
Open
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
54 changes: 53 additions & 1 deletion packages/util/src/lib/cloudinary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, ParseUrl>();

// 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`);
}
Expand Down
48 changes: 48 additions & 0 deletions packages/util/tests/lib/cloudinary.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down