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
package/src/file-router.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
-
const { ZodError } = require('zod');
|
|
9
8
|
const { compileSchema, invalidateSchema } = require('../core/compileSchema');
|
|
10
9
|
const { applySchema } = require('../core/applySchema');
|
|
10
|
+
const { translateValidationIssues, getTranslatorForRequest } = require('./api/validation-i18n');
|
|
11
11
|
const { createHelpers, getAssetManager } = require('./helpers');
|
|
12
12
|
const {
|
|
13
13
|
loadNjkRouteTemplate,
|
|
@@ -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
|
|
|
@@ -494,15 +550,15 @@ function routeRegistrationMeta(routePath) {
|
|
|
494
550
|
* @param {{ routePath: string }} b
|
|
495
551
|
*/
|
|
496
552
|
function compareRouteRegistrationOrder(a, b) {
|
|
497
|
-
const ma = routeRegistrationMeta(a.routePath);
|
|
498
|
-
const mb = routeRegistrationMeta(b.routePath);
|
|
553
|
+
const ma = a._regMeta || (a._regMeta = routeRegistrationMeta(a.routePath));
|
|
554
|
+
const mb = b._regMeta || (b._regMeta = routeRegistrationMeta(b.routePath));
|
|
499
555
|
if (ma.tier !== mb.tier) return ma.tier - mb.tier;
|
|
500
556
|
if (ma.literalSegCount !== mb.literalSegCount) {
|
|
501
557
|
return mb.literalSegCount - ma.literalSegCount;
|
|
502
558
|
}
|
|
503
559
|
if (ma.depth !== mb.depth) return mb.depth - ma.depth;
|
|
504
560
|
if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
|
|
505
|
-
return ma.routePath.
|
|
561
|
+
return ma.routePath < mb.routePath ? -1 : (ma.routePath > mb.routePath ? 1 : 0);
|
|
506
562
|
}
|
|
507
563
|
|
|
508
564
|
/**
|
|
@@ -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
|
}
|
|
@@ -1133,86 +1189,92 @@ function mountPages(app, options) {
|
|
|
1133
1189
|
// Get absolute path to pages directory
|
|
1134
1190
|
const absolutePagesDir = path.resolve(pagesDir);
|
|
1135
1191
|
|
|
1136
|
-
//
|
|
1137
|
-
function
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
return ':param';
|
|
1143
|
-
}
|
|
1144
|
-
return name;
|
|
1192
|
+
// Single-pass directory scanner with startup collision & case-sensitivity checks (0 statSync calls)
|
|
1193
|
+
function getGenericSegment(name) {
|
|
1194
|
+
if (name.startsWith('[') && name.endsWith(']')) {
|
|
1195
|
+
const inner = name.slice(1, -1);
|
|
1196
|
+
if (inner.startsWith('...')) return '*';
|
|
1197
|
+
return ':param';
|
|
1145
1198
|
}
|
|
1199
|
+
return name;
|
|
1200
|
+
}
|
|
1146
1201
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1202
|
+
function scanAndCheckDir(dirPath, relativeDir = '', isApi = false) {
|
|
1203
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
1204
|
+
let children;
|
|
1205
|
+
try {
|
|
1206
|
+
children = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1207
|
+
} catch (_) {
|
|
1208
|
+
return [];
|
|
1209
|
+
}
|
|
1210
|
+
const kept = [];
|
|
1211
|
+
const files = [];
|
|
1212
|
+
|
|
1213
|
+
for (const entry of children) {
|
|
1214
|
+
const child = entry.name;
|
|
1215
|
+
if (child.startsWith('_') || child === 'locales') continue;
|
|
1216
|
+
const fullPath = path.join(dirPath, child);
|
|
1217
|
+
const relPath = relativeDir ? path.join(relativeDir, child) : child;
|
|
1218
|
+
|
|
1219
|
+
// 1. Check case-sensitivity issues
|
|
1220
|
+
if (child !== child.toLowerCase()) {
|
|
1221
|
+
console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
|
|
1222
|
+
}
|
|
1157
1223
|
|
|
1158
|
-
|
|
1159
|
-
if (child
|
|
1160
|
-
|
|
1224
|
+
if (entry.isDirectory()) {
|
|
1225
|
+
if (child === 'api' && relativeDir === '') {
|
|
1226
|
+
files.push(...scanAndCheckDir(fullPath, relPath, true));
|
|
1227
|
+
} else {
|
|
1228
|
+
files.push(...scanAndCheckDir(fullPath, relPath, isApi));
|
|
1229
|
+
if (!isApi) {
|
|
1230
|
+
kept.push({ name: child, isDir: true, original: child });
|
|
1231
|
+
}
|
|
1161
1232
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
kept.push({ name: child, isDir: true, original: child });
|
|
1170
|
-
}
|
|
1233
|
+
} else if (entry.isFile()) {
|
|
1234
|
+
files.push(relPath);
|
|
1235
|
+
const ext = path.extname(child);
|
|
1236
|
+
if (isApi) {
|
|
1237
|
+
if (ext === '.js') {
|
|
1238
|
+
const base = path.basename(child, ext);
|
|
1239
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
1171
1240
|
}
|
|
1172
1241
|
} else {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
const base = path.basename(child, ext);
|
|
1177
|
-
kept.push({ name: base, isDir: false, original: child });
|
|
1178
|
-
}
|
|
1179
|
-
} else {
|
|
1180
|
-
if (ext === '.njk') {
|
|
1181
|
-
const base = path.basename(child, ext);
|
|
1182
|
-
kept.push({ name: base, isDir: false, original: child });
|
|
1183
|
-
}
|
|
1242
|
+
if (ext === '.njk') {
|
|
1243
|
+
const base = path.basename(child, ext);
|
|
1244
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
1184
1245
|
}
|
|
1185
1246
|
}
|
|
1186
1247
|
}
|
|
1248
|
+
}
|
|
1187
1249
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
groups.get(normalized).push(item);
|
|
1250
|
+
// 2. Check dynamic route collisions
|
|
1251
|
+
const groups = new Map();
|
|
1252
|
+
for (const item of kept) {
|
|
1253
|
+
const normalized = getGenericSegment(item.name);
|
|
1254
|
+
if (!groups.has(normalized)) {
|
|
1255
|
+
groups.set(normalized, []);
|
|
1196
1256
|
}
|
|
1257
|
+
groups.get(normalized).push(item);
|
|
1258
|
+
}
|
|
1197
1259
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
}
|
|
1260
|
+
for (const [norm, items] of groups.entries()) {
|
|
1261
|
+
if (items.length > 1) {
|
|
1262
|
+
const namesList = items.map(item => `"${item.original}"`).join(' and ');
|
|
1263
|
+
console.warn(`[webspresso] WARNING: Dynamic route collision in "pages/${relativeDir.split(path.sep).join('/')}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
|
|
1203
1264
|
}
|
|
1204
1265
|
}
|
|
1205
1266
|
|
|
1206
|
-
|
|
1267
|
+
return files;
|
|
1207
1268
|
}
|
|
1208
1269
|
|
|
1209
|
-
checkRoutesAtStartup();
|
|
1210
|
-
|
|
1211
1270
|
// Load global hooks
|
|
1212
1271
|
const globalHooks = loadGlobalHooks(absolutePagesDir, isDev);
|
|
1213
1272
|
|
|
1214
|
-
//
|
|
1215
|
-
const files =
|
|
1273
|
+
// Single-pass scan for files and checks (bypassed if pre-scanned files provided by discovery phase)
|
|
1274
|
+
const files = (Array.isArray(options.scannedFiles) && options.scannedFiles.length >= 0)
|
|
1275
|
+
? options.scannedFiles
|
|
1276
|
+
: scanAndCheckDir(absolutePagesDir, '');
|
|
1277
|
+
const filesSet = new Set(files);
|
|
1216
1278
|
|
|
1217
1279
|
// Separate SSR pages and API routes
|
|
1218
1280
|
const ssrRoutes = [];
|
|
@@ -1239,7 +1301,9 @@ function mountPages(app, options) {
|
|
|
1239
1301
|
} else if (ext === '.njk') {
|
|
1240
1302
|
// SSR page
|
|
1241
1303
|
const routePath = filePathToRoute(file, '.njk');
|
|
1242
|
-
const
|
|
1304
|
+
const configRel = file.replace(/\.njk$/, '.js');
|
|
1305
|
+
const hasCompanionConfig = filesSet.has(configRel);
|
|
1306
|
+
const configPath = path.join(absolutePagesDir, configRel);
|
|
1243
1307
|
const routeDir = path.dirname(path.join(absolutePagesDir, file));
|
|
1244
1308
|
|
|
1245
1309
|
ssrRoutes.push({
|
|
@@ -1247,6 +1311,7 @@ function mountPages(app, options) {
|
|
|
1247
1311
|
routePath,
|
|
1248
1312
|
fullPath: path.join(absolutePagesDir, file),
|
|
1249
1313
|
configPath,
|
|
1314
|
+
hasCompanionConfig,
|
|
1250
1315
|
routeDir
|
|
1251
1316
|
});
|
|
1252
1317
|
}
|
|
@@ -1280,21 +1345,27 @@ function mountPages(app, options) {
|
|
|
1280
1345
|
if (options.skipExistingRoutes && options.skipExistingRoutes.has(route.method || 'GET', route.routePath)) {
|
|
1281
1346
|
continue;
|
|
1282
1347
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
const routeMiddleware = handler.middleware;
|
|
1286
|
-
|
|
1287
|
-
const preResolvedMw = routeMiddleware
|
|
1288
|
-
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
1289
|
-
: EMPTY_ARRAY;
|
|
1290
|
-
|
|
1291
|
-
if (typeof handlerFn !== 'function') {
|
|
1292
|
-
console.warn(`API route ${route.file} does not export a function`);
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1348
|
+
let handlerFn = null;
|
|
1349
|
+
let preResolvedMw = null;
|
|
1296
1350
|
let preCompiledSchema = null;
|
|
1351
|
+
|
|
1352
|
+
// In production, eager-load to compile schemas & fail fast at startup
|
|
1297
1353
|
if (!isDev) {
|
|
1354
|
+
let handler;
|
|
1355
|
+
try {
|
|
1356
|
+
handler = require(route.fullPath);
|
|
1357
|
+
} catch (loadErr) {
|
|
1358
|
+
console.error(`Failed to load API route ${route.file}:`, loadErr);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
1362
|
+
if (typeof handlerFn !== 'function') {
|
|
1363
|
+
console.warn(`API route ${route.file} does not export a function`);
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
preResolvedMw = handler.middleware
|
|
1367
|
+
? resolveMiddlewares(handler.middleware, middlewares)
|
|
1368
|
+
: EMPTY_ARRAY;
|
|
1298
1369
|
try {
|
|
1299
1370
|
preCompiledSchema = compileSchema(route.fullPath, handler);
|
|
1300
1371
|
} catch (schemaErr) {
|
|
@@ -1329,11 +1400,29 @@ function mountPages(app, options) {
|
|
|
1329
1400
|
if (require.cache[require.resolve(route.fullPath)]) {
|
|
1330
1401
|
delete require.cache[require.resolve(route.fullPath)];
|
|
1331
1402
|
}
|
|
1332
|
-
|
|
1403
|
+
let currentHandler;
|
|
1404
|
+
try {
|
|
1405
|
+
currentHandler = require(route.fullPath);
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
console.error(`Failed to load API route ${route.file}:`, err);
|
|
1408
|
+
return res.status(500).json({ error: 'Internal Server Error', message: `Failed to load API route: ${err.message}` });
|
|
1409
|
+
}
|
|
1333
1410
|
fn = typeof currentHandler === 'function'
|
|
1334
1411
|
? currentHandler
|
|
1335
1412
|
: currentHandler.default || currentHandler.handler;
|
|
1336
1413
|
|
|
1414
|
+
if (typeof fn !== 'function') {
|
|
1415
|
+
console.warn(`API route ${route.file} does not export a function`);
|
|
1416
|
+
return res.status(500).json({ error: 'Internal Server Error', message: `Route ${route.file} does not export a valid handler function` });
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Stateful middlewares (e.g. rate limiters) must be resolved once and preserved across requests
|
|
1420
|
+
if (preResolvedMw === null) {
|
|
1421
|
+
preResolvedMw = currentHandler.middleware
|
|
1422
|
+
? resolveMiddlewares(currentHandler.middleware, middlewares)
|
|
1423
|
+
: EMPTY_ARRAY;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1337
1426
|
invalidateSchema(route.fullPath);
|
|
1338
1427
|
try {
|
|
1339
1428
|
compiledSchema = compileSchema(route.fullPath, currentHandler);
|
|
@@ -1344,13 +1433,21 @@ function mountPages(app, options) {
|
|
|
1344
1433
|
}
|
|
1345
1434
|
}
|
|
1346
1435
|
|
|
1436
|
+
const routeMw = preResolvedMw || EMPTY_ARRAY;
|
|
1437
|
+
|
|
1347
1438
|
try {
|
|
1348
1439
|
applySchema(req, compiledSchema);
|
|
1349
1440
|
} catch (err) {
|
|
1350
|
-
if (err
|
|
1441
|
+
if (err && (err.name === 'ZodError' || Array.isArray(err.issues))) {
|
|
1442
|
+
const translatedIssues = translateValidationIssues(err.issues, {
|
|
1443
|
+
req,
|
|
1444
|
+
pagesDir: absolutePagesDir,
|
|
1445
|
+
routeDir: route.routeDir || (route.fullPath ? path.dirname(route.fullPath) : ''),
|
|
1446
|
+
isDev,
|
|
1447
|
+
});
|
|
1351
1448
|
return res.status(400).json({
|
|
1352
1449
|
error: 'Validation Error',
|
|
1353
|
-
issues:
|
|
1450
|
+
issues: translatedIssues,
|
|
1354
1451
|
});
|
|
1355
1452
|
}
|
|
1356
1453
|
throw err;
|
|
@@ -1363,11 +1460,18 @@ function mountPages(app, options) {
|
|
|
1363
1460
|
if (res.headersSent) return;
|
|
1364
1461
|
}
|
|
1365
1462
|
|
|
1366
|
-
// Run middleware if defined
|
|
1367
|
-
if (
|
|
1368
|
-
await runMiddlewareChain(
|
|
1463
|
+
// Run middleware if defined
|
|
1464
|
+
if (routeMw.length) {
|
|
1465
|
+
await runMiddlewareChain(routeMw, req, res);
|
|
1369
1466
|
if (res.headersSent) return;
|
|
1370
1467
|
}
|
|
1468
|
+
|
|
1469
|
+
const tr = getTranslatorForRequest(req, {
|
|
1470
|
+
pagesDir: absolutePagesDir,
|
|
1471
|
+
routeDir: route.routeDir || (route.fullPath ? path.dirname(route.fullPath) : ''),
|
|
1472
|
+
isDev,
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1371
1475
|
const apiCtx = Object.assign((err) => next(err), {
|
|
1372
1476
|
req,
|
|
1373
1477
|
res,
|
|
@@ -1376,6 +1480,8 @@ function mountPages(app, options) {
|
|
|
1376
1480
|
query: req.query || {},
|
|
1377
1481
|
body: req.body || {},
|
|
1378
1482
|
input: req.input,
|
|
1483
|
+
locale: tr.locale,
|
|
1484
|
+
t: tr.t,
|
|
1379
1485
|
service: req.service,
|
|
1380
1486
|
db,
|
|
1381
1487
|
sse: res.sse,
|
|
@@ -1404,10 +1510,12 @@ function mountPages(app, options) {
|
|
|
1404
1510
|
if (options.skipExistingRoutes && options.skipExistingRoutes.has('GET', route.routePath)) {
|
|
1405
1511
|
continue;
|
|
1406
1512
|
}
|
|
1407
|
-
const mountConfig =
|
|
1408
|
-
|
|
1513
|
+
const mountConfig = (!isDev && route.hasCompanionConfig)
|
|
1514
|
+
? loadRouteConfig(route.configPath, false)
|
|
1515
|
+
: null;
|
|
1516
|
+
let preResolvedPageMw = mountConfig?.middleware
|
|
1409
1517
|
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
1410
|
-
: EMPTY_ARRAY;
|
|
1518
|
+
: (!isDev ? EMPTY_ARRAY : null);
|
|
1411
1519
|
const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
|
|
1412
1520
|
|
|
1413
1521
|
app.get(route.routePath, async (req, res, next) => {
|
|
@@ -1455,8 +1563,16 @@ function mountPages(app, options) {
|
|
|
1455
1563
|
}
|
|
1456
1564
|
|
|
1457
1565
|
// Load route config
|
|
1458
|
-
const config = isDev
|
|
1566
|
+
const config = isDev
|
|
1567
|
+
? (route.hasCompanionConfig ? loadRouteConfig(route.configPath, true) : null)
|
|
1568
|
+
: mountConfig;
|
|
1459
1569
|
const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
|
|
1570
|
+
if (preResolvedPageMw === null) {
|
|
1571
|
+
preResolvedPageMw = config?.middleware
|
|
1572
|
+
? resolveMiddlewares(config.middleware, middlewares)
|
|
1573
|
+
: EMPTY_ARRAY;
|
|
1574
|
+
}
|
|
1575
|
+
const pageMw = preResolvedPageMw;
|
|
1460
1576
|
|
|
1461
1577
|
// Create context with plugin helpers merged
|
|
1462
1578
|
const baseHelpers = createHelpers({ req, res, locale, t });
|
|
@@ -1521,9 +1637,9 @@ function mountPages(app, options) {
|
|
|
1521
1637
|
if (res.headersSent) return;
|
|
1522
1638
|
}
|
|
1523
1639
|
|
|
1524
|
-
// Run route middleware
|
|
1525
|
-
if (
|
|
1526
|
-
await runMiddlewareChain(
|
|
1640
|
+
// Run route middleware
|
|
1641
|
+
if (pageMw.length) {
|
|
1642
|
+
await runMiddlewareChain(pageMw, req, res);
|
|
1527
1643
|
if (res.headersSent) return;
|
|
1528
1644
|
}
|
|
1529
1645
|
|
|
@@ -1758,5 +1874,7 @@ module.exports = {
|
|
|
1758
1874
|
clearCompiledStringTemplateCache,
|
|
1759
1875
|
translatorCache,
|
|
1760
1876
|
clearTranslatorCache,
|
|
1877
|
+
translateValidationIssues,
|
|
1878
|
+
getTranslatorForRequest,
|
|
1761
1879
|
};
|
|
1762
1880
|
|