webspresso 0.0.102 → 0.0.103

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.
Files changed (39) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/core/auth/jwt.js +10 -17
  5. package/core/auth/user-agent.js +61 -42
  6. package/core/compression/compression-stream.js +2 -2
  7. package/core/compression/middleware.js +1 -1
  8. package/core/compression/negotiate.js +11 -4
  9. package/core/events/dispatcher.js +56 -33
  10. package/core/openapi/orm-components.js +2 -3
  11. package/core/orm/cache/listeners.js +12 -2
  12. package/core/orm/json-fields.js +7 -1
  13. package/core/orm/model.js +99 -8
  14. package/core/orm/query-builder.js +71 -25
  15. package/core/orm/repository.js +42 -17
  16. package/core/orm/schema-helpers.js +49 -0
  17. package/core/orm/scopes.js +40 -9
  18. package/core/orm/types.js +12 -1
  19. package/core/queue/manager.js +5 -0
  20. package/core/validation/index.js +9 -3
  21. package/index.d.ts +22 -5
  22. package/package.json +2 -1
  23. package/plugins/audit-log/middleware.js +39 -4
  24. package/plugins/email/service.js +32 -1
  25. package/plugins/file-manager/api-handlers.js +10 -17
  26. package/plugins/file-manager/index.js +11 -9
  27. package/plugins/file-manager/security.js +18 -6
  28. package/plugins/site-analytics/bot-patterns.js +3 -0
  29. package/plugins/site-analytics/client-error-handler.js +3 -1
  30. package/plugins/site-analytics/index.js +10 -1
  31. package/plugins/site-analytics/tracking.js +245 -46
  32. package/src/api/api-loader.js +1 -1
  33. package/src/discovery/scan-routes.js +14 -4
  34. package/src/file-router.js +62 -6
  35. package/src/helpers.js +139 -26
  36. package/src/plugin-manager.js +34 -13
  37. package/src/routing/route-table.js +14 -4
  38. package/src/server.js +23 -7
  39. package/src/services/validator.js +57 -32
@@ -11,6 +11,66 @@ const { detectCountry } = require('./geo');
11
11
  const STATIC_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif|mp4|webm)$/i;
12
12
  const DEFAULT_EXCLUDE = ['/_admin', '/api/', '/health', '/_next', '/__'];
13
13
 
14
+ /**
15
+ * Permanent / Fatal DB error codes that cannot be recovered by immediate retries.
16
+ */
17
+ const PERMANENT_ERROR_CODES = new Set([
18
+ 'SQLITE_FULL',
19
+ 'SQLITE_IOERR',
20
+ 'SQLITE_CORRUPT',
21
+ 'SQLITE_CANTOPEN',
22
+ 'SQLITE_NOTADB',
23
+ 'ER_DISK_FULL',
24
+ 'ENOSPC',
25
+ ]);
26
+
27
+ /**
28
+ * Patterns matching fatal disk full or corrupted database errors.
29
+ */
30
+ const PERMANENT_ERROR_PATTERNS = [
31
+ /database or disk is full/i,
32
+ /disk full/i,
33
+ /no space left on device/i,
34
+ /disk i\/o error/i,
35
+ /database disk image is malformed/i,
36
+ /file is not a database/i,
37
+ ];
38
+
39
+ /**
40
+ * Sanitize and format database error messages to prevent raw SQL query leakage in logs.
41
+ * Knex appends executed queries ("insert into ... - <actual error>") which can be 50-100 KB.
42
+ * @param {Error|Object|string} e
43
+ * @returns {string}
44
+ */
45
+ function formatDbError(e) {
46
+ if (!e) return 'Unknown error';
47
+
48
+ let msg;
49
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
50
+ msg = e.message.split(' - ').pop().trim();
51
+ } else if (typeof e.message === 'string') {
52
+ msg = e.message.trim();
53
+ } else if (typeof e === 'string') {
54
+ msg = e.trim();
55
+ } else {
56
+ msg = String(e.code || 'Database error');
57
+ }
58
+
59
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
60
+ }
61
+
62
+ /**
63
+ * Detect whether a database error is permanent/fatal (e.g. disk full, corruption).
64
+ * @param {Error|Object|string} e
65
+ * @returns {boolean}
66
+ */
67
+ function isPermanentDbError(e) {
68
+ if (!e) return false;
69
+ if (e.code && PERMANENT_ERROR_CODES.has(e.code)) return true;
70
+ const msg = typeof e.message === 'string' ? e.message : (typeof e === 'string' ? e : '');
71
+ return PERMANENT_ERROR_PATTERNS.some((pattern) => pattern.test(msg));
72
+ }
73
+
14
74
  /**
15
75
  * Hash a string with SHA-256 (truncated to 16 hex chars for compactness)
16
76
  */
