@oxy.so/core 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +5 -4
- package/dist/cjs/utils/cache.js +28 -19
- package/dist/cjs/utils/languageUtils.js +26 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/utils/cache.js +28 -19
- package/dist/esm/utils/languageUtils.js +25 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/utils/cache.d.ts +1 -4
- package/dist/types/utils/languageUtils.d.ts +13 -0
- package/package.json +12 -12
- package/src/index.ts +1 -0
- package/src/utils/__tests__/cacheLifecycle.test.ts +65 -0
- package/src/utils/__tests__/languageUtils.test.ts +25 -0
- package/src/utils/cache.ts +22 -20
- package/src/utils/languageUtils.ts +29 -0
package/dist/types/index.d.ts
CHANGED
|
@@ -76,7 +76,7 @@ export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferenc
|
|
|
76
76
|
export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
|
|
77
77
|
export { TopicType, TopicSource } from './models/Topic';
|
|
78
78
|
export type { TopicData, TopicTranslation, TopicListResult } from './models/Topic';
|
|
79
|
-
export { SUPPORTED_LANGUAGES, FALLBACK_LOCALE, getBaseLanguage, normalizeLocale, isSupportedLocale, getLanguageMetadata, getLanguageName, getNativeLanguageName, isRTLLocale, getUserLanguages, getPrimaryLanguage, } from './utils/languageUtils';
|
|
79
|
+
export { SUPPORTED_LANGUAGES, FALLBACK_LOCALE, getBaseLanguage, normalizeLocale, isSupportedLocale, getLanguageMetadata, getLanguageName, getNativeLanguageName, isRTLLocale, getUserLanguages, getPrimaryLanguage, coerceToSupportedLocale, } from './utils/languageUtils';
|
|
80
80
|
export type { SupportedLanguage } from './utils/languageUtils';
|
|
81
81
|
export { getPlatformOS, setPlatformOS, isWeb, isNative, isIOS, isAndroid, isWebBrowser, } from './utils/platform';
|
|
82
82
|
export type { PlatformOS } from './utils/platform';
|
|
@@ -118,10 +118,7 @@ export declare function createCache<T>(ttl?: number): TTLCache<T>;
|
|
|
118
118
|
* @param cache Cache instance to register
|
|
119
119
|
*/
|
|
120
120
|
export declare function registerCacheForCleanup(cache: TTLCache<any>): void;
|
|
121
|
-
/**
|
|
122
|
-
* Unregister a cache from automatic cleanup
|
|
123
|
-
* @param cache Cache instance to unregister
|
|
124
|
-
*/
|
|
121
|
+
/** Unregister a cache without changing its entries or TTL behavior. */
|
|
125
122
|
export declare function unregisterCacheFromCleanup(cache: TTLCache<any>): void;
|
|
126
123
|
/**
|
|
127
124
|
* Stop all cleanup intervals (useful for testing)
|
|
@@ -98,3 +98,16 @@ export declare function getUserLanguages(user: Pick<User, 'languages'> | null |
|
|
|
98
98
|
* locale.
|
|
99
99
|
*/
|
|
100
100
|
export declare function getPrimaryLanguage(user: Pick<User, 'languages'> | null | undefined): string | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Coerce a locale down to one from a CALLER-provided catalog: an exact
|
|
103
|
+
* canonical match wins, else the closest entry sharing the same base
|
|
104
|
+
* language (an `es-MX` account reads a host app's `es-ES` catalog), else
|
|
105
|
+
* `fallback`.
|
|
106
|
+
*
|
|
107
|
+
* A host app's own shipped translation catalog is rarely {@link SUPPORTED_LANGUAGES}
|
|
108
|
+
* itself — Oxy's account-locale catalog is broader than any one app's
|
|
109
|
+
* translated strings — so this is the one place that gap is bridged. Every Oxy
|
|
110
|
+
* app was independently re-deriving this exact algorithm against its own
|
|
111
|
+
* catalog before it moved here.
|
|
112
|
+
*/
|
|
113
|
+
export declare function coerceToSupportedLocale(locale: string | null | undefined, supportedLocales: readonly string[], fallback: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -115,18 +115,18 @@
|
|
|
115
115
|
}
|
|
116
116
|
},
|
|
117
117
|
"dependencies": {
|
|
118
|
-
"@oxy.so/telemetry": "
|
|
118
|
+
"@oxy.so/telemetry": "workspace:^",
|
|
119
119
|
"@noble/ciphers": "^1.3.0",
|
|
120
120
|
"@noble/hashes": "^1.8.0",
|
|
121
|
-
"@oxy.so/contracts": "
|
|
122
|
-
"@oxy.so/protocol": "
|
|
121
|
+
"@oxy.so/contracts": "workspace:^",
|
|
122
|
+
"@oxy.so/protocol": "workspace:^",
|
|
123
123
|
"@scure/bip39": "^1.6.0",
|
|
124
124
|
"buffer": "^6.0.3",
|
|
125
|
-
"express-rate-limit": "
|
|
125
|
+
"express-rate-limit": "catalog:",
|
|
126
126
|
"helmet": "^8.0.0",
|
|
127
127
|
"invariant": "^2.2.4",
|
|
128
128
|
"jwt-decode": "^4.0.0",
|
|
129
|
-
"socket.io-client": "
|
|
129
|
+
"socket.io-client": "catalog:",
|
|
130
130
|
"tldts": "^7.4.10",
|
|
131
131
|
"zod": "^3.25.64"
|
|
132
132
|
},
|
|
@@ -151,19 +151,19 @@
|
|
|
151
151
|
}
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
|
-
"@biomejs/biome": "
|
|
154
|
+
"@biomejs/biome": "catalog:",
|
|
155
155
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
156
|
-
"@types/express": "
|
|
156
|
+
"@types/express": "catalog:",
|
|
157
157
|
"@types/invariant": "^2.2.34",
|
|
158
158
|
"@types/node": "^20.19.43",
|
|
159
159
|
"expo-crypto": "~56.0.3",
|
|
160
160
|
"expo-secure-store": "~56.0.4",
|
|
161
|
-
"express": "
|
|
162
|
-
"express-rate-limit": "
|
|
161
|
+
"express": "catalog:",
|
|
162
|
+
"express-rate-limit": "catalog:",
|
|
163
163
|
"jest": "^30.5.1",
|
|
164
164
|
"regexpu-core": "^6.4.0",
|
|
165
|
-
"release-it": "
|
|
165
|
+
"release-it": "catalog:",
|
|
166
166
|
"typescript": "^5.9.2",
|
|
167
|
-
"helmet": "
|
|
167
|
+
"helmet": "catalog:"
|
|
168
168
|
}
|
|
169
169
|
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { TTLCache, registerCacheForCleanup, unregisterCacheFromCleanup, stopAllCleanupIntervals } from '../cache';
|
|
2
|
+
|
|
3
|
+
afterEach(() => { stopAllCleanupIntervals(); jest.useRealTimers(); });
|
|
4
|
+
|
|
5
|
+
test('empty registration is inert and first data starts one shared cleanup timer', () => {
|
|
6
|
+
jest.useFakeTimers();
|
|
7
|
+
const a = new TTLCache<string>(10);
|
|
8
|
+
const b = new TTLCache<string>(10);
|
|
9
|
+
registerCacheForCleanup(a);
|
|
10
|
+
registerCacheForCleanup(b);
|
|
11
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
12
|
+
a.set('a', 'value');
|
|
13
|
+
b.set('b', 'value');
|
|
14
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
15
|
+
jest.advanceTimersByTime(11);
|
|
16
|
+
expect(a.get('a')).toBeNull();
|
|
17
|
+
expect(b.has('b')).toBe(false);
|
|
18
|
+
jest.advanceTimersByTime(60_000);
|
|
19
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
test('periodic cleanup expires unread entries and clear restarts lazily without losing registration', () => {
|
|
22
|
+
jest.useFakeTimers();
|
|
23
|
+
const a = new TTLCache<string>(10);
|
|
24
|
+
registerCacheForCleanup(a);
|
|
25
|
+
a.set('expired', 'value');
|
|
26
|
+
jest.advanceTimersByTime(60_000);
|
|
27
|
+
expect(a.size()).toBe(0);
|
|
28
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
29
|
+
a.set('again', 'value');
|
|
30
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
31
|
+
a.clear();
|
|
32
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
33
|
+
a.set('after-clear', 'value');
|
|
34
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
test('unregister and stop preserve entries but prevent writes restarting automatic cleanup', () => {
|
|
37
|
+
jest.useFakeTimers();
|
|
38
|
+
const a = new TTLCache<string>(100_000);
|
|
39
|
+
const b = new TTLCache<string>(100_000);
|
|
40
|
+
a.set('a', 'value');
|
|
41
|
+
registerCacheForCleanup(a);
|
|
42
|
+
registerCacheForCleanup(b);
|
|
43
|
+
b.set('b', 'value');
|
|
44
|
+
unregisterCacheFromCleanup(a);
|
|
45
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
46
|
+
b.delete('b');
|
|
47
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
48
|
+
a.set('unregistered', 'value');
|
|
49
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
50
|
+
registerCacheForCleanup(a);
|
|
51
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
52
|
+
stopAllCleanupIntervals();
|
|
53
|
+
a.set('stopped', 'value');
|
|
54
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
55
|
+
expect(a.get('a')).toBe('value');
|
|
56
|
+
});
|
|
57
|
+
test('server barrel imports and client construction start no background timers', () => {
|
|
58
|
+
jest.useFakeTimers();
|
|
59
|
+
jest.isolateModules(() => {
|
|
60
|
+
const server = require('../../server');
|
|
61
|
+
expect(typeof server.createEcosystemTraffic).toBe('function');
|
|
62
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
63
|
+
require('../cache').stopAllCleanupIntervals();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isRTLLocale,
|
|
10
10
|
getUserLanguages,
|
|
11
11
|
getPrimaryLanguage,
|
|
12
|
+
coerceToSupportedLocale,
|
|
12
13
|
} from '../languageUtils';
|
|
13
14
|
|
|
14
15
|
describe('getBaseLanguage', () => {
|
|
@@ -187,6 +188,30 @@ describe('getPrimaryLanguage', () => {
|
|
|
187
188
|
});
|
|
188
189
|
});
|
|
189
190
|
|
|
191
|
+
describe('coerceToSupportedLocale', () => {
|
|
192
|
+
const hostCatalog = ['en-US', 'es-ES', 'fr-FR'] as const;
|
|
193
|
+
|
|
194
|
+
it('returns an exact canonical match from the host catalog', () => {
|
|
195
|
+
expect(coerceToSupportedLocale('es-es', hostCatalog, 'en-US')).toBe('es-ES');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('falls back to the closest entry sharing the same base language', () => {
|
|
199
|
+
// `es-MX` is a real Oxy locale, but this host never shipped a Mexican
|
|
200
|
+
// Spanish catalog — its Spanish (Spain) one is the closest match.
|
|
201
|
+
expect(coerceToSupportedLocale('es-MX', hostCatalog, 'en-US')).toBe('es-ES');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('falls back to the caller default when no base language matches', () => {
|
|
205
|
+
expect(coerceToSupportedLocale('ja-JP', hostCatalog, 'en-US')).toBe('en-US');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('falls back to the caller default for empty or unresolved input', () => {
|
|
209
|
+
expect(coerceToSupportedLocale(undefined, hostCatalog, 'en-US')).toBe('en-US');
|
|
210
|
+
expect(coerceToSupportedLocale(null, hostCatalog, 'en-US')).toBe('en-US');
|
|
211
|
+
expect(coerceToSupportedLocale('', hostCatalog, 'en-US')).toBe('en-US');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
190
215
|
describe('SUPPORTED_LANGUAGES catalog integrity', () => {
|
|
191
216
|
it('is non-empty', () => {
|
|
192
217
|
expect(SUPPORTED_LANGUAGES.length).toBeGreaterThan(0);
|
package/src/utils/cache.ts
CHANGED
|
@@ -104,6 +104,7 @@ export class TTLCache<T> {
|
|
|
104
104
|
const effectiveTTL = ttl === undefined ? this.defaultTTL : ttl;
|
|
105
105
|
const expiresAt = now + effectiveTTL;
|
|
106
106
|
this.cache.set(key, { data, timestamp: now, expiresAt });
|
|
107
|
+
if (!cleanupInterval && activeCaches.has(this)) updateCleanupInterval();
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/**
|
|
@@ -112,7 +113,9 @@ export class TTLCache<T> {
|
|
|
112
113
|
* @returns true if entry was deleted, false if not found
|
|
113
114
|
*/
|
|
114
115
|
delete(key: string): boolean {
|
|
115
|
-
|
|
116
|
+
const deleted = this.cache.delete(key);
|
|
117
|
+
if (activeCaches.has(this)) updateCleanupInterval();
|
|
118
|
+
return deleted;
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
/**
|
|
@@ -122,6 +125,7 @@ export class TTLCache<T> {
|
|
|
122
125
|
this.cache.clear();
|
|
123
126
|
this.hits = 0;
|
|
124
127
|
this.misses = 0;
|
|
128
|
+
if (activeCaches.has(this)) updateCleanupInterval();
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
/**
|
|
@@ -232,31 +236,29 @@ const activeCaches = new Set<TTLCache<any>>();
|
|
|
232
236
|
*/
|
|
233
237
|
export function registerCacheForCleanup(cache: TTLCache<any>): void {
|
|
234
238
|
activeCaches.add(cache);
|
|
239
|
+
updateCleanupInterval();
|
|
240
|
+
}
|
|
235
241
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// Never block process/worker exit on a housekeeping timer.
|
|
244
|
-
cleanupInterval.unref?.();
|
|
242
|
+
/** Empty registered caches need no timer; importing a client must stay inert. */
|
|
243
|
+
function updateCleanupInterval(): void {
|
|
244
|
+
const hasEntries = [...activeCaches].some(cache => cache.size() > 0);
|
|
245
|
+
if (!hasEntries) {
|
|
246
|
+
if (cleanupInterval) clearInterval(cleanupInterval);
|
|
247
|
+
cleanupInterval = null;
|
|
248
|
+
return;
|
|
245
249
|
}
|
|
250
|
+
if (cleanupInterval) return;
|
|
251
|
+
cleanupInterval = setInterval(() => {
|
|
252
|
+
for (const cache of activeCaches) cache.cleanup();
|
|
253
|
+
updateCleanupInterval();
|
|
254
|
+
}, 60_000);
|
|
255
|
+
cleanupInterval.unref?.();
|
|
246
256
|
}
|
|
247
257
|
|
|
248
|
-
/**
|
|
249
|
-
* Unregister a cache from automatic cleanup
|
|
250
|
-
* @param cache Cache instance to unregister
|
|
251
|
-
*/
|
|
258
|
+
/** Unregister a cache without changing its entries or TTL behavior. */
|
|
252
259
|
export function unregisterCacheFromCleanup(cache: TTLCache<any>): void {
|
|
253
260
|
activeCaches.delete(cache);
|
|
254
|
-
|
|
255
|
-
// Stop cleanup interval if no caches are registered
|
|
256
|
-
if (activeCaches.size === 0 && cleanupInterval) {
|
|
257
|
-
clearInterval(cleanupInterval);
|
|
258
|
-
cleanupInterval = null;
|
|
259
|
-
}
|
|
261
|
+
updateCleanupInterval();
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
/**
|
|
@@ -254,3 +254,32 @@ export function getUserLanguages(user: Pick<User, 'languages'> | null | undefine
|
|
|
254
254
|
export function getPrimaryLanguage(user: Pick<User, 'languages'> | null | undefined): string | undefined {
|
|
255
255
|
return getUserLanguages(user)[0];
|
|
256
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Coerce a locale down to one from a CALLER-provided catalog: an exact
|
|
260
|
+
* canonical match wins, else the closest entry sharing the same base
|
|
261
|
+
* language (an `es-MX` account reads a host app's `es-ES` catalog), else
|
|
262
|
+
* `fallback`.
|
|
263
|
+
*
|
|
264
|
+
* A host app's own shipped translation catalog is rarely {@link SUPPORTED_LANGUAGES}
|
|
265
|
+
* itself — Oxy's account-locale catalog is broader than any one app's
|
|
266
|
+
* translated strings — so this is the one place that gap is bridged. Every Oxy
|
|
267
|
+
* app was independently re-deriving this exact algorithm against its own
|
|
268
|
+
* catalog before it moved here.
|
|
269
|
+
*/
|
|
270
|
+
export function coerceToSupportedLocale(
|
|
271
|
+
locale: string | null | undefined,
|
|
272
|
+
supportedLocales: readonly string[],
|
|
273
|
+
fallback: string,
|
|
274
|
+
): string {
|
|
275
|
+
if (locale) {
|
|
276
|
+
const canonical = normalizeLocale(locale);
|
|
277
|
+
if (canonical && supportedLocales.includes(canonical)) {
|
|
278
|
+
return canonical;
|
|
279
|
+
}
|
|
280
|
+
const base = getBaseLanguage(locale);
|
|
281
|
+
const byBase = supportedLocales.find((entry) => getBaseLanguage(entry) === base);
|
|
282
|
+
if (byBase) return byBase;
|
|
283
|
+
}
|
|
284
|
+
return fallback;
|
|
285
|
+
}
|