Skip to content

Commit 6996be0

Browse files
authored
Merge pull request #68 from namecheap/perf/intl
perf: add TTL cache for Intl service
2 parents 1711dea + 04edf2d commit 6996be0

16 files changed

+442
-183
lines changed

.nycrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"functions": 90,
88
"statements": 90,
99
"exclude": [
10+
"dist",
1011
"test/**",
1112
"src/app/utils/resolveDirectory.ts"
1213
]

lint-staged.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
*/
44
module.exports = {
55
'*': 'prettier --ignore-unknown --write',
6-
'*.ts': 'npm run lint -- --fix',
6+
'src/app/**/*.ts': 'tslint -p src/app --fix',
7+
'src/server/**/*.ts': 'tslint -p src/server --fix',
78
};

src/app/IlcIntl.ts

Lines changed: 31 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
1-
import * as types from './types';
2-
import parseAsFullyQualifiedURI from './utils/parseAsFullyQualifiedURI';
3-
import defaultIntlAdapter from './defaultIntlAdapter';
4-
import { isSpecialUrl } from './utils/isSpecialUrl';
1+
import { defaultIntlAdapter } from './defaultIntlAdapter';
52
import { OptionsIntl } from './interfaces/OptionsSdk';
3+
import type { IntlAdapter, IntlConfig, IntlUpdateEvent, IntlUpdateEventInternal } from './types';
4+
import { TtlCache } from './utils/TtlCache';
5+
import { getCanonicalLocale } from './utils/getCanonicalLocale';
6+
import { localizeUrl } from './utils/localizeUrl';
7+
import { parseUrl } from './utils/parseUrl';
8+
9+
export const cache = new TtlCache();
610

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

1518
constructor(
16-
private appId: string,
17-
adapter?: types.IntlAdapter,
18-
private options?: OptionsIntl,
19-
) {
20-
if (!adapter) {
21-
adapter = defaultIntlAdapter;
22-
}
23-
24-
this.adapter = adapter;
25-
}
19+
private readonly appId: string,
20+
private readonly adapter: IntlAdapter = defaultIntlAdapter,
21+
private readonly options?: OptionsIntl,
22+
) {}
2623

