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.
Files changed (62) hide show
  1. package/.agents/AGENTS.md +1 -1
  2. package/.agents/ARCHITECTURE.md +11 -0
  3. package/.agents/CONVENTIONS.md +4 -0
  4. package/.agents/ORM.md +13 -4
  5. package/.agents/PLUGINS.md +32 -6
  6. package/.agents/ROUTING.md +99 -2
  7. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  10. package/AGENTS.md +6 -0
  11. package/README.md +20 -7
  12. package/bin/commands/add-tailwind.js +10 -0
  13. package/bin/commands/build.js +34 -0
  14. package/bin/commands/routes.js +8 -0
  15. package/core/applySchema.js +18 -1
  16. package/core/auth/jwt.js +10 -17
  17. package/core/auth/user-agent.js +61 -42
  18. package/core/compression/compression-stream.js +2 -2
  19. package/core/compression/middleware.js +1 -1
  20. package/core/compression/negotiate.js +11 -4
  21. package/core/events/dispatcher.js +56 -33
  22. package/core/openapi/orm-components.js +2 -3
  23. package/core/orm/cache/listeners.js +12 -2
  24. package/core/orm/json-fields.js +7 -1
  25. package/core/orm/model.js +99 -8
  26. package/core/orm/query-builder.js +71 -25
  27. package/core/orm/repository.js +42 -17
  28. package/core/orm/schema-helpers.js +49 -0
  29. package/core/orm/scopes.js +40 -9
  30. package/core/orm/types.js +12 -1
  31. package/core/queue/manager.js +5 -0
  32. package/core/validation/index.js +9 -3
  33. package/index.d.ts +178 -6
  34. package/index.js +71 -16
  35. package/package.json +6 -5
  36. package/plugins/admin-panel/index.js +12 -0
  37. package/plugins/audit-log/middleware.js +39 -4
  38. package/plugins/csrf/index.js +1 -1
  39. package/plugins/email/service.js +32 -1
  40. package/plugins/file-manager/api-handlers.js +10 -17
  41. package/plugins/file-manager/index.js +24 -9
  42. package/plugins/file-manager/security.js +18 -6
  43. package/plugins/index.js +2 -0
  44. package/plugins/lazy.js +258 -0
  45. package/plugins/site-analytics/bot-patterns.js +3 -0
  46. package/plugins/site-analytics/client-error-handler.js +3 -1
  47. package/plugins/site-analytics/index.js +10 -1
  48. package/plugins/site-analytics/tracking.js +245 -46
  49. package/src/api/api-loader.js +18 -2
  50. package/src/api/validation-i18n.js +231 -0
  51. package/src/discovery/route-manifest.js +197 -0
  52. package/src/discovery/scan-routes.js +84 -8
  53. package/src/file-router.js +215 -97
  54. package/src/helpers.js +139 -26
  55. package/src/njk-frontmatter.js +17 -1
  56. package/src/plugin-manager.js +46 -13
  57. package/src/routing/route-table.js +15 -5
  58. package/src/server.js +86 -15
  59. package/src/services/builtins/system.js +4 -6
  60. package/src/services/registry.js +12 -2
  61. package/src/services/validator.js +57 -32
  62. package/src/template/precompile.js +247 -0
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso - Ahead-Of-Time (AOT) Route Manifest
5
+ * Generates and loads portable, pre-compiled route manifests to eliminate filesystem
6
+ * scanning and speed up cold starts in production and containerized environments.
7
+ * @module src/discovery/route-manifest
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { scanRoutes } = require('./scan-routes');
13
+ const { isApiRouteFile } = require('./file-route-parser');
14
+ const { filePathToRoute, extractMethodFromFilename } = require('../file-router');
15
+
16
+ const DEFAULT_MANIFEST_REL_PATH = path.join('.webspresso', 'routes.manifest.json');
17
+
18
+ /**
19
+ * Generates an Ahead-Of-Time (AOT) route manifest and writes it to disk.
20
+ *
21
+ * @param {Object} [options={}]
22
+ * @param {string} [options.rootDir=process.cwd()] - Project root directory
23
+ * @param {string|Object} [options.pages] - Pages directory configuration or path
24
+ * @param {string|Object} [options.api] - API directory configuration or path
25
+ * @param {string|Object} [options.modules] - Modules directory configuration or path
26
+ * @param {string} [options.outDir] - Directory where manifest will be saved
27
+ * @param {string} [options.outFile] - Custom output file path for the manifest
28
+ * @param {boolean} [options.silent=false] - Suppress console output
29
+ * @returns {{ manifest: Object, manifestPath: string, routeCount: number, scannedFilesCount: number }}
30
+ */
31
+ function generateRouteManifest(options = {}) {
32
+ const rootDir = options.rootDir ? path.resolve(options.rootDir) : process.cwd();
33
+ const pagesDir = options.pagesDir
34
+ ? path.resolve(options.pagesDir)
35
+ : (typeof options.pages === 'string' ? path.resolve(options.pages) : path.join(rootDir, 'pages'));
36
+
37
+ const descriptors = scanRoutes({
38
+ rootDir,
39
+ pages: options.pages !== undefined ? options.pages : (fs.existsSync(pagesDir) ? { dir: path.relative(rootDir, pagesDir) || 'pages' } : true),
40
+ api: options.api,
41
+ modules: options.modules,
42
+ });
43
+
44
+ const routes = [];
45
+ const routeKeySet = new Set();
46
+
47
+ // 1. Discovered routes from scanRoutes (src/pages, src/api, modules/*)
48
+ for (const d of descriptors) {
49
+ const method = (d.method || 'GET').toUpperCase();
50
+ const key = `${method}:${d.path}`;
51
+ routeKeySet.add(key);
52
+ routes.push({
53
+ type: d.type,
54
+ method,
55
+ path: d.path,
56
+ source: d.source || (d.file ? path.relative(rootDir, d.file).split(path.sep).join('/') : ''),
57
+ module: d.module || null,
58
+ moduleConfig: d.moduleConfig ? { name: d.moduleConfig.name } : null,
59
+ target: 'discovered',
60
+ });
61
+ }
62
+
63
+ // 2. File-based routes from pagesDir (pages/api/**/*.js and pages/**/*.njk)
64
+ const pagesRel = path.relative(rootDir, pagesDir).split(path.sep).join('/') || 'pages';
65
+ const scannedPagesFiles = descriptors.scannedPagesFiles || [];
66
+
67
+ for (const relFile of scannedPagesFiles) {
68
+ const ext = path.extname(relFile);
69
+ if (isApiRouteFile(relFile) && ext === '.js') {
70
+ const { method: rawMethod, baseName } = extractMethodFromFilename(path.basename(relFile));
71
+ const method = (rawMethod || 'get').toUpperCase();
72
+ const dirPart = path.dirname(relFile);
73
+ const rawRoutePath = dirPart === '.'
74
+ ? `/${baseName}`
75
+ : `/${dirPart}/${baseName}`.split(path.sep).join('/');
76
+ const routePath = filePathToRoute(rawRoutePath, '');
77
+ const key = `${method}:${routePath}`;
78
+ if (!routeKeySet.has(key)) {
79
+ routeKeySet.add(key);
80
+ routes.push({
81
+ type: 'api',
82
+ method,
83
+ path: routePath,
84
+ source: path.join(pagesRel, relFile).split(path.sep).join('/'),
85
+ module: null,
86
+ moduleConfig: null,
87
+ target: 'pages',
88
+ });
89
+ }
90
+ } else if (ext === '.njk') {
91
+ const routePath = filePathToRoute(relFile, '.njk');
92
+ const key = `GET:${routePath}`;
93
+ if (!routeKeySet.has(key)) {
94
+ routeKeySet.add(key);
95
+ routes.push({
96
+ type: 'page',
97
+ method: 'GET',
98
+ path: routePath,
99
+ source: path.join(pagesRel, relFile).split(path.sep).join('/'),
100
+ module: null,
101
+ moduleConfig: null,
102
+ target: 'pages',
103
+ });
104
+ }
105
+ }
106
+ }
107
+
108
+ const manifest = {
109
+ version: 1,
110
+ generatedAt: new Date().toISOString(),
111
+ pagesDirRel: pagesRel,
112
+ scannedPagesFiles,
113
+ routes,
114
+ };
115
+
116
+ const outFile = options.outFile
117
+ ? path.resolve(options.outFile)
118
+ : (options.outDir
119
+ ? path.join(path.resolve(options.outDir), 'routes.manifest.json')
120
+ : path.join(rootDir, DEFAULT_MANIFEST_REL_PATH));
121
+
122
+ const outDir = path.dirname(outFile);
123
+ if (!fs.existsSync(outDir)) {
124
+ fs.mkdirSync(outDir, { recursive: true });
125
+ }
126
+
127
+ fs.writeFileSync(outFile, JSON.stringify(manifest, null, 2), 'utf8');
128
+
129
+ return {
130
+ manifest,
131
+ manifestPath: outFile,
132
+ routeCount: routes.length,
133
+ scannedFilesCount: scannedPagesFiles.length,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Loads an existing AOT route manifest from disk and reconstructs runtime descriptors.
139
+ *
140
+ * @param {Object} [options={}]
141
+ * @param {string} [options.rootDir=process.cwd()] - Project root directory
142
+ * @param {string} [options.manifestPath] - Explicit path to the manifest file
143
+ * @returns {{ manifest: Object, descriptors: Array<Object> }|null}
144
+ */
145
+ function loadRouteManifest(options = {}) {
146
+ const opts = typeof options === 'string' ? { manifestPath: options } : options;
147
+ const rootDir = opts.rootDir ? path.resolve(opts.rootDir) : process.cwd();
148
+ const manifestPath = opts.manifestPath
149
+ ? path.resolve(opts.manifestPath)
150
+ : path.join(rootDir, DEFAULT_MANIFEST_REL_PATH);
151
+
152
+ if (!fs.existsSync(manifestPath)) {
153
+ return null;
154
+ }
155
+
156
+ try {
157
+ const raw = fs.readFileSync(manifestPath, 'utf8');
158
+ const manifest = JSON.parse(raw);
159
+
160
+ if (!manifest || !Array.isArray(manifest.routes)) {
161
+ return null;
162
+ }
163
+
164
+ const descriptors = [];
165
+ for (const r of manifest.routes) {
166
+ if (r.target === 'discovered') {
167
+ const absFile = path.resolve(rootDir, r.source);
168
+ descriptors.push({
169
+ type: r.type,
170
+ method: r.method,
171
+ path: r.path,
172
+ file: absFile,
173
+ source: r.source,
174
+ module: r.module || undefined,
175
+ moduleConfig: r.moduleConfig || undefined,
176
+ });
177
+ }
178
+ }
179
+
180
+ const pagesDirRel = manifest.pagesDirRel || 'pages';
181
+ descriptors.scannedPagesDir = path.resolve(rootDir, pagesDirRel);
182
+ descriptors.scannedPagesFiles = Array.isArray(manifest.scannedPagesFiles) ? manifest.scannedPagesFiles : [];
183
+
184
+ return {
185
+ manifest,
186
+ descriptors,
187
+ };
188
+ } catch (err) {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ module.exports = {
194
+ DEFAULT_MANIFEST_REL_PATH,
195
+ generateRouteManifest,
196
+ loadRouteManifest,
197
+ };
@@ -13,28 +13,46 @@ const { discoverModuleDetails } = require('../modules/module-discovery');
13
13
 
14
14
  const SUPPORTED_EXTENSIONS = new Set(['.js', '.njk']);
15
15
 
16
+ function getGenericSegment(name) {
17
+ if (name.startsWith('[') && name.endsWith(']')) {
18
+ const inner = name.slice(1, -1);
19
+ if (inner.startsWith('...')) return '*';
20
+ return ':param';
21
+ }
22
+ return name;
23
+ }
24
+
16
25
  /**
17
26
  * Safely scans a directory recursively for route files
18
27
  * @param {string} dirPath - Absolute directory path
19
28
  * @param {string} [baseDir=dirPath] - Base directory for computing relative paths
29
+ * @param {boolean} [checkWarnings=false] - Whether to perform route collision & case-sensitivity checks
20
30
  * @returns {Array<{ absolutePath: string, relativePath: string }>}
21
31
  */
22
- function scanDirSafely(dirPath, baseDir = dirPath) {
32
+ function scanDirSafely(dirPath, baseDir = dirPath, checkWarnings = false) {
23
33
  const results = [];
24
34
  if (!fs.existsSync(dirPath)) return results;
25
35
 
26
36
  try {
27
37
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
38
+ const kept = checkWarnings ? [] : null;
28
39
 
29
40
  for (const entry of entries) {
30
41
  const name = entry.name;
31
- if (isPrivateOrIgnored(name)) continue;
42
+ if (name === 'locales' || isPrivateOrIgnored(name)) continue;
32
43
 
33
44
  const fullPath = path.join(dirPath, name);
34
45
  const relPath = path.relative(baseDir, fullPath);
35
46
 
47
+ if (checkWarnings && name !== name.toLowerCase()) {
48
+ console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
49
+ }
50
+
36
51
  if (entry.isDirectory()) {
37
- results.push(...scanDirSafely(fullPath, baseDir));
52
+ results.push(...scanDirSafely(fullPath, baseDir, checkWarnings));
53
+ if (checkWarnings && !relPath.startsWith('api') && relPath !== 'api') {
54
+ kept.push({ name, isDir: true, original: name });
55
+ }
38
56
  } else if (entry.isFile()) {
39
57
  const ext = path.extname(name);
40
58
  if (SUPPORTED_EXTENSIONS.has(ext)) {
@@ -42,6 +60,30 @@ function scanDirSafely(dirPath, baseDir = dirPath) {
42
60
  absolutePath: fullPath,
43
61
  relativePath: relPath,
44
62
  });
63
+ if (checkWarnings) {
64
+ const base = path.basename(name, ext);
65
+ kept.push({ name: base, isDir: false, original: name });
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ if (checkWarnings && kept && kept.length > 1) {
72
+ const groups = new Map();
73
+ for (const item of kept) {
74
+ const normalized = getGenericSegment(item.name);
75
+ if (!groups.has(normalized)) {
76
+ groups.set(normalized, []);
77
+ }
78
+ groups.get(normalized).push(item);
79
+ }
80
+
81
+ for (const [norm, items] of groups.entries()) {
82
+ if (items.length > 1) {
83
+ const namesList = items.map((item) => `"${item.original}"`).join(' and ');
84
+ const relDir = path.relative(baseDir, dirPath);
85
+ const dirLabel = relDir ? `pages/${relDir.split(path.sep).join('/')}` : 'pages';
86
+ console.warn(`[webspresso] WARNING: Dynamic route collision in "${dirLabel}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
45
87
  }
46
88
  }
47
89
  }
@@ -52,6 +94,19 @@ function scanDirSafely(dirPath, baseDir = dirPath) {
52
94
  return results;
53
95
  }
54
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
+
55
110
  /**
56
111
  * Scans configured pages, api, and module directories
57
112
  * @param {Object} options
@@ -64,10 +119,7 @@ function scanDirSafely(dirPath, baseDir = dirPath) {
64
119
  function scanRoutes(options = {}) {
65
120
  const rootDir = options.rootDir || (() => {
66
121
  if (process.env.NODE_ENV === 'test') {
67
- try {
68
- const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
69
- if (pkg.name === 'webspresso') return process.cwd();
70
- } catch (_) {}
122
+ if (checkIsWebspressoPackage()) return process.cwd();
71
123
  return path.resolve(__dirname, '../..');
72
124
  }
73
125
  return process.cwd();
@@ -93,9 +145,12 @@ function scanRoutes(options = {}) {
93
145
  : resolveDirCandidate(['src/pages', 'pages']);
94
146
 
95
147
  if (pagesDir && fs.existsSync(pagesDir)) {
96
- const files = scanDirSafely(pagesDir);
148
+ const files = scanDirSafely(pagesDir, pagesDir, true);
97
149
  const filesSet = new Set(files.map((f) => f.absolutePath));
98
150
 
151
+ descriptors.scannedPagesDir = path.resolve(pagesDir);
152
+ descriptors.scannedPagesFiles = files.map((f) => f.relativePath);
153
+
99
154
  for (const { absolutePath, relativePath } of files) {
100
155
  // Skip .njk templates (handled by classic Nunjucks mountPages)
101
156
  if (relativePath.endsWith('.njk')) {
@@ -268,7 +323,28 @@ function scanRoutes(options = {}) {
268
323
  return descriptors;
269
324
  }
270
325
 
326
+ /**
327
+ * Unified discovery of application routes (pages, api, modules) and compilation into a RouteTable
328
+ * @param {Object} options
329
+ * @returns {{ descriptors: Array<Object>, routeTable: import('../routing/route-table').RouteTable, scannedPagesFiles: Array<string>, scannedPagesDir: string|null }}
330
+ */
331
+ function discoverAppRoutes(options = {}) {
332
+ const { compileRouteTable } = require('../routing/route-table');
333
+ const descriptors = scanRoutes(options);
334
+ const routeTable = compileRouteTable(descriptors, {
335
+ explicitRoutes: options.routes || [],
336
+ isDev: options.isDev,
337
+ });
338
+ return {
339
+ descriptors,
340
+ routeTable,
341
+ scannedPagesFiles: descriptors.scannedPagesFiles || [],
342
+ scannedPagesDir: descriptors.scannedPagesDir || null,
343
+ };
344
+ }
345
+
271
346
  module.exports = {
272
347
  scanRoutes,
273
348
  scanDirSafely,
349
+ discoverAppRoutes,
274
350
  };