webspresso 0.0.98 → 0.0.100
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 +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- 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/admin-panel/modules/admin-users.js +1 -1
- 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/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/core/queue/manager.js
CHANGED
|
@@ -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, CRON_ALIASES } = 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,76 @@ 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
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Manually trigger a scheduled job immediately
|
|
322
|
+
* @param {string} idOrJobName
|
|
323
|
+
* @returns {Promise<Job|null>}
|
|
324
|
+
*/
|
|
325
|
+
triggerSchedule(idOrJobName) {
|
|
326
|
+
return this.scheduler.trigger(idOrJobName);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Alias for triggerSchedule()
|
|
331
|
+
* @param {string} idOrJobName
|
|
332
|
+
* @returns {Promise<Job|null>}
|
|
333
|
+
*/
|
|
334
|
+
triggerCron(idOrJobName) {
|
|
335
|
+
return this.scheduler.trigger(idOrJobName);
|
|
336
|
+
}
|
|
337
|
+
|
|
252
338
|
/**
|
|
253
339
|
* Delete completed or failed jobs older than a cutoff date
|
|
254
340
|
* @param {Object} [options={}]
|
|
@@ -371,7 +457,10 @@ class QueueManager extends EventEmitter {
|
|
|
371
457
|
if (entry.isDirectory()) {
|
|
372
458
|
readRecursive(fullPath, basePrefix ? `${basePrefix}.${entry.name}` : entry.name);
|
|
373
459
|
} else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
|
|
374
|
-
const
|
|
460
|
+
const isCronFile = entry.name.endsWith('.cron.js') || entry.name.endsWith('.cron.cjs');
|
|
461
|
+
const nameWithoutExt = isCronFile
|
|
462
|
+
? entry.name.replace(/\.cron\.(c)?js$/, '')
|
|
463
|
+
: entry.name.replace(/\.(c)?js$/, '');
|
|
375
464
|
const jobName = basePrefix ? `${basePrefix}.${nameWithoutExt}` : nameWithoutExt;
|
|
376
465
|
|
|
377
466
|
try {
|
|
@@ -381,6 +470,16 @@ class QueueManager extends EventEmitter {
|
|
|
381
470
|
|
|
382
471
|
if (typeof handler === 'function') {
|
|
383
472
|
this.define(jobName, handler, options);
|
|
473
|
+
|
|
474
|
+
const cronExpr = mod.cron || mod.schedule || (isCronFile ? (mod.expression || (CRON_ALIASES[`@${nameWithoutExt}`] ? `@${nameWithoutExt}` : null)) : null);
|
|
475
|
+
if (cronExpr) {
|
|
476
|
+
this.schedule(
|
|
477
|
+
cronExpr,
|
|
478
|
+
jobName,
|
|
479
|
+
mod.data || {},
|
|
480
|
+
{ ...options, ...(mod.scheduleOptions || {}) }
|
|
481
|
+
);
|
|
482
|
+
}
|
|
384
483
|
}
|
|
385
484
|
} catch (err) {
|
|
386
485
|
console.error(`[QueueManager] Failed to load job from ${fullPath}:`, err.message);
|
|
@@ -0,0 +1,415 @@
|
|
|
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
|
+
class TaskScheduler {
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('./manager').QueueManager} queue - QueueManager instance
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {boolean} [options.autoStart=true]
|
|
16
|
+
* @param {number} [options.maxIntervalMs=60000] - Maximum sleep interval in ms
|
|
17
|
+
*/
|
|
18
|
+
constructor(queue, options = {}) {
|
|
19
|
+
if (!queue) {
|
|
20
|
+
throw new Error('TaskScheduler requires a QueueManager instance');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.queue = queue;
|
|
24
|
+
this.maxIntervalMs = Math.max(100, options.maxIntervalMs || 60000);
|
|
25
|
+
this.isRunning = false;
|
|
26
|
+
this._timer = null;
|
|
27
|
+
|
|
28
|
+
/** @type {Map<string, Object>} */
|
|
29
|
+
this.schedules = new Map();
|
|
30
|
+
|
|
31
|
+
// Hook into queue events to track job lifecycle for overlap prevention
|
|
32
|
+
this._onJobCompleted = (job, result) => {
|
|
33
|
+
this._handleJobSettled(job, null, result);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this._onJobFailed = (job, err) => {
|
|
37
|
+
this._handleJobSettled(job, err, null);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
this.queue.on('job:completed', this._onJobCompleted);
|
|
41
|
+
this.queue.on('job:failed', this._onJobFailed);
|
|
42
|
+
|
|
43
|
+
if (options.autoStart !== false && queue.isRunning) {
|
|
44
|
+
this.start();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a new recurring scheduled task
|
|
50
|
+
* @param {string|CronExpression} cronExpression - Cron pattern or alias (e.g. '0 0 * * *', '@daily')
|
|
51
|
+
* @param {string} jobName - Name of the registered job in QueueManager
|
|
52
|
+
* @param {any} [data={}] - Payload passed to job on dispatch
|
|
53
|
+
* @param {Object} [options]
|
|
54
|
+
* @param {string} [options.id] - Optional custom schedule ID
|
|
55
|
+
* @param {boolean} [options.overlap=false] - Allow concurrent executions of the same schedule
|
|
56
|
+
* @param {boolean} [options.preventOverlap] - Inverted alias for overlap: false
|
|
57
|
+
* @param {number} [options.maxRuns] - Maximum number of runs before auto-unscheduling
|
|
58
|
+
* @param {boolean} [options.utc=false] - Evaluate schedule in UTC
|
|
59
|
+
* @param {string} [options.timezone] - IANA Timezone (e.g. 'Europe/Istanbul')
|
|
60
|
+
* @param {string} [options.tz] - Alias for timezone
|
|
61
|
+
* @param {boolean} [options.runImmediately=false] - Run immediately upon registration
|
|
62
|
+
* @returns {Object} Registered schedule object
|
|
63
|
+
*/
|
|
64
|
+
schedule(cronExpression, jobName, data = {}, options = {}) {
|
|
65
|
+
if (typeof jobName !== 'string' || !jobName.trim()) {
|
|
66
|
+
throw new Error('Job name must be a non-empty string');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const cron = cronExpression instanceof CronExpression
|
|
70
|
+
? cronExpression
|
|
71
|
+
: new CronExpression(cronExpression, {
|
|
72
|
+
utc: !!options.utc,
|
|
73
|
+
timezone: options.timezone || options.tz,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const id = options.id || `schedule:${jobName}:${cron.resolvedExpression}`;
|
|
77
|
+
|
|
78
|
+
const now = new Date();
|
|
79
|
+
const nextRunAt = cron.getNextDate(now);
|
|
80
|
+
|
|
81
|
+
const allowOverlap = options.overlap === true || (options.preventOverlap === false);
|
|
82
|
+
|
|
83
|
+
const scheduleObj = {
|
|
84
|
+
id,
|
|
85
|
+
name: jobName,
|
|
86
|
+
cronExpression: cron.rawExpression,
|
|
87
|
+
cron,
|
|
88
|
+
data,
|
|
89
|
+
options: {
|
|
90
|
+
overlap: allowOverlap,
|
|
91
|
+
preventOverlap: !allowOverlap,
|
|
92
|
+
maxRuns: typeof options.maxRuns === 'number' && options.maxRuns > 0 ? options.maxRuns : null,
|
|
93
|
+
runImmediately: !!options.runImmediately,
|
|
94
|
+
...options,
|
|
95
|
+
},
|
|
96
|
+
runsCount: 0,
|
|
97
|
+
lastRunAt: null,
|
|
98
|
+
nextRunAt,
|
|
99
|
+
isRunning: false,
|
|
100
|
+
activeJobId: null,
|
|
101
|
+
activeJobIds: new Set(),
|
|
102
|
+
registeredAt: now,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
this.schedules.set(id, scheduleObj);
|
|
106
|
+
this.queue.emit('schedule:registered', scheduleObj);
|
|
107
|
+
|
|
108
|
+
if (options.runImmediately) {
|
|
109
|
+
setImmediate(() => {
|
|
110
|
+
this.trigger(id).catch((err) => {
|
|
111
|
+
this.queue.emit('schedule:error', scheduleObj, err);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this._rescheduleTimer();
|
|
117
|
+
return scheduleObj;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Alias for schedule()
|
|
122
|
+
*/
|
|
123
|
+
cron(cronExpression, jobName, data, options) {
|
|
124
|
+
return this.schedule(cronExpression, jobName, data, options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Remove a scheduled task by ID or job name
|
|
129
|
+
* @param {string} idOrJobName
|
|
130
|
+
* @returns {boolean} True if removed, false if not found
|
|
131
|
+
*/
|
|
132
|
+
unschedule(idOrJobName) {
|
|
133
|
+
if (this.schedules.has(idOrJobName)) {
|
|
134
|
+
const scheduleObj = this.schedules.get(idOrJobName);
|
|
135
|
+
this.schedules.delete(idOrJobName);
|
|
136
|
+
this.queue.emit('schedule:unregistered', scheduleObj);
|
|
137
|
+
this._rescheduleTimer();
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toRemove = [];
|
|
142
|
+
for (const [id, s] of this.schedules.entries()) {
|
|
143
|
+
if (s.name === idOrJobName) {
|
|
144
|
+
toRemove.push({ id, s });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (toRemove.length === 0) return false;
|
|
149
|
+
|
|
150
|
+
for (const { id, s } of toRemove) {
|
|
151
|
+
this.schedules.delete(id);
|
|
152
|
+
this.queue.emit('schedule:unregistered', s);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this._rescheduleTimer();
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get all registered schedules
|
|
161
|
+
* @returns {Array<Object>}
|
|
162
|
+
*/
|
|
163
|
+
getSchedules() {
|
|
164
|
+
return Array.from(this.schedules.values()).map((s) => ({
|
|
165
|
+
id: s.id,
|
|
166
|
+
name: s.name,
|
|
167
|
+
cronExpression: s.cronExpression,
|
|
168
|
+
data: s.data,
|
|
169
|
+
options: s.options,
|
|
170
|
+
runsCount: s.runsCount,
|
|
171
|
+
lastRunAt: s.lastRunAt,
|
|
172
|
+
nextRunAt: s.nextRunAt,
|
|
173
|
+
isRunning: s.isRunning,
|
|
174
|
+
activeJobId: s.activeJobId,
|
|
175
|
+
activeJobIds: Array.from(s.activeJobIds || []),
|
|
176
|
+
registeredAt: s.registeredAt,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get single schedule by ID or job name
|
|
182
|
+
* @param {string} idOrJobName
|
|
183
|
+
* @returns {Object|null}
|
|
184
|
+
*/
|
|
185
|
+
getSchedule(idOrJobName) {
|
|
186
|
+
if (this.schedules.has(idOrJobName)) {
|
|
187
|
+
return this.schedules.get(idOrJobName);
|
|
188
|
+
}
|
|
189
|
+
for (const s of this.schedules.values()) {
|
|
190
|
+
if (s.name === idOrJobName) return s;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Manually trigger a schedule immediately
|
|
197
|
+
* @param {string} idOrJobName
|
|
198
|
+
* @returns {Promise<any>}
|
|
199
|
+
*/
|
|
200
|
+
async trigger(idOrJobName) {
|
|
201
|
+
const s = this.getSchedule(idOrJobName);
|
|
202
|
+
if (!s) {
|
|
203
|
+
throw new Error(`Schedule not found: '${idOrJobName}'`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const allowOverlap = s.options.overlap === true || s.options.preventOverlap === false;
|
|
207
|
+
if (!allowOverlap && s.isRunning) {
|
|
208
|
+
this.queue.emit('schedule:skipped', s, 'overlap');
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
s.isRunning = true;
|
|
213
|
+
s.lastRunAt = new Date();
|
|
214
|
+
s.runsCount++;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const job = await this.queue.dispatch(s.name, s.data, s.options);
|
|
218
|
+
s.activeJobId = job.id;
|
|
219
|
+
if (!s.activeJobIds) s.activeJobIds = new Set();
|
|
220
|
+
s.activeJobIds.add(job.id);
|
|
221
|
+
|
|
222
|
+
this.queue.emit('schedule:triggered', s, job);
|
|
223
|
+
|
|
224
|
+
if (s.options.maxRuns && s.runsCount >= s.options.maxRuns) {
|
|
225
|
+
this.unschedule(s.id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return job;
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (!s.activeJobIds || s.activeJobIds.size === 0) {
|
|
231
|
+
s.isRunning = false;
|
|
232
|
+
s.activeJobId = null;
|
|
233
|
+
}
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Start scheduler timer loop
|
|
240
|
+
*/
|
|
241
|
+
start() {
|
|
242
|
+
if (this.isRunning) return this;
|
|
243
|
+
this.isRunning = true;
|
|
244
|
+
this._rescheduleTimer();
|
|
245
|
+
this.queue.emit('schedule:start');
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Stop scheduler timer loop
|
|
251
|
+
*/
|
|
252
|
+
stop() {
|
|
253
|
+
this.isRunning = false;
|
|
254
|
+
if (this._timer) {
|
|
255
|
+
clearTimeout(this._timer);
|
|
256
|
+
this._timer = null;
|
|
257
|
+
}
|
|
258
|
+
this.queue.emit('schedule:stop');
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Pause alias for stop
|
|
264
|
+
*/
|
|
265
|
+
pause() {
|
|
266
|
+
return this.stop();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resume alias for start
|
|
271
|
+
*/
|
|
272
|
+
resume() {
|
|
273
|
+
return this.start();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Clear all schedules and stop timer
|
|
278
|
+
*/
|
|
279
|
+
clear() {
|
|
280
|
+
this.stop();
|
|
281
|
+
this.schedules.clear();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Cleanup event listeners
|
|
286
|
+
*/
|
|
287
|
+
destroy() {
|
|
288
|
+
this.stop();
|
|
289
|
+
this.schedules.clear();
|
|
290
|
+
this.queue.removeListener('job:completed', this._onJobCompleted);
|
|
291
|
+
this.queue.removeListener('job:failed', this._onJobFailed);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Internal job settled handler for overlap tracking
|
|
296
|
+
* @private
|
|
297
|
+
*/
|
|
298
|
+
_handleJobSettled(job, err, result) {
|
|
299
|
+
for (const s of this.schedules.values()) {
|
|
300
|
+
if (s.activeJobIds?.has(job.id) || s.activeJobId === job.id) {
|
|
301
|
+
if (s.activeJobIds) {
|
|
302
|
+
s.activeJobIds.delete(job.id);
|
|
303
|
+
}
|
|
304
|
+
if (!s.activeJobIds || s.activeJobIds.size === 0) {
|
|
305
|
+
s.isRunning = false;
|
|
306
|
+
s.activeJobId = null;
|
|
307
|
+
} else {
|
|
308
|
+
s.activeJobId = s.activeJobIds.values().next().value || null;
|
|
309
|
+
}
|
|
310
|
+
this.queue.emit('schedule:finished', s, job, err ? { error: err } : { result });
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Re-evaluates next timer tick according to earliest due schedule
|
|
318
|
+
* @private
|
|
319
|
+
*/
|
|
320
|
+
_rescheduleTimer() {
|
|
321
|
+
if (!this.isRunning) return;
|
|
322
|
+
|
|
323
|
+
if (this._timer) {
|
|
324
|
+
clearTimeout(this._timer);
|
|
325
|
+
this._timer = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this.schedules.size === 0) return;
|
|
329
|
+
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
let earliest = Infinity;
|
|
332
|
+
|
|
333
|
+
for (const s of this.schedules.values()) {
|
|
334
|
+
if (s.nextRunAt) {
|
|
335
|
+
const time = s.nextRunAt.getTime();
|
|
336
|
+
if (time < earliest) {
|
|
337
|
+
earliest = time;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (earliest === Infinity) return;
|
|
343
|
+
|
|
344
|
+
const delay = Math.max(0, Math.min(earliest - now, this.maxIntervalMs));
|
|
345
|
+
|
|
346
|
+
this._timer = setTimeout(() => this._tick(), delay);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Clock tick: evaluates due schedules and dispatches them
|
|
351
|
+
* @private
|
|
352
|
+
*/
|
|
353
|
+
async _tick() {
|
|
354
|
+
if (!this.isRunning) return;
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const now = new Date();
|
|
358
|
+
const nowTime = now.getTime();
|
|
359
|
+
|
|
360
|
+
const dueList = [];
|
|
361
|
+
for (const s of this.schedules.values()) {
|
|
362
|
+
if (s.nextRunAt && s.nextRunAt.getTime() <= nowTime) {
|
|
363
|
+
dueList.push(s);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const s of dueList) {
|
|
368
|
+
if (!this.isRunning) break;
|
|
369
|
+
|
|
370
|
+
// Advance next run date immediately to avoid double tick
|
|
371
|
+
s.nextRunAt = s.cron.getNextDate(now);
|
|
372
|
+
|
|
373
|
+
const allowOverlap = s.options.overlap === true || s.options.preventOverlap === false;
|
|
374
|
+
if (!allowOverlap && s.isRunning) {
|
|
375
|
+
this.queue.emit('schedule:skipped', s, 'overlap');
|
|
376
|
+
s.lastRunAt = now;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
s.isRunning = true;
|
|
381
|
+
s.lastRunAt = now;
|
|
382
|
+
s.runsCount++;
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const job = await this.queue.dispatch(s.name, s.data, s.options);
|
|
386
|
+
s.activeJobId = job.id;
|
|
387
|
+
if (!s.activeJobIds) s.activeJobIds = new Set();
|
|
388
|
+
s.activeJobIds.add(job.id);
|
|
389
|
+
|
|
390
|
+
this.queue.emit('schedule:triggered', s, job);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (!s.activeJobIds || s.activeJobIds.size === 0) {
|
|
393
|
+
s.isRunning = false;
|
|
394
|
+
s.activeJobId = null;
|
|
395
|
+
}
|
|
396
|
+
this.queue.emit('schedule:error', s, err);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Check maxRuns
|
|
400
|
+
if (s.options.maxRuns && s.runsCount >= s.options.maxRuns) {
|
|
401
|
+
this.unschedule(s.id);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
this.queue.emit('schedule:error', null, err);
|
|
406
|
+
} finally {
|
|
407
|
+
this._rescheduleTimer();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
TaskScheduler,
|
|
414
|
+
};
|
|
415
|
+
|
|
@@ -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
|
+
};
|