webspresso 0.0.98 → 0.0.99

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 (56) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/AUTH.md +169 -0
  4. package/.agents/ORM.md +88 -4
  5. package/.agents/PLUGINS.md +73 -5
  6. package/.agents/QUEUE.md +84 -0
  7. package/.agents/ROUTING.md +127 -7
  8. package/AGENTS.md +1 -0
  9. package/core/auth/index.js +60 -0
  10. package/core/auth/manager.js +134 -0
  11. package/core/auth/middleware.js +88 -0
  12. package/core/auth/oauth.js +517 -0
  13. package/core/auth/qrcode.js +499 -0
  14. package/core/auth/totp.js +504 -0
  15. package/core/orm/eager-loader.js +144 -10
  16. package/core/orm/index.js +8 -0
  17. package/core/orm/model.js +4 -0
  18. package/core/orm/query-builder.js +90 -4
  19. package/core/orm/repository.js +44 -3
  20. package/core/orm/search.js +213 -0
  21. package/core/queue/cron.js +331 -0
  22. package/core/queue/index.js +8 -1
  23. package/core/queue/manager.js +82 -1
  24. package/core/queue/scheduler.js +359 -0
  25. package/core/sse/index.js +19 -0
  26. package/core/sse/stream.js +430 -0
  27. package/index.d.ts +528 -0
  28. package/index.js +44 -0
  29. package/package.json +1 -1
  30. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  31. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  32. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  34. package/plugins/admin-panel/field-renderers/index.js +2 -0
  35. package/plugins/api-key/crypto.js +101 -0
  36. package/plugins/api-key/index.js +288 -0
  37. package/plugins/api-key/middleware.js +461 -0
  38. package/plugins/api-key/migration-template.js +48 -0
  39. package/plugins/api-key/model.js +50 -0
  40. package/plugins/api-key/scopes.js +125 -0
  41. package/plugins/audit-log/admin-component.js +487 -70
  42. package/plugins/audit-log/api-handlers.js +114 -0
  43. package/plugins/audit-log/index.js +24 -0
  44. package/plugins/file-manager/admin-component.js +20 -4
  45. package/plugins/file-manager/index.js +21 -4
  46. package/plugins/index.js +6 -0
  47. package/plugins/oauth/index.js +338 -0
  48. package/plugins/queue/index.js +4 -0
  49. package/src/api/api-loader.js +1 -0
  50. package/src/discovery/file-route-parser.js +44 -1
  51. package/src/discovery/scan-routes.js +2 -2
  52. package/src/file-router.js +137 -100
  53. package/src/helpers.js +9 -0
  54. package/src/routing/cascading.js +378 -0
  55. package/src/routing/middleware-resolver.js +92 -0
  56. package/src/server.js +6 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Webspresso Cascading Middleware & Nested Layout Engine
