webspresso 0.0.101 → 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/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/ORM.md +13 -4
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +99 -2
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +6 -0
- package/README.md +20 -7
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/routes.js +8 -0
- package/core/applySchema.js +18 -1
- 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 +178 -6
- package/index.js +71 -16
- package/package.json +6 -5
- package/plugins/admin-panel/index.js +12 -0
- package/plugins/audit-log/middleware.js +39 -4
- package/plugins/csrf/index.js +1 -1
- package/plugins/email/service.js +32 -1
- package/plugins/file-manager/api-handlers.js +10 -17
- package/plugins/file-manager/index.js +24 -9
- package/plugins/file-manager/security.js +18 -6
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- 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 +18 -2
- package/src/api/validation-i18n.js +231 -0
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +84 -8
- package/src/file-router.js +215 -97
- package/src/helpers.js +139 -26
- package/src/njk-frontmatter.js +17 -1
- package/src/plugin-manager.js +46 -13
- package/src/routing/route-table.js +15 -5
- package/src/server.js +86 -15
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/services/validator.js +57 -32
- package/src/template/precompile.js +247 -0
|
@@ -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
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* @module src/api/api-loader
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
const path = require('path');
|
|
9
10
|
const { ZodError } = require('zod');
|
|
10
11
|
const { z: zForApi } = require('../../core/validation');
|
|
11
12
|
const { resolveMiddlewares } = require('../file-router');
|
|
13
|
+
const { translateValidationIssues, getTranslatorForRequest } = require('./validation-i18n');
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Compiles a schema definition from an API module
|
|
@@ -93,7 +95,7 @@ function createApiHandler(descriptor, context) {
|
|
|
93
95
|
// 1. Reload module in development
|
|
94
96
|
if (isDev) {
|
|
95
97
|
try {
|
|
96
|
-
const resolvedPath = require.resolve(descriptor.file);
|
|
98
|
+
const resolvedPath = descriptor._resolvedPath || (descriptor._resolvedPath = require.resolve(descriptor.file));
|
|
97
99
|
if (require.cache[resolvedPath]) {
|
|
98
100
|
delete require.cache[resolvedPath];
|
|
99
101
|
cachedMiddlewares = null;
|
|
@@ -147,9 +149,17 @@ function createApiHandler(descriptor, context) {
|
|
|
147
149
|
validateRequestInput(req, compiledSchema);
|
|
148
150
|
} catch (err) {
|
|
149
151
|
if (err instanceof ZodError || err.name === 'ZodError' || Array.isArray(err?.issues)) {
|
|
152
|
+
const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
|
|
153
|
+
const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
|
|
154
|
+
const translatedIssues = translateValidationIssues(err.issues, {
|
|
155
|
+
req,
|
|
156
|
+
pagesDir,
|
|
157
|
+
routeDir,
|
|
158
|
+
isDev,
|
|
159
|
+
});
|
|
150
160
|
return res.status(400).json({
|
|
151
161
|
error: 'Validation Error',
|
|
152
|
-
issues:
|
|
162
|
+
issues: translatedIssues,
|
|
153
163
|
});
|
|
154
164
|
}
|
|
155
165
|
throw err;
|
|
@@ -185,6 +195,10 @@ function createApiHandler(descriptor, context) {
|
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
// 5. Execute handler
|
|
198
|
+
const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
|
|
199
|
+
const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
|
|
200
|
+
const tr = getTranslatorForRequest(req, { pagesDir, routeDir, isDev });
|
|
201
|
+
|
|
188
202
|
const apiCtx = {
|
|
189
203
|
req,
|
|
190
204
|
res,
|
|
@@ -192,6 +206,8 @@ function createApiHandler(descriptor, context) {
|
|
|
192
206
|
query: req.query || {},
|
|
193
207
|
body: req.body || {},
|
|
194
208
|
input: req.input,
|
|
209
|
+
locale: tr.locale,
|
|
210
|
+
t: tr.t,
|
|
195
211
|
service: (name, input, opts) =>
|
|
196
212
|
serviceRegistry ? serviceRegistry.call(name, input, { req, res, db }, opts) : null,
|
|
197
213
|
module: descriptor.module || null,
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso API Validation i18n & Error Translator
|
|
5
|
+
* Automatically localizes Zod validation error messages and keys based on request locale
|
|
6
|
+
* @module src/api/validation-i18n
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const EMPTY_OBJECT = Object.freeze({});
|
|
12
|
+
|
|
13
|
+
let _fileRouter = null;
|
|
14
|
+
function getFileRouter() {
|
|
15
|
+
if (!_fileRouter) {
|
|
16
|
+
_fileRouter = require('../file-router');
|
|
17
|
+
}
|
|
18
|
+
return _fileRouter;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves or creates a cached translator function for the given request and directory context
|
|
23
|
+
* @param {import('express').Request} req - Express request
|
|
24
|
+
* @param {Object} [options]
|
|
25
|
+
* @param {string} [options.pagesDir] - Pages directory path
|
|
26
|
+
* @param {string} [options.routeDir] - Route-specific directory
|
|
27
|
+
* @param {boolean} [options.isDev] - Is development environment
|
|
28
|
+
* @returns {{ t: Function, locale: string }}
|
|
29
|
+
*/
|
|
30
|
+
function getTranslatorForRequest(req, options = {}) {
|
|
31
|
+
if (req && req.t && req.locale) {
|
|
32
|
+
return { t: req.t, locale: req.locale };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
loadI18n,
|
|
37
|
+
createTranslator,
|
|
38
|
+
detectLocale,
|
|
39
|
+
translatorCache,
|
|
40
|
+
} = getFileRouter();
|
|
41
|
+
|
|
42
|
+
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
|
43
|
+
const locale = (req && req.locale) ? req.locale : (req ? detectLocale(req) : defaultLocale);
|
|
44
|
+
|
|
45
|
+
const rawPagesDir = options.pagesDir || (req?.app?.get?.('pagesDir')) || 'pages';
|
|
46
|
+
const pagesDir = path.resolve(rawPagesDir);
|
|
47
|
+
const routeDir = options.routeDir ? path.resolve(options.routeDir) : '';
|
|
48
|
+
const isDev = options.isDev !== undefined ? options.isDev : process.env.NODE_ENV !== 'production';
|
|
49
|
+
|
|
50
|
+
let t;
|
|
51
|
+
if (!isDev && translatorCache) {
|
|
52
|
+
const transKey = `${pagesDir}::${routeDir}::${locale}::${defaultLocale}`;
|
|
53
|
+
t = translatorCache.get(transKey);
|
|
54
|
+
if (!t) {
|
|
55
|
+
const translations = loadI18n(pagesDir, routeDir, locale, false);
|
|
56
|
+
const fallbackTranslations = (locale !== defaultLocale)
|
|
57
|
+
? loadI18n(pagesDir, routeDir, defaultLocale, false)
|
|
58
|
+
: EMPTY_OBJECT;
|
|
59
|
+
t = createTranslator(translations, {
|
|
60
|
+
locale,
|
|
61
|
+
fallbackTranslations,
|
|
62
|
+
fallbackLocale: defaultLocale,
|
|
63
|
+
});
|
|
64
|
+
translatorCache.set(transKey, t);
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const translations = loadI18n(pagesDir, routeDir, locale, true);
|
|
68
|
+
const fallbackTranslations = (locale !== defaultLocale)
|
|
69
|
+
? loadI18n(pagesDir, routeDir, defaultLocale, true)
|
|
70
|
+
: EMPTY_OBJECT;
|
|
71
|
+
t = createTranslator(translations, {
|
|
72
|
+
locale,
|
|
73
|
+
fallbackTranslations,
|
|
74
|
+
fallbackLocale: defaultLocale,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (req) {
|
|
79
|
+
req.locale = locale;
|
|
80
|
+
req.t = t;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { t, locale };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Translates a single Zod issue using candidate keys and parameter interpolation
|
|
88
|
+
* @param {Object} issue - Zod issue
|
|
89
|
+
* @param {Function} t - Translator function
|
|
90
|
+
* @returns {Object} Translated issue clone
|
|
91
|
+
*/
|
|
92
|
+
function translateSingleIssue(issue, t) {
|
|
93
|
+
if (!issue || typeof issue !== 'object') return issue;
|
|
94
|
+
|
|
95
|
+
const pathKey = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join('.') : '';
|
|
96
|
+
const field = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path[issue.path.length - 1] : '';
|
|
97
|
+
const isRequired = (issue.code === 'invalid_type' && (issue.received === 'undefined' || issue.message === 'Required'))
|
|
98
|
+
|| issue.message === 'Required';
|
|
99
|
+
|
|
100
|
+
const params = Object.assign(
|
|
101
|
+
{
|
|
102
|
+
path: pathKey,
|
|
103
|
+
field,
|
|
104
|
+
min: issue.minimum !== undefined ? issue.minimum : undefined,
|
|
105
|
+
max: issue.maximum !== undefined ? issue.maximum : undefined,
|
|
106
|
+
minimum: issue.minimum !== undefined ? issue.minimum : undefined,
|
|
107
|
+
maximum: issue.maximum !== undefined ? issue.maximum : undefined,
|
|
108
|
+
limit: issue.minimum !== undefined ? issue.minimum : (issue.maximum !== undefined ? issue.maximum : undefined),
|
|
109
|
+
expected: issue.expected,
|
|
110
|
+
received: issue.received,
|
|
111
|
+
type: issue.type,
|
|
112
|
+
validation: issue.validation,
|
|
113
|
+
},
|
|
114
|
+
issue
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const candidates = [];
|
|
118
|
+
|
|
119
|
+
// 1. Explicit key in custom message (e.g. z.string({ message: 'auth.email_required' }))
|
|
120
|
+
if (typeof issue.message === 'string' && typeof t.has === 'function' && t.has(issue.message)) {
|
|
121
|
+
candidates.push(issue.message);
|
|
122
|
+
} else if (typeof issue.message === 'string' && issue.message.includes('{{')) {
|
|
123
|
+
const interpolated = issue.message.replace(/{{\s*(\w+)\s*}}/g, (_, k) =>
|
|
124
|
+
params[k] !== undefined ? String(params[k]) : ''
|
|
125
|
+
);
|
|
126
|
+
return Object.assign({}, issue, { message: interpolated });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Field-specific overrides (e.g. validation.fields.email.required or validation.email.required)
|
|
130
|
+
if (pathKey) {
|
|
131
|
+
if (isRequired) {
|
|
132
|
+
candidates.push(`validation.fields.${pathKey}.required`);
|
|
133
|
+
candidates.push(`validation.${pathKey}.required`);
|
|
134
|
+
candidates.push(`validation.fields.${pathKey}`);
|
|
135
|
+
candidates.push(`validation.${pathKey}`);
|
|
136
|
+
} else {
|
|
137
|
+
if (issue.validation) {
|
|
138
|
+
candidates.push(`validation.fields.${pathKey}.${issue.validation}`);
|
|
139
|
+
candidates.push(`validation.${pathKey}.${issue.validation}`);
|
|
140
|
+
}
|
|
141
|
+
candidates.push(`validation.fields.${pathKey}.${issue.code}`);
|
|
142
|
+
candidates.push(`validation.${pathKey}.${issue.code}`);
|
|
143
|
+
candidates.push(`validation.fields.${pathKey}`);
|
|
144
|
+
candidates.push(`validation.${pathKey}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 3. General validation code translations
|
|
149
|
+
if (isRequired) {
|
|
150
|
+
candidates.push('validation.required');
|
|
151
|
+
candidates.push('zod.required');
|
|
152
|
+
candidates.push('validation.invalid_type');
|
|
153
|
+
candidates.push('zod.invalid_type');
|
|
154
|
+
} else if (issue.code === 'invalid_string' && issue.validation) {
|
|
155
|
+
candidates.push(`validation.invalid_string.${issue.validation}`);
|
|
156
|
+
candidates.push(`validation.${issue.validation}`);
|
|
157
|
+
candidates.push(`zod.invalid_string.${issue.validation}`);
|
|
158
|
+
candidates.push(`zod.${issue.validation}`);
|
|
159
|
+
candidates.push('validation.invalid_string');
|
|
160
|
+
candidates.push('zod.invalid_string');
|
|
161
|
+
} else {
|
|
162
|
+
candidates.push(`validation.${issue.code}`);
|
|
163
|
+
candidates.push(`zod.${issue.code}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let matchedKey = null;
|
|
167
|
+
if (typeof t.has === 'function') {
|
|
168
|
+
for (const key of candidates) {
|
|
169
|
+
if (t.has(key)) {
|
|
170
|
+
matchedKey = key;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (matchedKey) {
|
|
177
|
+
return Object.assign({}, issue, {
|
|
178
|
+
message: t(matchedKey, params),
|
|
179
|
+
key: matchedKey,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// If no translation found, interpolate params in custom message if it contains placeholders
|
|
184
|
+
if (typeof issue.message === 'string' && issue.message.includes('{{')) {
|
|
185
|
+
const interpolated = issue.message.replace(/{{\s*(\w+)\s*}}/g, (_, k) =>
|
|
186
|
+
params[k] !== undefined ? String(params[k]) : ''
|
|
187
|
+
);
|
|
188
|
+
return Object.assign({}, issue, { message: interpolated });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return Object.assign({}, issue);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Translates an array of Zod validation issues using the active request locale
|
|
196
|
+
* @param {Array<Object>} issues - Zod error issues array
|
|
197
|
+
* @param {Object} [options]
|
|
198
|
+
* @param {import('express').Request} [options.req] - Express request
|
|
199
|
+
* @param {Function} [options.t] - Optional direct translator
|
|
200
|
+
* @param {string} [options.locale] - Optional direct locale
|
|
201
|
+
* @param {string} [options.pagesDir] - Pages directory path
|
|
202
|
+
* @param {string} [options.routeDir] - Route directory path
|
|
203
|
+
* @param {boolean} [options.isDev] - Is development environment
|
|
204
|
+
* @returns {Array<Object>} Translated issues
|
|
205
|
+
*/
|
|
206
|
+
function translateValidationIssues(issues, options = {}) {
|
|
207
|
+
if (!Array.isArray(issues) || issues.length === 0) {
|
|
208
|
+
return issues;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let t = options.t || options.req?.t;
|
|
212
|
+
let locale = options.locale || options.req?.locale;
|
|
213
|
+
|
|
214
|
+
if (!t && options.req) {
|
|
215
|
+
const tr = getTranslatorForRequest(options.req, options);
|
|
216
|
+
t = tr.t;
|
|
217
|
+
locale = tr.locale;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!t) {
|
|
221
|
+
return issues;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return issues.map((issue) => translateSingleIssue(issue, t));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
getTranslatorForRequest,
|
|
229
|
+
translateSingleIssue,
|
|
230
|
+
translateValidationIssues,
|
|
231
|
+
};
|