From 4e87bbb0c0fcf0447c46d30ca41ae0cb0704166b Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Sun, 6 Sep 2026 11:51:48 +0200 Subject: [PATCH 1/2] perf(registry): memoize component-info discoverability probe per baseUrl component-info HTML route self-probed the registry over HTTP (isUrlDiscoverable GET to own baseUrl) on every ~info request, even though the result only changes when deployment config changes. Cache the probe promise per baseUrl for process lifetime with in-flight dedup; add clearDiscoverabilityCache() escape hatch for tests plus OC_DISCOVERABILITY_NO_CACHE=1/true to force re-probing. Fallback to //host+prefix on not-discoverable is unchanged. --- .../oc/src/registry/routes/component-info.ts | 3 + .../routes/helpers/is-url-discoverable.ts | 53 ++++++- ...y-routes-component-info-discoverability.js | 150 ++++++++++++++++++ ...stry-routes-helpers-is-url-discoverable.js | 126 ++++++++++++++- 4 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 packages/oc/test/unit/registry-routes-component-info-discoverability.js diff --git a/packages/oc/src/registry/routes/component-info.ts b/packages/oc/src/registry/routes/component-info.ts index 02b1de043..c14ad181a 100644 --- a/packages/oc/src/registry/routes/component-info.ts +++ b/packages/oc/src/registry/routes/component-info.ts @@ -71,6 +71,9 @@ function componentInfo( ? component.repository : (component.repository?.url ?? null); + // isUrlDiscoverable is memoized per baseUrl for process lifetime + // (see helpers/is-url-discoverable), so this self-probe costs one HTTP + // request no matter how often the info page is rendered. fromPromise(isUrlDiscoverable)(href, (_err, result) => { if (!result.isDiscoverable) { href = `//${req.headers.host}${res.conf.prefix}`; diff --git a/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts b/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts index 92685e2c5..b3ef771f4 100644 --- a/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts +++ b/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts @@ -1,8 +1,23 @@ import { request } from 'undici'; -export default async function isUrlDiscoverable( +export interface DiscoverabilityResult { + isDiscoverable: boolean; +} + +// Memoized per baseUrl for process lifetime. The probe result only changes +// when deployment config changes, so re-probing over HTTP on every HTML +// info-page request is wasted work on the ~info hot path. The promise itself +// is cached so concurrent in-flight requests dedupe onto a single probe. +const discoverabilityCache = new Map>(); + +function shouldBypassCache(): boolean { + const flag = process.env['OC_DISCOVERABILITY_NO_CACHE']; + return flag === '1' || flag?.toLowerCase() === 'true'; +} + +async function probeUrlDiscoverability( url: string -): Promise<{ isDiscoverable: boolean }> { +): Promise { try { const res = await request(url, { headers: { accept: 'text/html' } }); @@ -15,3 +30,37 @@ export default async function isUrlDiscoverable( return { isDiscoverable: false }; } } + +export function clearDiscoverabilityCache(url?: string): void { + if (url === undefined) { + discoverabilityCache.clear(); + } else { + discoverabilityCache.delete(url); + } +} + +export default async function isUrlDiscoverable( + url: string +): Promise { + if (!shouldBypassCache()) { + const cached = discoverabilityCache.get(url); + if (cached) { + return cached; + } + } + + const pending = probeUrlDiscoverability(url); + + if (!shouldBypassCache()) { + discoverabilityCache.set(url, pending); + // Don't let a rejection permanently poison the cache entry. + // (probe currently never rejects, this is defensive.) + pending.catch(() => { + if (discoverabilityCache.get(url) === pending) { + discoverabilityCache.delete(url); + } + }); + } + + return pending; +} diff --git a/packages/oc/test/unit/registry-routes-component-info-discoverability.js b/packages/oc/test/unit/registry-routes-component-info-discoverability.js new file mode 100644 index 000000000..508b27a92 --- /dev/null +++ b/packages/oc/test/unit/registry-routes-component-info-discoverability.js @@ -0,0 +1,150 @@ +const expect = require('chai').expect; +const injectr = require('injectr'); + +describe('registry : routes : component-info discoverability probe cache', () => { + const baseComponent = () => ({ + name: 'hello-world', + version: '1.0.0', + allVersions: ['1.0.0'], + author: 'Jane Doe ', + dependencies: { lodash: '^4.0.0' }, + description: 'A component', + oc: { + container: false, + date: 123, + files: { + template: { + hashKey: 'template-key', + src: 'template.js', + type: 'oc-template-es6', + version: '1.0.0' + } + }, + packaged: true, + parameters: {}, + plugins: [], + state: 'experimental', + version: '0.50.61' + } + }); + + const loadRouteWithProbe = (requestStub) => { + const helper = injectr( + '../../dist/registry/routes/helpers/is-url-discoverable.js', + { undici: { request: requestStub } }, + { process } + ); + const route = injectr('../../dist/registry/routes/component-info.js', { + './helpers/is-url-discoverable': { + __esModule: true, + default: helper.default + } + }).default; + return { helper, route }; + }; + + const renderInfoHtml = (route, { baseUrl, host }) => + new Promise((resolve, reject) => { + const repository = { + getComponent: () => Promise.resolve(baseComponent()), + getComponentsDetails: () => Promise.resolve(undefined) + }; + const handler = route({}, repository); + handler( + { + cookies: {}, + headers: { accept: 'text/html', host }, + params: { componentName: 'hello-world', componentVersion: '1.0.0' } + }, + { + conf: { + baseUrl, + discovery: { api: true, ui: true }, + prefix: '/v2/', + robots: false + }, + send: (html) => resolve(html) + } + ).catch(reject); + }); + + it('issues ONE probe for repeated info renders with the same baseUrl', async () => { + let probeCalls = 0; + const { route } = loadRouteWithProbe(() => { + probeCalls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + const first = await renderInfoHtml(route, { + baseUrl: 'https://registry-info.company.com/', + host: 'registry-info.company.com' + }); + const second = await renderInfoHtml(route, { + baseUrl: 'https://registry-info.company.com/', + host: 'registry-info.company.com' + }); + + expect(probeCalls).to.equal(1); + expect(first).to.contain('https://registry-info.company.com/'); + expect(second).to.contain('https://registry-info.company.com/'); + }); + + it('probes again for a different baseUrl', async () => { + let probeCalls = 0; + const { route } = loadRouteWithProbe(() => { + probeCalls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + await renderInfoHtml(route, { + baseUrl: 'https://registry-a.company.com/', + host: 'registry-a.company.com' + }); + await renderInfoHtml(route, { + baseUrl: 'https://registry-b.company.com/', + host: 'registry-b.company.com' + }); + + expect(probeCalls).to.equal(2); + }); + + it('re-probes after clearDiscoverabilityCache(baseUrl)', async () => { + let probeCalls = 0; + const { helper, route } = loadRouteWithProbe(() => { + probeCalls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + const baseUrl = 'https://registry-clear.company.com/'; + + await renderInfoHtml(route, { baseUrl, host: 'registry-clear.company.com' }); + expect(probeCalls).to.equal(1); + helper.clearDiscoverabilityCache(baseUrl); + await renderInfoHtml(route, { baseUrl, host: 'registry-clear.company.com' }); + expect(probeCalls).to.equal(2); + }); + + it('keeps the //host+prefix fallback when baseUrl is not discoverable', async () => { + let probeCalls = 0; + const { route } = loadRouteWithProbe(() => { + probeCalls++; + return Promise.resolve({ + headers: { 'content-type': 'application/json; charset=utf-8' } + }); + }); + + const html = await renderInfoHtml(route, { + baseUrl: 'https://undiscoverable.company.com/', + host: 'info-host.test' + }); + + expect(probeCalls).to.equal(1); + expect(html).to.contain('//info-host.test/v2/'); + expect(html).to.not.contain('https://undiscoverable.company.com/'); + }); +}); diff --git a/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js b/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js index 083bd3745..c656bbb74 100644 --- a/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js +++ b/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js @@ -16,7 +16,8 @@ describe('registry : routes : helpers : is-url-discoverable', () => { } }) } - } + }, + { process } ).default; isDiscoverable('https://baseurl.company.com/') @@ -45,10 +46,11 @@ describe('registry : routes : helpers : is-url-discoverable', () => { } }) } - } + }, + { process } ).default; - isDiscoverable('https://baseurl.company.com/') + isDiscoverable('https://other-baseurl.company.com/') .then((res) => { result = res; }) @@ -59,4 +61,122 @@ describe('registry : routes : helpers : is-url-discoverable', () => { expect(result.isDiscoverable).to.be.true; }); }); + + describe('memoization per baseUrl', () => { + const loadHelper = (requestStub) => + injectr( + '../../dist/registry/routes/helpers/is-url-discoverable.js', + { undici: { request: requestStub } }, + { process } + ); + + it('issues ONE probe for repeated calls with the same baseUrl', async () => { + let calls = 0; + const helper = loadHelper(() => { + calls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + const first = await helper.default('https://memo-one.company.com/'); + const second = await helper.default('https://memo-one.company.com/'); + + expect(first.isDiscoverable).to.be.true; + expect(second.isDiscoverable).to.be.true; + expect(calls).to.equal(1); + }); + + it('dedupes concurrent in-flight probes onto a single request', async () => { + let calls = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const helper = loadHelper(() => { + calls++; + return gate.then(() => ({ + headers: { 'content-type': 'text/html; charset=utf-8' } + })); + }); + + const pending = Promise.all([ + helper.default('https://memo-inflight.company.com/'), + helper.default('https://memo-inflight.company.com/') + ]); + release(); + const [first, second] = await pending; + + expect(first.isDiscoverable).to.be.true; + expect(second.isDiscoverable).to.be.true; + expect(calls).to.equal(1); + }); + + it('probes again for a different baseUrl', async () => { + let calls = 0; + const helper = loadHelper(() => { + calls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + await helper.default('https://memo-a.company.com/'); + await helper.default('https://memo-b.company.com/'); + + expect(calls).to.equal(2); + }); + + it('clearDiscoverabilityCache(url) forces a re-probe for that url', async () => { + let calls = 0; + const helper = loadHelper(() => { + calls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + await helper.default('https://memo-clear-one.company.com/'); + expect(calls).to.equal(1); + helper.clearDiscoverabilityCache('https://memo-clear-one.company.com/'); + await helper.default('https://memo-clear-one.company.com/'); + expect(calls).to.equal(2); + }); + + it('clearDiscoverabilityCache() clears all entries', async () => { + let calls = 0; + const helper = loadHelper(() => { + calls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + await helper.default('https://memo-clear-all-a.company.com/'); + await helper.default('https://memo-clear-all-b.company.com/'); + expect(calls).to.equal(2); + helper.clearDiscoverabilityCache(); + await helper.default('https://memo-clear-all-a.company.com/'); + expect(calls).to.equal(3); + }); + + it('OC_DISCOVERABILITY_NO_CACHE=1 forces a re-probe every call', async () => { + let calls = 0; + const helper = loadHelper(() => { + calls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + process.env.OC_DISCOVERABILITY_NO_CACHE = '1'; + try { + await helper.default('https://memo-nocache.company.com/'); + await helper.default('https://memo-nocache.company.com/'); + } finally { + delete process.env.OC_DISCOVERABILITY_NO_CACHE; + } + expect(calls).to.equal(2); + }); + }); }); From 7b41279f9a606aac6b7f23fec514bd7e4e4e656e Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Sun, 6 Sep 2026 11:58:09 +0200 Subject: [PATCH 2/2] perf(registry): bound discoverability probe cache with LRU Unbounded module-level Map keyed by baseUrl could grow forever when a host-based baseUrlFunc yields distinct baseUrls per Host (perf item 7). Use the registry's BoundedCache (LRU, max 100 entries) keyed by baseUrl instead. Semantics unchanged: per-baseUrl memoization, in-flight promise dedup, clearDiscoverabilityCache(url?)/clear-all, OC_DISCOVERABILITY_NO_CACHE bypass, rejection eviction adapted to the BoundedCache API. --- .../oc/src/registry/routes/component-info.ts | 6 ++-- .../routes/helpers/is-url-discoverable.ts | 35 +++++++++++++------ ...stry-routes-helpers-is-url-discoverable.js | 20 +++++++++++ 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/oc/src/registry/routes/component-info.ts b/packages/oc/src/registry/routes/component-info.ts index c14ad181a..8c0ea66af 100644 --- a/packages/oc/src/registry/routes/component-info.ts +++ b/packages/oc/src/registry/routes/component-info.ts @@ -71,9 +71,9 @@ function componentInfo( ? component.repository : (component.repository?.url ?? null); - // isUrlDiscoverable is memoized per baseUrl for process lifetime - // (see helpers/is-url-discoverable), so this self-probe costs one HTTP - // request no matter how often the info page is rendered. + // isUrlDiscoverable is memoized per baseUrl (bounded LRU, see + // helpers/is-url-discoverable), so this self-probe costs one HTTP + // request per baseUrl no matter how often the info page is rendered. fromPromise(isUrlDiscoverable)(href, (_err, result) => { if (!result.isDiscoverable) { href = `//${req.headers.host}${res.conf.prefix}`; diff --git a/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts b/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts index b3ef771f4..e6cc1e651 100644 --- a/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts +++ b/packages/oc/src/registry/routes/helpers/is-url-discoverable.ts @@ -1,14 +1,21 @@ import { request } from 'undici'; +import BoundedCache from '../../../utils/bounded-cache'; export interface DiscoverabilityResult { isDiscoverable: boolean; } -// Memoized per baseUrl for process lifetime. The probe result only changes -// when deployment config changes, so re-probing over HTTP on every HTML -// info-page request is wasted work on the ~info hot path. The promise itself -// is cached so concurrent in-flight requests dedupe onto a single probe. -const discoverabilityCache = new Map>(); +// Memoized per baseUrl. The probe result only changes when deployment +// config changes, so re-probing over HTTP on every HTML info-page request +// is wasted work on the ~info hot path. Bounded (LRU) because a host-based +// baseUrlFunc can produce distinct baseUrls per Host — an unbounded map +// would grow forever. The promise itself is cached so concurrent in-flight +// requests dedupe onto a single probe. +const MAX_DISCOVERABILITY_CACHE_ENTRIES = 100; +const DISCOVERABILITY_CACHE_NAMESPACE = 'discoverability'; +const discoverabilityCache = new BoundedCache( + MAX_DISCOVERABILITY_CACHE_ENTRIES +); function shouldBypassCache(): boolean { const flag = process.env['OC_DISCOVERABILITY_NO_CACHE']; @@ -35,7 +42,7 @@ export function clearDiscoverabilityCache(url?: string): void { if (url === undefined) { discoverabilityCache.clear(); } else { - discoverabilityCache.delete(url); + discoverabilityCache.delete(DISCOVERABILITY_CACHE_NAMESPACE, url); } } @@ -43,7 +50,10 @@ export default async function isUrlDiscoverable( url: string ): Promise { if (!shouldBypassCache()) { - const cached = discoverabilityCache.get(url); + const cached = discoverabilityCache.get>( + DISCOVERABILITY_CACHE_NAMESPACE, + url + ); if (cached) { return cached; } @@ -52,12 +62,17 @@ export default async function isUrlDiscoverable( const pending = probeUrlDiscoverability(url); if (!shouldBypassCache()) { - discoverabilityCache.set(url, pending); + discoverabilityCache.set(DISCOVERABILITY_CACHE_NAMESPACE, url, pending); // Don't let a rejection permanently poison the cache entry. // (probe currently never rejects, this is defensive.) pending.catch(() => { - if (discoverabilityCache.get(url) === pending) { - discoverabilityCache.delete(url); + if ( + discoverabilityCache.get>( + DISCOVERABILITY_CACHE_NAMESPACE, + url + ) === pending + ) { + discoverabilityCache.delete(DISCOVERABILITY_CACHE_NAMESPACE, url); } }); } diff --git a/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js b/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js index c656bbb74..7cc2df6f5 100644 --- a/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js +++ b/packages/oc/test/unit/registry-routes-helpers-is-url-discoverable.js @@ -160,6 +160,26 @@ describe('registry : routes : helpers : is-url-discoverable', () => { expect(calls).to.equal(3); }); + it('evicts the least-recently-used entry beyond max capacity', async () => { + let calls = 0; + const helper = loadHelper(() => { + calls++; + return Promise.resolve({ + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + }); + + const first = 'https://memo-evict-first.company.com/'; + await helper.default(first); + expect(calls).to.equal(1); + for (let i = 0; i < 100; i++) { + await helper.default(`https://memo-evict-${i}.company.com/`); + } + expect(calls).to.equal(101); + await helper.default(first); + expect(calls).to.equal(102); + }); + it('OC_DISCOVERABILITY_NO_CACHE=1 forces a re-probe every call', async () => { let calls = 0; const helper = loadHelper(() => {