diff --git a/packages/oc/src/registry/routes/component-info.ts b/packages/oc/src/registry/routes/component-info.ts index 02b1de043..8c0ea66af 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 (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 92685e2c5..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,8 +1,30 @@ import { request } from 'undici'; +import BoundedCache from '../../../utils/bounded-cache'; -export default async function isUrlDiscoverable( +export interface DiscoverabilityResult { + isDiscoverable: boolean; +} + +// 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']; + 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 +37,45 @@ export default async function isUrlDiscoverable( return { isDiscoverable: false }; } } + +export function clearDiscoverabilityCache(url?: string): void { + if (url === undefined) { + discoverabilityCache.clear(); + } else { + discoverabilityCache.delete(DISCOVERABILITY_CACHE_NAMESPACE, url); + } +} + +export default async function isUrlDiscoverable( + url: string +): Promise { + if (!shouldBypassCache()) { + const cached = discoverabilityCache.get>( + DISCOVERABILITY_CACHE_NAMESPACE, + url + ); + if (cached) { + return cached; + } + } + + const pending = probeUrlDiscoverability(url); + + if (!shouldBypassCache()) { + 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>( + DISCOVERABILITY_CACHE_NAMESPACE, + url + ) === pending + ) { + discoverabilityCache.delete(DISCOVERABILITY_CACHE_NAMESPACE, 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..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 @@ -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,142 @@ 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('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(() => { + 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); + }); + }); });