webspresso 0.0.97 → 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 +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- 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/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- 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/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- 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/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- 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 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
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 } = require('./cron');
|
|
14
|
+
const { TaskScheduler } = require('./scheduler');
|
|
13
15
|
|
|
14
16
|
class QueueManager extends EventEmitter {
|
|
15
17
|
/**
|
|
@@ -42,6 +44,48 @@ class QueueManager extends EventEmitter {
|
|
|
42
44
|
this.isRunning = false;
|
|
43
45
|
this._timer = null;
|
|
44
46
|
|
|
47
|
+
// Retention configuration
|
|
48
|
+
const retentionOption = options.retention;
|
|
49
|
+
const isRetentionDisabled =
|
|
50
|
+
retentionOption === false ||
|
|
51
|
+
(typeof retentionOption === 'object' && retentionOption.enabled === false) ||
|
|
52
|
+
options.retentionDays === null;
|
|
53
|
+
|
|
54
|
+
const autoCleanupEnabled =
|
|
55
|
+
!isRetentionDisabled &&
|
|
56
|
+
options.autoCleanup !== false &&
|
|
57
|
+
(typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false) &&
|
|
58
|
+
(retentionOption !== undefined || options.retentionDays !== undefined || options.cleanupIntervalMs !== undefined);
|
|
59
|
+
|
|
60
|
+
this.retentionConfig = {
|
|
61
|
+
enabled: !isRetentionDisabled && autoCleanupEnabled,
|
|
62
|
+
days: (typeof options.retentionDays === 'number' && options.retentionDays >= 0)
|
|
63
|
+
? options.retentionDays
|
|
64
|
+
: (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 7),
|
|
65
|
+
statuses: (typeof retentionOption === 'object' && retentionOption?.statuses)
|
|
66
|
+
? retentionOption.statuses
|
|
67
|
+
: (options.retentionStatuses || ['completed']),
|
|
68
|
+
intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
|
|
69
|
+
? options.cleanupIntervalMs
|
|
70
|
+
: (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
|
|
71
|
+
runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
|
|
72
|
+
? !!retentionOption.runOnStart
|
|
73
|
+
: (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : false),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this._cleanupInterval = null;
|
|
77
|
+
this._cleanupStartTimeout = null;
|
|
78
|
+
|
|
79
|
+
if (this.retentionConfig.enabled) {
|
|
80
|
+
this.startCleanup();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Initialize TaskScheduler for recurring cron tasks
|
|
84
|
+
this.scheduler = new TaskScheduler(this, {
|
|
85
|
+
autoStart: options.autoStart !== false,
|
|
86
|
+
...(options.scheduler || {}),
|
|
87
|
+
});
|
|
88
|
+
|
|
45
89
|
if (this.jobsDir && fs.existsSync(this.jobsDir)) {
|
|
46
90
|
this.loadJobsFromDirectory(this.jobsDir);
|
|
47
91
|
}
|
|
@@ -145,6 +189,9 @@ class QueueManager extends EventEmitter {
|
|
|
145
189
|
this.isPaused = false;
|
|
146
190
|
this._currentPollInterval = this.pollInterval;
|
|
147
191
|
this._scheduleNextTick(0);
|
|
192
|
+
if (this.scheduler) {
|
|
193
|
+
this.scheduler.start();
|
|
194
|
+
}
|
|
148
195
|
this.emit('start');
|
|
149
196
|
return this;
|
|
150
197
|
}
|
|
@@ -158,6 +205,9 @@ class QueueManager extends EventEmitter {
|
|
|
158
205
|
clearTimeout(this._timer);
|
|
159
206
|
this._timer = null;
|
|
160
207
|
}
|
|
208
|
+
if (this.scheduler) {
|
|
209
|
+
this.scheduler.stop();
|
|
210
|
+
}
|
|
161
211
|
this.emit('pause');
|
|
162
212
|
return this;
|
|
163
213
|
}
|
|
@@ -170,6 +220,9 @@ class QueueManager extends EventEmitter {
|
|
|
170
220
|
this.isPaused = false;
|
|
171
221
|
this._currentPollInterval = this.pollInterval;
|
|
172
222
|
this._scheduleNextTick(0);
|
|
223
|
+
if (this.scheduler) {
|
|
224
|
+
this.scheduler.start();
|
|
225
|
+
}
|
|
173
226
|
this.emit('resume');
|
|
174
227
|
return this;
|
|
175
228
|
}
|
|
@@ -182,6 +235,9 @@ class QueueManager extends EventEmitter {
|
|
|
182
235
|
async drain(timeoutMs = 10000) {
|
|
183
236
|
this.isDraining = true;
|
|
184
237
|
this.pause();
|
|
238
|
+
if (this.scheduler) {
|
|
239
|
+
this.scheduler.stop();
|
|
240
|
+
}
|
|
185
241
|
|
|
186
242
|
if (this.runningCount === 0) {
|
|
187
243
|
this.isDraining = false;
|
|
@@ -199,6 +255,7 @@ class QueueManager extends EventEmitter {
|
|
|
199
255
|
|
|
200
256
|
this.isDraining = false;
|
|
201
257
|
this.isRunning = false;
|
|
258
|
+
this.stopCleanup();
|
|
202
259
|
this.emit('drain');
|
|
203
260
|
}
|
|
204
261
|
|
|
@@ -208,9 +265,143 @@ class QueueManager extends EventEmitter {
|
|
|
208
265
|
async stop() {
|
|
209
266
|
this.pause();
|
|
210
267
|
this.isRunning = false;
|
|
268
|
+
if (this.scheduler) {
|
|
269
|
+
this.scheduler.stop();
|
|
270
|
+
}
|
|
271
|
+
this.stopCleanup();
|
|
211
272
|
this.emit('stop');
|
|
212
273
|
}
|
|
213
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
|
+
* Delete completed or failed jobs older than a cutoff date
|
|
322
|
+
* @param {Object} [options={}]
|
|
323
|
+
* @param {string|string[]} [options.status=['completed']]
|
|
324
|
+
* @param {Date|string|number} [options.olderThan]
|
|
325
|
+
* @param {number} [options.days=7]
|
|
326
|
+
* @param {number} [options.retentionDays]
|
|
327
|
+
* @param {number} [options.limit]
|
|
328
|
+
* @returns {Promise<number>}
|
|
329
|
+
*/
|
|
330
|
+
async clean(options = {}) {
|
|
331
|
+
if (this.adapter && typeof this.adapter.clean === 'function') {
|
|
332
|
+
return this.adapter.clean(options);
|
|
333
|
+
}
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Run one-time retention cleanup pass
|
|
339
|
+
* @returns {Promise<number>}
|
|
340
|
+
*/
|
|
341
|
+
async runCleanup() {
|
|
342
|
+
try {
|
|
343
|
+
return await this.clean({
|
|
344
|
+
status: this.retentionConfig.statuses,
|
|
345
|
+
days: this.retentionConfig.days,
|
|
346
|
+
});
|
|
347
|
+
} catch (err) {
|
|
348
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
349
|
+
console.warn('[QueueManager] Failed to run retention cleanup:', err.message);
|
|
350
|
+
}
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Start auto-retention cleanup timer
|
|
357
|
+
* @param {number} [customIntervalMs]
|
|
358
|
+
*/
|
|
359
|
+
startCleanup(customIntervalMs) {
|
|
360
|
+
this.stopCleanup();
|
|
361
|
+
if (!this.retentionConfig.enabled) return;
|
|
362
|
+
if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
|
|
363
|
+
this.retentionConfig.intervalMs = customIntervalMs;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (this.retentionConfig.runOnStart) {
|
|
367
|
+
const delayMs = process.env.NODE_ENV === 'test' ? 50 : 5000;
|
|
368
|
+
this._cleanupStartTimeout = setTimeout(() => {
|
|
369
|
+
this.runCleanup();
|
|
370
|
+
}, delayMs);
|
|
371
|
+
if (this._cleanupStartTimeout && this._cleanupStartTimeout.unref) {
|
|
372
|
+
this._cleanupStartTimeout.unref();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
this._cleanupInterval = setInterval(() => {
|
|
377
|
+
this.runCleanup();
|
|
378
|
+
}, this.retentionConfig.intervalMs);
|
|
379
|
+
if (this._cleanupInterval && this._cleanupInterval.unref) {
|
|
380
|
+
this._cleanupInterval.unref();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Stop auto-retention cleanup timer
|
|
386
|
+
*/
|
|
387
|
+
stopCleanup() {
|
|
388
|
+
if (this._cleanupStartTimeout) {
|
|
389
|
+
clearTimeout(this._cleanupStartTimeout);
|
|
390
|
+
this._cleanupStartTimeout = null;
|
|
391
|
+
}
|
|
392
|
+
if (this._cleanupInterval) {
|
|
393
|
+
clearInterval(this._cleanupInterval);
|
|
394
|
+
this._cleanupInterval = null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Get retention configuration
|
|
400
|
+
*/
|
|
401
|
+
getRetentionConfig() {
|
|
402
|
+
return { ...this.retentionConfig };
|
|
403
|
+
}
|
|
404
|
+
|
|
214
405
|
/**
|
|
215
406
|
* Get queue statistics
|
|
216
407
|
*/
|
|
@@ -248,7 +439,10 @@ class QueueManager extends EventEmitter {
|
|
|
248
439
|
if (entry.isDirectory()) {
|
|
249
440
|
readRecursive(fullPath, basePrefix ? `${basePrefix}.${entry.name}` : entry.name);
|
|
250
441
|
} else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.cjs'))) {
|
|
251
|
-
const
|
|
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$/, '');
|
|
252
446
|
const jobName = basePrefix ? `${basePrefix}.${nameWithoutExt}` : nameWithoutExt;
|
|
253
447
|
|
|
254
448
|
try {
|
|
@@ -258,6 +452,16 @@ class QueueManager extends EventEmitter {
|
|
|
258
452
|
|
|
259
453
|
if (typeof handler === 'function') {
|
|
260
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
|
+
}
|
|
261
465
|
}
|
|
262
466
|
} catch (err) {
|
|
263
467
|
console.error(`[QueueManager] Failed to load job from ${fullPath}:`, err.message);
|
|
@@ -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
|
+
};
|