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..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 Cache from 'nice-cache'; +import * as LRUCacheModule from 'lru-cache'; import Client from 'oc-client'; import emptyResponseHandler from 'oc-empty-response-handler'; import { fromPromise } from 'universalify'; @@ -136,9 +136,13 @@ 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 + // 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); const customHeadersByConfig = new WeakMap | undefined>(); @@ -180,8 +184,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 +193,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 +542,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 +595,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 +639,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 +795,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 = {