From abdb04918d49257b0f4f8e0bbe8e9cac0c41905b Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Sat, 13 Jun 2026 13:25:21 +0200 Subject: [PATCH 1/2] perf(registry): bound component file cache with lru-cache and add cacheMaxSize config Replace the unbounded nice-cache in get-component with lru-cache so compiled templates, data providers and env files are evicted after reaching cacheMaxSize (default 100). Also avoid shallow-cloning every component detail map in components-details when nothing has changed. - Add lru-cache@^10.4.3 dependency, remove nice-cache - Add cacheMaxSize config option with sanitiser default - Update tests for the new config default --- package-lock.json | 10 +++++-- packages/oc/package.json | 2 +- packages/oc/src/globals.d.ts | 11 ------- .../src/registry/domain/components-details.ts | 12 +++++++- .../src/registry/domain/options-sanitiser.ts | 4 +++ .../registry/routes/helpers/get-component.ts | 25 ++++++++-------- packages/oc/src/types.ts | 9 ++++++ .../unit/registry-domain-options-sanitiser.js | 29 +++++++++++++++++++ 8 files changed, 74 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4060c471..42a13517e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16610,9 +16610,9 @@ "getport": "^0.1.0", "livereload": "^0.10.3", "lodash.isequal": "^4.5.0", + "lru-cache": "^10.4.3", "morgan": "^1.11.0", "multer": "^2.2.0", - "nice-cache": "^0.0.5", "oc-client": "^4.0.3", "oc-client-browser": "^2.1.12", "oc-empty-response-handler": "^1.0.2", @@ -17348,7 +17348,7 @@ "nice-cache": "^0.0.5", "node-dir": "^0.1.17", "oc-storage-adapters-utils": "^2.1.2", - "tmp": "0.2.7" + "tmp": "^0.2.7" }, "devDependencies": { "@biomejs/biome": "^2.5.0", @@ -17591,6 +17591,12 @@ "node": ">=14.17" } }, + "packages/oc/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "packages/oc/node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/packages/oc/package.json b/packages/oc/package.json index fa6b29fcc..9eb6660a2 100644 --- a/packages/oc/package.json +++ b/packages/oc/package.json @@ -91,9 +91,9 @@ "getport": "^0.1.0", "livereload": "^0.10.3", "lodash.isequal": "^4.5.0", + "lru-cache": "^10.4.3", "morgan": "^1.11.0", "multer": "^2.2.0", - "nice-cache": "^0.0.5", "oc-client": "^4.0.3", "oc-client-browser": "^2.1.12", "oc-empty-response-handler": "^1.0.2", diff --git a/packages/oc/src/globals.d.ts b/packages/oc/src/globals.d.ts index d874cee6c..f78e59eb9 100644 --- a/packages/oc/src/globals.d.ts +++ b/packages/oc/src/globals.d.ts @@ -91,14 +91,3 @@ declare module 'getport' { export = getPort; } - -declare module 'nice-cache' { - class Cache { - constructor(opt: { refreshInterval?: number; verbose?: boolean }); - - get(type: string, key: string): any; - set(type: string, key: string, data: unknown): void; - } - - export = Cache; -} diff --git a/packages/oc/src/registry/domain/components-details.ts b/packages/oc/src/registry/domain/components-details.ts index e9fe59cde..8d997a659 100644 --- a/packages/oc/src/registry/domain/components-details.ts +++ b/packages/oc/src/registry/domain/components-details.ts @@ -83,10 +83,11 @@ export default function componentsDetails( for (const [name, componentDetails] of Object.entries( options.details?.components || {} )) { - details.components[name] = { ...componentDetails }; + details.components[name] = componentDetails; } const missing: Array<{ name: string; version: string }> = []; + const componentsNeedingClone = new Set(); for (const [name, versions] of Object.entries( options.componentsList.components )) { @@ -96,10 +97,19 @@ export default function componentsDetails( for (const version of versions) { if (!componentDetails[version]) { missing.push({ name, version }); + if (options.details?.components?.[name]) { + componentsNeedingClone.add(name); + } } } } + // Only shallow-clone component detail maps that will be mutated. This + // keeps memory usage lower when most components are already up-to-date. + for (const name of componentsNeedingClone) { + details.components[name] = { ...details.components[name] }; + } + const limit = pLimit(cdn.maxConcurrentRequests); await Promise.all( diff --git a/packages/oc/src/registry/domain/options-sanitiser.ts b/packages/oc/src/registry/domain/options-sanitiser.ts index 6edcaacf6..657c7491e 100644 --- a/packages/oc/src/registry/domain/options-sanitiser.ts +++ b/packages/oc/src/registry/domain/options-sanitiser.ts @@ -87,6 +87,10 @@ export default function optionsSanitiser< options.verbosity = 0; } + if (typeof options.cacheMaxSize !== 'number' || options.cacheMaxSize <= 0) { + options.cacheMaxSize = 100; + } + const showApi = typeof options.discovery === 'boolean' ? true diff --git a/packages/oc/src/registry/routes/helpers/get-component.ts b/packages/oc/src/registry/routes/helpers/get-component.ts index c3dbf4af6..cda478e8c 100644 --- a/packages/oc/src/registry/routes/helpers/get-component.ts +++ b/packages/oc/src/registry/routes/helpers/get-component.ts @@ -3,7 +3,7 @@ import Domain from 'node:domain'; import type { IncomingHttpHeaders } from 'node:http'; import vm from 'node:vm'; import acceptLanguageParser from 'accept-language-parser'; -import Cache from 'nice-cache'; +import { LRUCache } from 'lru-cache'; import Client from 'oc-client'; import emptyResponseHandler from 'oc-empty-response-handler'; import { fromPromise } from 'universalify'; @@ -136,9 +136,8 @@ function pluginConverter(plugins: Plugins = {}) { export default function getComponent(conf: Config, repository: Repository) { const client = Client({ templates: conf.templates }); - const cache = new Cache({ - verbose: !!conf.verbosity, - refreshInterval: conf.refreshInterval + const cache = new LRUCache({ + max: conf.cacheMaxSize ?? 100 }); const convertPlugins = pluginConverter(conf.plugins); const customHeadersByConfig = new WeakMap | undefined>(); @@ -180,8 +179,8 @@ export default function getComponent(conf: Config, repository: Repository) { const getEnv = async ( component: Component ): Promise> => { - const cacheKey = `${component.name}/${component.version}/.env`; - const cached = cache.get('file-contents', cacheKey); + const cacheKey = `file-contents:${component.name}/${component.version}/.env`; + const cached = cache.get(cacheKey); if (cached) return cached; @@ -189,7 +188,7 @@ export default function getComponent(conf: Config, repository: Repository) { const env = component.oc.files.env ? await repository.getEnv(component.name, component.version) : {}; - cache.set('file-contents', cacheKey, env); + cache.set(cacheKey, env); return env; }); }; @@ -538,8 +537,8 @@ export default function getComponent(conf: Config, repository: Repository) { }) }); } else { - const cacheKey = `${component.name}/${component.version}/template.js`; - const cached = cache.get('file-contents', cacheKey); + const cacheKey = `file-contents:${component.name}/${component.version}/template.js`; + const cached = cache.get(cacheKey); const key = component.oc.files.template.hashKey; const id = randomUUID(); const renderOptions = { @@ -591,7 +590,7 @@ export default function getComponent(conf: Config, repository: Repository) { templateText, key ); - cache.set('file-contents', cacheKey, template); + cache.set(cacheKey, template); return template; }; const templatePromise = conf.hotReloading @@ -635,8 +634,8 @@ export default function getComponent(conf: Config, repository: Repository) { }); } - const cacheKey = `${component.name}/${component.version}/server.js`; - const cached = cache.get('file-contents', cacheKey); + const cacheKey = `file-contents:${component.name}/${component.version}/server.js`; + const cached = cache.get(cacheKey); const domain = Domain.create(); const setEmptyResponse = emptyResponseHandler.contextDecorator(returnComponent); @@ -791,7 +790,7 @@ export default function getComponent(conf: Config, repository: Repository) { vm.runInNewContext(dataProvider.content, context, vmOptions); const processData = context.module.exports['data'] || context.exports['data']; - cache.set('file-contents', cacheKey, processData); + cache.set(cacheKey, processData); return processData; }; diff --git a/packages/oc/src/types.ts b/packages/oc/src/types.ts index f60d1fcec..9b93df6d9 100644 --- a/packages/oc/src/types.ts +++ b/packages/oc/src/types.ts @@ -454,6 +454,15 @@ export interface Config< * @default 120000 */ timeout: number; + /** + * Maximum number of compiled component files (templates, data providers, + * env files) kept in the in-memory LRU cache. Older entries are evicted + * when the limit is reached. Lower values reduce memory usage; higher + * values improve performance for frequently accessed components. + * + * @default 100 + */ + cacheMaxSize: number; /** * Verbosity level of the console logger (0 = silent). * diff --git a/packages/oc/test/unit/registry-domain-options-sanitiser.js b/packages/oc/test/unit/registry-domain-options-sanitiser.js index fd692810d..52ff11c7e 100644 --- a/packages/oc/test/unit/registry-domain-options-sanitiser.js +++ b/packages/oc/test/unit/registry-domain-options-sanitiser.js @@ -76,6 +76,35 @@ describe('registry : domain : options-sanitiser', () => { }); }); + describe('cacheMaxSize', () => { + describe('when not provided', () => { + const options = { baseUrl: 'http://my-registry.com' }; + + it('should default to 100', () => { + expect(sanitise(options).cacheMaxSize).to.equal(100); + }); + }); + + describe('when provided', () => { + const options = { baseUrl: 'http://my-registry.com', cacheMaxSize: 500 }; + + it('should leave value untouched', () => { + expect(sanitise(options).cacheMaxSize).to.equal(500); + }); + }); + + describe('when provided with an invalid value', () => { + const options = { + baseUrl: 'http://my-registry.com', + cacheMaxSize: -1 + }; + + it('should default to 100', () => { + expect(sanitise(options).cacheMaxSize).to.equal(100); + }); + }); + }); + describe('customHeadersToSkipOnWeakVersion', () => { describe('when it contains valid elements', () => { const options = { From 5338b2cd5ac7a9c1326a642a46d94de01236ee80 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Sat, 25 Jul 2026 22:17:27 +0200 Subject: [PATCH 2/2] fix(get-component): resolve lru-cache CJS/ESM constructor interop Named `LRUCache` import compiled to `.LRUCache` access which fails when the wrong lru-cache shape is resolved; use the same defensive ctor lookup as the original PR. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/oc/src/registry/routes/helpers/get-component.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/oc/src/registry/routes/helpers/get-component.ts b/packages/oc/src/registry/routes/helpers/get-component.ts index cda478e8c..8fbd179c4 100644 --- a/packages/oc/src/registry/routes/helpers/get-component.ts +++ b/packages/oc/src/registry/routes/helpers/get-component.ts @@ -3,7 +3,7 @@ import Domain from 'node:domain'; import type { IncomingHttpHeaders } from 'node:http'; import vm from 'node:vm'; import acceptLanguageParser from 'accept-language-parser'; -import { LRUCache } from 'lru-cache'; +import * as LRUCacheModule from 'lru-cache'; import Client from 'oc-client'; import emptyResponseHandler from 'oc-empty-response-handler'; import { fromPromise } from 'universalify'; @@ -136,7 +136,12 @@ function pluginConverter(plugins: Plugins = {}) { export default function getComponent(conf: Config, repository: Repository) { const client = Client({ templates: conf.templates }); - const cache = new LRUCache({ + // lru-cache v10 exports { LRUCache }; older/CJS shapes may be default or the ctor itself + const LRUCacheCtor = + (LRUCacheModule as any).LRUCache || + (LRUCacheModule as any).default || + LRUCacheModule; + const cache = new LRUCacheCtor({ max: conf.cacheMaxSize ?? 100 }); const convertPlugins = pluginConverter(conf.plugins);