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
82 changes: 82 additions & 0 deletions packages/oc/src/registry/routes/discovery-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { OcRequest } from '../domain/http-server/types';

export type DiscoveryResponseKind = 'html' | 'json';

export interface DiscoveryCacheVariant {
kind: DiscoveryResponseKind;
baseUrl: string;
local: boolean;
state: string;
meta: string;
theme: string;
discoveryApi: boolean;
discoveryExperimental: boolean;
}

// Maximum number of query variants cached per components generation. Variants
// are bounded in practice (state/meta/theme), the cap only guards against
// hostile query strings (e.g. random `meta` values) growing memory.
const MAX_VARIANTS_PER_GENERATION = 50;

const normaliseQueryValue = (value: unknown): string =>
typeof value === 'string' ? value : '';

export const getDiscoveryVariant = (
req: OcRequest,
baseUrl: string,
local: boolean,
discoveryApi: boolean,
discoveryExperimental: boolean,
kind: DiscoveryResponseKind
): DiscoveryCacheVariant => ({
kind,
baseUrl,
local,
// `q` is intentionally excluded: it is unused by the JSON branch and the
// HTML branch skips the cache whenever `q` is present.
state: kind === 'json' ? normaliseQueryValue(req.query['state']) : '',
meta: kind === 'json' ? normaliseQueryValue(req.query['meta']) : '',
theme: kind === 'html' ? req.cookies?.['oc-theme'] || 'dark' : '',
discoveryApi,
discoveryExperimental
});

export const getDiscoveryCacheKey = (variant: DiscoveryCacheVariant): string =>
JSON.stringify(variant);

interface DiscoveryCacheEntry {
lastEdit: number;
body: unknown;
}

export class DiscoveryResponseCache {
private generation: number | undefined;
private readonly entries = new Map<string, DiscoveryCacheEntry>();

get(lastEdit: number, key: string): unknown {
if (this.generation !== lastEdit) {
return undefined;
}

return this.entries.get(key)?.body;
}

set(lastEdit: number, key: string, body: unknown): void {
if (this.generation !== lastEdit) {
// `lastEdit` advances on every publish/poll update, so dropping the
// previous generation is both the invalidation mechanism and the memory
// bound for stale generations.
this.generation = lastEdit;
this.entries.clear();
} else if (
this.entries.size >= MAX_VARIANTS_PER_GENERATION &&
!this.entries.has(key)
) {
const oldest = this.entries.keys().next();
if (!oldest.done) {
this.entries.delete(oldest.value);
}
}
this.entries.set(key, { lastEdit, body });
}
}
119 changes: 98 additions & 21 deletions packages/oc/src/registry/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ import parseAuthor from 'parse-author';

import dateStringified from '../../utils/date-stringify';
import indexView from '../views';
import {
DiscoveryResponseCache,
getDiscoveryCacheKey,
getDiscoveryVariant
} from './discovery-cache';
import getAvailableDependencies from './helpers/get-available-dependencies';

import urlBuilder = require('../domain/url-builder');

import type { IncomingHttpHeaders } from 'node:http';
import type { PackageJson } from 'type-fest';
import type { Author, Component, ParsedComponent } from '../../types';
import type { OcHandler } from '../domain/http-server/types';
import type { OcHandler, OcResponse } from '../domain/http-server/types';
import type { Repository } from '../domain/repository';

const packageInfo: PackageJson = fs.readJsonSync(
Expand Down Expand Up @@ -39,8 +44,69 @@ const isHtmlRequest = (headers: IncomingHttpHeaders) =>

const excludedMeta = ['dependencies', 'devDependencies'];

const setDiscoveryCacheControl = (res: OcResponse): void => {
const pollingInterval = res.conf.pollingInterval;
if (
typeof pollingInterval === 'number' &&
Number.isFinite(pollingInterval) &&
pollingInterval >= 0
) {
res.set('Cache-Control', `public, max-age=${Math.floor(pollingInterval)}`);
}
};

export default function (repository: Repository): OcHandler {
// Per-registry-instance cache of built discovery responses. Entries are
// keyed by `componentsList.lastEdit` (via `getComponentsDetails`, which
// advances on every publish/poll update) plus the query variant, so a
// repeat `GET /` skips the O(registry) `getComponent` fan-out and the
// index view re-render. A cache hit still costs a single cheap
// `getComponentsDetails()` read to learn the current `lastEdit`; every
// other repository call is skipped.
const cache = new DiscoveryResponseCache();

return async (req, res): Promise<void> => {
const wantsHtml = isHtmlRequest(req.headers) && res.conf.discovery.ui;

let lastEdit: number | undefined;
if (typeof repository.getComponentsDetails === 'function') {
try {
const details = await repository.getComponentsDetails();
if (details && typeof details.lastEdit === 'number') {
lastEdit = details.lastEdit;
}
} catch {
lastEdit = undefined;
}
}

// HTML-with-query responses embed the per-request search string, so they
// bypass the cache (unbounded query cardinality); everything else is
// keyed by its variant.
const htmlQuery = wantsHtml ? (req.query['q'] as string) || '' : '';
const variant = getDiscoveryVariant(
req,
res.conf.baseUrl,
res.conf.local,
res.conf.discovery.api,
res.conf.discovery.experimental,
wantsHtml ? 'html' : 'json'
);
const cacheKey = getDiscoveryCacheKey(variant);

if (lastEdit !== undefined && htmlQuery === '') {
const cached = cache.get(lastEdit, cacheKey);
if (cached !== undefined) {
if (wantsHtml) {
res.send(cached);
} else {
setDiscoveryCacheControl(res);
res.status(200).json(cached);
}
return;
}
}

let componentNames: string[];
try {
componentNames = await repository.getComponents();
Expand Down Expand Up @@ -91,25 +157,29 @@ export default function (repository: Repository): OcHandler {

const userTheme = req.cookies?.['oc-theme'] || 'dark';

res.send(
indexView(
// @ts-expect-error existing code relies on runtime merging
Object.assign(baseResponse, {
availableDependencies: getAvailableDependencies(
res.conf.dependencies
),
availablePlugins: res.conf.plugins,
components: processedComponents,
componentsReleases: totalReleases,
componentsList,
q: req.query['q'] || '',
stateCounts,
templates: repository.getTemplatesInfo(),
title: 'OpenComponents Registry',
theme: userTheme
})
)
const html = indexView(
// @ts-expect-error existing code relies on runtime merging
Object.assign(baseResponse, {
availableDependencies: getAvailableDependencies(
res.conf.dependencies
),
availablePlugins: res.conf.plugins,
components: processedComponents,
componentsReleases: totalReleases,
componentsList,
q: req.query['q'] || '',
stateCounts,
templates: repository.getTemplatesInfo(),
title: 'OpenComponents Registry',
theme: userTheme
})
);

if (lastEdit !== undefined && htmlQuery === '') {
cache.set(lastEdit, cacheKey, html);
}

res.send(html);
} else {
const requestedState = (req.query['state'] as string) || '';
const includeMetadata =
Expand Down Expand Up @@ -173,10 +243,17 @@ export default function (repository: Repository): OcHandler {
return componentUrl;
});

res.status(200).json({
const body = {
...baseResponse,
components: componentResponses
});
};

if (lastEdit !== undefined) {
cache.set(lastEdit, cacheKey, body);
}

setDiscoveryCacheControl(res);
res.status(200).json(body);
}
};
}
Loading
Loading