Skip to content
Draft
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 .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ ilc/public
ilc/dist
.nyc_output
.karma_output

# Not ours to reformat: mkdocs uses 2-space YAML, prettier rewrites the whole file
mkdocs.yml
231 changes: 231 additions & 0 deletions docs/ssr_fragment_caching.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions ilc/client/registry/BrowserCacheStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ export class BrowserCacheStorage implements CacheStorage {
setItem(key: string, cache: CacheResult<unknown>): void {
this.storage.setItem(key, JSON.stringify(cache));
}

deleteItem(key: string): void {
this.storage.removeItem(key);
}
}
2 changes: 2 additions & 0 deletions ilc/common/DefaultCacheWrapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ describe('DefaultCacheWrapper', () => {
storageMock = {
getItem: (key) => storageMockCache[key] ?? null,
setItem: (key, cache) => (storageMockCache[key] = cache),
deleteItem: (key) => delete storageMockCache[key],
};
const cacheWrapper = new DefaultCacheWrapper(storageMock, loggerMock, null);
wrappedFn = cacheWrapper.wrap(fn, { name: 'testCacheName' });
Expand Down Expand Up @@ -266,6 +267,7 @@ describe('DefaultCacheWrapper', () => {
{
setItem,
getItem: () => null,
deleteItem: sinon.stub(),
},
loggerMock,
null,
Expand Down
34 changes: 34 additions & 0 deletions ilc/common/EvictingCacheStorage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,38 @@ describe('EvictingCacheStorage', () => {
expect(cache.getItem('c')).to.deep.equal({ data: 3, cachedAt: 1 });
expect(cache.getItem('d')).to.deep.equal({ data: 4, cachedAt: 1 });
});

it('should evict least-recently-used entries until the total weight is within budget', () => {
const weightedCache = new EvictingCacheStorage({
maxSize: 10,
maxWeight: 10,
getWeight: (entry) => entry.data as number,
});

weightedCache.setItem('a', { data: 4, cachedAt: 1 });
weightedCache.setItem('b', { data: 4, cachedAt: 1 });
weightedCache.getItem('a');
weightedCache.setItem('c', { data: 5, cachedAt: 1 });

expect(weightedCache.getItem('b')).to.be.null;
expect(weightedCache.getItem('a')).to.deep.equal({ data: 4, cachedAt: 1 });
expect(weightedCache.getItem('c')).to.deep.equal({ data: 5, cachedAt: 1 });
});

it('should update the total weight when an existing entry is replaced or deleted', () => {
const weightedCache = new EvictingCacheStorage({
maxSize: 10,
maxWeight: 10,
getWeight: (entry) => entry.data as number,
});

weightedCache.setItem('a', { data: 8, cachedAt: 1 });
weightedCache.setItem('a', { data: 2, cachedAt: 1 });
weightedCache.setItem('b', { data: 8, cachedAt: 1 });
weightedCache.deleteItem('a');
weightedCache.setItem('c', { data: 2, cachedAt: 1 });

expect(weightedCache.getItem('b')).to.deep.equal({ data: 8, cachedAt: 1 });
expect(weightedCache.getItem('c')).to.deep.equal({ data: 2, cachedAt: 1 });
});
});
35 changes: 25 additions & 10 deletions ilc/common/EvictingCacheStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,55 @@ import { CacheResult, CacheStorage } from './types/CacheWrapper';

type EvictingCacheStorageOptions = {
maxSize: number;
maxWeight?: number;
getWeight?: (cache: CacheResult<unknown>) => number;
onEvict?: (evictedKey: string) => void;
};

export class EvictingCacheStorage implements CacheStorage {
private readonly cache: Map<string, CacheResult<any>> = new Map();
private totalWeight = 0;

constructor(private readonly options: EvictingCacheStorageOptions) {}

getItem<T>(key: string): CacheResult<T> | null {
if (!this.cache.has(key)) {
const value = this.cache.get(key);
if (value === undefined) {
return null;
}

// Move the accessed key to the end to mark it as recently used
const value = this.cache.get(key)!;
this.cache.delete(key);
this.cache.set(key, value);
return value;
}

setItem(key: string, cache: CacheResult<unknown>): void {
// If the key already exists, delete it to update the order
if (this.cache.has(key)) {
this.cache.delete(key);
deleteItem(key: string): void {
const existing = this.cache.get(key);
if (existing !== undefined) {
this.totalWeight -= this.getWeight(existing);
}
this.cache.delete(key);
}

setItem(key: string, cache: CacheResult<unknown>): void {
this.deleteItem(key);

// Add the new item to the cache
this.cache.set(key, cache);
this.totalWeight += this.getWeight(cache);

// Evict the least recently used item if the cache exceeds maxSize
if (this.cache.size > this.options.maxSize) {
while (this.cache.size > this.options.maxSize || this.isOverWeightBudget()) {
const oldestKey = this.cache.keys().next().value!; // Get the first key (LRU)
this.cache.delete(oldestKey);
this.deleteItem(oldestKey);
this.options.onEvict?.(oldestKey);
}
}

private getWeight(cache: CacheResult<unknown>): number {
return this.options.getWeight?.(cache) ?? 0;
}

private isOverWeightBudget(): boolean {
return this.options.maxWeight !== undefined && this.totalWeight > this.options.maxWeight;
}
}
1 change: 1 addition & 0 deletions ilc/common/types/CacheWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type CacheHashFn = (value: string) => string;
export interface CacheStorage {
getItem<T>(key: string): CacheResult<T> | null;
setItem(key: string, cache: CacheResult<unknown>): void;
deleteItem(key: string): void;
}

export interface CacheWrapper {
Expand Down
2 changes: 2 additions & 0 deletions ilc/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function cloneDeep<T extends object>(source: T): T {

export const uniqueArray = <T>(array: T[]): T[] => [...new Set(array)];

export const nowInSec = (): number => Math.floor(Date.now() / 1000);

export const encodeHtmlEntities = (value: string): string =>
value.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export const decodeHtmlEntities = (value: string): string =>
Expand Down
8 changes: 8 additions & 0 deletions ilc/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ilc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@babel/preset-typescript": "^7.28.5",
"@types/chai": "^5.2.3",
"@types/config": "^3.3.5",
"@types/lodash": "^4.17.25",
"@types/mocha": "^10.0.10",
"@types/newrelic": "^9.14.8",
"@types/node": "^22.19.2",
Expand Down
2 changes: 1 addition & 1 deletion ilc/server/TransitionHooksExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class TransitionHooksExecutor {
meta: route.meta,
url: route.reqUrl,
hostname: req.host,
route: route.route,
route: route.route as string,
},
log: req.log,
req: req.raw,
Expand Down
53 changes: 0 additions & 53 deletions ilc/server/tailor/factory.js

This file was deleted.

74 changes: 74 additions & 0 deletions ilc/server/tailor/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import _ from 'lodash';
import newrelic from 'newrelic';
import type { Logger } from 'ilc-plugins-sdk';
import Tailor from '@namecheap/tailorx';

import { fetchTemplate } from './fetch-template';
import { filterHeaders } from './filter-headers';
import errorHandlerSetup from './error-handler';
import * as fragmentHooks from './fragment-hooks';
import { ConfigsInjector } from './configs-injector';
import processFragmentResponse from './process-fragment-response';
import requestFragmentFactory from './request-fragment';
import { wrapRequestFragmentWithCache } from './request-fragment-cache';
import type { PatchedHttpRequest } from '../types/PatchedHttpRequest';
import type { Registry } from '../types/Registry';
import type { ErrorHandler } from '../types/ErrorHandler';

export default function tailorFactory(
registryService: Registry,
errorHandlingService: ErrorHandler,
cdnUrl: string,
nrCustomClientJsWrapper: string | null = null,
nrAutomaticallyInjectClientScript = true,
logger: Logger,
) {
const configsInjector = new ConfigsInjector(
newrelic,
cdnUrl,
nrCustomClientJsWrapper,
nrAutomaticallyInjectClientScript,
);

const tailorOptions = {
fetchContext: async function (request: PatchedHttpRequest) {
return request.router!.getFragmentsContext();
},
fetchTemplate: fetchTemplate(configsInjector, newrelic, registryService),
requestFragment: wrapRequestFragmentWithCache(
requestFragmentFactory(filterHeaders, processFragmentResponse, logger),
{
logger,
onCacheEvent: (event, { appId, source, reason }) => {
// at most one qualifier is ever set: `source` on 'error', `reason` on 'refuse'
const qualifier = source ?? reason;
const metricName = qualifier
? `FragmentCache/${appId}/${event}/${qualifier}`
: `FragmentCache/${appId}/${event}`;
newrelic.incrementMetric(metricName);
},
},
),
processFragmentResponse,
systemScripts: '',
filterHeaders,
fragmentHooks: {
insertStart: fragmentHooks.insertStart.bind(null, logger),
insertEnd: fragmentHooks.insertEnd,
},
botsGuardEnabled: true,
getAssetsToPreload: configsInjector.getAssetsToPreload,
filterResponseHeaders: (_attributes: unknown, headers: Record<string, unknown>) =>
_.pick(headers, ['set-cookie']),
baseTemplatesCacheSize: 1,
shouldSetPrimaryFragmentAssetsToPreload: false,
};

// @namecheap/tailorx's bundled .d.ts is stale (see index.js) and covers fewer options than
// the runtime reads; this assertion bridges to that outdated third-party declaration.
const tailor = new Tailor(tailorOptions as unknown as ConstructorParameters<typeof Tailor>[0]);

errorHandlerSetup(tailor, errorHandlingService);

return tailor;
}
Loading
Loading