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.
- package/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +1 -1
- package/.agents/AUTH.md +169 -0
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +73 -5
- package/.agents/QUEUE.md +84 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +8 -0
- package/core/orm/model.js +4 -0
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +82 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/index.d.ts +528 -0
- package/index.js +44 -0
- package/package.json +1 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +6 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/src/api/api-loader.js +1 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +137 -100
- package/src/helpers.js +9 -0
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- 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
|
+
};
|