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,359 @@
1
+ /**
2
+ * Webspresso Task Scheduler Engine
3
+ * Manages recurring cron jobs, overlap prevention, and worker dispatching
4
+ * @module core/queue/scheduler
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { CronExpression } = require('./cron');
10
+
11
+ let scheduleCounter = 0;
12
+
13
+ class TaskScheduler {
14
+ /**
15
+ * @param {import('./manager').QueueManager} queue - QueueManager instance
16
+ * @param {Object} [options]
17
+ * @param {boolean} [options.autoStart=true]
18
+ * @param {number} [options.maxIntervalMs=60000] - Maximum sleep interval in ms
19
+ */
20
+ constructor(queue, options = {}) {
21
+ if (!queue) {
22
+ throw new Error('TaskScheduler requires a QueueManager instance');
23
+ }
24
+
25
+ this.queue = queue;
26
+ this.maxIntervalMs = options.maxIntervalMs || 60000;
27
+ this.isRunning = false;
28
+ this._timer = null;
29
+
30
+ /** @type {Map<string, Object>} */
31
+ this.schedules = new Map();
32
+
33
+ // Hook into queue events to track job lifecycle for overlap prevention
34
+ this._onJobCompleted = (job, result) => {
35
+ this._handleJobSettled(job, null, result);
36
+ };
37
+
38
+ this._onJobFailed = (job, err) => {
39
+ this._handleJobSettled(job, err, null);
40
+ };
41
+
42
+ this.queue.on('job:completed', this._onJobCompleted);
43
+ this.queue.on('job:failed', this._onJobFailed);
44
+
45
+ if (options.autoStart !== false && queue.isRunning) {
46
+ this.start();
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Register a new recurring scheduled task
52
+ * @param {string|CronExpression} cronExpression - Cron pattern or alias (e.g. '0 0 * * *', '@daily')
53
+ * @param {string} jobName - Name of the registered job in QueueManager
54
+ * @param {any} [data={}] - Payload passed to job on dispatch
55
+ * @param {Object} [options]
56
+ * @param {string} [options.id] - Optional custom schedule ID
57
+ * @param {boolean} [options.overlap=false] - Allow concurrent executions of the same schedule
58
+ * @param {number} [options.maxRuns] - Maximum number of runs before auto-unscheduling
59
+ * @param {boolean} [options.utc=false] - Evaluate schedule in UTC
60
+ * @param {boolean} [options.runImmediately=false] - Run immediately upon registration
61
+ * @returns {Object} Registered schedule object
62
+ */
63
+ schedule(cronExpression, jobName, data = {}, options = {}) {
64
+ if (typeof jobName !== 'string' || !jobName.trim()) {
65
+ throw new Error('Job name must be a non-empty string');
66
+ }
67
+
68
+ const cron = cronExpression instanceof CronExpression
69
+ ? cronExpression
70
+ : new CronExpression(cronExpression, { utc: !!options.utc });
71
+
72
+ const id = options.id || `schedule:${jobName}:${cron.resolvedExpression}`;
73
+
74
+ const now = new Date();
75
+ const nextRunAt = cron.getNextDate(now);
76
+
77
+ const scheduleObj = {
78
+ id,
79
+ name: jobName,
80
+ cronExpression: cron.rawExpression,
81
+ cron,
82
+ data,
83
+ options: {
84
+ overlap: options.overlap === true,
85
+ maxRuns: typeof options.maxRuns === 'number' && options.maxRuns > 0 ? options.maxRuns : null,
86
+ runImmediately: !!options.runImmediately,
87
+ ...options,
88
+ },
89
+ runsCount: 0,
90
+ lastRunAt: null,
91
+ nextRunAt,
92
+ isRunning: false,
93
+ activeJobId: null,
94
+ registeredAt: now,
95
+ };
96
+
97
+ this.schedules.set(id, scheduleObj);
98
+ this.queue.emit('schedule:registered', scheduleObj);
99
+
100
+ if (options.runImmediately) {
101
+ setImmediate(() => this.trigger(id));
102
+ }
103
+
104
+ this._rescheduleTimer();
105
+ return scheduleObj;
106
+ }
107
+
108
+ /**
109
+ * Alias for schedule()
110
+ */
111
+ cron(cronExpression, jobName, data, options) {
112
+ return this.schedule(cronExpression, jobName, data, options);
113
+ }
114
+
115
+ /**
116
+ * Remove a scheduled task by ID or job name
117
+ * @param {string} idOrJobName
118
+ * @returns {boolean} True if removed, false if not found
119
+ */
120
+ unschedule(idOrJobName) {
121
+ let targetId = null;
122
+
123
+ if (this.schedules.has(idOrJobName)) {
124
+ targetId = idOrJobName;
125
+ } else {
126
+ for (const [id, s] of this.schedules.entries()) {
127
+ if (s.name === idOrJobName) {
128
+ targetId = id;
129
+ break;
130
+ }
131
+ }
132
+ }
133
+
134
+ if (!targetId) return false;
135
+
136
+ const scheduleObj = this.schedules.get(targetId);
137
+ this.schedules.delete(targetId);
138
+ this.queue.emit('schedule:unregistered', scheduleObj);
139
+ this._rescheduleTimer();
140
+ return true;
141
+ }
142
+
143
+ /**
144
+ * Get all registered schedules
145
+ * @returns {Array<Object>}
146
+ */
147
+ getSchedules() {
148
+ return Array.from(this.schedules.values()).map((s) => ({
149
+ id: s.id,
150
+ name: s.name,
151
+ cronExpression: s.cronExpression,
152
+ data: s.data,
153
+ options: s.options,
154
+ runsCount: s.runsCount,
155
+ lastRunAt: s.lastRunAt,
156
+ nextRunAt: s.nextRunAt,
157
+ isRunning: s.isRunning,
158
+ activeJobId: s.activeJobId,
159
+ registeredAt: s.registeredAt,
160
+ }));
161
+ }
162
+
163
+ /**
164
+ * Get single schedule by ID or job name
165
+ * @param {string} idOrJobName
166
+ * @returns {Object|null}
167
+ */
168
+ getSchedule(idOrJobName) {
169
+ if (this.schedules.has(idOrJobName)) {
170
+ return this.schedules.get(idOrJobName);
171
+ }
172
+ for (const s of this.schedules.values()) {
173
+ if (s.name === idOrJobName) return s;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Manually trigger a schedule immediately
180
+ * @param {string} idOrJobName
181
+ * @returns {Promise<any>}
182
+ */
183
+ async trigger(idOrJobName) {
184
+ const s = this.getSchedule(idOrJobName);
185
+ if (!s) {
186
+ throw new Error(`Schedule not found: '${idOrJobName}'`);
187
+ }
188
+
189
+ if (!s.options.overlap && s.isRunning) {
190
+ this.queue.emit('schedule:skipped', s, 'overlap');
191
+ return null;
192
+ }
193
+
194
+ s.isRunning = true;
195
+ s.lastRunAt = new Date();
196
+ s.runsCount++;
197
+
198
+ try {
199
+ const job = await this.queue.dispatch(s.name, s.data, s.options);
200
+ s.activeJobId = job.id;
201
+ this.queue.emit('schedule:triggered', s, job);
202
+
203
+ if (s.options.maxRuns && s.runsCount >= s.options.maxRuns) {
204
+ this.unschedule(s.id);
205
+ }
206
+
207
+ return job;
208
+ } catch (err) {
209
+ s.isRunning = false;
210
+ s.activeJobId = null;
211
+ throw err;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Start scheduler timer loop
217
+ */
218
+ start() {
219
+ if (this.isRunning) return this;
220
+ this.isRunning = true;
221
+ this._rescheduleTimer();
222
+ this.queue.emit('schedule:start');
223
+ return this;
224
+ }
225
+
226
+ /**
227
+ * Stop scheduler timer loop
228
+ */
229
+ stop() {
230
+ this.isRunning = false;
231
+ if (this._timer) {
232
+ clearTimeout(this._timer);
233
+ this._timer = null;
234
+ }
235
+ this.queue.emit('schedule:stop');
236
+ return this;
237
+ }
238
+
239
+ /**
240
+ * Clear all schedules and stop timer
241
+ */
242
+ clear() {
243
+ this.stop();
244
+ this.schedules.clear();
245
+ }
246
+
247
+ /**
248
+ * Cleanup event listeners
249
+ */
250
+ destroy() {
251
+ this.stop();
252
+ this.schedules.clear();
253
+ this.queue.removeListener('job:completed', this._onJobCompleted);
254
+ this.queue.removeListener('job:failed', this._onJobFailed);
255
+ }
256
+
257
+ /**
258
+ * Internal job settled handler for overlap tracking
259
+ * @private
260
+ */
261
+ _handleJobSettled(job, err, result) {
262
+ for (const s of this.schedules.values()) {
263
+ if (s.activeJobId === job.id) {
264
+ s.isRunning = false;
265
+ s.activeJobId = null;
266
+ this.queue.emit('schedule:finished', s, job, err ? { error: err } : { result });
267
+ break;
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Re-evaluates next timer tick according to earliest due schedule
274
+ * @private
275
+ */
276
+ _rescheduleTimer() {
277
+ if (!this.isRunning) return;
278
+
279
+ if (this._timer) {
280
+ clearTimeout(this._timer);
281
+ this._timer = null;
282
+ }
283
+
284
+ if (this.schedules.size === 0) return;
285
+
286
+ const now = Date.now();
287
+ let earliest = Infinity;
288
+
289
+ for (const s of this.schedules.values()) {
290
+ if (s.nextRunAt) {
291
+ const time = s.nextRunAt.getTime();
292
+ if (time < earliest) {
293
+ earliest = time;
294
+ }
295
+ }
296
+ }
297
+
298
+ if (earliest === Infinity) return;
299
+
300
+ const delay = Math.max(50, Math.min(earliest - now, this.maxIntervalMs));
301
+
302
+ this._timer = setTimeout(() => this._tick(), delay);
303
+ }
304
+
305
+ /**
306
+ * Clock tick: evaluates due schedules and dispatches them
307
+ * @private
308
+ */
309
+ async _tick() {
310
+ if (!this.isRunning) return;
311
+
312
+ const now = new Date();
313
+ const nowTime = now.getTime();
314
+
315
+ const dueList = [];
316
+ for (const s of this.schedules.values()) {
317
+ if (s.nextRunAt && s.nextRunAt.getTime() <= nowTime) {
318
+ dueList.push(s);
319
+ }
320
+ }
321
+
322
+ for (const s of dueList) {
323
+ // Calculate next run date immediately
324
+ s.nextRunAt = s.cron.getNextDate(now);
325
+
326
+ // Check overlap
327
+ if (!s.options.overlap && s.isRunning) {
328
+ this.queue.emit('schedule:skipped', s, 'overlap');
329
+ s.lastRunAt = now;
330
+ continue;
331
+ }
332
+
333
+ s.isRunning = true;
334
+ s.lastRunAt = now;
335
+ s.runsCount++;
336
+
337
+ try {
338
+ const job = await this.queue.dispatch(s.name, s.data, s.options);
339
+ s.activeJobId = job.id;
340
+ this.queue.emit('schedule:triggered', s, job);
341
+ } catch (err) {
342
+ s.isRunning = false;
343
+ s.activeJobId = null;
344
+ this.queue.emit('schedule:error', s, err);
345
+ }
346
+
347
+ // Check maxRuns
348
+ if (s.options.maxRuns && s.runsCount >= s.options.maxRuns) {
349
+ this.unschedule(s.id);
350
+ }
351
+ }
352
+
353
+ this._rescheduleTimer();
354
+ }
355
+ }
356
+
357
+ module.exports = {
358
+ TaskScheduler,
359
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Webspresso Server-Sent Events (SSE) Module
3
+ * @module core/sse
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const {
9
+ ServerSentEventsStream,
10
+ createSseStream,
11
+ formatSseFrame,
12
+ } = require('./stream');
13
+
14
+ module.exports = {
15
+ ServerSentEventsStream,
16
+ createSseStream,
17
+ formatSseFrame,
18
+ sse: createSseStream,
19
+ };