webspresso 0.0.101 → 0.0.102

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.
@@ -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
  }
@@ -93,9 +135,12 @@ function scanRoutes(options = {}) {
93
135
  : resolveDirCandidate(['src/pages', 'pages']);
94
136
 
95
137
  if (pagesDir && fs.existsSync(pagesDir)) {
96
- const files = scanDirSafely(pagesDir);
138
+ const files = scanDirSafely(pagesDir, pagesDir, true);
97
139
  const filesSet = new Set(files.map((f) => f.absolutePath));
98
140
 
141
+ descriptors.scannedPagesDir = path.resolve(pagesDir);
142
+ descriptors.scannedPagesFiles = files.map((f) => f.relativePath);
143
+
99
144
  for (const { absolutePath, relativePath } of files) {
100
145
  // Skip .njk templates (handled by classic Nunjucks mountPages)
101
146
  if (relativePath.endsWith('.njk')) {
@@ -268,7 +313,28 @@ function scanRoutes(options = {}) {
268
313
  return descriptors;
269
314
  }
270
315
 
316
+ /**
317
+ * Unified discovery of application routes (pages, api, modules) and compilation into a RouteTable
318
+ * @param {Object} options
319
+ * @returns {{ descriptors: Array<Object>, routeTable: import('../routing/route-table').RouteTable, scannedPagesFiles: Array<string>, scannedPagesDir: string|null }}
320
+ */
321
+ function discoverAppRoutes(options = {}) {
322
+ const { compileRouteTable } = require('../routing/route-table');
323
+ const descriptors = scanRoutes(options);
324
+ const routeTable = compileRouteTable(descriptors, {
325
+ explicitRoutes: options.routes || [],
326
+ isDev: options.isDev,
327
+ });
328
+ return {
329
+ descriptors,
330
+ routeTable,
331
+ scannedPagesFiles: descriptors.scannedPagesFiles || [],
332
+ scannedPagesDir: descriptors.scannedPagesDir || null,
333
+ };
334
+ }
335
+
271
336
  module.exports = {
272
337
  scanRoutes,
273
338
  scanDirSafely,
339
+ discoverAppRoutes,
274
340
  };