webspresso 0.0.100 → 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.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +3 -1
  3. package/.agents/ARCHITECTURE.md +11 -0
  4. package/.agents/AUTH.md +63 -0
  5. package/.agents/CLI.md +81 -0
  6. package/.agents/CONVENTIONS.md +4 -0
  7. package/.agents/EMAIL.md +84 -2
  8. package/.agents/EVENTS.md +179 -0
  9. package/.agents/ORM.md +59 -0
  10. package/.agents/PLUGINS.md +32 -6
  11. package/.agents/ROUTING.md +163 -3
  12. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  13. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  14. package/AGENTS.md +7 -0
  15. package/README.md +18 -5
  16. package/bin/commands/add-tailwind.js +10 -0
  17. package/bin/commands/build.js +34 -0
  18. package/bin/commands/db-makemigrations.js +83 -0
  19. package/bin/commands/list.js +96 -0
  20. package/bin/commands/make-command.js +93 -0
  21. package/bin/commands/make-event.js +80 -0
  22. package/bin/commands/make-factory.js +72 -0
  23. package/bin/commands/make-mail.js +93 -0
  24. package/bin/commands/make-policy.js +106 -0
  25. package/bin/commands/routes.js +8 -0
  26. package/bin/commands/run.js +195 -0
  27. package/bin/webspresso.js +17 -0
  28. package/core/applySchema.js +18 -1
  29. package/core/auth/bouncer.js +196 -0
  30. package/core/auth/index.js +10 -0
  31. package/core/auth/middleware.js +15 -1
  32. package/core/commands/define-command.js +48 -0
  33. package/core/commands/index.js +18 -0
  34. package/core/commands/registry.js +156 -0
  35. package/core/commands/runner.js +76 -0
  36. package/core/compression/compressible.js +1 -1
  37. package/core/compression/middleware.js +35 -251
  38. package/core/content/service.js +8 -2
  39. package/core/events/discovery.js +142 -0
  40. package/core/events/dispatcher.js +552 -0
  41. package/core/events/index.js +23 -0
  42. package/core/events/signal.js +92 -0
  43. package/core/forms/flash.js +234 -0
  44. package/core/forms/form-builder.js +214 -0
  45. package/core/forms/index.js +16 -0
  46. package/core/mail/index.js +23 -0
  47. package/core/mail/mailable.js +250 -0
  48. package/core/mail/mailer.js +184 -0
  49. package/core/mail/transports.js +238 -0
  50. package/core/orm/factory.js +250 -0
  51. package/core/orm/index.js +6 -0
  52. package/core/orm/migrations/diff.js +185 -0
  53. package/core/orm/migrations/index.js +22 -4
  54. package/core/orm/model.js +4 -0
  55. package/index.d.ts +422 -1
  56. package/index.js +111 -16
  57. package/package.json +9 -5
  58. package/plugins/admin-panel/api.js +509 -3
  59. package/plugins/admin-panel/index.js +29 -0
  60. package/plugins/content/models/content-entry.js +4 -3
  61. package/plugins/content/models/content-type.js +4 -3
  62. package/plugins/cors.js +2 -2
  63. package/plugins/csrf/index.js +1 -1
  64. package/plugins/email/index.js +48 -0
  65. package/plugins/email/preview-handler.js +555 -0
  66. package/plugins/email/service.js +23 -12
  67. package/plugins/email/transport.js +30 -11
  68. package/plugins/file-manager/index.js +13 -0
  69. package/plugins/index.js +2 -0
  70. package/plugins/lazy.js +258 -0
  71. package/plugins/redirect/index.js +1 -1
  72. package/src/api/api-loader.js +18 -2
  73. package/src/api/validation-i18n.js +231 -0
  74. package/src/app-context.js +48 -6
  75. package/src/dev-reload/index.js +9 -1
  76. package/src/discovery/route-manifest.js +197 -0
  77. package/src/discovery/scan-routes.js +80 -5
  78. package/src/file-router.js +153 -91
  79. package/src/helpers.js +143 -0
  80. package/src/njk-frontmatter.js +17 -1
  81. package/src/pages/page-loader.js +1 -1
  82. package/src/plugin-manager.js +12 -0
  83. package/src/routing/cascading.js +14 -10
  84. package/src/routing/route-table.js +1 -1
  85. package/src/server.js +243 -34
  86. package/src/services/builtins/system.js +4 -6
  87. package/src/services/registry.js +12 -2
  88. package/src/template/precompile.js +247 -0
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * HTTP Compression Middleware
3
3
  * Provides threshold-based streaming response compression for Webspresso applications.
