webspresso 0.0.91 → 0.0.93
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/README.md +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +2 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +38 -2
- package/core/orm/model.js +11 -2
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +45 -20
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +4 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/src/file-router.js +184 -123
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
package/src/file-router.js
CHANGED
|
@@ -367,9 +367,31 @@ function scanDirectory(dir, baseDir = dir) {
|
|
|
367
367
|
/**
|
|
368
368
|
* Load i18n JSON file with caching
|
|
369
369
|
* @param {string} filePath - Path to JSON file
|
|
370
|
+
* @param {boolean} [isDev=false] - Whether in development mode
|
|
370
371
|
* @returns {Object} Parsed JSON or empty object
|
|
371
372
|
*/
|
|
372
|
-
function loadI18nFile(filePath) {
|
|
373
|
+
function loadI18nFile(filePath, isDev = (process.env.NODE_ENV !== 'production')) {
|
|
374
|
+
if (!isDev) {
|
|
375
|
+
const cached = i18nCache.get(filePath);
|
|
376
|
+
if (cached) {
|
|
377
|
+
return cached.data;
|
|
378
|
+
}
|
|
379
|
+
if (!fs.existsSync(filePath)) {
|
|
380
|
+
i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
|
|
381
|
+
return EMPTY_OBJECT;
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
385
|
+
const data = JSON.parse(content);
|
|
386
|
+
i18nCache.set(filePath, { mtime: 0, data });
|
|
387
|
+
return data;
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error('Error loading i18n file:', filePath, err.message);
|
|
390
|
+
i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
|
|
391
|
+
return EMPTY_OBJECT;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
373
395
|
if (!fs.existsSync(filePath)) {
|
|
374
396
|
return EMPTY_OBJECT;
|
|
375
397
|
}
|
|
@@ -398,25 +420,43 @@ function loadI18nFile(filePath) {
|
|
|
398
420
|
* @param {string} pagesDir - Pages directory path
|
|
399
421
|
* @param {string} routeDir - Route directory path
|
|
400
422
|
* @param {string} locale - Locale code
|
|
423
|
+
* @param {boolean} [isDev=false] - Whether in development mode
|
|
401
424
|
* @returns {Object} Merged translations
|
|
402
425
|
*/
|
|
403
|
-
function loadI18n(pagesDir, routeDir, locale) {
|
|
426
|
+
function loadI18n(pagesDir, routeDir, locale, isDev = (process.env.NODE_ENV !== 'production')) {
|
|
427
|
+
const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
|
|
428
|
+
|
|
429
|
+
if (!isDev) {
|
|
430
|
+
const mergedCached = mergedI18nCache.get(cacheKey);
|
|
431
|
+
if (mergedCached) {
|
|
432
|
+
return mergedCached.data;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
404
436
|
// Load global translations
|
|
405
437
|
const globalPath = path.join(pagesDir, 'locales', `${locale}.json`);
|
|
406
|
-
const globalTranslations = loadI18nFile(globalPath);
|
|
438
|
+
const globalTranslations = loadI18nFile(globalPath, isDev);
|
|
407
439
|
|
|
408
440
|
// Load route-specific translations
|
|
409
441
|
const routePath = path.join(routeDir, 'locales', `${locale}.json`);
|
|
410
|
-
const routeTranslations = loadI18nFile(routePath);
|
|
442
|
+
const routeTranslations = loadI18nFile(routePath, isDev);
|
|
411
443
|
|
|
412
444
|
const gEmpty = globalTranslations === EMPTY_OBJECT || !globalTranslations || Object.keys(globalTranslations).length === 0;
|
|
413
445
|
const rEmpty = routeTranslations === EMPTY_OBJECT || !routeTranslations || Object.keys(routeTranslations).length === 0;
|
|
414
446
|
|
|
415
|
-
if (gEmpty && rEmpty)
|
|
416
|
-
|
|
417
|
-
|
|
447
|
+
if (gEmpty && rEmpty) {
|
|
448
|
+
if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: EMPTY_OBJECT });
|
|
449
|
+
return EMPTY_OBJECT;
|
|
450
|
+
}
|
|
451
|
+
if (gEmpty) {
|
|
452
|
+
if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: routeTranslations });
|
|
453
|
+
return routeTranslations;
|
|
454
|
+
}
|
|
455
|
+
if (rEmpty) {
|
|
456
|
+
if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: globalTranslations });
|
|
457
|
+
return globalTranslations;
|
|
458
|
+
}
|
|
418
459
|
|
|
419
|
-
const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
|
|
420
460
|
const gCached = i18nCache.get(globalPath);
|
|
421
461
|
const rCached = i18nCache.get(routePath);
|
|
422
462
|
const gm = gCached?.mtime ?? 0;
|
|
@@ -626,32 +666,41 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
|
|
|
626
666
|
* @returns {Object|null} Route config or null
|
|
627
667
|
*/
|
|
628
668
|
function loadRouteConfig(configPath, isDev) {
|
|
669
|
+
if (!isDev) {
|
|
670
|
+
if (configCache.has(configPath)) {
|
|
671
|
+
return configCache.get(configPath);
|
|
672
|
+
}
|
|
673
|
+
if (!fs.existsSync(configPath)) {
|
|
674
|
+
configCache.set(configPath, null);
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
const config = require(configPath);
|
|
679
|
+
configCache.set(configPath, config);
|
|
680
|
+
return config;
|
|
681
|
+
} catch (err) {
|
|
682
|
+
console.error('Error loading route config:', configPath, err.message);
|
|
683
|
+
configCache.set(configPath, null);
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
629
688
|
if (!fs.existsSync(configPath)) {
|
|
630
689
|
routeConfigDevCache.delete(configPath);
|
|
631
690
|
return null;
|
|
632
691
|
}
|
|
633
692
|
|
|
634
693
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return devCached.config;
|
|
640
|
-
}
|
|
641
|
-
if (require.cache[require.resolve(configPath)]) {
|
|
642
|
-
delete require.cache[require.resolve(configPath)];
|
|
643
|
-
}
|
|
644
|
-
const config = require(configPath);
|
|
645
|
-
routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
|
|
646
|
-
return config;
|
|
694
|
+
const stats = fs.statSync(configPath);
|
|
695
|
+
const devCached = routeConfigDevCache.get(configPath);
|
|
696
|
+
if (devCached && devCached.mtime >= stats.mtimeMs) {
|
|
697
|
+
return devCached.config;
|
|
647
698
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
return configCache.get(configPath);
|
|
699
|
+
if (require.cache[require.resolve(configPath)]) {
|
|
700
|
+
delete require.cache[require.resolve(configPath)];
|
|
651
701
|
}
|
|
652
|
-
|
|
653
702
|
const config = require(configPath);
|
|
654
|
-
|
|
703
|
+
routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
|
|
655
704
|
return config;
|
|
656
705
|
} catch (err) {
|
|
657
706
|
console.error('Error loading route config:', configPath, err.message);
|
|
@@ -976,123 +1025,133 @@ function mountPages(app, options) {
|
|
|
976
1025
|
/** Register API routes (shared by static phase and dynamic phase). */
|
|
977
1026
|
const registerApiRoutes = (routes) => {
|
|
978
1027
|
for (const route of routes) {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1028
|
+
const handler = require(route.fullPath);
|
|
1029
|
+
const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
1030
|
+
const routeMiddleware = handler.middleware;
|
|
982
1031
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if (typeof handlerFn !== 'function') {
|
|
988
|
-
console.warn(`API route ${route.file} does not export a function`);
|
|
989
|
-
continue;
|
|
990
|
-
}
|
|
1032
|
+
const preResolvedMw = routeMiddleware
|
|
1033
|
+
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
1034
|
+
: EMPTY_ARRAY;
|
|
991
1035
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
req.db = db;
|
|
997
|
-
}
|
|
998
|
-
if (serviceRegistry != null) {
|
|
999
|
-
req.service = (name, input, opts) =>
|
|
1000
|
-
serviceRegistry.call(name, input, { req, res, db }, opts);
|
|
1001
|
-
}
|
|
1036
|
+
if (typeof handlerFn !== 'function') {
|
|
1037
|
+
console.warn(`API route ${route.file} does not export a function`);
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1002
1040
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
delete require.cache[require.resolve(route.fullPath)];
|
|
1006
|
-
}
|
|
1007
|
-
const currentHandler = isDev
|
|
1008
|
-
? require(route.fullPath)
|
|
1009
|
-
: handler;
|
|
1010
|
-
const fn = typeof currentHandler === 'function'
|
|
1011
|
-
? currentHandler
|
|
1012
|
-
: currentHandler.default || currentHandler.handler;
|
|
1013
|
-
|
|
1014
|
-
if (isDev) {
|
|
1015
|
-
invalidateSchema(route.fullPath);
|
|
1016
|
-
}
|
|
1017
|
-
let compiledSchema;
|
|
1041
|
+
let preCompiledSchema = null;
|
|
1042
|
+
if (!isDev) {
|
|
1018
1043
|
try {
|
|
1019
|
-
|
|
1044
|
+
preCompiledSchema = compileSchema(route.fullPath, handler);
|
|
1020
1045
|
} catch (schemaErr) {
|
|
1021
1046
|
console.error(`API schema compile error ${route.routePath}:`, schemaErr);
|
|
1022
|
-
res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
|
|
1023
|
-
return;
|
|
1024
1047
|
}
|
|
1025
|
-
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
app[route.method](route.routePath, async (req, res, next) => {
|
|
1026
1051
|
try {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
return res.status(400).json({
|
|
1031
|
-
error: 'Validation Error',
|
|
1032
|
-
issues: err.issues,
|
|
1033
|
-
});
|
|
1052
|
+
// Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
|
|
1053
|
+
if (db != null) {
|
|
1054
|
+
req.db = db;
|
|
1034
1055
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1056
|
+
if (serviceRegistry != null) {
|
|
1057
|
+
req.service = (name, input, opts) =>
|
|
1058
|
+
serviceRegistry.call(name, input, { req, res, db }, opts);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
let fn = handlerFn;
|
|
1062
|
+
let compiledSchema = preCompiledSchema;
|
|
1063
|
+
|
|
1064
|
+
// Reload handler and schema in dev mode
|
|
1065
|
+
if (isDev) {
|
|
1066
|
+
if (require.cache[require.resolve(route.fullPath)]) {
|
|
1067
|
+
delete require.cache[require.resolve(route.fullPath)];
|
|
1068
|
+
}
|
|
1069
|
+
const currentHandler = require(route.fullPath);
|
|
1070
|
+
fn = typeof currentHandler === 'function'
|
|
1071
|
+
? currentHandler
|
|
1072
|
+
: currentHandler.default || currentHandler.handler;
|
|
1073
|
+
|
|
1074
|
+
invalidateSchema(route.fullPath);
|
|
1075
|
+
try {
|
|
1076
|
+
compiledSchema = compileSchema(route.fullPath, currentHandler);
|
|
1077
|
+
} catch (schemaErr) {
|
|
1078
|
+
console.error(`API schema compile error ${route.routePath}:`, schemaErr);
|
|
1079
|
+
res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
try {
|
|
1085
|
+
applySchema(req, compiledSchema);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
if (err instanceof ZodError) {
|
|
1088
|
+
return res.status(400).json({
|
|
1089
|
+
error: 'Validation Error',
|
|
1090
|
+
issues: err.issues,
|
|
1045
1091
|
});
|
|
1046
|
-
}
|
|
1092
|
+
}
|
|
1093
|
+
throw err;
|
|
1047
1094
|
}
|
|
1095
|
+
|
|
1096
|
+
// Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
|
|
1097
|
+
if (preResolvedMw.length) {
|
|
1098
|
+
for (const mw of preResolvedMw) {
|
|
1099
|
+
await new Promise((resolve, reject) => {
|
|
1100
|
+
mw(req, res, (err) => {
|
|
1101
|
+
if (err) reject(err);
|
|
1102
|
+
else resolve();
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
await fn(req, res, next);
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
console.error(`API error ${route.routePath}:`, err);
|
|
1111
|
+
const hookCtx = { req, res, error: err };
|
|
1112
|
+
try {
|
|
1113
|
+
await executeHook(globalHooks, 'onError', hookCtx, err);
|
|
1114
|
+
} catch (hookErr) {
|
|
1115
|
+
console.error('Error in onError hook:', hookErr);
|
|
1116
|
+
}
|
|
1117
|
+
return next(err);
|
|
1048
1118
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
}
|
|
1052
|
-
console.error(`API error ${route.routePath}:`, err);
|
|
1053
|
-
const hookCtx = { req, res, error: err };
|
|
1054
|
-
try {
|
|
1055
|
-
await executeHook(globalHooks, 'onError', hookCtx, err);
|
|
1056
|
-
} catch (hookErr) {
|
|
1057
|
-
console.error('Error in onError hook:', hookErr);
|
|
1058
|
-
}
|
|
1059
|
-
return next(err);
|
|
1060
|
-
}
|
|
1061
|
-
});
|
|
1062
|
-
|
|
1063
|
-
log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
|
|
1064
1122
|
}
|
|
1065
1123
|
};
|
|
1066
1124
|
|
|
1067
1125
|
/** Register SSR GET routes (shared by static phase and dynamic phase). */
|
|
1068
1126
|
const registerSsrRoutes = (routes) => {
|
|
1069
1127
|
for (const route of routes) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
app.get(route.routePath, async (req, res, next) => {
|
|
1076
|
-
try {
|
|
1077
|
-
// Detect locale
|
|
1078
|
-
const locale = detectLocale(req);
|
|
1079
|
-
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
|
1080
|
-
|
|
1081
|
-
// Load translations (primary + fallback)
|
|
1082
|
-
const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
|
|
1083
|
-
const fallbackTranslations = (locale !== defaultLocale)
|
|
1084
|
-
? loadI18n(absolutePagesDir, route.routeDir, defaultLocale)
|
|
1085
|
-
: EMPTY_OBJECT;
|
|
1128
|
+
const mountConfig = loadRouteConfig(route.configPath, isDev);
|
|
1129
|
+
const preResolvedPageMw = mountConfig?.middleware
|
|
1130
|
+
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
1131
|
+
: EMPTY_ARRAY;
|
|
1132
|
+
const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
|
|
1086
1133
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1134
|
+
app.get(route.routePath, async (req, res, next) => {
|
|
1135
|
+
try {
|
|
1136
|
+
// Detect locale
|
|
1137
|
+
const locale = detectLocale(req);
|
|
1138
|
+
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
|
1139
|
+
|
|
1140
|
+
// Load translations (primary + fallback)
|
|
1141
|
+
const translations = loadI18n(absolutePagesDir, route.routeDir, locale, isDev);
|
|
1142
|
+
const fallbackTranslations = (locale !== defaultLocale)
|
|
1143
|
+
? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, isDev)
|
|
1144
|
+
: EMPTY_OBJECT;
|
|
1145
|
+
|
|
1146
|
+
const t = createTranslator(translations, {
|
|
1147
|
+
locale,
|
|
1148
|
+
fallbackTranslations,
|
|
1149
|
+
fallbackLocale: defaultLocale,
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
// Load route config
|
|
1153
|
+
const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
|
|
1154
|
+
const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
|
|
1096
1155
|
|
|
1097
1156
|
// Create context with plugin helpers merged
|
|
1098
1157
|
const baseHelpers = createHelpers({ req, res, locale, t });
|
|
@@ -1134,6 +1193,8 @@ function mountPages(app, options) {
|
|
|
1134
1193
|
clientRuntime,
|
|
1135
1194
|
};
|
|
1136
1195
|
|
|
1196
|
+
req.context = ctx;
|
|
1197
|
+
|
|
1137
1198
|
// Execute hooks: onRequest
|
|
1138
1199
|
await executeHook(globalHooks, 'onRequest', ctx);
|
|
1139
1200
|
await executeHook(routeHooks, 'onRequest', ctx);
|
package/src/njk-frontmatter.js
CHANGED
|
@@ -115,6 +115,24 @@ function readAndParse(absPath, isDevLike = false) {
|
|
|
115
115
|
* @param {boolean} isDev NODE_ENV !== 'production' style
|
|
116
116
|
*/
|
|
117
117
|
function loadNjkRouteTemplate(absPath, isDev) {
|
|
118
|
+
if (!isDev) {
|
|
119
|
+
const cached = prodRouteCache.get(absPath);
|
|
120
|
+
if (cached) {
|
|
121
|
+
return cached;
|
|
122
|
+
}
|
|
123
|
+
if (!fs.existsSync(absPath)) {
|
|
124
|
+
const empty = {
|
|
125
|
+
useStringRender: false,
|
|
126
|
+
templateBody: null,
|
|
127
|
+
metaPatch: {},
|
|
128
|
+
dataPatch: {},
|
|
129
|
+
};
|
|
130
|
+
prodRouteCache.set(absPath, empty);
|
|
131
|
+
return empty;
|
|
132
|
+
}
|
|
133
|
+
return readAndParse(absPath, false);
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
if (!fs.existsSync(absPath)) {
|
|
119
137
|
return {
|
|
120
138
|
useStringRender: false,
|
|
@@ -125,21 +143,13 @@ function loadNjkRouteTemplate(absPath, isDev) {
|
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
const stat = fs.statSync(absPath);
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
if (prev && prev.mtimeMs >= stat.mtimeMs) {
|
|
132
|
-
return prev.cached;
|
|
133
|
-
}
|
|
134
|
-
const fresh = readAndParse(absPath, true);
|
|
135
|
-
devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
|
|
136
|
-
return fresh;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (prodRouteCache.has(absPath)) {
|
|
140
|
-
return prodRouteCache.get(absPath);
|
|
146
|
+
const prev = devRouteCache.get(absPath);
|
|
147
|
+
if (prev && prev.mtimeMs >= stat.mtimeMs) {
|
|
148
|
+
return prev.cached;
|
|
141
149
|
}
|
|
142
|
-
|
|
150
|
+
const fresh = readAndParse(absPath, true);
|
|
151
|
+
devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
|
|
152
|
+
return fresh;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
function clearNjkFrontmatterCaches() {
|
package/src/server.js
CHANGED
|
@@ -47,7 +47,7 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
47
47
|
const origRender = env.render.bind(env);
|
|
48
48
|
const origRenderString = env.renderString.bind(env);
|
|
49
49
|
|
|
50
|
-
function wrapTemplateRoot(tmpl, name
|
|
50
|
+
function wrapTemplateRoot(tmpl, name) {
|
|
51
51
|
if (!tmpl || tmpl._safeWrapped) return;
|
|
52
52
|
tmpl._safeWrapped = true;
|
|
53
53
|
const origRoot = tmpl.rootRenderFunc;
|
|
@@ -55,21 +55,28 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
55
55
|
|
|
56
56
|
tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
|
|
57
57
|
const normalizedName = String(name || tmpl.path || 'anonymous');
|
|
58
|
-
store.
|
|
58
|
+
const store = renderStackStorage.getStore();
|
|
59
|
+
if (store) {
|
|
60
|
+
store.stack.push(normalizedName);
|
|
61
|
+
}
|
|
59
62
|
let finished = false;
|
|
60
63
|
const done = (err, out) => {
|
|
61
64
|
if (!finished) {
|
|
62
65
|
finished = true;
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
if (store) {
|
|
67
|
+
const idx = store.stack.lastIndexOf(normalizedName);
|
|
68
|
+
if (idx !== -1) store.stack.splice(idx, 1);
|
|
69
|
+
}
|
|
65
70
|
}
|
|
66
71
|
renderCb(err, out);
|
|
67
72
|
};
|
|
68
73
|
try {
|
|
69
74
|
return origRoot.call(this, e, context, frame, runtime, done);
|
|
70
75
|
} catch (ex) {
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
if (store) {
|
|
77
|
+
const idx = store.stack.lastIndexOf(normalizedName);
|
|
78
|
+
if (idx !== -1) store.stack.splice(idx, 1);
|
|
79
|
+
}
|
|
73
80
|
throw ex;
|
|
74
81
|
}
|
|
75
82
|
};
|
|
@@ -97,15 +104,15 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
|
|
100
|
-
if (tmpl
|
|
101
|
-
wrapTemplateRoot(tmpl, name
|
|
107
|
+
if (tmpl) {
|
|
108
|
+
wrapTemplateRoot(tmpl, name);
|
|
102
109
|
}
|
|
103
110
|
cb(err, tmpl);
|
|
104
111
|
} : undefined;
|
|
105
112
|
|
|
106
113
|
const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
|
|
107
|
-
if (res
|
|
108
|
-
wrapTemplateRoot(res, name
|
|
114
|
+
if (res) {
|
|
115
|
+
wrapTemplateRoot(res, name);
|
|
109
116
|
}
|
|
110
117
|
return res;
|
|
111
118
|
};
|
|
@@ -467,10 +474,21 @@ function createApp(options = {}) {
|
|
|
467
474
|
const app = express();
|
|
468
475
|
app.serviceRegistry = serviceRegistry;
|
|
469
476
|
|
|
470
|
-
//
|
|
477
|
+
// Request context & services caller middleware
|
|
471
478
|
app.use((req, res, next) => {
|
|
479
|
+
if (options.db) {
|
|
480
|
+
req.db = options.db;
|
|
481
|
+
}
|
|
482
|
+
req.context = {
|
|
483
|
+
req,
|
|
484
|
+
res,
|
|
485
|
+
db: options.db ?? null,
|
|
486
|
+
app,
|
|
487
|
+
serviceRegistry,
|
|
488
|
+
...(req.context || {}),
|
|
489
|
+
};
|
|
472
490
|
req.service = (name, input, opts) =>
|
|
473
|
-
serviceRegistry.call(name, input,
|
|
491
|
+
serviceRegistry.call(name, input, req.context, opts);
|
|
474
492
|
next();
|
|
475
493
|
});
|
|
476
494
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
|
|
9
10
|
|
|
10
11
|
const VALID_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
|
|
11
12
|
|
|
@@ -50,7 +51,7 @@ function filePathToServiceName(relativePath) {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// Cross-platform slash normalization (Windows \ to Unix /)
|
|
53
|
-
const normalized = relativePath.replace(/\\/g, '/')
|
|
54
|
+
const normalized = trimUrlPathSlashes(relativePath.replace(/\\/g, '/'));
|
|
54
55
|
const ext = path.extname(normalized);
|
|
55
56
|
const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
|
|
56
57
|
const segments = withoutExt.split('/').filter(Boolean);
|
package/src/services/registry.js
CHANGED
|
@@ -102,6 +102,7 @@ class ServiceRegistry {
|
|
|
102
102
|
rawHandler: definition.handler,
|
|
103
103
|
memoize: memoizeFn,
|
|
104
104
|
metadata: definition.metadata || {},
|
|
105
|
+
description: definition.description || definition.metadata?.description || '',
|
|
105
106
|
auth: definition.auth,
|
|
106
107
|
cache: definition.cache,
|
|
107
108
|
timeout: definition.timeout,
|
|
@@ -158,6 +159,7 @@ class ServiceRegistry {
|
|
|
158
159
|
rawHandler: def.handler,
|
|
159
160
|
memoize: memoizeFn,
|
|
160
161
|
metadata: def.metadata || {},
|
|
162
|
+
description: def.description || def.metadata?.description || '',
|
|
161
163
|
auth: def.auth,
|
|
162
164
|
cache: def.cache,
|
|
163
165
|
timeout: def.timeout,
|
|
@@ -4,60 +4,9 @@
|
|
|
4
4
|
* @module src/services/validator
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const z = require('zod');
|
|
8
7
|
const { ValidationError } = require('../../core/errors');
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
/** Zod instance extended with z.nanoid() */
|
|
12
|
-
const zForServices = extendZ(z);
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Check if object contains dangerous prototype pollution keys
|
|
16
|
-
* @param {*} obj
|
|
17
|
-
* @returns {boolean}
|
|
18
|
-
*/
|
|
19
|
-
function hasDangerousKeys(obj) {
|
|
20
|
-
if (obj === null || typeof obj !== 'object') {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
if (Array.isArray(obj)) {
|
|
24
|
-
return obj.some(item => hasDangerousKeys(item));
|
|
25
|
-
}
|
|
26
|
-
for (const key of Object.keys(obj)) {
|
|
27
|
-
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
if (typeof obj[key] === 'object' && obj[key] !== null && hasDangerousKeys(obj[key])) {
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Sanitize input against prototype pollution
|
|
39
|
-
* @param {*} input
|
|
40
|
-
* @returns {*} Sanitized input
|
|
41
|
-
*/
|
|
42
|
-
function sanitizeInput(input) {
|
|
43
|
-
if (input === null || typeof input !== 'object') {
|
|
44
|
-
return input;
|
|
45
|
-
}
|
|
46
|
-
if (!hasDangerousKeys(input)) {
|
|
47
|
-
return input;
|
|
48
|
-
}
|
|
49
|
-
if (Array.isArray(input)) {
|
|
50
|
-
return input.map(item => sanitizeInput(item));
|
|
51
|
-
}
|
|
52
|
-
const clean = {};
|
|
53
|
-
for (const key of Object.keys(input)) {
|
|
54
|
-
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
clean[key] = sanitizeInput(input[key]);
|
|
58
|
-
}
|
|
59
|
-
return clean;
|
|
60
|
-
}
|
|
8
|
+
const { z: zForServices, sanitizeInput, hasDangerousKeys } = require('../../core/validation');
|
|
9
|
+
const z = zForServices;
|
|
61
10
|
|
|
62
11
|
/**
|
|
63
12
|
* Compile a concise type string descriptor into a Zod schema
|
|
@@ -4,8 +4,10 @@ Sections follow the source repo layout (`index.js`, `src/`, `core/orm`, `doc/ind
|
|
|
4
4
|
|
|
5
5
|
## 1. What this framework is
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
7
|
+
- **Architecture**: Modern full-stack Node.js framework with Next.js/Nuxt-style file-based routing.
|
|
8
|
+
- **SSR**: Native server-side rendering with **Nunjucks**; URLs map directly to files under `pages/`.
|
|
9
|
+
- **API**: 100% file-based handlers under `pages/api/` with HTTP method suffixes (`.get.js`, `.post.js`, `[id].delete.js`) and declarative **Zod** validation (`req.input`).
|
|
10
|
+
- ⚠️ **CRITICAL ANTI-PATTERN**: **NEVER** create a `routes/` or `src/routes/` directory. **NEVER** use `app.get()`, `app.post()`, or `express.Router()`. All application routes must live under `pages/` and `pages/api/`.
|
|
9
11
|
- **i18n**: JSON locales; `t('key')` in templates.
|
|
10
12
|
- **ORM**: Knex-backed layer in `core/orm` — `defineModel`, `zdb` schema helpers, repositories, query builder, migrations.
|
|
11
13
|
- **Plugins**: Register in `createApp({ plugins })`; optional `db` passed as `ctx.db`.
|
|
@@ -60,7 +62,7 @@ project/
|
|
|
60
62
|
| `pageAssets` | Opt-in **`true`** or **`{ enabled?, stylesheets?, scripts? }`**. When on, route **`load()`** may return reserved keys **`stylesheets`** (string or list; also `{ href, media? }` objects) and **`scripts`** (string, `{ src, defer?, async?, type? }`, or list). They are removed from the root Nunjucks context and passed as **`pageHead`** with **`pageAssets: true`**. The app layout must print them (see [`views/layout.njk`](../../../views/layout.njk) in the package). Default **off** — `stylesheets` / `scripts` in **`load()`** behave as normal data keys. |
|
|
61
63
|
| `clientRuntime` | Opt-in **`{ alpine?: boolean \| object, swup?: boolean \| object }`**. Serves **`/__webspresso/client-runtime/*`** (Alpine 3, swup 4 + Head + Scripts plugins + bootstrap). Template context **`clientRuntime`**; include [`views/partials/webspresso-client-runtime.njk`](../../../views/partials/webspresso-client-runtime.njk) and set **`<main id="swup">`** when swup is on. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`** (`1` or `true`). Admin / dev dashboard HTML is unchanged (Mithril). Use **`data-no-swup`** on links for full page loads. HTMX is not used. |
|
|
62
64
|
| `auth` | `AuthManager` from **`createAuth()`** / **`quickAuth()`** (`webspresso/core/auth`). Mounts cookie parser + **`express-session`** + per-request **`authenticate`**; sets **`req.user`**, **`req.auth`**. Injects named route middleware **`auth`** and **`guest`** (overwrites same keys in `middlewares` if you passed both — avoid reusing those names for custom handlers). |
|
|
63
|
-
| `setupRoutes(app, ctx)` |
|
|
65
|
+
| `setupRoutes(app, ctx)` | Low-level server extension hook (e.g. mounting third-party sub-apps or raw middleware). ⚠️ **NEVER** use `setupRoutes` to define application domain or API routes. All application endpoints **MUST** live under `pages/` and `pages/api/`. |
|
|
64
66
|
|
|
65
67
|
**Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is **`null`** when `auth` was not configured.
|
|
66
68
|
|
|
@@ -267,7 +269,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
267
269
|
|
|
268
270
|
## 14. Pitfalls (for agents)
|
|
269
271
|
|
|
270
|
-
1. **
|
|
272
|
+
1. **NEVER use Express routes (`app.get` / `routes/`)** — Webspresso is 100% file-based. Never create `routes/` or call `app.get()`. All routes must live in `pages/` and `pages/api/`.
|
|
271
273
|
2. **File-router order** — understand precedence vs manual routes.
|
|
272
274
|
3. **`ctx.db`** — only when `createApp({ db })` is set.
|
|
273
275
|
4. **ORM hidden fields** — never expose to clients unintentionally.
|