webspresso 0.0.67 → 0.0.69
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 +58 -0
- package/bin/commands/doctor.js +23 -0
- package/bin/commands/new.js +253 -79
- package/core/orm/migrations/scaffold.js +6 -0
- package/core/orm/schema-helpers.js +19 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +34 -1
- package/index.js +15 -1
- package/package.json +6 -1
- package/plugins/admin-panel/api.js +5 -5
- package/plugins/admin-panel/components.js +184 -0
- package/plugins/admin-panel/core/registry.js +1 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +135 -66
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/admin-panel/index.js +8 -0
- package/plugins/index.js +3 -0
- package/plugins/upload/index.js +188 -0
- package/plugins/upload/local-file-provider.js +122 -0
- package/src/file-router.js +95 -34
- package/templates/skills/webspresso-usage/SKILL.md +62 -22
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File upload plugin — multipart parsing, validation, pluggable storage.
|
|
3
|
+
* @module plugins/upload
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const multer = require('multer');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { createLocalFileProvider } = require('./local-file-provider');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('express').Request} ExpressRequest
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} UploadPutArgs
|
|
16
|
+
* @property {Buffer} [buffer]
|
|
17
|
+
* @property {import('stream').Readable} [stream]
|
|
18
|
+
* @property {string} originalName
|
|
19
|
+
* @property {string} mimeType
|
|
20
|
+
* @property {number} size
|
|
21
|
+
* @property {ExpressRequest} req
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} UploadPutResult
|
|
26
|
+
* @property {string} publicUrl
|
|
27
|
+
* @property {string} [key]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} UploadStorageProvider
|
|
32
|
+
* @property {(args: UploadPutArgs) => Promise<UploadPutResult>} put
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} [p]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function normalizeRoutePath(p) {
|
|
40
|
+
const s = (p || '/api/upload').trim();
|
|
41
|
+
return s.startsWith('/') ? s : `/${s}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} [name]
|
|
46
|
+
* @returns {string} lowercase extension without dot
|
|
47
|
+
*/
|
|
48
|
+
function extensionFromName(name) {
|
|
49
|
+
return path.extname(path.basename(name || '')).replace(/^\./, '').toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {number} maxBytes
|
|
54
|
+
* @param {string} fieldName
|
|
55
|
+
* @returns {import('express').RequestHandler}
|
|
56
|
+
*/
|
|
57
|
+
function createMulterSingleMiddleware(maxBytes, fieldName) {
|
|
58
|
+
const upload = multer({
|
|
59
|
+
storage: multer.memoryStorage(),
|
|
60
|
+
limits: { fileSize: maxBytes, files: 1 },
|
|
61
|
+
});
|
|
62
|
+
return upload.single(fieldName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {unknown} err
|
|
67
|
+
* @returns {{ status: number, message: string }}
|
|
68
|
+
*/
|
|
69
|
+
function multerErrorResponse(err) {
|
|
70
|
+
if (err instanceof multer.MulterError) {
|
|
71
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
72
|
+
return { status: 413, message: 'File too large' };
|
|
73
|
+
}
|
|
74
|
+
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
|
75
|
+
return { status: 400, message: 'Invalid upload (too many files or unexpected field)' };
|
|
76
|
+
}
|
|
77
|
+
return { status: 400, message: err.message || 'Upload error' };
|
|
78
|
+
}
|
|
79
|
+
return { status: 400, message: err instanceof Error ? err.message : String(err) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Object} [options]
|
|
84
|
+
* @param {string} [options.path='/api/upload'] — POST route
|
|
85
|
+
* @param {UploadStorageProvider} [options.provider] — Storage backend; default: local disk
|
|
86
|
+
* @param {Object} [options.local] — Shorthand for createLocalFileProvider when provider omitted
|
|
87
|
+
* @param {string} [options.local.destDir]
|
|
88
|
+
* @param {string} [options.local.publicBasePath]
|
|
89
|
+
* @param {number} [options.maxBytes=10485760] — 10 MiB default
|
|
90
|
+
* @param {string[]|null} [options.mimeAllowlist] — If set, reject other MIME types (415)
|
|
91
|
+
* @param {string[]|null} [options.extensionAllowlist] — Optional extra guard on original extension
|
|
92
|
+
* @param {import('express').RequestHandler|import('express').RequestHandler[]} [options.middleware]
|
|
93
|
+
* @param {string} [options.fieldName='file'] — Multipart field name
|
|
94
|
+
* @returns {Object} Webspresso plugin
|
|
95
|
+
*/
|
|
96
|
+
function uploadPlugin(options = {}) {
|
|
97
|
+
const {
|
|
98
|
+
path: routePath = '/api/upload',
|
|
99
|
+
provider: userProvider,
|
|
100
|
+
local,
|
|
101
|
+
maxBytes = 10 * 1024 * 1024,
|
|
102
|
+
mimeAllowlist = null,
|
|
103
|
+
extensionAllowlist = null,
|
|
104
|
+
middleware = [],
|
|
105
|
+
fieldName = 'file',
|
|
106
|
+
} = options;
|
|
107
|
+
|
|
108
|
+
const normalizedPath = normalizeRoutePath(routePath);
|
|
109
|
+
|
|
110
|
+
const provider =
|
|
111
|
+
userProvider ||
|
|
112
|
+
createLocalFileProvider({
|
|
113
|
+
destDir: local?.destDir,
|
|
114
|
+
publicBasePath: local?.publicBasePath,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
|
|
118
|
+
const parseMultipart = createMulterSingleMiddleware(maxBytes, fieldName);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: 'upload',
|
|
122
|
+
version: '1.0.0',
|
|
123
|
+
description: 'Multipart file uploads with pluggable storage',
|
|
124
|
+
|
|
125
|
+
onRoutesReady(ctx) {
|
|
126
|
+
const { app } = ctx;
|
|
127
|
+
app.set('webspresso.uploadPath', normalizedPath);
|
|
128
|
+
|
|
129
|
+
const handler = (req, res) => {
|
|
130
|
+
parseMultipart(req, res, async (err) => {
|
|
131
|
+
if (err) {
|
|
132
|
+
const { status, message } = multerErrorResponse(err);
|
|
133
|
+
return res.status(status).json({ error: message, message });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const file = req.file;
|
|
137
|
+
if (!file || !file.buffer) {
|
|
138
|
+
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
142
|
+
const originalName = file.originalname || 'upload';
|
|
143
|
+
const ext = extensionFromName(originalName);
|
|
144
|
+
|
|
145
|
+
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
146
|
+
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
extensionAllowlist &&
|
|
150
|
+
extensionAllowlist.length &&
|
|
151
|
+
(!ext || !extensionAllowlist.includes(ext))
|
|
152
|
+
) {
|
|
153
|
+
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = await provider.put({
|
|
158
|
+
buffer: file.buffer,
|
|
159
|
+
originalName,
|
|
160
|
+
mimeType: mime,
|
|
161
|
+
size: file.size,
|
|
162
|
+
req,
|
|
163
|
+
});
|
|
164
|
+
res.json({
|
|
165
|
+
url: result.publicUrl,
|
|
166
|
+
publicUrl: result.publicUrl,
|
|
167
|
+
key: result.key,
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
171
|
+
res.status(500).json({ error: message, message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
ctx.addRoute('post', normalizedPath, ...mw, handler);
|
|
177
|
+
|
|
178
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
179
|
+
console.log(` Upload plugin: POST ${normalizedPath}`);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
uploadPlugin,
|
|
187
|
+
createLocalFileProvider,
|
|
188
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default upload storage: write files to local disk and expose a public URL path.
|
|
3
|
+
* @module plugins/upload/local-file-provider
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs/promises');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { randomBytes } = require('crypto');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Common MIME → canonical extension (lowercase, with leading dot).
|
|
12
|
+
* Used so stored filenames match the MIME the server accepted (see uploadPlugin mimeAllowlist).
|
|
13
|
+
* Does not verify file content; spoofed MIME is a separate concern (allowlist + optional magic-byte later).
|
|
14
|
+
*/
|
|
15
|
+
const MIME_TO_EXT = {
|
|
16
|
+
'image/jpeg': '.jpg',
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/gif': '.gif',
|
|
19
|
+
'image/webp': '.webp',
|
|
20
|
+
'image/svg+xml': '.svg',
|
|
21
|
+
'image/avif': '.avif',
|
|
22
|
+
'application/pdf': '.pdf',
|
|
23
|
+
'text/plain': '.txt',
|
|
24
|
+
'text/csv': '.csv',
|
|
25
|
+
'text/html': '.html',
|
|
26
|
+
'application/json': '.json',
|
|
27
|
+
'application/zip': '.zip',
|
|
28
|
+
'application/gzip': '.gz',
|
|
29
|
+
'video/mp4': '.mp4',
|
|
30
|
+
'video/webm': '.webm',
|
|
31
|
+
'audio/mpeg': '.mp3',
|
|
32
|
+
'audio/webm': '.weba',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize public URL prefix (no trailing slash).
|
|
37
|
+
* @param {string} [publicBasePath]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function normalizePublicBase(publicBasePath) {
|
|
41
|
+
let p = (publicBasePath == null || publicBasePath === '' ? '/uploads' : String(publicBasePath)).trim();
|
|
42
|
+
p = p.replace(/\/+$/, '');
|
|
43
|
+
if (!p.startsWith('/')) {
|
|
44
|
+
p = `/${p}`;
|
|
45
|
+
}
|
|
46
|
+
return p || '/uploads';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} [mimeType]
|
|
51
|
+
* @returns {string|null} extension with leading dot, or null if unknown
|
|
52
|
+
*/
|
|
53
|
+
function canonicalExtFromMime(mimeType) {
|
|
54
|
+
if (!mimeType || typeof mimeType !== 'string') return null;
|
|
55
|
+
const base = mimeType.split(';')[0].trim().toLowerCase();
|
|
56
|
+
return MIME_TO_EXT[base] || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Prefer a safe extension from the filename; align with MIME when we have a mapping.
|
|
61
|
+
* @param {string} [mimeType]
|
|
62
|
+
* @param {string} extFromName - already normalized (lowercase, with dot or '')
|
|
63
|
+
* @returns {string} extension with leading dot or empty string
|
|
64
|
+
*/
|
|
65
|
+
function resolveStoredExtension(mimeType, extFromName) {
|
|
66
|
+
const fromMime = canonicalExtFromMime(mimeType);
|
|
67
|
+
if (fromMime) {
|
|
68
|
+
if (!extFromName || extFromName !== fromMime) {
|
|
69
|
+
return fromMime;
|
|
70
|
+
}
|
|
71
|
+
return extFromName;
|
|
72
|
+
}
|
|
73
|
+
return extFromName || '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Object} [options]
|
|
78
|
+
* @param {string} [options.destDir] — Absolute or cwd-relative directory for stored files
|
|
79
|
+
* @param {string} [options.publicBasePath='/uploads'] — URL prefix (first segment of public URL)
|
|
80
|
+
* @returns {import('./index').UploadStorageProvider}
|
|
81
|
+
*/
|
|
82
|
+
function createLocalFileProvider(options = {}) {
|
|
83
|
+
const destDir = path.resolve(
|
|
84
|
+
options.destDir || path.join(process.cwd(), 'public', 'uploads')
|
|
85
|
+
);
|
|
86
|
+
const publicBase = normalizePublicBase(options.publicBasePath);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
async put({ buffer, originalName, mimeType, size, req }) {
|
|
90
|
+
void size;
|
|
91
|
+
void req;
|
|
92
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const base = path.basename(originalName || 'upload');
|
|
95
|
+
let ext = path.extname(base).toLowerCase();
|
|
96
|
+
if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
|
|
97
|
+
ext = '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ext = resolveStoredExtension(mimeType, ext);
|
|
101
|
+
|
|
102
|
+
const storedName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
|
|
103
|
+
const target = path.join(destDir, storedName);
|
|
104
|
+
const resolvedDest = path.resolve(destDir);
|
|
105
|
+
if (!target.startsWith(resolvedDest + path.sep) && target !== resolvedDest) {
|
|
106
|
+
throw new Error('Invalid storage path');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await fs.writeFile(target, buffer);
|
|
110
|
+
|
|
111
|
+
const publicUrl = `${publicBase}/${storedName}`.replace(/\/{2,}/g, '/');
|
|
112
|
+
return { publicUrl, key: storedName };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
createLocalFileProvider,
|
|
119
|
+
normalizePublicBase,
|
|
120
|
+
canonicalExtFromMime,
|
|
121
|
+
resolveStoredExtension,
|
|
122
|
+
};
|
package/src/file-router.js
CHANGED
|
@@ -16,6 +16,12 @@ const i18nCache = new Map();
|
|
|
16
16
|
// Cache for route configs in production
|
|
17
17
|
const configCache = new Map();
|
|
18
18
|
|
|
19
|
+
// Dev-only: avoid require() on every SSR request when the .js file is unchanged (mtime)
|
|
20
|
+
const routeConfigDevCache = new Map();
|
|
21
|
+
|
|
22
|
+
// Cache for API filename -> { method, baseName } (basename keys; stable per process)
|
|
23
|
+
const methodFromFilenameCache = new Map();
|
|
24
|
+
|
|
19
25
|
/**
|
|
20
26
|
* Convert a file path to an Express route pattern
|
|
21
27
|
* @param {string} filePath - Relative path from pages/
|
|
@@ -51,26 +57,87 @@ function filePathToRoute(filePath, ext) {
|
|
|
51
57
|
return route;
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Metadata for ordering route registration: more specific Express paths must be
|
|
62
|
+
* registered before less specific ones (static before dynamic; more literal
|
|
63
|
+
* segments before fewer; deeper paths before shallower among same class).
|
|
64
|
+
* @param {string} routePath
|
|
65
|
+
* @returns {{ tier: number, literalSegCount: number, paramSegCount: number, depth: number, routePath: string }}
|
|
66
|
+
*/
|
|
67
|
+
function routeRegistrationMeta(routePath) {
|
|
68
|
+
const hasCatchAll = routePath.includes('*');
|
|
69
|
+
const hasDynamic = routePath.includes(':');
|
|
70
|
+
let tier;
|
|
71
|
+
if (hasCatchAll) tier = 2;
|
|
72
|
+
else if (hasDynamic) tier = 1;
|
|
73
|
+
else tier = 0;
|
|
74
|
+
|
|
75
|
+
const segments = routePath.split('/').filter(Boolean);
|
|
76
|
+
let literalSegCount = 0;
|
|
77
|
+
let paramSegCount = 0;
|
|
78
|
+
for (const seg of segments) {
|
|
79
|
+
if (seg === '*' || (seg.length > 0 && seg.includes('*'))) continue;
|
|
80
|
+
if (seg.includes(':')) paramSegCount += 1;
|
|
81
|
+
else literalSegCount += 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
tier,
|
|
86
|
+
literalSegCount,
|
|
87
|
+
paramSegCount,
|
|
88
|
+
depth: segments.length,
|
|
89
|
+
routePath,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compare two routes for registration order (negative if a before b).
|
|
95
|
+
* @param {{ routePath: string }} a
|
|
96
|
+
* @param {{ routePath: string }} b
|
|
97
|
+
*/
|
|
98
|
+
function compareRouteRegistrationOrder(a, b) {
|
|
99
|
+
const ma = routeRegistrationMeta(a.routePath);
|
|
100
|
+
const mb = routeRegistrationMeta(b.routePath);
|
|
101
|
+
if (ma.tier !== mb.tier) return ma.tier - mb.tier;
|
|
102
|
+
if (ma.literalSegCount !== mb.literalSegCount) {
|
|
103
|
+
return mb.literalSegCount - ma.literalSegCount;
|
|
104
|
+
}
|
|
105
|
+
if (ma.depth !== mb.depth) return mb.depth - ma.depth;
|
|
106
|
+
if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
|
|
107
|
+
return ma.routePath.localeCompare(mb.routePath);
|
|
108
|
+
}
|
|
109
|
+
|
|
54
110
|
/**
|
|
55
111
|
* Extract HTTP method from API filename
|
|
56
112
|
* @param {string} filename - Filename like health.get.js
|
|
57
113
|
* @returns {{ method: string, baseName: string }}
|
|
58
114
|
*/
|
|
59
115
|
function extractMethodFromFilename(filename) {
|
|
116
|
+
const hit = methodFromFilenameCache.get(filename);
|
|
117
|
+
if (hit !== undefined) {
|
|
118
|
+
return hit;
|
|
119
|
+
}
|
|
120
|
+
|
|
60
121
|
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
61
122
|
const parts = filename.replace('.js', '').split('.');
|
|
62
|
-
|
|
123
|
+
let result;
|
|
124
|
+
|
|
63
125
|
if (parts.length > 1) {
|
|
64
126
|
const lastPart = parts[parts.length - 1].toLowerCase();
|
|
65
127
|
if (methods.includes(lastPart)) {
|
|
66
|
-
|
|
128
|
+
result = {
|
|
67
129
|
method: lastPart,
|
|
68
|
-
baseName: parts.slice(0, -1).join('.')
|
|
130
|
+
baseName: parts.slice(0, -1).join('.'),
|
|
69
131
|
};
|
|
70
132
|
}
|
|
71
133
|
}
|
|
72
|
-
|
|
73
|
-
|
|
134
|
+
|
|
135
|
+
if (!result) {
|
|
136
|
+
result = { method: 'get', baseName: parts.join('.') };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
methodFromFilenameCache.set(filename, result);
|
|
140
|
+
return result;
|
|
74
141
|
}
|
|
75
142
|
|
|
76
143
|
/**
|
|
@@ -201,25 +268,31 @@ function createTranslator(translations) {
|
|
|
201
268
|
*/
|
|
202
269
|
function loadRouteConfig(configPath, isDev) {
|
|
203
270
|
if (!fs.existsSync(configPath)) {
|
|
271
|
+
routeConfigDevCache.delete(configPath);
|
|
204
272
|
return null;
|
|
205
273
|
}
|
|
206
|
-
|
|
274
|
+
|
|
207
275
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
276
|
+
if (isDev) {
|
|
277
|
+
const stats = fs.statSync(configPath);
|
|
278
|
+
const devCached = routeConfigDevCache.get(configPath);
|
|
279
|
+
if (devCached && devCached.mtime >= stats.mtimeMs) {
|
|
280
|
+
return devCached.config;
|
|
281
|
+
}
|
|
282
|
+
if (require.cache[require.resolve(configPath)]) {
|
|
283
|
+
delete require.cache[require.resolve(configPath)];
|
|
284
|
+
}
|
|
285
|
+
const config = require(configPath);
|
|
286
|
+
routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
|
|
287
|
+
return config;
|
|
211
288
|
}
|
|
212
|
-
|
|
213
|
-
if (
|
|
289
|
+
|
|
290
|
+
if (configCache.has(configPath)) {
|
|
214
291
|
return configCache.get(configPath);
|
|
215
292
|
}
|
|
216
|
-
|
|
293
|
+
|
|
217
294
|
const config = require(configPath);
|
|
218
|
-
|
|
219
|
-
if (!isDev) {
|
|
220
|
-
configCache.set(configPath, config);
|
|
221
|
-
}
|
|
222
|
-
|
|
295
|
+
configCache.set(configPath, config);
|
|
223
296
|
return config;
|
|
224
297
|
} catch (err) {
|
|
225
298
|
console.error(`Error loading route config ${configPath}:`, err.message);
|
|
@@ -431,22 +504,8 @@ function mountPages(app, options) {
|
|
|
431
504
|
}
|
|
432
505
|
}
|
|
433
506
|
|
|
434
|
-
// Sort routes:
|
|
435
|
-
const sortRoutes = (routes) =>
|
|
436
|
-
return routes.sort((a, b) => {
|
|
437
|
-
const aHasCatchAll = a.routePath.includes('*');
|
|
438
|
-
const bHasCatchAll = b.routePath.includes('*');
|
|
439
|
-
const aHasDynamic = a.routePath.includes(':');
|
|
440
|
-
const bHasDynamic = b.routePath.includes(':');
|
|
441
|
-
|
|
442
|
-
if (aHasCatchAll && !bHasCatchAll) return 1;
|
|
443
|
-
if (!aHasCatchAll && bHasCatchAll) return -1;
|
|
444
|
-
if (aHasDynamic && !bHasDynamic) return 1;
|
|
445
|
-
if (!aHasDynamic && bHasDynamic) return -1;
|
|
446
|
-
|
|
447
|
-
return a.routePath.localeCompare(b.routePath);
|
|
448
|
-
});
|
|
449
|
-
};
|
|
507
|
+
// Sort routes: static before dynamic before catch-all; then more literal segments, then deeper paths
|
|
508
|
+
const sortRoutes = (routes) => routes.sort(compareRouteRegistrationOrder);
|
|
450
509
|
|
|
451
510
|
// Register API routes
|
|
452
511
|
for (const route of sortRoutes(apiRoutes)) {
|
|
@@ -686,6 +745,8 @@ module.exports = {
|
|
|
686
745
|
loadI18n,
|
|
687
746
|
createTranslator,
|
|
688
747
|
detectLocale,
|
|
689
|
-
resolveMiddlewares
|
|
748
|
+
resolveMiddlewares,
|
|
749
|
+
routeRegistrationMeta,
|
|
750
|
+
compareRouteRegistrationOrder,
|
|
690
751
|
};
|
|
691
752
|
|