3
+ * Provides folder-level _middleware.js inheritance and _layout.njk template nesting
4
+ * @module src/routing/cascading
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { resolveMiddlewares } = require('./middleware-resolver');
12
+ const { loadNjkRouteTemplate } = require('../njk-frontmatter');
13
+
14
+ const EMPTY_ARRAY = Object.freeze([]);
15
+
16
+ // Caches for production mode (key: directory path)
17
+ const middlewareFileCache = new Map();
18
+ const layoutFileCache = new Map();
19
+
20
+ /**
21
+ * Returns ancestor directory segments from root down to file's directory.
22
+ * E.g., 'dashboard/settings/profile.njk' -> ['', 'dashboard', 'dashboard/settings']
23
+ *
24
+ * @param {string} relativeFilePath
25
+ * @returns {string[]}
26
+ */
27
+ function getDirectoryChain(relativeFilePath) {
28
+ const normalized = relativeFilePath.split(/[/\\]/).join('/');
29
+ const dir = path.posix.dirname(normalized);
30
+
31
+ if (dir === '.' || dir === '') {
32
+ return [''];
33
+ }
34
+
35
+ const parts = dir.split('/');
36
+ const chain = [''];
37
+ let current = '';
38
+
39
+ for (const part of parts) {
40
+ if (!part) continue;
41
+ current = current ? `${current}/${part}` : part;
42
+ chain.push(current);
43
+ }
44
+
45
+ return chain;
46
+ }
47
+
48
+ /**
49
+ * Check if a template content explicitly declares its own layout inheritance via `{% extends ... %}`
50
+ *
51
+ * @param {string} [templateBody]
52
+ * @param {string} [fullPath]
53
+ * @returns {boolean}
54
+ */
55
+ function templateHasExtends(templateBody, fullPath) {
56
+ if (typeof templateBody === 'string' && /\{%\s*extends\s+/.test(templateBody)) {
57
+ return true;
58
+ }
59
+ if (fullPath && fs.existsSync(fullPath)) {
60
+ try {
61
+ const content = fs.readFileSync(fullPath, 'utf8');
62
+ return /\{%\s*extends\s+/.test(content);
63
+ } catch (_) {}
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Discover all existing _middleware.js files in directory chain (from root to target dir).
70
+ *
71
+ * @param {string} baseDir - Base pages directory
72
+ * @param {string} relativeFilePath - Relative file path
73
+ * @param {boolean} [isDev=false]
74
+ * @returns {string[]} Absolute paths to existing _middleware.js files
75
+ */
76
+ function findCascadingMiddlewareFiles(baseDir, relativeFilePath, isDev = false) {
77
+ const chain = getDirectoryChain(relativeFilePath);
78
+ const cacheKey = `${baseDir}::${chain.join('>')}`;
79
+
80
+ if (!isDev && middlewareFileCache.has(cacheKey)) {
81
+ return middlewareFileCache.get(cacheKey);
82
+ }
83
+
84
+ const existingFiles = [];
85
+ for (const dirSegment of chain) {
86
+ const middlewarePath = dirSegment === ''
87
+ ? path.join(baseDir, '_middleware.js')
88
+ : path.join(baseDir, ...dirSegment.split('/'), '_middleware.js');
89
+
90
+ if (fs.existsSync(middlewarePath)) {
91
+ existingFiles.push(middlewarePath);
92
+ }
93
+ }
94
+
95
+ if (!isDev) {
96
+ middlewareFileCache.set(cacheKey, existingFiles);
97
+ }
98
+
99
+ return existingFiles;
100
+ }
101
+
102
+ /**
103
+ * Loads and resolves cascading middlewares for a given route file.
104
+ *
105
+ * @param {string} baseDir - Base directory (e.g. pagesDir)
106
+ * @param {string} relativeFilePath - Relative file path
107
+ * @param {Object} middlewareRegistry - Available named middlewares
108
+ * @param {boolean} [isDev=false]
109
+ * @returns {Function[]} Ordered list of resolved Express middleware functions
110
+ */
111
+ function loadCascadingMiddlewares(baseDir, relativeFilePath, middlewareRegistry = {}, isDev = false) {
112
+ const files = findCascadingMiddlewareFiles(baseDir, relativeFilePath, isDev);
113
+ if (files.length === 0) return EMPTY_ARRAY;
114
+
115
+ const resolved = [];
116
+
117
+ for (const file of files) {
118
+ try {
119
+ if (isDev && require.cache[require.resolve(file)]) {
120
+ delete require.cache[require.resolve(file)];
121
+ }
122
+
123
+ const mod = require(file);
124
+ const candidate = mod?.default || mod;
125
+ const raw = typeof candidate === 'function'
126
+ ? candidate
127
+ : (candidate?.middleware || mod?.middleware || candidate);
128
+
129
+ if (!raw) continue;
130
+
131
+ if (typeof raw === 'function') {
132
+ resolved.push(raw);
133
+ } else if (Array.isArray(raw)) {
134
+ const chain = resolveMiddlewares(raw, middlewareRegistry);
135
+ resolved.push(...chain);
136
+ } else if (typeof raw === 'string') {
137
+ const chain = resolveMiddlewares([raw], middlewareRegistry);
138
+ resolved.push(...chain);
139
+ }
140
+ } catch (err) {
141
+ console.error(`[webspresso] Failed to load middleware from "${file}":`, err.message);
142
+ }
143
+ }
144
+
145
+ return resolved;
146
+ }
147
+
148
+ /**
149
+ * Run a single Express middleware with support for async functions, early completion (res.finish/close),
150
+ * and standard next()/next(err).
151
+ *
152
+ * @param {Function} mw
153
+ * @param {import('http').IncomingMessage} req
154
+ * @param {import('http').ServerResponse} res
155
+ * @returns {Promise<void>}
156
+ */
157
+ function runMiddleware(mw, req, res) {
158
+ if (res.headersSent) return Promise.resolve();
159
+
160
+ return new Promise((resolve, reject) => {
161
+ let settled = false;
162
+
163
+ const onFinish = () => {
164
+ if (!settled) {
165
+ settled = true;
166
+ cleanup();
167
+ resolve();
168
+ }
169
+ };
170
+
171
+ const cleanup = () => {
172
+ res.removeListener('finish', onFinish);
173
+ res.removeListener('close', onFinish);
174
+ };
175
+
176
+ res.once('finish', onFinish);
177
+ res.once('close', onFinish);
178
+
179
+ try {
180
+ const result = mw(req, res, (err) => {
181
+ if (settled) return;
182
+ settled = true;
183
+ cleanup();
184
+ if (err) reject(err);
185
+ else resolve();
186
+ });
187
+
188
+ if (result && typeof result.then === 'function') {
189
+ result.then(
190
+ () => {
191
+ if (!settled) {
192
+ settled = true;
193
+ cleanup();
194
+ resolve();
195
+ }
196
+ },
197
+ (err) => {
198
+ if (!settled) {
199
+ settled = true;
200
+ cleanup();
201
+ reject(err);
202
+ }
203
+ }
204
+ );
205
+ }
206
+ } catch (err) {
207
+ if (!settled) {
208
+ settled = true;
209
+ cleanup();
210
+ reject(err);
211
+ }
212
+ }
213
+ });
214
+ }
215
+
216
+ /**
217
+ * Run a chain of middlewares sequentially. Halts early if response is sent.
218
+ *
219
+ * @param {Function[]} chain
220
+ * @param {import('http').IncomingMessage} req
221
+ * @param {import('http').ServerResponse} res
222
+ * @returns {Promise<void>}
223
+ */
224
+ async function runMiddlewareChain(chain, req, res) {
225
+ if (!chain || chain.length === 0) return;
226
+ for (const mw of chain) {
227
+ if (res.headersSent) return;
228
+ await runMiddleware(mw, req, res);
229
+ if (res.headersSent) return;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Discover all existing _layout.njk files in directory chain (from root to target dir).
235
+ *
236
+ * @param {string} baseDir - Base pages directory
237
+ * @param {string} relativeFilePath - Relative file path
238
+ * @param {boolean} [isDev=false]
239
+ * @returns {Array<{ layoutRelPath: string, fullPath: string, configPath: string|null }>}
240
+ */
241
+ function findCascadingLayouts(baseDir, relativeFilePath, isDev = false) {
242
+ const chain = getDirectoryChain(relativeFilePath);
243
+ const cacheKey = `${baseDir}::${chain.join('>')}`;
244
+
245
+ if (!isDev && layoutFileCache.has(cacheKey)) {
246
+ return layoutFileCache.get(cacheKey);
247
+ }
248
+
249
+ const layouts = [];
250
+
251
+ for (const dirSegment of chain) {
252
+ const layoutNjkName = '_layout.njk';
253
+ const layoutJsName = '_layout.js';
254
+
255
+ const layoutRelPath = dirSegment === ''
256
+ ? layoutNjkName
257
+ : `${dirSegment}/${layoutNjkName}`;
258
+
259
+ const fullPath = dirSegment === ''
260
+ ? path.join(baseDir, layoutNjkName)
261
+ : path.join(baseDir, ...dirSegment.split('/'), layoutNjkName);
262
+
263
+ const fullJsPath = dirSegment === ''
264
+ ? path.join(baseDir, layoutJsName)
265
+ : path.join(baseDir, ...dirSegment.split('/'), layoutJsName);
266
+
267
+ if (fs.existsSync(fullPath)) {
268
+ layouts.push({
269
+ layoutRelPath: layoutRelPath.split(path.sep).join('/'),
270
+ fullPath,
271
+ configPath: fs.existsSync(fullJsPath) ? fullJsPath : null,
272
+ });
273
+ }
274
+ }
275
+
276
+ if (!isDev) {
277
+ layoutFileCache.set(cacheKey, layouts);
278
+ }
279
+
280
+ return layouts;
281
+ }
282
+
283
+ /**
284
+ * Executes load() functions from companion _layout.js files in top-down order (outermost first).
285
+ *
286
+ * @param {Array<{ configPath: string|null }>} layouts
287
+ * @param {import('http').IncomingMessage} req
288
+ * @param {Object} ctx - Route execution context
289
+ * @param {boolean} [isDev=false]
290
+ * @returns {Promise<void>}
291
+ */
292
+ async function executeCascadingLayoutLoads(layouts, req, ctx, isDev = false) {
293
+ for (const layout of layouts) {
294
+ if (!layout.configPath) continue;
295
+
296
+ try {
297
+ if (isDev && require.cache[require.resolve(layout.configPath)]) {
298
+ delete require.cache[require.resolve(layout.configPath)];
299
+ }
300
+
301
+ const mod = require(layout.configPath);
302
+ const candidate = mod?.default || mod;
303
+ const loadFn = typeof candidate === 'function' ? candidate : candidate?.load;
304
+
305
+ if (typeof loadFn === 'function') {
306
+ const loadedData = await loadFn(req, ctx);
307
+ if (loadedData && typeof loadedData === 'object' && !Array.isArray(loadedData)) {
308
+ ctx.data = Object.assign({}, ctx.data, loadedData);
309
+ }
310
+ }
311
+ } catch (err) {
312
+ console.error(`[webspresso] Error in layout loader "${layout.configPath}":`, err.message);
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Wraps page HTML with discovered nested layouts (from innermost to outermost).
319
+ *
320
+ * @param {Object} nunjucksEnv - Nunjucks environment instance
321
+ * @param {Array<{ layoutRelPath: string, fullPath: string }>} layouts - Discovered layouts from outermost to innermost
322
+ * @param {string} pageHtml - Rendered child HTML
323
+ * @param {Object} renderContext - Full template render context
324
+ * @param {boolean} [isDev=false]
325
+ * @returns {string} Fully wrapped HTML
326
+ */
327
+ function applyCascadingLayouts(nunjucksEnv, layouts, pageHtml, renderContext, isDev = false) {
328
+ if (!layouts || layouts.length === 0) {
329
+ return pageHtml;
330
+ }
331
+
332
+ let currentHtml = pageHtml;
333
+
334
+ // Wrap from innermost layout up to outermost (reverse of chain)
335
+ const reversedLayouts = [...layouts].reverse();
336
+
337
+ for (const layout of reversedLayouts) {
338
+ try {
339
+ const layoutContext = Object.assign({}, renderContext, {
340
+ content: currentHtml,
341
+ });
342
+
343
+ const njkTpl = loadNjkRouteTemplate(layout.fullPath, isDev);
344
+ if (njkTpl.dataPatch && Object.keys(njkTpl.dataPatch).length > 0) {
345
+ Object.assign(layoutContext, njkTpl.dataPatch);
346
+ }
347
+
348
+ if (njkTpl.useStringRender && njkTpl.templateBody != null) {
349
+ currentHtml = nunjucksEnv.renderString(njkTpl.templateBody, layoutContext, { path: layout.fullPath });
350
+ } else {
351
+ currentHtml = nunjucksEnv.render(layout.layoutRelPath, layoutContext);
352
+ }
353
+ } catch (err) {
354
+ console.error(`[webspresso] Failed rendering layout "${layout.layoutRelPath}":`, err.message);
355
+ throw err;
356
+ }
357
+ }
358
+
359
+ return currentHtml;
360
+ }
361
+
362
+ function clearCascadingCaches() {
363
+ middlewareFileCache.clear();
364
+ layoutFileCache.clear();
365
+ }
366
+
367
+ module.exports = {
368
+ getDirectoryChain,
369
+ templateHasExtends,
370
+ findCascadingMiddlewareFiles,
371
+ loadCascadingMiddlewares,
372
+ runMiddleware,
373
+ runMiddlewareChain,
374
+ findCascadingLayouts,
375
+ executeCascadingLayoutLoads,
376
+ applyCascadingLayouts,
377
+ clearCascadingCaches,
378
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const EMPTY_OBJECT = Object.freeze({});
4
+ const EMPTY_ARRAY = Object.freeze([]);
5
+
6
+ /**
7
+ * True when the registry entry is (options) => (req, res, next) => …
8
+ * Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
9
+ * @param {Function} fn
10
+ * @returns {boolean}
11
+ */
12
+ function isMiddlewareFactory(fn) {
13
+ return typeof fn === 'function' && fn.length <= 1;
14
+ }
15
+
16
+ /**
17
+ * Resolve a named middleware from createApp({ middlewares }).
18
+ * @param {string} name
19
+ * @param {Function} entry
20
+ * @param {boolean} fromTuple - true when route used ['name', options]
21
+ * @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
22
+ * @param {Object} middlewareRegistry - for error messages
23
+ * @returns {Function} Express middleware
24
+ */
25
+ function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
26
+ if (!entry) {
27
+ throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
28
+ }
29
+ if (typeof entry !== 'function') {
30
+ throw new Error(`Middleware "${name}" must be a function`);
31
+ }
32
+
33
+ if (fromTuple) {
34
+ if (!isMiddlewareFactory(entry)) {
35
+ throw new Error(
36
+ `Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
37
+ );
38
+ }
39
+ const produced = entry(tupleOptions);
40
+ if (typeof produced !== 'function') {
41
+ throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
42
+ }
43
+ return produced;
44
+ }
45
+
46
+ if (isMiddlewareFactory(entry)) {
47
+ const produced = entry({});
48
+ if (typeof produced !== 'function') {
49
+ throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
50
+ }
51
+ return produced;
52
+ }
53
+
54
+ return entry;
55
+ }
56
+
57
+ /**
58
+ * Resolve middleware from config — functions, string names, or [name, options] tuples
59
+ * @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
60
+ * @param {Object} [middlewareRegistry] - Named middleware registry (plain handlers or option factories)
61
+ * @returns {Array<Function>} Array of resolved middleware functions
62
+ */
63
+ function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
64
+ if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
65
+ return EMPTY_ARRAY;
66
+ }
67
+
68
+ return middlewareConfig.map((mw, index) => {
69
+ if (typeof mw === 'function') {
70
+ return mw;
71
+ }
72
+
73
+ if (typeof mw === 'string') {
74
+ return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
75
+ }
76
+
77
+ if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
78
+ const name = mw[0];
79
+ return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
80
+ }
81
+
82
+ throw new Error(
83
+ `Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
84
+ );
85
+ });
86
+ }
87
+
88
+ module.exports = {
89
+ isMiddlewareFactory,
90
+ resolveNamedMiddleware,
91
+ resolveMiddlewares,
92
+ };
package/src/server.js CHANGED
@@ -571,6 +571,7 @@ function createApp(options = {}) {
571
571
 
572
572
  // Unified Request Context & Lifecycle Initializer middleware
573
573
  const { renderStream } = require('../core/ssr/stream');
574
+ const { createSseStream } = require('../core/sse/stream');
574
575
  app.use((req, res, next) => {
575
576
  // 1. Request draining check during graceful shutdown
576
577
  if (shutdownManager.isShuttingDown) {
@@ -618,6 +619,11 @@ function createApp(options = {}) {
618
619
  });
619
620
  };
620
621
 
622
+ // 5. Server-Sent Events (SSE) Response Helper
623
+ res.sse = (sourceOrOptions, maybeOptions) => {
624
+ return createSseStream(req, res, sourceOrOptions, maybeOptions);
625
+ };
626
+
621
627
  next();
622
628
  });
623
629