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.
- package/.agents/ORM.md +13 -4
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/README.md +2 -2
- package/core/auth/jwt.js +10 -17
- package/core/auth/user-agent.js +61 -42
- package/core/compression/compression-stream.js +2 -2
- package/core/compression/middleware.js +1 -1
- package/core/compression/negotiate.js +11 -4
- package/core/events/dispatcher.js +56 -33
- package/core/openapi/orm-components.js +2 -3
- package/core/orm/cache/listeners.js +12 -2
- package/core/orm/json-fields.js +7 -1
- package/core/orm/model.js +99 -8
- package/core/orm/query-builder.js +71 -25
- package/core/orm/repository.js +42 -17
- package/core/orm/schema-helpers.js +49 -0
- package/core/orm/scopes.js +40 -9
- package/core/orm/types.js +12 -1
- package/core/queue/manager.js +5 -0
- package/core/validation/index.js +9 -3
- package/index.d.ts +22 -5
- package/package.json +2 -1
- package/plugins/audit-log/middleware.js +39 -4
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/index.js +11 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/site-analytics/bot-patterns.js +3 -0
- package/plugins/site-analytics/client-error-handler.js +3 -1
- package/plugins/site-analytics/index.js +10 -1
- package/plugins/site-analytics/tracking.js +245 -46
- package/src/api/api-loader.js +1 -1
- package/src/discovery/scan-routes.js +14 -4
- package/src/file-router.js +62 -6
- package/src/helpers.js +139 -26
- package/src/plugin-manager.js +34 -13
- package/src/routing/route-table.js +14 -4
- package/src/server.js +23 -7
- 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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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 (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
},
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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 = {
|
|
393
|
+
module.exports = {
|
|
394
|
+
createTrackingMiddleware,
|
|
395
|
+
formatDbError,
|
|
396
|
+
isPermanentDbError,
|
|
397
|
+
PERMANENT_ERROR_CODES,
|
|
398
|
+
PERMANENT_ERROR_PATTERNS,
|
|
399
|
+
};
|
package/src/api/api-loader.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/file-router.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
1002
|
+
return getCachedRelativeTimeFormat(locale, relOpts).format(Number(val), unit);
|
|
947
1003
|
} catch {
|
|
948
1004
|
return `${val} ${unit}`;
|
|
949
1005
|
}
|