@@ -38,6 +98,10 @@ function getClientIp(req) {
38
98
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
39
99
  * @param {number} [options.batchSize=20] - Flush when queue reaches this size
40
100
  * @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
101
+ * @param {number} [options.maxRetries=3] - Maximum retry attempts per batch item before dropping
102
+ * @param {number} [options.maxBackoffMs=60000] - Max exponential backoff cooldown
103
+ * @param {number} [options.maxQueueSize=5000] - Queue memory cap to prevent OOM
104
+ * @param {number} [options.errorLogIntervalMs=60000] - Rate limit window for batch failure logs
41
105
  */
42
106
  function createTrackingMiddleware(options) {
43
107
  const {
@@ -47,12 +111,26 @@ function createTrackingMiddleware(options) {
47
111
  tableName = 'analytics_page_views',
48
112
  batchSize = 20,
49
113
  flushIntervalMs = 3000,
114
+ maxRetries = 3,
115
+ maxBackoffMs = 60000,
116
+ maxQueueSize = 5000,
117
+ errorLogIntervalMs = 60000,
50
118
  } = options;
51
119
 
52
120
  const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
53
121
  let tableReady = false;
54
122
  const queue = [];
55
- let flushScheduled = false;
123
+
124
+ // Circuit breaker & backoff state
125
+ let failureCount = 0;
126
+ let nextAllowedFlushTime = 0;
127
+ let isFlushing = false;
128
+ let flushPromise = null;
129
+ let flushTimeout = null;
130
+
131
+ // Log throttling state
132
+ let lastErrorLogTime = 0;
133
+ let suppressedErrorCount = 0;
56
134
 
57
135
  async function ensureTable() {
58
136
  if (tableReady) return;
@@ -75,7 +153,26 @@ function createTrackingMiddleware(options) {
75
153
  }
76
154
  tableReady = true;
77
155
  } catch (e) {
78
- console.error('[site-analytics] Failed to ensure table:', e.message);
156
+ logError(e, 'Failed to ensure table');
157
+ throw e;
158
+ }
159
+ }
160
+
161
+ function logError(e, prefix = 'Batch insert failed') {
162
+ const now = Date.now();
163
+ const formatted = formatDbError(e);
164
+
165
+ if (lastErrorLogTime === 0 || now - lastErrorLogTime >= errorLogIntervalMs) {
166
+ const elapsedSec = lastErrorLogTime > 0 ? Math.round((now - lastErrorLogTime) / 1000) : 0;
167
+ if (suppressedErrorCount > 0) {
168
+ console.error(`[site-analytics] ${prefix} (suppressed ${suppressedErrorCount} occurrences in last ${elapsedSec}s): ${formatted}`);
169
+ } else {
170
+ console.error(`[site-analytics] ${prefix}: ${formatted}`);
171
+ }
172
+ lastErrorLogTime = now;
173
+ suppressedErrorCount = 0;
174
+ } else {
175
+ suppressedErrorCount++;
79
176
  }
80
177
  }
81
178
 
@@ -83,17 +180,17 @@ function createTrackingMiddleware(options) {
83
180
  const sessionMap = new Map();
84
181
  const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
85
182
  const MAX_SESSIONS = 10000;
86
- const MAX_QUEUE_SIZE = 5000;
87
183
 
88
184
  // Periodic session cleanup
89
- setInterval(() => {
185
+ const sessionCleanupInterval = setInterval(() => {
90
186
  const now = Date.now();
91
187
  for (const [vid, data] of sessionMap) {
92
188
  if (now - data.lastSeen > SESSION_TIMEOUT * 2) {
93
189
  sessionMap.delete(vid);
94
190
  }
95
191
  }
96
- }, 5 * 60 * 1000).unref();
192
+ }, 5 * 60 * 1000);
193
+ if (sessionCleanupInterval.unref) sessionCleanupInterval.unref();
97
194
 
98
195
  function getSessionId(visitorId) {
99
196
  const now = Date.now();
@@ -115,44 +212,93 @@ function createTrackingMiddleware(options) {
115
212
  }
116
213
 
117
214
  async function flushQueue() {
215
+ if (flushPromise) return flushPromise;
118
216
  if (queue.length === 0) return;
119
- const batch = queue.splice(0, queue.length);
120
217
 
121
- try {
122
- await ensureTable();
123
- for (const row of batch) {
124
- row.created_at = knex.fn.now();
125
- }
126
- await knex(tableName).insert(batch);
127
- } catch (e) {
128
- console.error('[site-analytics] Batch insert failed:', e.message);
129
- // Re-queue failed items with max size limit to prevent memory leak
130
- if (queue.length + batch.length <= MAX_QUEUE_SIZE) {
131
- queue.unshift(...batch);
132
- } else {
133
- const available = Math.max(0, MAX_QUEUE_SIZE - queue.length);
134
- if (available > 0) {
135
- queue.unshift(...batch.slice(batch.length - available));
218
+ const now = Date.now();
219
+ if (now < nextAllowedFlushTime) {
220
+ scheduleFlush(nextAllowedFlushTime - now);
221
+ return;
222
+ }
223
+
224
+ isFlushing = true;
225
+ flushPromise = (async () => {
226
+ const batch = queue.splice(0, queue.length);
227
+
228
+ try {
229
+ await ensureTable();
230
+ const dbBatch = batch.map(({ _retries, ...row }) => ({
231
+ ...row,
232
+ created_at: knex.fn.now(),
233
+ }));
234
+ await knex(tableName).insert(dbBatch);
235
+
236
+ // Successfully inserted: reset circuit breaker & failure counters
237
+ failureCount = 0;
238
+ nextAllowedFlushTime = 0;
239
+ lastErrorLogTime = 0;
240
+ suppressedErrorCount = 0;
241
+ } catch (e) {
242
+ failureCount++;
243
+ const backoffDelay = Math.min(flushIntervalMs * Math.pow(2, failureCount), maxBackoffMs);
244
+ nextAllowedFlushTime = Date.now() + backoffDelay;
245
+
246
+ // Rate-limited sanitized logging (no raw SQL query leakage)
247
+ logError(e, 'Batch insert failed');
248
+
249
+ // Schedule next attempt respecting backoff
250
+ scheduleFlush(backoffDelay);
251
+
252
+ // Drop policy: If fatal/permanent error (disk full, corrupted DB, etc.), drop entire batch
253
+ if (!isPermanentDbError(e)) {
254
+ const retryable = [];
255
+ for (const item of batch) {
256
+ item._retries = (item._retries || 0) + 1;
257
+ if (item._retries < maxRetries) {
258
+ retryable.push(item);
259
+ }
260
+ }
261
+
262
+ // Re-queue items that haven't exceeded retry limits, respecting maxQueueSize
263
+ if (retryable.length > 0) {
264
+ const available = Math.max(0, maxQueueSize - queue.length);
265
+ if (available > 0) {
266
+ queue.unshift(...retryable.slice(0, available));
267
+ }
268
+ }
136
269
  }
270
+ } finally {
271
+ isFlushing = false;
272
+ flushPromise = null;
137
273
  }
138
- }
274
+ })();
275
+
276
+ return flushPromise;
139
277
  }
140
278
 
141
- function scheduleFlush() {
142
- if (flushScheduled || queue.length === 0) return;
143
- flushScheduled = true;
144
- setTimeout(() => {
145
- flushScheduled = false;
279
+ function scheduleFlush(customDelay) {
280
+ if (flushTimeout || queue.length === 0) return;
281
+ const now = Date.now();
282
+ const delay = customDelay !== undefined
283
+ ? customDelay
284
+ : Math.max(flushIntervalMs, nextAllowedFlushTime - now, 0);
285
+
286
+ flushTimeout = setTimeout(() => {
287
+ flushTimeout = null;
146
288
  flushQueue().catch(() => {});
147
- }, flushIntervalMs);
289
+ }, delay);
290
+ if (flushTimeout.unref) flushTimeout.unref();
148
291
  }
149
292
 
150
293
  // Periodic flush (catches low-traffic case)
151
- setInterval(() => {
152
- if (queue.length > 0) flushQueue().catch(() => {});
153
- }, flushIntervalMs).unref();
294
+ const periodicFlushInterval = setInterval(() => {
295
+ if (queue.length > 0 && Date.now() >= nextAllowedFlushTime && !isFlushing) {
296
+ flushQueue().catch(() => {});
297
+ }
298
+ }, flushIntervalMs);
299
+ if (periodicFlushInterval.unref) periodicFlushInterval.unref();
154
300
 
155
- return async function trackingMiddleware(req, res, next) {
301
+ async function trackingMiddleware(req, res, next) {
156
302
  next();
157
303
 
158
304
  if (req.method !== 'GET') return;
@@ -168,33 +314,86 @@ function createTrackingMiddleware(options) {
168
314
 
169
315
  if (isBot && !trackBots) return;
170
316
 
317
+ // Protect queue under pressure or during active database failures:
318
+ // Drop bots immediately to preserve queue capacity and avoid wasting DB I/O
319
+ if (isBot && (failureCount > 0 || queue.length >= maxQueueSize * 0.7)) {
320
+ return;
321
+ }
322
+
171
323
  const ipHash = quickHash(ip);
172
324
  const visitorId = quickHash(ip + '|' + userAgent);
173
- const sessionId = getSessionId(visitorId);
325
+
326
+ // Do not thrash human sessionMap with bot/crawler visits
327
+ const sessionId = isBot
328
+ ? quickHash('bot-' + (botName || 'crawler') + '-' + visitorId)
329
+ : getSessionId(visitorId);
330
+
174
331
  const country = detectCountry(req);
175
332
  const referrer = req.headers['referer'] || req.headers['referrer'] || null;
176
333
 
177
- queue.push({
178
- session_id: sessionId,
179
- visitor_id: visitorId,
180
- path,
181
- referrer: referrer ? referrer.slice(0, 500) : null,
182
- user_agent: userAgent.slice(0, 1000),
183
- ip_hash: ipHash,
184
- country,
185
- is_bot: isBot,
186
- bot_name: botName,
187
- });
334
+ if (queue.length < maxQueueSize) {
335
+ queue.push({
336
+ session_id: sessionId,
337
+ visitor_id: visitorId,
338
+ path,
339
+ referrer: referrer ? referrer.slice(0, 500) : null,
340
+ user_agent: userAgent.slice(0, 1000),
341
+ ip_hash: ipHash,
342
+ country,
343
+ is_bot: isBot,
344
+ bot_name: botName,
345
+ });
346
+ }
188
347
 
189
348
  if (queue.length >= batchSize) {
190
- flushQueue().catch(() => {});
349
+ if (Date.now() >= nextAllowedFlushTime) {
350
+ flushQueue().catch(() => {});
351
+ } else {
352
+ scheduleFlush();
353
+ }
191
354
  } else {
192
355
  scheduleFlush();
193
356
  }
194
357
  } catch (e) {
195
358
  // Non-blocking: don't let tracking errors affect the app
196
359
  }
360
+ }
361
+
362
+ // Diagnostic / testing accessors
363
+ trackingMiddleware.flush = () => flushQueue();
364
+ trackingMiddleware.getQueue = () => queue;
365
+ trackingMiddleware.getFailureCount = () => failureCount;
366
+ trackingMiddleware.getNextAllowedFlushTime = () => nextAllowedFlushTime;
367
+ trackingMiddleware.resetState = (opts = {}) => {
368
+ failureCount = 0;
369
+ nextAllowedFlushTime = 0;
370
+ const resetLogs = typeof opts === 'boolean' ? opts : !!opts.resetLogs;
371
+ if (resetLogs) {
372
+ lastErrorLogTime = 0;
373
+ suppressedErrorCount = 0;
374
+ }
375
+ queue.length = 0;
376
+ if (flushTimeout) {
377
+ clearTimeout(flushTimeout);
378
+ flushTimeout = null;
379
+ }
197
380
  };
381
+ trackingMiddleware.stop = () => {
382
+ if (flushTimeout) {
383
+ clearTimeout(flushTimeout);
384
+ flushTimeout = null;
385
+ }
386
+ clearInterval(sessionCleanupInterval);
387
+ clearInterval(periodicFlushInterval);
388
+ };
389
+
390
+ return trackingMiddleware;
198
391
  }
199
392
 
200
- module.exports = { createTrackingMiddleware };
393
+ module.exports = {
394
+ createTrackingMiddleware,
395
+ formatDbError,
396
+ isPermanentDbError,
397
+ PERMANENT_ERROR_CODES,
398
+ PERMANENT_ERROR_PATTERNS,
399
+ };
@@ -95,7 +95,7 @@ function createApiHandler(descriptor, context) {
95
95
  // 1. Reload module in development
96
96
  if (isDev) {
97
97
  try {
98
- const resolvedPath = require.resolve(descriptor.file);
98
+ const resolvedPath = descriptor._resolvedPath || (descriptor._resolvedPath = require.resolve(descriptor.file));
99
99
  if (require.cache[resolvedPath]) {
100
100
  delete require.cache[resolvedPath];
101
101
  cachedMiddlewares = null;
@@ -94,6 +94,19 @@ function scanDirSafely(dirPath, baseDir = dirPath, checkWarnings = false) {
94
94
  return results;
95
95
  }
96
96
 
97
+ let isWebspressoPackageCache = null;
98
+ function checkIsWebspressoPackage() {
99
+ if (isWebspressoPackageCache === null) {
100
+ try {
101
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
102
+ isWebspressoPackageCache = Boolean(pkg && pkg.name === 'webspresso');
103
+ } catch (_) {
104
+ isWebspressoPackageCache = false;
105
+ }
106
+ }
107
+ return isWebspressoPackageCache;
108
+ }
109
+
97
110
  /**
98
111
  * Scans configured pages, api, and module directories
99
112
  * @param {Object} options
@@ -106,10 +119,7 @@ function scanDirSafely(dirPath, baseDir = dirPath, checkWarnings = false) {
106
119
  function scanRoutes(options = {}) {
107
120
  const rootDir = options.rootDir || (() => {
108
121
  if (process.env.NODE_ENV === 'test') {
109
- try {
110
- const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
111
- if (pkg.name === 'webspresso') return process.cwd();
112
- } catch (_) {}
122
+ if (checkIsWebspressoPackage()) return process.cwd();
113
123
  return path.resolve(__dirname, '../..');
114
124
  }
115
125
  return process.cwd();
@@ -190,6 +190,10 @@ const translatorCache = new Map();
190
190
 
191
191
  function clearTranslatorCache() {
192
192
  translatorCache.clear();
193
+ intlNumberFormatCache.clear();
194
+ intlDateTimeFormatCache.clear();
195
+ intlRelativeTimeFormatCache.clear();
196
+ filePathToRouteCache.clear();
193
197
  }
194
198
 
195
199
  // Cache for route configs in production
@@ -215,6 +219,48 @@ const methodFromFilenameCache = new Map();
215
219
  // Cache for translation param replacement regexes
216
220
  const paramRegexCache = new Map();
217
221
 
222
+ // Caches for file-to-route resolution and Intl formatters
223
+ const filePathToRouteCache = new Map();
224
+ const intlNumberFormatCache = new Map();
225
+ const intlDateTimeFormatCache = new Map();
226
+ const intlRelativeTimeFormatCache = new Map();
227
+
228
+ function getCachedNumberFormat(locale, opts) {
229
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
230
+ let fmt = intlNumberFormatCache.get(key);
231
+ if (!fmt) {
232
+ fmt = new Intl.NumberFormat(locale, opts);
233
+ if (intlNumberFormatCache.size < 500) {
234
+ intlNumberFormatCache.set(key, fmt);
235
+ }
236
+ }
237
+ return fmt;
238
+ }
239
+
240
+ function getCachedDateTimeFormat(locale, opts) {
241
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
242
+ let fmt = intlDateTimeFormatCache.get(key);
243
+ if (!fmt) {
244
+ fmt = new Intl.DateTimeFormat(locale, opts);
245
+ if (intlDateTimeFormatCache.size < 500) {
246
+ intlDateTimeFormatCache.set(key, fmt);
247
+ }
248
+ }
249
+ return fmt;
250
+ }
251
+
252
+ function getCachedRelativeTimeFormat(locale, opts) {
253
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
254
+ let fmt = intlRelativeTimeFormatCache.get(key);
255
+ if (!fmt) {
256
+ fmt = new Intl.RelativeTimeFormat(locale, opts);
257
+ if (intlRelativeTimeFormatCache.size < 500) {
258
+ intlRelativeTimeFormatCache.set(key, fmt);
259
+ }
260
+ }
261
+ return fmt;
262
+ }
263
+
218
264
  const MAX_LOCALE_LEN = 16;
219
265
 
220
266
  let cachedSupportedLocalesRaw = null;
@@ -404,6 +450,12 @@ function isApiRouteFile(file) {
404
450
  * @returns {string} Express route pattern
405
451
  */
406
452
  function filePathToRoute(filePath, ext) {
453
+ const cacheKey = filePath + '::' + ext;
454
+ const cached = filePathToRouteCache.get(cacheKey);
455
+ if (cached !== undefined) {
456
+ return cached;
457
+ }
458
+
407
459
  // Remove extension
408
460
  let route = filePath.replace(ext, '');
409
461
 
@@ -429,6 +481,10 @@ function filePathToRoute(filePath, ext) {
429
481
  route = '/' + route;
430
482
  }
431
483
 
484
+ if (filePathToRouteCache.size < 5000) {
485
+ filePathToRouteCache.set(cacheKey, route);
486
+ }
487
+
432
488
  return route;
433
489
  }
434
490
 
@@ -886,8 +942,8 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
886
942
  return value;
887
943
  }
888
944
 
889
- // Replace params like {{name}} in the translation
890
- if (interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
945
+ // Replace params like {{name}} in the translation (skip if no placeholders in template)
946
+ if (value.includes('{{') && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
891
947
  for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
892
948
  let regex = paramRegexCache.get(paramKey);
893
949
  if (regex === undefined) {
@@ -911,7 +967,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
911
967
  t.number = function formatNumber(num, numOpts) {
912
968
  if (num === null || num === undefined || Number.isNaN(Number(num))) return '';
913
969
  try {
914
- return new Intl.NumberFormat(locale, numOpts).format(Number(num));
970
+ return getCachedNumberFormat(locale, numOpts).format(Number(num));
915
971
  } catch {
916
972
  return String(num);
917
973
  }
@@ -921,7 +977,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
921
977
  t.currency = function formatCurrency(amount, currency = 'USD', currOpts = {}) {
922
978
  if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '';
923
979
  try {
924
- return new Intl.NumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
980
+ return getCachedNumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
925
981
  } catch {
926
982
  return `${amount} ${currency}`;
927
983
  }
@@ -933,7 +989,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
933
989
  try {
934
990
  const d = date instanceof Date ? date : new Date(date);
935
991
  if (Number.isNaN(d.getTime())) return '';
936
- return new Intl.DateTimeFormat(locale, dateOpts).format(d);
992
+ return getCachedDateTimeFormat(locale, dateOpts).format(d);
937
993
  } catch {
938
994
  return String(date);
939
995
  }
@@ -943,7 +999,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
943
999
  t.relativeTime = function formatRelativeTime(val, unit = 'day', relOpts = { numeric: 'auto' }) {
944
1000
  if (val === null || val === undefined || Number.isNaN(Number(val))) return '';
945
1001
  try {
946
- return new Intl.RelativeTimeFormat(locale, relOpts).format(Number(val), unit);
1002
+ return getCachedRelativeTimeFormat(locale, relOpts).format(Number(val), unit);
947
1003
  } catch {
948
1004
  return `${val} ${unit}`;
949
1005
  }