2724
/**
2825
* Allows to retrieve current i18n configuration
@@ -50,7 +47,7 @@ export class IlcIntl {
5047
*
5148
* @param config
5249
*/
53-
public set(config: types.IntlConfig): void {
50+
public set(config: IntlConfig): void {
5451
if (!this.adapter.set) {
5552
throw new Error("Looks like you're trying to call CSR only method during SSR.");
5653
}
@@ -93,14 +90,14 @@ export class IlcIntl {
9390
* @returns - callback that can be used to unsubscribe from changes
9491
*/
9592
public onChange<T>(
96-
prepareForChange: (event: types.IntlUpdateEvent) => Promise<T> | T,
97-
performChange: (event: types.IntlUpdateEvent, preparedData: T) => Promise<void> | void,
93+
prepareForChange: (event: IntlUpdateEvent) => Promise<T> | T,
94+
performChange: (event: IntlUpdateEvent, preparedData: T) => Promise<void> | void,
9895
) {
9996
if (!this.adapter.set) {
10097
return () => {}; // Looks like you're trying to call CSR only method during SSR. Doing nothing...
10198
}
10299

103-
const wrappedCb = (e: types.IntlUpdateEventInternal) => {
100+
const wrappedCb = (e: IntlUpdateEventInternal) => {
104101
e.detail.addHandler({
105102
actorId: this.appId,
106103
prepare: prepareForChange,
@@ -114,7 +111,7 @@ export class IlcIntl {
114111
return () => {
115112
for (const row of this.listeners) {
116113
if (row === wrappedCb) {
117-
window.removeEventListener(IlcIntl.eventName, row);
114+
window.removeEventListener(IlcIntl.eventName, row as EventListener);
118115
this.listeners.slice(this.listeners.indexOf(wrappedCb), 1);
119116
break;
120117
}
@@ -131,7 +128,7 @@ export class IlcIntl {
131128
}
132129

133130
for (const callback of this.listeners) {
134-
window.removeEventListener(IlcIntl.eventName, callback);
131+
window.removeEventListener(IlcIntl.eventName, callback as EventListener);
135132
}
136133

137134
this.listeners = [];
@@ -142,38 +139,19 @@ export class IlcIntl {
142139
*
143140
* @param config
144141
* @param url - absolute path or absolute URI. Ex: "/test?a=1" or "http://tst.com/"
145-
* @param configOverride - allows to override default locale
142+
* @param configOverride - allows to override default locales
146143
*
147144
* @internal Used internally by ILC
148145
*/
149-
static localizeUrl(config: types.IntlAdapterConfig, url: string, configOverride: { locale?: string } = {}): string {
150-
if (isSpecialUrl(url)) {
151-
return url;
152-
}
153-
154-
const parsedUri = parseAsFullyQualifiedURI(url);
155-
url = parsedUri.uri;
156-
157-
if (!url.startsWith('/')) {
158-
throw new Error(`Localization of relative URLs is not supported. Received: "${url}"`);
159-
}
160-
161-
url = IlcIntl.parseUrl(config, url).cleanUrl;
162-
163-
const receivedLocale = configOverride.locale || config.default.locale;
164-
165-
const loc = IlcIntl.getCanonicalLocale(receivedLocale, config.supported.locale);
166-
167-
if (loc === null) {
168-
throw new Error(`Unsupported locale passed. Received: "${receivedLocale}"`);
169-
}
170-
171-
if (config.routingStrategy === types.RoutingStrategy.PrefixExceptDefault && loc === config.default.locale) {
172-
return parsedUri.origin + url;
173-
}
174-
175-
return `${parsedUri.origin}/${IlcIntl.getShortenedLocale(loc, config.supported.locale)}${url}`;
176-
}
146+
static localizeUrl = cache.wrap(
147+
localizeUrl,
148+
/**
149+
* supported locales and routing strategy are not expected to change during the runtime frequently
150+
* they are not included in the cache key
151+
* values will be cleaned up by TTL
152+
*/
153+
(config, url, override) => `${override?.locale ?? config.default.locale}:${url}`,
154+
);
177155

178156
/**
179157
* Allows to parse URL and receive "unlocalized" URL and information about locale that was encoded in URL.
@@ -183,30 +161,7 @@ export class IlcIntl {
183161
*
184162
* @internal Used internally by ILC
185163
*/
186-
static parseUrl(config: types.IntlAdapterConfig, url: string): { locale: string; cleanUrl: string } {
187-
if (isSpecialUrl(url)) {
188-
return {
189-
cleanUrl: url,
190-
locale: config.default.locale,
191-
};
192-
}
193-
194-
const parsedUri = parseAsFullyQualifiedURI(url);
195-
url = parsedUri.uri;
196-
197-
if (!url.startsWith('/')) {
198-
throw new Error(`Localization of relative URLs is not supported. Received: "${url}"`);
199-
}
200-
201-
const [, langPart, ...path] = url.split('/');
202-
const lang = IlcIntl.getCanonicalLocale(langPart, config.supported.locale);
203-
204-
if (lang !== null && config.supported.locale.indexOf(lang) !== -1) {
205-
return { cleanUrl: `${parsedUri.origin}/${path.join('/')}`, locale: lang };
206-
}
207-
208-
return { cleanUrl: parsedUri.origin + url, locale: config.default.locale };
209-
}
164+
static parseUrl = cache.wrap(parseUrl, (config, url) => url);
210165

211166
/**
212167
* Returns properly formatted locale string.
@@ -217,56 +172,5 @@ export class IlcIntl {
217172
*
218173
* @internal Used internally by ILC
219174
*/
220-
static getCanonicalLocale(locale = '', supportedLocales: string[]) {
221-
const supportedLangs = supportedLocales.map((v) => v.split('-')[0]).filter((v, i, a) => a.indexOf(v) === i);
222-
223-
const locData = locale.split('-');
224-
225-
if (locData.length === 2) {
226-
locale = locData[0].toLowerCase() + '-' + locData[1].toUpperCase();
227-
} else if (locData.length === 1) {
228-
locale = locData[0].toLowerCase();
229-
} else {
230-
return null;
231-
}
232-
233-
if (supportedLangs.indexOf(locale.toLowerCase()) !== -1) {
234-
for (const v of supportedLocales) {
235-
if (v.split('-')[0] === locale) {
236-
locale = v;
237-
break;
238-
}
239-
}
240-
} else if (supportedLocales.indexOf(locale) === -1) {
241-
return null;
242-
}
243-
244-
return locale;
245-
}
246-
247-
/**
248-
* Returns properly formatted short form of locale string.
249-
* Ex: en-US -> en, but en-GB -> en-GB
250-
*
251-
* @internal Used internally by ILC
252-
*/
253-
static getShortenedLocale(canonicalLocale: string, supportedLocales: string[]): string {
254-
if (supportedLocales.indexOf(canonicalLocale) === -1) {
255-
throw new Error(`Unsupported locale passed. Received: ${canonicalLocale}`);
256-
}
257-
258-
for (const loc of supportedLocales) {
259-
if (loc.split('-')[0] !== canonicalLocale.split('-')[0]) {
260-
continue;
261-
}
262-
263-
if (loc === canonicalLocale) {
264-
return loc.split('-')[0];
265-
} else {
266-
return canonicalLocale;
267-
}
268-
}
269-
270-
return canonicalLocale;
271-
}
175+
static getCanonicalLocale = cache.wrap(getCanonicalLocale, (locale) => locale);
272176
}

src/app/defaultIntlAdapter.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { IntlAdapter, RoutingStrategy } from './interfaces/common';
1+
import { type IntlAdapter, RoutingStrategy } from './interfaces/common';
22

33
/**
44
* Used when i18n capability is disabled in ILC.
55
* @internal
66
*/
7-
const adapter: IntlAdapter = {
7+
export const defaultIntlAdapter: IntlAdapter = {
88
config: {
99
default: { locale: 'en-US', currency: 'USD' },
1010
supported: { locale: ['en-US'], currency: ['USD'] },
@@ -19,7 +19,7 @@ const adapter: IntlAdapter = {
1919
const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined';
2020
/* istanbul ignore if */
2121
if (isBrowser) {
22-
adapter.set = () => {};
22+
defaultIntlAdapter.set = () => {};
2323
}
2424

25-
export default adapter;
25+
export default defaultIntlAdapter;

src/app/tsconfig.json

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
{
2-
"extends": "../../tsconfig.json",
3-
"compilerOptions": {
4-
"composite": true,
5-
"target": "es2015",
6-
"module": "commonjs",
7-
"declaration": true,
8-
"outDir": "../../dist/app",
9-
"strict": true,
10-
"types" : ["mocha"],
11-
"esModuleInterop": true
12-
},
13-
"include": ["**/*.ts"],
14-
}
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"composite": true,
5+
"target": "es2015",
6+
"module": "commonjs",
7+
"declaration": true,
8+
"outDir": "../../dist/app",
9+
"strict": true,
10+
"esModuleInterop": true
11+
},
12+
"include": ["**/*.ts"]
13+
}

src/app/utils/TtlCache.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
type Options = {
2+
ttl?: number;
3+
cleanupInterval?: number;
4+
};
5+
6+
type CacheKeyFn<K, U extends unknown[]> = (...args: U) => K;
7+
8+
type CacheItem<V> = {
9+
value: V;
10+
expiresAt: number;
11+
};
12+
13+
export class TtlCache<K, V> {
14+
private cache: Map<K, CacheItem<V>> = new Map();
15+
private ttl: number; // Time-to-live in milliseconds
16+
private cleanupInterval: number;
17+
private timeoutId?: NodeJS.Timeout;
18+
19+
constructor({ ttl = 10_000, cleanupInterval = 5000 }: Options = {}) {
20+
this.ttl = ttl;
21+
this.cleanupInterval = cleanupInterval;
22+
this.scheduleCleanup();
23+
}
24+
25+
// Set a value with expiration
26+
public set(key: K, value: V): void {
27+
this.cache.set(key, { value, expiresAt: Date.now() + this.ttl });
28+
}
29+
30+
public get(key: K): V | undefined {
31+
const entry = this.cache.get(key);
32+
if (!entry) return undefined;
33+
return entry.value;
34+
}
35+
36+
public wrap<T extends (...args: any[]) => any>(
37+
fn: T,
38+
cacheKeyFn: CacheKeyFn<K, Parameters<T>>,
39+
): (...args: Parameters<T>) => ReturnType<T> {
40+
return (...args: Parameters<T>) => {
41+
const cacheKey = cacheKeyFn(...args);
42+
const cachedValue = this.get(cacheKey);
43+
if (cachedValue !== undefined) {
44+
return cachedValue;
45+
} else {
46+
const value = fn(...args);
47+
this.set(cacheKey, value);
48+
return value;
49+
}
50+
};
51+
}
52+
53+
private cleanup(): void {
54+
const now = Date.now();
55+
for (const [key, entry] of this.cache) {
56+
if (entry.expiresAt <= now) {
57+
this.cache.delete(key);
58+
}
59+
}
60+
this.scheduleCleanup();
61+
}
62+
63+
private scheduleCleanup(): void {
64+
this.timeoutId = setTimeout(() => this.cleanup(), this.cleanupInterval);
65+
this.timeoutId.unref?.();
66+
}
67+
68+
// Clear the entire cache and stop the cleanup interval
69+
public clear(): void {
70+
this.cache.clear();
71+
}
72+
73+
public destroy(): void {
74+
this.clear();
75+
clearTimeout(this.timeoutId);
76+
}
77+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export function getCanonicalLocale(locale = '', supportedLocales: string[]) {
2+
const supportedLangs = supportedLocales.map((v) => v.split('-')[0]).filter((v, i, a) => a.indexOf(v) === i);
3+
4+
const locData = locale.split('-');
5+
6+
if (locData.length === 2) {
7+
locale = locData[0].toLowerCase() + '-' + locData[1].toUpperCase();
8+
} else if (locData.length === 1) {
9+
locale = locData[0].toLowerCase();
10+
} else {
11+
return null;
12+
}
13+
14+
if (supportedLangs.indexOf(locale.toLowerCase()) !== -1) {
15+
for (const v of supportedLocales) {
16+
if (v.split('-')[0] === locale) {
17+
locale = v;
18+
break;
19+
}
20+
}
21+
} else if (supportedLocales.indexOf(locale) === -1) {
22+
return null;
23+
}
24+
25+
return locale;
26+
}

0 commit comments

Comments
 (0)