Skip to content

Commit

Permalink
Merge pull request #68 from namecheap/perf/intl
Browse files Browse the repository at this point in the history
perf: add TTL cache for Intl service
  • Loading branch information
stas-nc authored Feb 5, 2025
2 parents 1711dea + 04edf2d commit 6996be0
Show file tree
Hide file tree
Showing 16 changed files with 442 additions and 183 deletions.
1 change: 1 addition & 0 deletions .nycrc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"functions": 90,
"statements": 90,
"exclude": [
"dist",
"test/**",
"src/app/utils/resolveDirectory.ts"
]
Expand Down
3 changes: 2 additions & 1 deletion lint-staged.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
*/
module.exports = {
'*': 'prettier --ignore-unknown --write',
'*.ts': 'npm run lint -- --fix',
'src/app/**/*.ts': 'tslint -p src/app --fix',
'src/server/**/*.ts': 'tslint -p src/server --fix',
};
158 changes: 31 additions & 127 deletions src/app/IlcIntl.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
import * as types from './types';
import parseAsFullyQualifiedURI from './utils/parseAsFullyQualifiedURI';
import defaultIntlAdapter from './defaultIntlAdapter';
import { isSpecialUrl } from './utils/isSpecialUrl';
import { defaultIntlAdapter } from './defaultIntlAdapter';
import { OptionsIntl } from './interfaces/OptionsSdk';
import type { IntlAdapter, IntlConfig, IntlUpdateEvent, IntlUpdateEventInternal } from './types';
import { TtlCache } from './utils/TtlCache';
import { getCanonicalLocale } from './utils/getCanonicalLocale';
import { localizeUrl } from './utils/localizeUrl';
import { parseUrl } from './utils/parseUrl';

export const cache = new TtlCache();

/**
* **WARNING:** this class shouldn't be imported directly in the apps or adapters. Use `IlcAppSdk` instead.
*/
export class IlcIntl {
private adapter: types.IntlAdapter;
private listeners: any[] = [];
private listeners: ((event: IntlUpdateEventInternal) => void)[] = [];
private static eventName = 'ilc:intl-update';

constructor(
private appId: string,
adapter?: types.IntlAdapter,
private options?: OptionsIntl,
) {
if (!adapter) {
adapter = defaultIntlAdapter;
}

this.adapter = adapter;
}
private readonly appId: string,
private readonly adapter: IntlAdapter = defaultIntlAdapter,
private readonly options?: OptionsIntl,
) {}

