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,331 @@
1
+ /**
2
+ * Webspresso Native Cron Expression Parser & Matcher
3
+ * Pure Node.js standard 5-part cron implementation (zero external dependencies)
4
+ * @module core/queue/cron
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const MONTH_NAMES = {
10
+ jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
11
+ jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12
12
+ };
13
+
14
+ const DAY_NAMES = {
15
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6
16
+ };
17
+
18
+ const CRON_ALIASES = {
19
+ '@yearly': '0 0 1 1 *',
20
+ '@annually': '0 0 1 1 *',
21
+ '@monthly': '0 0 1 * *',
22
+ '@weekly': '0 0 * * 0',
23
+ '@daily': '0 0 * * *',
24
+ '@midnight': '0 0 * * *',
25
+ '@hourly': '0 * * * *',
26
+ '@every_minute': '* * * * *',
27
+ '@minutely': '* * * * *',
28
+ };
29
+
30
+ /**
31
+ * Resolves alias string (e.g. '@daily', '@every 5m') into a 5-part cron expression.
32
+ * @param {string} str
33
+ * @returns {string}
34
+ */
35
+ function resolveCronAlias(str) {
36
+ const trimmed = str.trim();
37
+ const lower = trimmed.toLowerCase();
38
+
39
+ if (CRON_ALIASES[lower]) {
40
+ return CRON_ALIASES[lower];
41
+ }
42
+
43
+ // Support '@every 5m', '@every 15m', '@every 2h'
44
+ const everyMatch = lower.match(/^@every\s+(\d+)\s*([mh])$/);
45
+ if (everyMatch) {
46
+ const amount = parseInt(everyMatch[1], 10);
47
+ const unit = everyMatch[2];
48
+ if (unit === 'm' && amount >= 1 && amount <= 59) {
49
+ return `*/${amount} * * * *`;
50
+ }
51
+ if (unit === 'h' && amount >= 1 && amount <= 23) {
52
+ return `0 */${amount} * * *`;
53
+ }
54
+ }
55
+
56
+ return trimmed;
57
+ }
58
+
59
+ /**
60
+ * Parse a single cron field with ranges, steps, and lists.
61
+ * @param {string} field
62
+ * @param {number} min
63
+ * @param {number} max
64
+ * @param {Record<string, number>} [nameMap]
65
+ * @returns {Set<number>}
66
+ */
67
+ function parseField(field, min, max, nameMap = null) {
68
+ let str = field.trim().toLowerCase();
69
+
70
+ if (nameMap) {
71
+ for (const [name, val] of Object.entries(nameMap)) {
72
+ str = str.replace(new RegExp(`\\b${name}\\b`, 'g'), String(val));
73
+ }
74
+ }
75
+
76
+ const values = new Set();
77
+ const parts = str.split(',');
78
+
79
+ for (const part of parts) {
80
+ if (!part) {
81
+ throw new Error(`Invalid empty cron segment in '${field}'`);
82
+ }
83
+
84
+ let step = 1;
85
+ let rangeStr = part;
86
+
87
+ if (part.includes('/')) {
88
+ const sub = part.split('/');
89
+ if (sub.length !== 2) {
90
+ throw new Error(`Invalid step syntax in '${part}'`);
91
+ }
92
+ rangeStr = sub[0];
93
+ step = parseInt(sub[1], 10);
94
+ if (isNaN(step) || step <= 0) {
95
+ throw new Error(`Invalid step value '${sub[1]}' in '${part}'`);
96
+ }
97
+ }
98
+
99
+ let start;
100
+ let end;
101
+
102
+ if (rangeStr === '*' || rangeStr === '') {
103
+ start = min;
104
+ end = max;
105
+ } else if (rangeStr.includes('-')) {
106
+ const sub = rangeStr.split('-');
107
+ if (sub.length !== 2) {
108
+ throw new Error(`Invalid range syntax in '${rangeStr}'`);
109
+ }
110
+ start = parseInt(sub[0], 10);
111
+ end = parseInt(sub[1], 10);
112
+ if (isNaN(start) || isNaN(end) || start > end) {
113
+ throw new Error(`Invalid range boundary '${rangeStr}' in '${part}'`);
114
+ }
115
+ } else {
116
+ start = parseInt(rangeStr, 10);
117
+ end = start;
118
+ if (isNaN(start)) {
119
+ throw new Error(`Invalid numeric value '${rangeStr}' in '${part}'`);
120
+ }
121
+ }
122
+
123
+ // Day of week special case: 7 is Sunday (same as 0)
124
+ if (max === 6 && (start === 7 || end === 7)) {
125
+ if (start === 7) start = 0;
126
+ if (end === 7) end = 0;
127
+ }
128
+
129
+ if (start < min || end > max) {
130
+ throw new Error(`Cron value out of range [${min}-${max}]: '${part}'`);
131
+ }
132
+
133
+ for (let i = start; i <= end; i += step) {
134
+ values.add(i === 7 && max === 6 ? 0 : i);
135
+ }
136
+ }
137
+
138
+ return values;
139
+ }
140
+
141
+ class CronExpression {
142
+ /**
143
+ * @param {string} expression - Standard 5-field cron or alias
144
+ * @param {Object} [options]
145
+ * @param {boolean} [options.utc=false] - Evaluate dates in UTC instead of local time
146
+ */
147
+ constructor(expression, options = {}) {
148
+ if (typeof expression !== 'string' || !expression.trim()) {
149
+ throw new Error('Cron expression must be a non-empty string');
150
+ }
151
+
152
+ this.rawExpression = expression;
153
+ this.resolvedExpression = resolveCronAlias(expression);
154
+ this.utc = !!options.utc;
155
+
156
+ const parts = this.resolvedExpression.trim().split(/\s+/);
157
+ if (parts.length !== 5) {
158
+ throw new Error(
159
+ `Invalid cron expression '${expression}': expected 5 fields (minute hour day-of-month month day-of-week), got ${parts.length}`
160
+ );
161
+ }
162
+
163
+ this.domAny = parts[2] === '*';
164
+ this.dowAny = parts[4] === '*';
165
+
166
+ this.minutes = parseField(parts[0], 0, 59);
167
+ this.hours = parseField(parts[1], 0, 23);
168
+ this.doms = parseField(parts[2], 1, 31);
169
+ this.months = parseField(parts[3], 1, 12, MONTH_NAMES);
170
+ this.dows = parseField(parts[4], 0, 6, DAY_NAMES);
171
+ }
172
+
173
+ /**
174
+ * Check if a given Date matches the cron expression
175
+ * @param {Date} [date=new Date()]
176
+ * @returns {boolean}
177
+ */
178
+ match(date = new Date()) {
179
+ const minute = this.utc ? date.getUTCMinutes() : date.getMinutes();
180
+ const hour = this.utc ? date.getUTCHours() : date.getHours();
181
+ const month = (this.utc ? date.getUTCMonth() : date.getMonth()) + 1;
182
+ const dom = this.utc ? date.getUTCDate() : date.getDate();
183
+ const dow = this.utc ? date.getUTCDay() : date.getDay();
184
+
185
+ if (!this.minutes.has(minute)) return false;
186
+ if (!this.hours.has(hour)) return false;
187
+ if (!this.months.has(month)) return false;
188
+
189
+ // Standard POSIX / crontab union behavior:
190
+ // If either dom or dow is '*', the other specifies matching day.
191
+ // If both are specified (neither is '*'), either match satisfies.
192
+ if (this.domAny && this.dowAny) return true;
193
+ if (!this.domAny && this.dowAny) return this.doms.has(dom);
194
+ if (this.domAny && !this.dowAny) return this.dows.has(dow);
195
+ return this.doms.has(dom) || this.dows.has(dow);
196
+ }
197
+
198
+ /**
199
+ * Calculate next matching Date after fromDate (default: now)
200
+ * Uses jumping skips to avoid long iteration loops.
201
+ *
202
+ * @param {Date} [fromDate=new Date()]
203
+ * @returns {Date|null}
204
+ */
205
+ getNextDate(fromDate = new Date()) {
206
+ const d = new Date(fromDate.getTime());
207
+
208
+ if (this.utc) {
209
+ d.setUTCSeconds(0, 0);
210
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
211
+ } else {
212
+ d.setSeconds(0, 0);
213
+ d.setMinutes(d.getMinutes() + 1);
214
+ }
215
+
216
+ const startYear = this.utc ? d.getUTCFullYear() : d.getFullYear();
217
+ const maxYear = startYear + 5;
218
+
219
+ while ((this.utc ? d.getUTCFullYear() : d.getFullYear()) <= maxYear) {
220
+ const month = (this.utc ? d.getUTCMonth() : d.getMonth()) + 1;
221
+ if (!this.months.has(month)) {
222
+ if (this.utc) {
223
+ d.setUTCMonth(d.getUTCMonth() + 1, 1);
224
+ d.setUTCHours(0, 0, 0, 0);
225
+ } else {
226
+ d.setMonth(d.getMonth() + 1, 1);
227
+ d.setHours(0, 0, 0, 0);
228
+ }
229
+ continue;
230
+ }
231
+
232
+ const dom = this.utc ? d.getUTCDate() : d.getDate();
233
+ const dow = this.utc ? d.getUTCDay() : d.getDay();
234
+
235
+ const dayMatch = (this.domAny && this.dowAny)
236
+ ? true
237
+ : (!this.domAny && this.dowAny)
238
+ ? this.doms.has(dom)
239
+ : (this.domAny && !this.dowAny)
240
+ ? this.dows.has(dow)
241
+ : (this.doms.has(dom) || this.dows.has(dow));
242
+
243
+ if (!dayMatch) {
244
+ if (this.utc) {
245
+ d.setUTCDate(d.getUTCDate() + 1);
246
+ d.setUTCHours(0, 0, 0, 0);
247
+ } else {
248
+ d.setDate(d.getDate() + 1);
249
+ d.setHours(0, 0, 0, 0);
250
+ }
251
+ continue;
252
+ }
253
+
254
+ const hour = this.utc ? d.getUTCHours() : d.getHours();
255
+ if (!this.hours.has(hour)) {
256
+ if (this.utc) {
257
+ d.setUTCHours(d.getUTCHours() + 1, 0, 0, 0);
258
+ } else {
259
+ d.setHours(d.getHours() + 1, 0, 0, 0);
260
+ }
261
+ continue;
262
+ }
263
+
264
+ const minute = this.utc ? d.getUTCMinutes() : d.getMinutes();
265
+ if (!this.minutes.has(minute)) {
266
+ if (this.utc) {
267
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
268
+ } else {
269
+ d.setMinutes(d.getMinutes() + 1);
270
+ }
271
+ continue;
272
+ }
273
+
274
+ return d;
275
+ }
276
+
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * Return the next N matching Dates
282
+ * @param {number} count
283
+ * @param {Date} [fromDate=new Date()]
284
+ * @returns {Date[]}
285
+ */
286
+ getNextDates(count = 5, fromDate = new Date()) {
287
+ const dates = [];
288
+ let current = fromDate;
289
+
290
+ for (let i = 0; i < count; i++) {
291
+ const next = this.getNextDate(current);
292
+ if (!next) break;
293
+ dates.push(next);
294
+ current = next;
295
+ }
296
+
297
+ return dates;
298
+ }
299
+
300
+ /**
301
+ * Static helper to validate a cron expression string
302
+ * @param {string} expression
303
+ * @returns {boolean}
304
+ */
305
+ static isValid(expression) {
306
+ try {
307
+ new CronExpression(expression);
308
+ return true;
309
+ } catch (_) {
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Parse expression into a CronExpression instance
316
+ * @param {string} expression
317
+ * @param {Object} [options]
318
+ * @returns {CronExpression}
319
+ */
320
+ static parse(expression, options) {
321
+ return new CronExpression(expression, options);
322
+ }
323
+ }
324
+
325
+ module.exports = {
326
+ CronExpression,
327
+ resolveCronAlias,
328
+ isValidCron: CronExpression.isValid,
329
+ parseCron: CronExpression.parse,
330
+ CRON_ALIASES,
331
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Webspresso Core Background Job Queue Engine
2
+ * Webspresso Core Background Job Queue & Task Scheduler Engine
3
3
  * @module core/queue
4
4
  */
5
5
 
@@ -10,6 +10,8 @@ const { QueueManager, createQueueManager } = require('./manager');
10
10
  const { MemoryQueueAdapter } = require('./adapters/memory');
11
11
  const { DatabaseQueueAdapter } = require('./adapters/database');
12
12
  const { RedisQueueAdapter } = require('./adapters/redis');
13
+ const { CronExpression, isValidCron, parseCron, resolveCronAlias } = require('./cron');
14
+ const { TaskScheduler } = require('./scheduler');
13
15
 
14
16
  module.exports = {
15
17
  Job,
@@ -18,4 +20,9 @@ module.exports = {
18
20
  MemoryQueueAdapter,
19
21
  DatabaseQueueAdapter,
20
22
  RedisQueueAdapter,
23
+ CronExpression,
24
+ isValidCron,
25
+ parseCron,
26
+ resolveCronAlias,
27
+ TaskScheduler,
21
28
  };
@@ -10,6 +10,8 @@ const path = require('path');
10
10
  const fs = require('fs');
11
11
  const { Job } = require('./job');
12
12
  const { MemoryQueueAdapter } = require('./adapters/memory');
13
+ const { CronExpression } = require('./cron');
14
+ const { TaskScheduler } = require('./scheduler');
13
15
 
14
16
  class QueueManager extends EventEmitter {
15
17
  /**
@@ -78,6 +80,12 @@ class QueueManager extends EventEmitter {
78
80
  this.startCleanup();
79
81
  }
80
82
 
83
+ // Initialize TaskScheduler for recurring cron tasks
84
+ this.scheduler = new TaskScheduler(this, {
85
+ autoStart: options.autoStart !== false,
86
+ ...(options.scheduler || {}),
87
+ });
88
+
81
89
  if (this.jobsDir && fs.existsSync(this.jobsDir)) {
82
90
  this.loadJobsFromDirectory(this.jobsDir);
83
91
  }
@@ -181,6 +189,9 @@ class QueueManager extends EventEmitter {
181
189
  this.isPaused = false;
182
190
  this._currentPollInterval = this.pollInterval;
183
191
  this._scheduleNextTick(0);
192
+ if (this.scheduler) {
193
+ this.scheduler.start();
194
+ }
184
195
  this.emit('start');
185
196
  return this;
186
197
  }
@@ -194,6 +205,9 @@ class QueueManager extends EventEmitter {
194
205
  clearTimeout(this._timer);
195
206
  this._timer = null;
196
207
  }
208
+ if (this.scheduler) {
209
+ this.scheduler.stop();
210
+ }
197
211
  this.emit('pause');
198
212
  return this;
199
213
  }
@@ -206,6 +220,9 @@ class QueueManager extends EventEmitter {
206
220
  this.isPaused = false;
207
221
  this._currentPollInterval = this.pollInterval;
208
222
  this._scheduleNextTick(0);
223
+ if (this.scheduler) {
224
+ this.scheduler.start();
225
+ }
209
226
  this.emit('resume');
210
227
  return this;
211
228
  }
@@ -218,6 +235,9 @@ class QueueManager extends EventEmitter {
218
235
  async drain(timeoutMs = 10000) {
219
236
  this.isDraining = true;
220
237
  this.pause();
238
+ if (this.scheduler) {
239
+ this.scheduler.stop();
240
+ }
221
241
 
222
242
  if (this.runningCount === 0) {
223
243
  this.isDraining = false;
@@ -245,10 +265,58 @@ class QueueManager extends EventEmitter {
245
265
  async stop() {
246
266
  this.pause();
247
267
  this.isRunning = false;
268
+ if (this.scheduler) {
269
+ this.scheduler.stop();
270
+ }
248
271
  this.stopCleanup();
249
272
  this.emit('stop');
250
273
  }
251
274
 
275
+ /**
276
+ * Schedule a recurring cron job
277
+ * @param {string|CronExpression} cronExpression - Cron pattern or alias (e.g. '0 0 * * *', '@daily')
278
+ * @param {string} name - Registered job name
279
+ * @param {any} [data={}] - Job data payload
280
+ * @param {Object} [options={}] - Schedule options (overlap, maxRuns, utc, runImmediately)
281
+ * @returns {Object} Registered schedule object
282
+ */
283
+ schedule(cronExpression, name, data = {}, options = {}) {
284
+ return this.scheduler.schedule(cronExpression, name, data, options);
285
+ }
286
+
287
+ /**
288
+ * Alias for schedule()
289
+ */
290
+ cron(cronExpression, name, data, options) {
291
+ return this.schedule(cronExpression, name, data, options);
292
+ }
293
+
294
+ /**
295
+ * Unschedule a recurring job by ID or job name
296
+ * @param {string} idOrJobName
297
+ * @returns {boolean}
298
+ */
299
+ unschedule(idOrJobName) {
300
+ return this.scheduler.unschedule(idOrJobName);
301
+ }
302
+
303
+ /**
304
+ * Get all registered schedules
305
+ * @returns {Array<Object>}
306
+ */
307
+ getSchedules() {
308
+ return this.scheduler.getSchedules();
309
+ }
310
+
311
+ /**
312
+ * Get single schedule by ID or job name
313
+ * @param {string} idOrJobName
314
+ * @returns {Object|null}
315
+ */
316
+ getSchedule(idOrJobName) {
317
+ return this.scheduler.getSchedule(idOrJobName);
318
+ }
319
+
252
320
  /**
253
321
  * Delete completed or failed jobs older than a cutoff date
254
322
  * @param {Object} [options={}]
@@ -371,7 +439,10 @@ class QueueManager extends EventEmitter {
371
439
  if (entry.isDirectory()) {
372
440
  readRecursive(fullPath, basePrefix ? `${basePrefix}.${entry.name}` : entry.name);
373
441
  } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
374
- const nameWithoutExt = entry.name.replace(/\.(c)?js$/, '');
442
+ const isCronFile = entry.name.endsWith('.cron.js') || entry.name.endsWith('.cron.cjs');
443
+ const nameWithoutExt = isCronFile
444
+ ? entry.name.replace(/\.cron\.(c)?js$/, '')
445
+ : entry.name.replace(/\.(c)?js$/, '');
375
446
  const jobName = basePrefix ? `${basePrefix}.${nameWithoutExt}` : nameWithoutExt;
376
447
 
377
448
  try {
@@ -381,6 +452,16 @@ class QueueManager extends EventEmitter {
381
452
 
382
453
  if (typeof handler === 'function') {
383
454
  this.define(jobName, handler, options);
455
+
456
+ const cronExpr = mod.cron || mod.schedule || (isCronFile ? (mod.expression || `@${nameWithoutExt}`) : null);
457
+ if (cronExpr) {
458
+ this.schedule(
459
+ cronExpr,
460
+ jobName,
461
+ mod.data || {},
462
+ { ...options, ...(mod.scheduleOptions || {}) }
463
+ );
464
+ }
384
465
  }
385
466
  } catch (err) {
386
467
  console.error(`[QueueManager] Failed to load job from ${fullPath}:`, err.message);