4
+ * Backed by the official Express compression package with native Brotli (br), Gzip, and Deflate support.
4
5
  * @module core/compression/middleware
5
6
  */
6
7
 
7
- const { selectEncoding, getDefaultSupportedEncodings } = require('./negotiate');
8
- const { isCompressible } = require('./compressible');
9
- const { createCompressionStream } = require('./compression-stream');
8
+ const compression = require('compression');
10
9
 
11
10
  /**
12
11
  * Safely append a field to the HTTP Vary header
@@ -30,6 +29,15 @@ function appendVary(res, field) {
30
29
  res.setHeader('Vary', `${currentStr}, ${field}`);
31
30
  }
32
31
 
32
+ /**
33
+ * Response helper to opt-out of compression for a specific route
34
+ * @param {boolean} [enable=true]
35
+ */
36
+ function resCompress(enable = true) {
37
+ this._noCompression = !enable;
38
+ return this;
39
+ }
40
+
33
41
  /**
34
42
  * Create Webspresso HTTP Response Compression Middleware
35
43
  * @param {Object} [options]
@@ -42,271 +50,47 @@ function appendVary(res, field) {
42
50
  * @param {Object} [options.deflate] - Custom Deflate options
43
51
  * @returns {import('express').RequestHandler}
44
52
  */
