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.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -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 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$/, '');
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
+ };