/**
* Allows to retrieve current i18n configuration
Expand Down Expand Up @@ -50,7 +47,7 @@ export class IlcIntl {
*
* @param config
*/
public set(config: types.IntlConfig): void {
public set(config: IntlConfig): void {
if (!this.adapter.set) {
throw new Error("Looks like you're trying to call CSR only method during SSR.");
}
Expand Down Expand Up @@ -93,14 +90,14 @@ export class IlcIntl {
* @returns - callback that can be used to unsubscribe from changes
*/
public onChange<T>(
prepareForChange: (event: types.IntlUpdateEvent) => Promise<T> | T,
performChange: (event: types.IntlUpdateEvent, preparedData: T) => Promise<void> | void,
prepareForChange: (event: IntlUpdateEvent) => Promise<T> | T,
performChange: (event: IntlUpdateEvent, preparedData: T) => Promise<void> | void,
) {
if (!this.adapter.set) {
return () => {}; // Looks like you're trying to call CSR only method during SSR. Doing nothing...
}

const wrappedCb = (e: types.IntlUpdateEventInternal) => {
const wrappedCb = (e: IntlUpdateEventInternal) => {
e.detail.addHandler({
actorId: this.appId,
prepare: prepareForChange,
Expand All @@ -114,7 +111,7 @@ export class IlcIntl {
return () => {
for (const row of this.listeners) {
if (row === wrappedCb) {
window.removeEventListener(IlcIntl.eventName, row);
window.removeEventListener(IlcIntl.eventName, row as EventListener);
this.listeners.slice(this.listeners.indexOf(wrappedCb), 1);
break;
}
Expand All @@ -131,7 +128,7 @@ export class IlcIntl {
}

for (const callback of this.listeners) {
window.removeEventListener(IlcIntl.eventName, callback);
window.removeEventListener(IlcIntl.eventName, callback as EventListener);
}

this.listeners = [];
Expand All @@ -142,38 +139,19 @@ export class IlcIntl {
*
* @param config
* @param url - absolute path or absolute URI. Ex: "/test?a=1" or "http://tst.com/"
* @param configOverride - allows to override default locale
* @param configOverride - allows to override default locales
*
* @internal Used internally by ILC
*/
static localizeUrl(config: types.IntlAdapterConfig, url: string, configOverride: { locale?: string } = {}): string {
if (isSpecialUrl(url)) {
return url;
}

const parsedUri = parseAsFullyQualifiedURI(url);
url = parsedUri.uri;

if (!url.startsWith('/')) {
throw new Error(`Localization of relative URLs is not supported. Received: "${url}"`);
}

url = IlcIntl.parseUrl(config, url).cleanUrl;

const receivedLocale = configOverride.locale || config.default.locale;

const loc = IlcIntl.getCanonicalLocale(receivedLocale, config.supported.locale);

if (loc === null) {
throw new Error(`Unsupported locale passed. Received: "${receivedLocale}"`);
}

if (config.routingStrategy === types.RoutingStrategy.PrefixExceptDefault && loc === config.default.locale) {
return parsedUri.origin + url;
}

return `${parsedUri.origin}/${IlcIntl.getShortenedLocale(loc, config.supported.locale)}${url}`;
}
static localizeUrl = cache.wrap(
localizeUrl,
/**
* supported locales and routing strategy are not expected to change during the runtime frequently
* they are not included in the cache key
* values will be cleaned up by TTL
*/
(config, url, override) => `${override?.locale ?? config.default.locale}:${url}`,
);

/**
* Allows to parse URL and receive "unlocalized" URL and information about locale that was encoded in URL.
Expand All @@ -183,30 +161,7 @@ export class IlcIntl {
*
* @internal Used internally by ILC
*/
static parseUrl(config: types.IntlAdapterConfig, url: string): { locale: string; cleanUrl: string } {
if (isSpecialUrl(url)) {
return {
cleanUrl: url,
locale: config.default.locale,
};
}

const parsedUri = parseAsFullyQualifiedURI(url);
url = parsedUri.uri;

if (!url.startsWith('/')) {
throw new Error(`Localization of relative URLs is not supported. Received: "${url}"`);
}

const [, langPart, ...path] = url.split('/');
const lang = IlcIntl.getCanonicalLocale(langPart, config.supported.locale);

if (lang !== null && config.supported.locale.indexOf(lang) !== -1) {
return { cleanUrl: `${parsedUri.origin}/${path.join('/')}`, locale: lang };
}

return { cleanUrl: parsedUri.origin + url, locale: config.default.locale };
}
static parseUrl = cache.wrap(parseUrl, (config, url) => url);

/**
* Returns properly formatted locale string.
Expand All @@ -217,56 +172,5 @@ export class IlcIntl {
*
* @internal Used internally by ILC
*/
static getCanonicalLocale(locale = '', supportedLocales: string[]) {
const supportedLangs = supportedLocales.map((v) => v.split('-')[0]).filter((v, i, a) => a.indexOf(v) === i);

const locData = locale.split('-');

if (locData.length === 2) {
locale = locData[0].toLowerCase() + '-' + locData[1].toUpperCase();
} else if (locData.length === 1) {
locale = locData[0].toLowerCase();
} else {
return null;
}

if (supportedLangs.indexOf(locale.toLowerCase()) !== -1) {
for (const v of supportedLocales) {
if (v.split('-')[0] === locale) {
locale = v;
break;
}
}
} else if (supportedLocales.indexOf(locale) === -1) {
return null;
}

return locale;
}

/**
* Returns properly formatted short form of locale string.
* Ex: en-US -> en, but en-GB -> en-GB
*
* @internal Used internally by ILC
*/
static getShortenedLocale(canonicalLocale: string, supportedLocales: string[]): string {
if (supportedLocales.indexOf(canonicalLocale) === -1) {
throw new Error(`Unsupported locale passed. Received: ${canonicalLocale}`);
}

for (const loc of supportedLocales) {
if (loc.split('-')[0] !== canonicalLocale.split('-')[0]) {
continue;
}

if (loc === canonicalLocale) {
return loc.split('-')[0];
} else {
return canonicalLocale;
}
}

return canonicalLocale;
}
static getCanonicalLocale = cache.wrap(getCanonicalLocale, (locale) => locale);
}
8 changes: 4 additions & 4 deletions src/app/defaultIntlAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { IntlAdapter, RoutingStrategy } from './interfaces/common';
import { type IntlAdapter, RoutingStrategy } from './interfaces/common';

/**
* Used when i18n capability is disabled in ILC.
* @internal
*/
const adapter: IntlAdapter = {
export const defaultIntlAdapter: IntlAdapter = {
config: {
default: { locale: 'en-US', currency: 'USD' },
supported: { locale: ['en-US'], currency: ['USD'] },
Expand All @@ -19,7 +19,7 @@ const adapter: IntlAdapter = {
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
/* istanbul ignore if */
if (isBrowser) {
adapter.set = () => {};
defaultIntlAdapter.set = () => {};
}

export default adapter;
export default defaultIntlAdapter;
25 changes: 12 additions & 13 deletions src/app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "es2015",
"module": "commonjs",
"declaration": true,
"outDir": "../../dist/app",
"strict": true,
"types" : ["mocha"],
"esModuleInterop": true
},
"include": ["**/*.ts"],
}
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "es2015",
"module": "commonjs",
"declaration": true,
"outDir": "../../dist/app",
"strict": true,
"esModuleInterop": true
},
"include": ["**/*.ts"]
}
77 changes: 77 additions & 0 deletions src/app/utils/TtlCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
type Options = {
ttl?: number;
cleanupInterval?: number;
};

type CacheKeyFn<K, U extends unknown[]> = (...args: U) => K;

type CacheItem<V> = {
value: V;
expiresAt: number;
};

export class TtlCache<K, V> {
private cache: Map<K, CacheItem<V>> = new Map();
private ttl: number; // Time-to-live in milliseconds
private cleanupInterval: number;
private timeoutId?: NodeJS.Timeout;

constructor({ ttl = 10_000, cleanupInterval = 5000 }: Options = {}) {
this.ttl = ttl;
this.cleanupInterval = cleanupInterval;
this.scheduleCleanup();
}

// Set a value with expiration
public set(key: K, value: V): void {
this.cache.set(key, { value, expiresAt: Date.now() + this.ttl });
}

public get(key: K): V | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
return entry.value;
}

public wrap<T extends (...args: any[]) => any>(
fn: T,
cacheKeyFn: CacheKeyFn<K, Parameters<T>>,
): (...args: Parameters<T>) => ReturnType<T> {
return (...args: Parameters<T>) => {
const cacheKey = cacheKeyFn(...args);
const cachedValue = this.get(cacheKey);
if (cachedValue !== undefined) {
return cachedValue;
} else {
const value = fn(...args);
this.set(cacheKey, value);
return value;
}
};
}

private cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache) {
if (entry.expiresAt <= now) {
this.cache.delete(key);
}
}
this.scheduleCleanup();
}

private scheduleCleanup(): void {
this.timeoutId = setTimeout(() => this.cleanup(), this.cleanupInterval);
this.timeoutId.unref?.();
}

// Clear the entire cache and stop the cleanup interval
public clear(): void {
this.cache.clear();
}

public destroy(): void {
this.clear();
clearTimeout(this.timeoutId);
}
}
26 changes: 26 additions & 0 deletions src/app/utils/getCanonicalLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function getCanonicalLocale(locale = '', supportedLocales: string[]) {
const supportedLangs = supportedLocales.map((v) => v.split('-')[0]).filter((v, i, a) => a.indexOf(v) === i);

const locData = locale.split('-');

if (locData.length === 2) {
locale = locData[0].toLowerCase() + '-' + locData[1].toUpperCase();
} else if (locData.length === 1) {
locale = locData[0].toLowerCase();
} else {
return null;
}

if (supportedLangs.indexOf(locale.toLowerCase()) !== -1) {
for (const v of supportedLocales) {
if (v.split('-')[0] === locale) {
locale = v;
break;
}
}
} else if (supportedLocales.indexOf(locale) === -1) {
return null;
}

return locale;
}
Loading

0 comments on commit 6996be0

Please sign in to comment.