45
- function resCompress(enable = true) {
46
- this._noCompression = !enable;
47
- return this;
48
- }
49
-
50
53
  function createCompressionMiddleware(options = {}) {
51
54
  const threshold = typeof options.threshold === 'number' && options.threshold >= 0 ? options.threshold : 1024;
52
- const supportedEncodings = Array.isArray(options.encodings) && options.encodings.length > 0
53
- ? options.encodings
54
- : getDefaultSupportedEncodings();
55
-
56
55
  const customFilter = typeof options.filter === 'function' ? options.filter : null;
57
56
 
58
- return function compressionMiddleware(req, res, next) {
59
- // Expose opt-out API on response object
60
- res._noCompression = false;
61
- res.compress = resCompress;
62
-
63
- // Skip compression for HEAD requests
64
- if (req.method === 'HEAD') {
65
- return next();
66
- }
67
-
68
- const acceptEncoding = req.headers['accept-encoding'];
69
- if (!acceptEncoding) {
70
- return next();
71
- }
72
-
73
- let selectedEncoding = null;
74
- let checkedEncoding = false;
75
-
76
- function getSelectedEncoding() {
77
- if (!checkedEncoding) {
78
- checkedEncoding = true;
79
- selectedEncoding = selectEncoding(acceptEncoding, supportedEncodings);
80
- }
81
- return selectedEncoding;
82
- }
83
-
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;
88
-
89
- let compressStream = null;
90
- let isCompressing = false;
91
- let headersSent = false;
92
- let bufferedChunks = [];
93
- let bufferedLength = 0;
94
- let decided = false;
95
-
96
- /**
97
- * Check if response should be compressed
98
- * @param {number} [expectedLength]
99
- * @returns {boolean}
100
- */
101
- function shouldCompress(expectedLength) {
57
+ const comp = compression({
58
+ threshold,
59
+ brotli: options.brotli,
60
+ level: options.level,
61
+ filter: (req, res) => {
62
+ // Respect Webspresso opt-out flags
102
63
  if (res._noCompression || res.locals?.compress === false || res.shouldCompress === false) {
103
64
  return false;
104
65
  }
105
-
106
- // Check status codes that must not have a body
107
- const statusCode = res.statusCode || 200;
108
- if (statusCode === 204 || statusCode === 304 || statusCode === 205 || statusCode < 200) {
109
- return false;
110
- }
111
-
112
- // If response already has a Content-Encoding, do not double-compress
113
- if (res.getHeader('Content-Encoding')) {
114
- return false;
115
- }
116
-
117
- // Check Content-Type compressibility
66
+ // Never compress server-sent event streams
118
67
  const contentType = res.getHeader('Content-Type');
119
- if (!contentType || !isCompressible(contentType)) {
120
- return false;
121
- }
122
-
123
- // Check threshold if length is known
124
- if (typeof expectedLength === 'number' && expectedLength < threshold) {
125
- return false;
126
- }
127
-
128
- // Custom filter hook
129
- if (customFilter && !customFilter(req, res)) {
68
+ if (contentType && String(contentType).toLowerCase().includes('text/event-stream')) {
130
69
  return false;
131
70
  }
132
-
133
- // Check if client supports any available encoding
134
- const encoding = getSelectedEncoding();
135
- if (!encoding || encoding === 'identity') {
136
- return false;
137
- }
138
-
139
- return true;
140
- }
141
-
142
- /**
143
- * Initialize compression stream and headers
144
- */
145
- function startCompression() {
146
- const encoding = getSelectedEncoding();
147
- compressStream = createCompressionStream(encoding, options);
148
- if (!compressStream) {
149
- isCompressing = false;
150
- return;
151
- }
152
-
153
- isCompressing = true;
154
- appendVary(res, 'Accept-Encoding');
155
- res.setHeader('Content-Encoding', encoding);
156
- res.removeHeader('Content-Length');
157
-
158
- // Pipe compression stream chunks directly into underlying socket/response
159
- compressStream.on('data', (chunk) => {
160
- origWrite.call(res, chunk);
161
- });
162
-
163
- compressStream.on('end', () => {
164
- origEnd.call(res);
165
- });
166
-
167
- compressStream.on('error', (err) => {
168
- origEnd.call(res);
169
- });
170
- }
171
-
172
- // Wrap writeHead
173
- res.writeHead = function(statusCode, ...args) {
174
- if (res.headersSent) return res;
175
-
176
- if (typeof args[0] === 'object' && args[0] !== null) {
177
- for (const [k, v] of Object.entries(args[0])) {
178
- res.setHeader(k, v);
179
- }
180
- } else if (typeof args[1] === 'object' && args[1] !== null) {
181
- for (const [k, v] of Object.entries(args[1])) {
182
- res.setHeader(k, v);
183
- }
184
- }
185
-
186
- if (isCompressing) {
187
- const encoding = getSelectedEncoding();
188
- if (encoding) {
189
- res.setHeader('Content-Encoding', encoding);
190
- }
191
- res.removeHeader('Content-Length');
192
- appendVary(res, 'Accept-Encoding');
193
- }
194
-
195
- return origWriteHead.call(res, statusCode, ...args);
196
- };
197
-
198
- // Wrap write
199
- res.write = function(chunk, encoding, callback) {
200
- if (typeof encoding === 'function') {
201
- callback = encoding;
202
- encoding = undefined;
203
- }
204
-
205
- if (!chunk) {
206
- return true;
207
- }
208
-
209
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
210
-
211
- if (!decided) {
212
- bufferedChunks.push(buf);
213
- bufferedLength += buf.length;
214
-
215
- // If buffered size exceeds threshold, decide to start compression immediately
216
- if (bufferedLength >= threshold) {
217
- decided = true;
218
- if (shouldCompress()) {
219
- startCompression();
220
- // Flush buffered chunks through compressor
221
- for (const b of bufferedChunks) {
222
- compressStream.write(b);
223
- }
224
- bufferedChunks = [];
225
- } else {
226
- // Send buffered chunks uncompressed
227
- for (const b of bufferedChunks) {
228
- origWrite.call(res, b);
229
- }
230
- bufferedChunks = [];
231
- }
232
- }
233
- if (typeof callback === 'function') callback();
234
- return true;
71
+ if (customFilter) {
72
+ return customFilter(req, res);
235
73
  }
74
+ return compression.filter(req, res);
75
+ },
76
+ ...options,
77
+ });
236
78
 
237
- if (isCompressing && compressStream) {
238
- return compressStream.write(buf, callback);
239
- }
240
-
241
- return origWrite.call(res, buf, callback);
242
- };
243
-
244
- // Wrap end
245
- res.end = function(chunk, encoding, callback) {
246
- if (typeof chunk === 'function') {
247
- callback = chunk;
248
- chunk = null;
249
- } else if (typeof encoding === 'function') {
250
- callback = encoding;
251
- encoding = undefined;
252
- }
253
-
254
- if (chunk) {
255
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
256
- if (!decided) {
257
- bufferedChunks.push(buf);
258
- bufferedLength += buf.length;
259
- } else if (isCompressing && compressStream) {
260
- compressStream.write(buf);
261
- } else {
262
- origWrite.call(res, buf);
263
- }
264
- }
265
-
266
- if (!decided) {
267
- decided = true;
268
- // Check if total length satisfies threshold and compressibility
269
- if (shouldCompress(bufferedLength)) {
270
- startCompression();
271
- if (bufferedChunks.length === 1) {
272
- if (typeof callback === 'function') {
273
- compressStream.once('end', callback);
274
- }
275
- const singleBuf = bufferedChunks[0];
276
- bufferedChunks = [];
277
- compressStream.end(singleBuf);
278
- return res;
279
- }
280
- for (const b of bufferedChunks) {
281
- compressStream.write(b);
282
- }
283
- bufferedChunks = [];
284
- } else {
285
- // Output uncompressed
286
- if (shouldCompress(Infinity) && bufferedLength < threshold) {
287
- // Ensure Vary is set if content was compressible but just below threshold
288
- appendVary(res, 'Accept-Encoding');
289
- }
290
- for (const b of bufferedChunks) {
291
- origWrite.call(res, b);
292
- }
293
- bufferedChunks = [];
294
- return origEnd.call(res, callback);
295
- }
296
- }
79
+ return function compressionMiddleware(req, res, next) {
80
+ // Expose opt-out API on response object
81
+ res._noCompression = false;
82
+ res.compress = resCompress;
297
83
 
298
- if (isCompressing && compressStream) {
299
- if (typeof callback === 'function') {
300
- compressStream.once('end', callback);
301
- }
302
- compressStream.end();
84
+ // Prevent Content-Length header mismatch if setHeader is invoked after compression starts
85
+ const origSetHeader = res.setHeader;
86
+ res.setHeader = function(field, val) {
87
+ if (res.getHeader('Content-Encoding') && typeof field === 'string' && field.toLowerCase() === 'content-length') {
303
88
  return res;
304
89
  }
305
-
306
- return origEnd.call(res, callback);
90
+ return origSetHeader.call(res, field, val);
307
91
  };
308
92
 
309
- next();
93
+ return comp(req, res, next);
310
94
  };
311
95
  }
312
96
 
@@ -81,7 +81,10 @@ function createContentService(db, options = {}) {
81
81
  const cached = cache.get(key);
82
82
  if (cached) return cached;
83
83
 
84
- const row = await TypeRepo().findById(id);
84
+ let row = await TypeRepo().findById(id);
85
+ if (!row && typeof id === 'string' && /^\d+$/.test(id)) {
86
+ row = await TypeRepo().findById(parseInt(id, 10));
87
+ }
85
88
  cacheTypeRow(row);
86
89
  return row;
87
90
  }
@@ -281,7 +284,10 @@ function createContentService(db, options = {}) {
281
284
  const cached = cache.get(key);
282
285
  if (cached) return cached;
283
286
 
284
- const entry = await EntryRepo().findById(id);
287
+ let entry = await EntryRepo().findById(id);
288
+ if (!entry && typeof id === 'string' && /^\d+$/.test(id)) {
289
+ entry = await EntryRepo().findById(parseInt(id, 10));
290
+ }
285
291
  if (!entry) return null;
286
292
 
287
293
  const type = await getTypeById(entry.content_type_id);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Webspresso Events - File-based Listener Discovery & Definition
3
+ * Auto-discovers event listeners from `listeners/` or `events/` directories.
4
+ * @module core/events/discovery
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Define a typed event listener
12
+ * @param {string|string[]} event - Event name or array of event names
13
+ * @param {Function} handler - Handler function (data, meta) => any
14
+ * @param {Object} [options] - Listener options ({ sender, priority, once })
15
+ * @returns {Object}
16
+ */
17
+ function defineListener(event, handler, options = {}) {
18
+ if (!event) {
19
+ throw new TypeError('Listener event must be specified');
20
+ }
21
+ if (typeof handler !== 'function') {
22
+ throw new TypeError('Listener handler must be a function');
23
+ }
24
+ return {
25
+ event,
26
+ handle: handler,
27
+ ...options,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Convert file path to dot-notation event name fallback
33
+ * e.g. 'order/created.js' -> 'order.created'
34
+ * @param {string} relPath
35
+ * @returns {string}
36
+ */
37
+ function filePathToEventName(relPath) {
38
+ const parsed = path.parse(relPath);
39
+ const parts = [];
40
+ if (parsed.dir) {
41
+ parts.push(...parsed.dir.split(path.sep));
42
+ }
43
+ parts.push(parsed.name);
44
+ return parts.join('.');
45
+ }
46
+
47
+ /**
48
+ * Recursively find all .js / .cjs files in a directory
49
+ * @param {string} dir
50
+ * @returns {string[]}
51
+ */
52
+ function walkDirectory(dir) {
53
+ const files = [];
54
+ if (!fs.existsSync(dir)) return files;
55
+
56
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
57
+ for (const entry of entries) {
58
+ const fullPath = path.join(dir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ files.push(...walkDirectory(fullPath));
61
+ } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
62
+ if (!entry.name.startsWith('_') && !entry.name.endsWith('.test.js')) {
63
+ files.push(fullPath);
64
+ }
65
+ }
66
+ }
67
+ return files;
68
+ }
69
+
70
+ /**
71
+ * Load and register listeners from a directory into an EventDispatcher
72
+ * @param {string} dirPath - Absolute or relative directory path
73
+ * @param {import('./dispatcher').EventDispatcher} dispatcher
74
+ * @param {Object} [options]
75
+ * @returns {Array<{ file: string, event: string, handler: Function }>}
76
+ */
77
+ function loadListeners(dirPath, dispatcher, options = {}) {
78
+ if (!dispatcher || typeof dispatcher.on !== 'function') {
79
+ throw new TypeError('A valid EventDispatcher instance must be provided');
80
+ }
81
+
82
+ const resolvedDir = path.resolve(dirPath);
83
+ if (!fs.existsSync(resolvedDir)) {
84
+ return [];
85
+ }
86
+
87
+ const filePaths = walkDirectory(resolvedDir);
88
+ const loaded = [];
89
+
90
+ for (const filePath of filePaths) {
91
+ try {
92
+ const moduleContent = require(filePath);
93
+ const candidates = Array.isArray(moduleContent) ? moduleContent : [moduleContent];
94
+
95
+ for (const item of candidates) {
96
+ if (!item) continue;
97
+
98
+ let eventName = item.event;
99
+ let handler = item.handle || item.handler;
100
+
101
+ // If default export is a function directly
102
+ if (typeof item === 'function') {
103
+ handler = item;
104
+ eventName = item.event || filePathToEventName(path.relative(resolvedDir, filePath));
105
+ }
106
+
107
+ if (handler && typeof handler === 'function') {
108
+ if (!eventName) {
109
+ eventName = filePathToEventName(path.relative(resolvedDir, filePath));
110
+ }
111
+
112
+ const eventsList = Array.isArray(eventName) ? eventName : [eventName];
113
+ for (const evt of eventsList) {
114
+ dispatcher.on(evt, handler, {
115
+ sender: item.sender,
116
+ priority: item.priority,
117
+ once: item.once,
118
+ });
119
+
120
+ loaded.push({
121
+ file: filePath,
122
+ event: evt,
123
+ handler,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ } catch (err) {
129
+ if (options.logger && typeof options.logger.warn === 'function') {
130
+ options.logger.warn(`[loadListeners] Failed to load listener from ${filePath}:`, err.message);
131
+ }
132
+ }
133
+ }
134
+
135
+ return loaded;
136
+ }
137
+
138
+ module.exports = {
139
+ defineListener,
140
+ loadListeners,
141
+ filePathToEventName,
142
+ };