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
3 changes: 3 additions & 0 deletions packages/oc/src/registry/routes/component-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
68 changes: 66 additions & 2 deletions packages/oc/src/registry/routes/helpers/is-url-discoverable.ts
Original file line number Diff line number Diff line change
@@ -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<DiscoverabilityResult> {
try {
const res = await request(url, { headers: { accept: 'text/html' } });

Expand All @@ -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<DiscoverabilityResult> {
if (!shouldBypassCache()) {
const cached = discoverabilityCache.get<Promise<DiscoverabilityResult>>(
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<Promise<DiscoverabilityResult>>(
DISCOVERABILITY_CACHE_NAMESPACE,
url
) === pending
) {
discoverabilityCache.delete(DISCOVERABILITY_CACHE_NAMESPACE, url);
}
});
}

return pending;
}
Original file line number Diff line number Diff line change
@@ -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 <jane@example.com>',
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/');
});
});
Loading
Loading