webspresso 0.0.96 → 0.0.98
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/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
package/bin/webspresso.js
CHANGED
|
@@ -18,7 +18,9 @@ const { registerCommand: registerPage } = require('./commands/page');
|
|
|
18
18
|
const { registerCommand: registerApi } = require('./commands/api');
|
|
19
19
|
const { registerCommand: registerDev } = require('./commands/dev');
|
|
20
20
|
const { registerCommand: registerStart } = require('./commands/start');
|
|
21
|
+
const { registerCommand: registerBuild } = require('./commands/build');
|
|
21
22
|
const { registerCommand: registerAddTailwind } = require('./commands/add-tailwind');
|
|
23
|
+
const { registerCommand: registerAddEsbuild } = require('./commands/add-esbuild');
|
|
22
24
|
const { registerCommand: registerDbMigrate } = require('./commands/db-migrate');
|
|
23
25
|
const { registerCommand: registerDbRollback } = require('./commands/db-rollback');
|
|
24
26
|
const { registerCommand: registerDbStatus } = require('./commands/db-status');
|
|
@@ -29,6 +31,10 @@ const { registerCommand: registerAdminSetup } = require('./commands/admin-setup'
|
|
|
29
31
|
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
30
32
|
const { registerCommand: registerFaviconGenerate } = require('./commands/favicon-generate');
|
|
31
33
|
const { registerCommand: registerAuditPrune } = require('./commands/audit-prune');
|
|
34
|
+
const { registerCommand: registerAnalyticsPrune } = require('./commands/analytics-prune');
|
|
35
|
+
const { registerCommand: registerEmailPrune } = require('./commands/email-prune');
|
|
36
|
+
const { registerCommand: registerQueuePrune } = require('./commands/queue-prune');
|
|
37
|
+
const { registerCommand: registerUploadPrune } = require('./commands/upload-prune');
|
|
32
38
|
const { registerCommand: registerDoctor } = require('./commands/doctor');
|
|
33
39
|
const { registerCommand: registerUpgrade } = require('./commands/upgrade');
|
|
34
40
|
const { registerCommand: registerOrmMap } = require('./commands/orm-map');
|
|
@@ -36,13 +42,20 @@ const { registerCommand: registerService } = require('./commands/service');
|
|
|
36
42
|
const { registerCommand: registerMcp } = require('./commands/mcp');
|
|
37
43
|
const { registerCommand: registerPolarMigrate } = require('./commands/polar-migrate');
|
|
38
44
|
const { registerCommand: registerAgentsInit } = require('./commands/agents-init');
|
|
45
|
+
const { registerCommand: registerRoutes } = require('./commands/routes');
|
|
46
|
+
const { registerCommand: registerMakeModel } = require('./commands/make-model');
|
|
47
|
+
const { registerCommand: registerMakeJob } = require('./commands/make-job');
|
|
48
|
+
const { registerCommand: registerOpenapi } = require('./commands/openapi');
|
|
49
|
+
const { registerCommand: registerTypegen } = require('./commands/typegen');
|
|
39
50
|
|
|
40
51
|
registerNew(program);
|
|
41
52
|
registerPage(program);
|
|
42
53
|
registerApi(program);
|
|
43
54
|
registerDev(program);
|
|
44
55
|
registerStart(program);
|
|
56
|
+
registerBuild(program);
|
|
45
57
|
registerAddTailwind(program);
|
|
58
|
+
registerAddEsbuild(program);
|
|
46
59
|
registerDbMigrate(program);
|
|
47
60
|
registerDbRollback(program);
|
|
48
61
|
registerDbStatus(program);
|
|
@@ -53,6 +66,10 @@ registerAdminSetup(program);
|
|
|
53
66
|
registerAdminPassword(program);
|
|
54
67
|
registerFaviconGenerate(program);
|
|
55
68
|
registerAuditPrune(program);
|
|
69
|
+
registerAnalyticsPrune(program);
|
|
70
|
+
registerEmailPrune(program);
|
|
71
|
+
registerQueuePrune(program);
|
|
72
|
+
registerUploadPrune(program);
|
|
56
73
|
registerDoctor(program);
|
|
57
74
|
registerUpgrade(program);
|
|
58
75
|
registerOrmMap(program);
|
|
@@ -60,6 +77,11 @@ registerService(program);
|
|
|
60
77
|
registerMcp(program);
|
|
61
78
|
registerPolarMigrate(program);
|
|
62
79
|
registerAgentsInit(program);
|
|
80
|
+
registerRoutes(program);
|
|
81
|
+
registerMakeModel(program);
|
|
82
|
+
registerMakeJob(program);
|
|
83
|
+
registerOpenapi(program);
|
|
84
|
+
registerTypegen(program);
|
|
63
85
|
|
|
64
86
|
// Parse arguments
|
|
65
87
|
program.parse();
|
package/core/applySchema.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
* Parses and validates request input against compiled schemas
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const EMPTY_INPUT = Object.freeze({
|
|
7
|
+
body: undefined,
|
|
8
|
+
params: undefined,
|
|
9
|
+
query: undefined,
|
|
10
|
+
});
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
13
|
* Apply compiled schema to request
|
|
8
14
|
* Parses body, params, and query against their respective schemas
|
|
@@ -14,18 +20,19 @@
|
|
|
14
20
|
* @throws {ZodError} If validation fails
|
|
15
21
|
*/
|
|
16
22
|
function applySchema(req, compiledSchema) {
|
|
23
|
+
// No schema means no validation
|
|
24
|
+
if (!compiledSchema) {
|
|
25
|
+
req.input = EMPTY_INPUT;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
// Initialize req.input
|
|
18
30
|
req.input = {
|
|
19
31
|
body: undefined,
|
|
20
32
|
params: undefined,
|
|
21
|
-
query: undefined
|
|
33
|
+
query: undefined,
|
|
22
34
|
};
|
|
23
35
|
|
|
24
|
-
// No schema means no validation
|
|
25
|
-
if (!compiledSchema) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
36
|
// Parse body if schema exists
|
|
30
37
|
if (compiledSchema.body) {
|
|
31
38
|
req.input.body = compiledSchema.body.parse(req.body);
|
package/core/auth/hash.js
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
* @module core/auth/hash
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
let bcrypt;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
let bcrypt = null;
|
|
8
|
+
let bcryptChecked = false;
|
|
9
|
+
function getBcrypt() {
|
|
10
|
+
if (!bcryptChecked) {
|
|
11
|
+
bcryptChecked = true;
|
|
12
|
+
try {
|
|
13
|
+
bcrypt = require('bcrypt');
|
|
14
|
+
} catch {
|
|
15
|
+
bcrypt = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return bcrypt;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
/**
|
|
@@ -25,7 +31,8 @@ const DEFAULT_ROUNDS = process.env.NODE_ENV === 'test' ? 1 : 12;
|
|
|
25
31
|
* @returns {Promise<string>} Hashed password
|
|
26
32
|
*/
|
|
27
33
|
async function hash(password, rounds = DEFAULT_ROUNDS) {
|
|
28
|
-
|
|
34
|
+
const b = getBcrypt();
|
|
35
|
+
if (!b) {
|
|
29
36
|
throw new Error('bcrypt is required for password hashing. Install it with: npm install bcrypt');
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -33,7 +40,7 @@ async function hash(password, rounds = DEFAULT_ROUNDS) {
|
|
|
33
40
|
throw new Error('Password must be a non-empty string');
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
return
|
|
43
|
+
return b.hash(password, rounds);
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|
|
@@ -43,7 +50,8 @@ async function hash(password, rounds = DEFAULT_ROUNDS) {
|
|
|
43
50
|
* @returns {Promise<boolean>} True if password matches
|
|
44
51
|
*/
|
|
45
52
|
async function verify(password, hashedPassword) {
|
|
46
|
-
|
|
53
|
+
const b = getBcrypt();
|
|
54
|
+
if (!b) {
|
|
47
55
|
throw new Error('bcrypt is required for password verification. Install it with: npm install bcrypt');
|
|
48
56
|
}
|
|
49
57
|
|
|
@@ -52,7 +60,7 @@ async function verify(password, hashedPassword) {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
try {
|
|
55
|
-
return await
|
|
63
|
+
return await b.compare(password, hashedPassword);
|
|
56
64
|
} catch {
|
|
57
65
|
return false;
|
|
58
66
|
}
|
|
@@ -65,7 +73,8 @@ async function verify(password, hashedPassword) {
|
|
|
65
73
|
* @returns {boolean} True if rehash is needed
|
|
66
74
|
*/
|
|
67
75
|
function needsRehash(hashedPassword, rounds = DEFAULT_ROUNDS) {
|
|
68
|
-
|
|
76
|
+
const b = getBcrypt();
|
|
77
|
+
if (!b) {
|
|
69
78
|
throw new Error('bcrypt is required. Install it with: npm install bcrypt');
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -74,7 +83,7 @@ function needsRehash(hashedPassword, rounds = DEFAULT_ROUNDS) {
|
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
try {
|
|
77
|
-
const hashRounds =
|
|
86
|
+
const hashRounds = b.getRounds(hashedPassword);
|
|
78
87
|
return hashRounds < rounds;
|
|
79
88
|
} catch {
|
|
80
89
|
return true;
|
|
@@ -92,14 +101,19 @@ function generateToken(length = 32) {
|
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
/**
|
|
95
|
-
* Hash a token for storage (SHA-256)
|
|
96
|
-
* Used for
|
|
97
|
-
*
|
|
98
|
-
*
|
|
104
|
+
* Hash a high-entropy random lookup token for database storage (SHA-256).
|
|
105
|
+
* Note: Used exclusively for 256-bit cryptographically secure random lookup tokens
|
|
106
|
+
* (remember-me tokens, email verification tokens, password reset tokens), NOT user passwords.
|
|
107
|
+
* User passwords must always be hashed using `hash()` (bcrypt).
|
|
108
|
+
*
|
|
109
|
+
* // CodeQL [js/insufficient-password-hash] SHA-256 is intentionally used for 256-bit high-entropy random lookup token hashing, not user passwords.
|
|
110
|
+
* @param {string} token - High-entropy random token
|
|
111
|
+
* @returns {string} Hex-encoded SHA-256 digest
|
|
99
112
|
*/
|
|
100
113
|
function hashToken(token) {
|
|
101
114
|
const crypto = require('crypto');
|
|
102
|
-
|
|
115
|
+
// CodeQL [js/insufficient-password-hash] False positive: token is a 256-bit random string, not a user password
|
|
116
|
+
return crypto.createHash('sha256').update(String(token)).digest('hex');
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
module.exports = {
|
|
@@ -42,6 +42,11 @@ function appendVary(res, field) {
|
|
|
42
42
|
* @param {Object} [options.deflate] - Custom Deflate options
|
|
43
43
|
* @returns {import('express').RequestHandler}
|
|
44
44
|
*/
|
|
45
|
+
function resCompress(enable = true) {
|
|
46
|
+
this._noCompression = !enable;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
function createCompressionMiddleware(options = {}) {
|
|
46
51
|
const threshold = typeof options.threshold === 'number' && options.threshold >= 0 ? options.threshold : 1024;
|
|
47
52
|
const supportedEncodings = Array.isArray(options.encodings) && options.encodings.length > 0
|
|
@@ -53,10 +58,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
53
58
|
return function compressionMiddleware(req, res, next) {
|
|
54
59
|
// Expose opt-out API on response object
|
|
55
60
|
res._noCompression = false;
|
|
56
|
-
res.compress =
|
|
57
|
-
res._noCompression = !enable;
|
|
58
|
-
return res;
|
|
59
|
-
};
|
|
61
|
+
res.compress = resCompress;
|
|
60
62
|
|
|
61
63
|
// Skip compression for HEAD requests
|
|
62
64
|
if (req.method === 'HEAD') {
|
|
@@ -64,6 +66,10 @@ function createCompressionMiddleware(options = {}) {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const acceptEncoding = req.headers['accept-encoding'];
|
|
69
|
+
if (!acceptEncoding) {
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
let selectedEncoding = null;
|
|
68
74
|
let checkedEncoding = false;
|
|
69
75
|
|
|
@@ -75,10 +81,10 @@ function createCompressionMiddleware(options = {}) {
|
|
|
75
81
|
return selectedEncoding;
|
|
76
82
|
}
|
|
77
83
|
|
|
78
|
-
// Original methods to wrap
|
|
79
|
-
const origWrite = res.write
|
|
80
|
-
const origEnd = res.end
|
|
81
|
-
const origWriteHead = res.writeHead
|
|
84
|
+
// Original methods to wrap (store references without .bind() allocation)
|
|
85
|
+
const origWrite = res.write;
|
|
86
|
+
const origEnd = res.end;
|
|
87
|
+
const origWriteHead = res.writeHead;
|
|
82
88
|
|
|
83
89
|
let compressStream = null;
|
|
84
90
|
let isCompressing = false;
|
|
@@ -151,15 +157,15 @@ function createCompressionMiddleware(options = {}) {
|
|
|
151
157
|
|
|
152
158
|
// Pipe compression stream chunks directly into underlying socket/response
|
|
153
159
|
compressStream.on('data', (chunk) => {
|
|
154
|
-
origWrite(chunk);
|
|
160
|
+
origWrite.call(res, chunk);
|
|
155
161
|
});
|
|
156
162
|
|
|
157
163
|
compressStream.on('end', () => {
|
|
158
|
-
origEnd();
|
|
164
|
+
origEnd.call(res);
|
|
159
165
|
});
|
|
160
166
|
|
|
161
167
|
compressStream.on('error', (err) => {
|
|
162
|
-
origEnd();
|
|
168
|
+
origEnd.call(res);
|
|
163
169
|
});
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -219,7 +225,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
219
225
|
} else {
|
|
220
226
|
// Send buffered chunks uncompressed
|
|
221
227
|
for (const b of bufferedChunks) {
|
|
222
|
-
origWrite(b);
|
|
228
|
+
origWrite.call(res, b);
|
|
223
229
|
}
|
|
224
230
|
bufferedChunks = [];
|
|
225
231
|
}
|
|
@@ -232,7 +238,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
232
238
|
return compressStream.write(buf, callback);
|
|
233
239
|
}
|
|
234
240
|
|
|
235
|
-
return origWrite(buf, callback);
|
|
241
|
+
return origWrite.call(res, buf, callback);
|
|
236
242
|
};
|
|
237
243
|
|
|
238
244
|
// Wrap end
|
|
@@ -253,7 +259,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
253
259
|
} else if (isCompressing && compressStream) {
|
|
254
260
|
compressStream.write(buf);
|
|
255
261
|
} else {
|
|
256
|
-
origWrite(buf);
|
|
262
|
+
origWrite.call(res, buf);
|
|
257
263
|
}
|
|
258
264
|
}
|
|
259
265
|
|
|
@@ -282,10 +288,10 @@ function createCompressionMiddleware(options = {}) {
|
|
|
282
288
|
appendVary(res, 'Accept-Encoding');
|
|
283
289
|
}
|
|
284
290
|
for (const b of bufferedChunks) {
|
|
285
|
-
origWrite(b);
|
|
291
|
+
origWrite.call(res, b);
|
|
286
292
|
}
|
|
287
293
|
bufferedChunks = [];
|
|
288
|
-
return origEnd(callback);
|
|
294
|
+
return origEnd.call(res, callback);
|
|
289
295
|
}
|
|
290
296
|
}
|
|
291
297
|
|
|
@@ -297,7 +303,7 @@ function createCompressionMiddleware(options = {}) {
|
|
|
297
303
|
return res;
|
|
298
304
|
}
|
|
299
305
|
|
|
300
|
-
return origEnd(callback);
|
|
306
|
+
return origEnd.call(res, callback);
|
|
301
307
|
};
|
|
302
308
|
|
|
303
309
|
next();
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Build OpenAPI 3 document from file-router API route metadata + Zod schemas
|
|
5
|
+
* @module core/openapi/build-from-api-routes
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
const fs = require('fs');
|
|
@@ -16,11 +19,81 @@ const OPENAPI_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head'
|
|
|
16
19
|
* @returns {string}
|
|
17
20
|
*/
|
|
18
21
|
function expressPathToOpenApi(expressPath) {
|
|
22
|
+
if (!expressPath) return '';
|
|
19
23
|
return String(expressPath)
|
|
20
24
|
.replace(/:([A-Za-z0-9_]+)/g, '{$1}')
|
|
25
|
+
.replace(/\[([A-Za-z0-9_]+)\]/g, '{$1}')
|
|
21
26
|
.replace(/\*/g, '{wildcard}');
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the physical file path of an API route descriptor
|
|
31
|
+
* @param {Object} route
|
|
32
|
+
* @param {string} [pagesDir]
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
function resolveRouteFilePath(route, pagesDir) {
|
|
36
|
+
const rawFile = route.file || route.source;
|
|
37
|
+
if (!rawFile) return null;
|
|
38
|
+
|
|
39
|
+
if (path.isAbsolute(rawFile) && fs.existsSync(rawFile)) {
|
|
40
|
+
return rawFile;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fromCwd = path.resolve(process.cwd(), rawFile);
|
|
44
|
+
if (fs.existsSync(fromCwd)) {
|
|
45
|
+
return fromCwd;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (pagesDir) {
|
|
49
|
+
const fromPages = path.join(path.resolve(pagesDir), rawFile);
|
|
50
|
+
if (fs.existsSync(fromPages)) {
|
|
51
|
+
return fromPages;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Infers an expressive operation summary from HTTP method and path
|
|
60
|
+
* @param {string} method
|
|
61
|
+
* @param {string} routePath
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function inferOperationSummary(method, routePath) {
|
|
65
|
+
const clean = String(routePath || '').replace(/^\/api\/?/, '').replace(/\/$/, '');
|
|
66
|
+
const segments = clean.split('/').filter(Boolean);
|
|
67
|
+
const lastSeg = segments[segments.length - 1] || 'root';
|
|
68
|
+
const isParam = lastSeg.startsWith(':') || (lastSeg.startsWith('{') && lastSeg.endsWith('}')) || (lastSeg.startsWith('[') && lastSeg.endsWith(']'));
|
|
69
|
+
const resource = isParam && segments.length > 1 ? segments[segments.length - 2] : lastSeg;
|
|
70
|
+
const m = String(method || 'GET').toUpperCase();
|
|
71
|
+
|
|
72
|
+
if (m === 'GET') {
|
|
73
|
+
return isParam ? `Get ${resource} by ID` : `List ${resource}`;
|
|
74
|
+
}
|
|
75
|
+
if (m === 'POST') return `Create ${resource}`;
|
|
76
|
+
if (m === 'PUT' || m === 'PATCH') return `Update ${resource}`;
|
|
77
|
+
if (m === 'DELETE') return `Delete ${resource}`;
|
|
78
|
+
return `${m} /api/${clean}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Infers tag category from route path segments
|
|
83
|
+
* @param {string} routePath
|
|
84
|
+
* @param {string[]} [explicitTags]
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function inferOperationTags(routePath, explicitTags) {
|
|
88
|
+
if (Array.isArray(explicitTags) && explicitTags.length > 0) return explicitTags;
|
|
89
|
+
const clean = String(routePath || '').replace(/^\/api\/?/, '').replace(/\/$/, '');
|
|
90
|
+
const firstSeg = clean.split('/').filter(Boolean)[0];
|
|
91
|
+
if (firstSeg && !firstSeg.startsWith(':') && !firstSeg.startsWith('{') && !firstSeg.startsWith('[')) {
|
|
92
|
+
return [firstSeg];
|
|
93
|
+
}
|
|
94
|
+
return ['api'];
|
|
95
|
+
}
|
|
96
|
+
|
|
24
97
|
/**
|
|
25
98
|
* @param {import('zod').ZodObject<any>} zodObj
|
|
26
99
|
* @param {'path' | 'query'} where
|
|
@@ -38,19 +111,31 @@ function expandZodObjectToParameters(zodObj, where) {
|
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
/**
|
|
41
|
-
* @param {object} route - { type, method, pattern, file }
|
|
114
|
+
* @param {object} route - { type, method, pattern, path, file }
|
|
42
115
|
* @param {object|null} compiled - compileSchema result
|
|
116
|
+
* @param {object|null} [mod] - Raw route module
|
|
43
117
|
* @returns {object} OpenAPI Operation Object
|
|
44
118
|
*/
|
|
45
|
-
function buildOperation(route, compiled) {
|
|
46
|
-
const filePath = route.file;
|
|
47
|
-
const method = route.method.toLowerCase();
|
|
48
|
-
const
|
|
119
|
+
function buildOperation(route, compiled, mod = null) {
|
|
120
|
+
const filePath = route.file || route.source || '';
|
|
121
|
+
const method = (route.method || 'GET').toLowerCase();
|
|
122
|
+
const routePath = route.path || route.pattern || filePath;
|
|
123
|
+
|
|
124
|
+
let summary = (mod && (mod.summary || mod.openapi?.summary)) || route.summary;
|
|
125
|
+
if (!summary) {
|
|
126
|
+
if (filePath) {
|
|
127
|
+
summary = `${method.toUpperCase()} ${filePath}`;
|
|
128
|
+
} else {
|
|
129
|
+
summary = inferOperationSummary(method, routePath);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const tags = inferOperationTags(routePath, (mod && (mod.tags || mod.openapi?.tags)) || route.tags);
|
|
49
134
|
|
|
50
135
|
const op = {
|
|
51
|
-
tags
|
|
136
|
+
tags,
|
|
52
137
|
summary,
|
|
53
|
-
operationId: String(filePath)
|
|
138
|
+
operationId: String(filePath || routePath)
|
|
54
139
|
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
55
140
|
.replace(/^_|_$/g, ''),
|
|
56
141
|
responses: {
|
|
@@ -60,6 +145,25 @@ function buildOperation(route, compiled) {
|
|
|
60
145
|
},
|
|
61
146
|
};
|
|
62
147
|
|
|
148
|
+
if (mod && mod.description) {
|
|
149
|
+
op.description = mod.description;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Auth / Security detection
|
|
153
|
+
const middlewares = Array.isArray(route.middleware)
|
|
154
|
+
? route.middleware
|
|
155
|
+
: (Array.isArray(mod?.middleware) ? mod.middleware : []);
|
|
156
|
+
const security = [];
|
|
157
|
+
if (middlewares.includes('jwt') || middlewares.includes('bearer')) {
|
|
158
|
+
security.push({ bearerAuth: [] });
|
|
159
|
+
}
|
|
160
|
+
if (middlewares.includes('auth') || middlewares.includes('session')) {
|
|
161
|
+
security.push({ sessionAuth: [] });
|
|
162
|
+
}
|
|
163
|
+
if (security.length > 0) {
|
|
164
|
+
op.security = security;
|
|
165
|
+
}
|
|
166
|
+
|
|
63
167
|
if (compiled?.response) {
|
|
64
168
|
op.responses['200'] = {
|
|
65
169
|
description: 'OK',
|
|
@@ -94,16 +198,17 @@ function buildOperation(route, compiled) {
|
|
|
94
198
|
}
|
|
95
199
|
|
|
96
200
|
/**
|
|
201
|
+
* Builds an OpenAPI 3.0 document from application routes and ORM components
|
|
97
202
|
* @param {object} opts
|
|
98
|
-
* @param {object[]} opts.routes -
|
|
99
|
-
* @param {string} opts.pagesDir
|
|
100
|
-
* @param {boolean} [opts.includeOrmSchemas]
|
|
101
|
-
* @param {string[]} [opts.ormExclude]
|
|
203
|
+
* @param {object[]} opts.routes - Route metadata array
|
|
204
|
+
* @param {string} opts.pagesDir - Pages directory path
|
|
205
|
+
* @param {boolean} [opts.includeOrmSchemas=false]
|
|
206
|
+
* @param {string[]} [opts.ormExclude=[]]
|
|
102
207
|
* @param {object} [opts.info]
|
|
103
208
|
* @param {object[]} [opts.servers]
|
|
104
209
|
* @returns {object} OpenAPI 3.0.x document
|
|
105
210
|
*/
|
|
106
|
-
function buildOpenApiDocument(opts) {
|
|
211
|
+
function buildOpenApiDocument(opts = {}) {
|
|
107
212
|
const {
|
|
108
213
|
routes = [],
|
|
109
214
|
pagesDir,
|
|
@@ -118,31 +223,39 @@ function buildOpenApiDocument(opts) {
|
|
|
118
223
|
}
|
|
119
224
|
|
|
120
225
|
const paths = Object.create(null);
|
|
121
|
-
const apiRoutes = routes.filter((r) => r.type === 'api');
|
|
122
|
-
const absPages = path.resolve(pagesDir);
|
|
226
|
+
const apiRoutes = routes.filter((r) => r.type === 'api' || (r.path && r.path.startsWith('/api')));
|
|
123
227
|
|
|
124
228
|
for (const route of apiRoutes) {
|
|
125
|
-
const fullPath =
|
|
126
|
-
if (!fs.existsSync(fullPath)) {
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
229
|
+
const fullPath = resolveRouteFilePath(route, pagesDir);
|
|
129
230
|
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
mod = require(fullPath);
|
|
133
|
-
} catch {
|
|
231
|
+
if (route.file && !fullPath) {
|
|
134
232
|
continue;
|
|
135
233
|
}
|
|
136
234
|
|
|
235
|
+
let mod = null;
|
|
137
236
|
let compiled = null;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
237
|
+
|
|
238
|
+
if (fullPath) {
|
|
239
|
+
try {
|
|
240
|
+
mod = require(fullPath);
|
|
241
|
+
} catch {
|
|
242
|
+
mod = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (mod) {
|
|
246
|
+
try {
|
|
247
|
+
compiled = compileSchema(fullPath, mod);
|
|
248
|
+
} catch {
|
|
249
|
+
compiled = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
142
252
|
}
|
|
143
253
|
|
|
144
|
-
const
|
|
145
|
-
|
|
254
|
+
const routePattern = route.path || route.pattern || '';
|
|
255
|
+
if (!routePattern) continue;
|
|
256
|
+
|
|
257
|
+
const openApiPath = expressPathToOpenApi(routePattern);
|
|
258
|
+
const method = (route.method || 'GET').toLowerCase();
|
|
146
259
|
|
|
147
260
|
if (!OPENAPI_METHODS.has(method)) {
|
|
148
261
|
continue;
|
|
@@ -152,7 +265,7 @@ function buildOpenApiDocument(opts) {
|
|
|
152
265
|
paths[openApiPath] = Object.create(null);
|
|
153
266
|
}
|
|
154
267
|
|
|
155
|
-
paths[openApiPath][method] = buildOperation(route, compiled);
|
|
268
|
+
paths[openApiPath][method] = buildOperation(route, compiled, mod);
|
|
156
269
|
}
|
|
157
270
|
|
|
158
271
|
const doc = {
|
|
@@ -165,6 +278,20 @@ function buildOpenApiDocument(opts) {
|
|
|
165
278
|
servers,
|
|
166
279
|
paths,
|
|
167
280
|
components: {
|
|
281
|
+
securitySchemes: {
|
|
282
|
+
bearerAuth: {
|
|
283
|
+
type: 'http',
|
|
284
|
+
scheme: 'bearer',
|
|
285
|
+
bearerFormat: 'JWT',
|
|
286
|
+
description: 'Stateless JWT Bearer token authentication',
|
|
287
|
+
},
|
|
288
|
+
sessionAuth: {
|
|
289
|
+
type: 'apiKey',
|
|
290
|
+
in: 'cookie',
|
|
291
|
+
name: 'connect.sid',
|
|
292
|
+
description: 'Session cookie authentication',
|
|
293
|
+
},
|
|
294
|
+
},
|
|
168
295
|
schemas: includeOrmSchemas ? generateOrmOpenApiSchemas({ exclude: ormExclude }) : {},
|
|
169
296
|
},
|
|
170
297
|
};
|
|
@@ -174,5 +301,9 @@ function buildOpenApiDocument(opts) {
|
|
|
174
301
|
|
|
175
302
|
module.exports = {
|
|
176
303
|
expressPathToOpenApi,
|
|
304
|
+
resolveRouteFilePath,
|
|
305
|
+
inferOperationSummary,
|
|
306
|
+
inferOperationTags,
|
|
307
|
+
buildOperation,
|
|
177
308
|
buildOpenApiDocument,
|
|
178
309
|
};
|
|
@@ -28,7 +28,21 @@ function stableValue(v) {
|
|
|
28
28
|
const k = keys[0];
|
|
29
29
|
return { [k]: stableValue(v[k]) };
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
if (keyLen === 2) {
|
|
32
|
+
const k0 = keys[0];
|
|
33
|
+
const k1 = keys[1];
|
|
34
|
+
return k0 < k1
|
|
35
|
+
? { [k0]: stableValue(v[k0]), [k1]: stableValue(v[k1]) }
|
|
36
|
+
: { [k1]: stableValue(v[k1]), [k0]: stableValue(v[k0]) };
|
|
37
|
+
}
|
|
38
|
+
let isSorted = true;
|
|
39
|
+
for (let i = 1; i < keyLen; i++) {
|
|
40
|
+
if (keys[i - 1] > keys[i]) {
|
|
41
|
+
isSorted = false;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!isSorted) keys.sort();
|
|
32
46
|
const out = {};
|
|
33
47
|
for (let i = 0; i < keyLen; i++) {
|
|
34
48
|
const k = keys[i];
|
|
@@ -56,8 +56,20 @@ function createMemoryCacheProvider(options = {}) {
|
|
|
56
56
|
if (store.has(key)) removeKey(key);
|
|
57
57
|
|
|
58
58
|
if (maxEntries > 0 && store.size >= maxEntries) {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// Active sweep: evict expired entries before falling back to FIFO eviction
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
let freed = 0;
|
|
62
|
+
for (const [k, entry] of store.entries()) {
|
|
63
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
64
|
+
removeKey(k);
|
|
65
|
+
freed++;
|
|
66
|
+
if (freed >= 10 || store.size < maxEntries) break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (store.size >= maxEntries) {
|
|
70
|
+
const first = store.keys().next().value;
|
|
71
|
+
if (first !== undefined) removeKey(first);
|
|
72
|
+
}
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
|
|
@@ -82,6 +94,18 @@ function createMemoryCacheProvider(options = {}) {
|
|
|
82
94
|
for (const t of tags) tagToKeys.delete(t);
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
function pruneExpired() {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
let count = 0;
|
|
100
|
+
for (const [k, entry] of store.entries()) {
|
|
101
|
+
if (entry.expiresAt > 0 && now > entry.expiresAt) {
|
|
102
|
+
removeKey(k);
|
|
103
|
+
count++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return count;
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
function clear() {
|
|
86
110
|
store.clear();
|
|
87
111
|
tagToKeys.clear();
|
|
@@ -99,6 +123,7 @@ function createMemoryCacheProvider(options = {}) {
|
|
|
99
123
|
get,
|
|
100
124
|
set,
|
|
101
125
|
invalidateTags,
|
|
126
|
+
pruneExpired,
|
|
102
127
|
clear,
|
|
103
128
|
getSizeStats,
|
|
104
129
|
};
|