UNPKG

blazerjob

Version:

TypeScript library for scheduling, executing, and managing asynchronous tasks (custom, HTTP, Cosmos) with a SQLite backend.

382 lines (381 loc) 16.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.BlazeJob = void 0; exports.stopServer = stopServer; const dotenv = __importStar(require("dotenv")); dotenv.config(); const fastify_1 = __importDefault(require("fastify")); // @ts-ignore const Database = require('better-sqlite3'); const node_fetch_1 = __importDefault(require("node-fetch")); // si node <18 const stargate_1 = require("@cosmjs/stargate"); const proto_signing_1 = require("@cosmjs/proto-signing"); const queries_1 = require("./cosmos/queries"); const queries_2 = require("./http/queries"); class BlazeJob { onAllTasksEnded(cb) { this.onAllTasksEndedCb = cb; } constructor(options) { // Map: taskId -> { runCount, startedAt, maxRuns, maxDurationMs } this.taskRunStats = new Map(); this.taskErrorCount = new Map(); this.periodicTaskCount = 0; // Map: taskId -> taskFn (en mémoire) this.taskFns = new Map(); this.db = new Database(options.dbPath); this.autoExit = !!options.autoExit; this.db.prepare(` CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, runAt TEXT, interval INTEGER, priority INTEGER, retriesLeft INTEGER, type TEXT, config TEXT, webhookUrl TEXT, status TEXT DEFAULT 'pending', executed_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, lastError TEXT ) `).run(); try { this.db.prepare('ALTER TABLE tasks ADD COLUMN lastError TEXT').run(); } catch (e) { // Ignore si déjà présent } try { this.db.prepare('ALTER TABLE tasks ADD COLUMN webhookUrl TEXT').run(); } catch (e) { // Ignore si déjà présent } } async start() { if (!this.timer) { this.timer = setInterval(() => this.tick(), 500); } } stop() { if (this.timer) { clearInterval(this.timer); this.timer = undefined; } } async tick() { const now = new Date().toISOString(); const selectStmt = this.db.prepare(` SELECT * FROM tasks WHERE runAt <= @now AND status = 'pending' ORDER BY priority DESC, runAt ASC `); const dueTasks = selectStmt.all({ now }); for (const task of dueTasks) { const stat = this.taskRunStats.get(task.id); if (task.type === 'cosmos' && task.config) { console.log('[BlazeJob] tâche Cosmos détectée', task); } // Mark as running this.db.prepare(`UPDATE tasks SET status = 'running' WHERE id = ?`).run(task.id); let taskFn = async () => { }; // Si la tâche a une fonction JS custom (stockée en mémoire), on l'utilise if (this.taskFns.has(task.id)) { taskFn = this.taskFns.get(task.id); } else if (task.type === 'cosmos') { const config = typeof task.config === 'string' ? JSON.parse(task.config) : task.config; console.log('[BlazeJob] Config Cosmos parsée pour la tâche', task.id, config); let configCopy = JSON.parse(JSON.stringify(config)); // clonage profond if (typeof configCopy === 'string') { configCopy = JSON.parse(configCopy); } taskFn = (0, queries_1.makeCosmosTaskFn)(configCopy); console.log('[BlazeJob] taskFn Cosmos construit pour la tâche', task.id); } else if (task.type === 'http' && task.config) { let config = task.config; // Correction : parser plusieurs fois si besoin for (let i = 0; i < 2; i++) { if (typeof config === 'string') { try { config = JSON.parse(config); } catch (e) { console.error('[BlazeJob][HTTP] Erreur de parsing config:', e, config); break; } } } console.log('[BlazeJob] Config HTTP finale pour la tâche', task.id, config, 'Type:', typeof config, 'Keys:', Object.keys(config)); taskFn = (0, queries_2.makeHttpTaskFn)(config); } else { console.log('[BlazeJob] Tâche Custom, type:', task.type, 'id:', task.id); // NE PAS réassigner taskFn ici pour laisser la fonction custom s'exécuter } console.log('[BlazeJob] Avant exécution de taskFn pour la tâche', task.id); await taskFn(); console.log('[BlazeJob] Après exécution de taskFn pour la tâche', task.id); // Après exécution de la tâche (succès ou erreur) if (stat) { stat.runCount++; // Vérifie si on a atteint maxRuns ou maxDuration if ((stat.maxRuns && stat.runCount >= stat.maxRuns) || (stat.maxDurationMs && Date.now() - stat.startedAt > stat.maxDurationMs)) { let shouldTerminate = true; } } if (typeof task.interval === 'number' && task.interval > 0 && (!stat?.maxRuns || stat.runCount < stat.maxRuns)) { // Replanifier la tâche périodique const nextRunAt = new Date(Date.now() + task.interval).toISOString(); this.db.prepare(`UPDATE tasks SET status = 'pending', runAt = ? WHERE id = ?`).run(nextRunAt, task.id); console.log(`[BlazeJob] Tâche ${task.id} reprogrammée pour ${nextRunAt} (runCount=${stat?.runCount})`); } else { // Tâche terminée (succès ou fin de retry) this.db.prepare(`UPDATE tasks SET status = 'success', executed_at = ? WHERE id = ?`).run(new Date().toISOString(), task.id); if (stat && stat.onEnd) stat.onEnd({ runCount: stat.runCount, errorCount: stat.errorCount || 0 }); this.taskRunStats.delete(task.id); this.periodicTaskCount--; // console.log('[BlazeJob][DEBUG] Après suppression, periodicTaskCount:', this.periodicTaskCount, 'taskRunStats:', Array.from(this.taskRunStats.keys())); if (this.periodicTaskCount === 0 && this.onAllTasksEndedCb) this.onAllTasksEndedCb(); if (this.periodicTaskCount === 0 && this.autoExit) { // console.log('[BlazeJob][DEBUG] Condition autoExit atteinte, arrêt dans 200ms'); setTimeout(() => { try { this.stop(); } catch (e) { console.error('[BlazeJob] Erreur lors de l\'arrêt du scheduler', e); } try { if (this.db && typeof this.db.close === 'function') this.db.close(); } catch (e) { console.error('[BlazeJob] Erreur lors de la fermeture de la base', e); } console.log('[BlazeJob] Toutes les tâches périodiques sont terminées. Arrêt automatique du process.'); process.exit(0); }, 200); } } } } static async sendWebhook(url, payload) { try { await (0, node_fetch_1.default)(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); } catch (e) { // Optionnel : log ou ignorer } } /** * Programme nativement plusieurs requêtes Cosmos d'un coup. * @param opts * - count: nombre de requêtes à programmer * - address: adresse Cosmos cible * - queryType: type de requête ('balance', 'tx', ...) * - intervalMs: intervalle entre chaque tâche (ms, défaut 100) * - configOverrides: options avancées (optionnel) * - retriesLeft, priority, runAt, webhookUrl (optionnel) */ async scheduleManyCosmosQueries(opts) { const { count, address, queryType, intervalMs = 100, configOverrides = {}, retriesLeft = 0, priority = 0, runAt, webhookUrl } = opts; for (let i = 0; i < count; i++) { const scheduledAt = runAt ? (runAt instanceof Date ? new Date(runAt.getTime() + i * intervalMs) : new Date(new Date(runAt).getTime() + i * intervalMs)) : new Date(Date.now() + i * intervalMs); this.schedule(async () => { }, { type: 'cosmos', runAt: scheduledAt, priority, retriesLeft, webhookUrl, config: { queryType, queryParams: { address }, ...configOverrides } }); } } /** * Schedule a new task and store its function in the taskMap. * @param taskFn The function to execute for this task * @param opts Task options: runAt, interval, priority, retriesLeft, type, config, webhookUrl * @returns The inserted task ID */ schedule(taskFn, options) { const { runAt, interval, priority, retriesLeft, type, config, webhookUrl, maxRuns, maxDurationMs, onEnd } = options; const stmt = this.db.prepare(` INSERT INTO tasks (runAt, interval, priority, retriesLeft, type, config, webhookUrl) VALUES (?, ?, ?, ?, ?, ?, ?) `); const result = stmt.run(runAt instanceof Date ? runAt.toISOString() : runAt, interval, priority, retriesLeft, type, config ? JSON.stringify(config) : null, webhookUrl); const taskId = result.lastInsertRowid; // Stocke la fonction JS en mémoire pour ce taskId this.taskFns.set(taskId, taskFn); this.taskRunStats.set(taskId, { runCount: 0, startedAt: Date.now(), maxRuns, maxDurationMs, onEnd, errorCount: 0 }); return taskId; } } exports.BlazeJob = BlazeJob; // Initialize Fastify server const app = (0, fastify_1.default)({ logger: true }); // Register form body parser for x-www-form-urlencoded (before declaring routes) // eslint-disable-next-line @typescript-eslint/no-var-requires app.register(require('@fastify/formbody')); // Initialize SQLite database const db = new Database('blazerjob.db'); const jobs = new BlazeJob({ dbPath: 'blazerjob.db' }); // GET /tasks: return all scheduled tasks app.get('/tasks', async (request, reply) => { const tasks = db.prepare('SELECT * FROM tasks').all(); reply.send(tasks); }); // POST /task: schedule a new task app.post('/task', async (request, reply) => { const { runAt, interval, priority, retriesLeft, type, config, webhookUrl, maxRuns, maxDurationMs, onEnd } = request.body ?? {}; let taskFn = async () => { }; if (type === 'cosmos' && config) { const cfg = JSON.parse(config); const rpcUrl = cfg.rpcUrl || process.env.COSMOS_RPC_URL; const mnemonic = cfg.mnemonic || process.env.COSMOS_MNEMONIC; if (!rpcUrl) throw new Error('No Cosmos rpcUrl (set in config or .env)'); if (cfg.to && cfg.amount && cfg.denom && mnemonic && cfg.chainId) { // Send tokens taskFn = async () => { const wallet = await proto_signing_1.DirectSecp256k1HdWallet.fromMnemonic(mnemonic, { prefix: 'cosmos' }); const [account] = await wallet.getAccounts(); const client = await stargate_1.SigningStargateClient.connectWithSigner(rpcUrl, wallet); const fee = { amount: (0, stargate_1.coins)(cfg.gas || '5000', cfg.denom), gas: cfg.gas || '200000', }; const result = await client.sendTokens(account.address, cfg.to, (0, stargate_1.coins)(cfg.amount, cfg.denom), fee, cfg.memo || ''); if (result.code !== 0) throw new Error(result.rawLog); return; }; } else if (cfg.queryType) { // Query taskFn = async () => { const client = await stargate_1.StargateClient.connect(rpcUrl); let _res; if (cfg.queryType === 'balance') { _res = await client.getAllBalances(cfg.queryParams?.address); console.log('[Cosmos][balance]', cfg.queryParams?.address, _res); } else if (cfg.queryType === 'tx') { _res = await client.getTx(cfg.queryParams?.hash); console.log('[Cosmos][tx]', cfg.queryParams?.hash, _res); } else { throw new Error('Unknown Cosmos queryType'); } console.log('[Cosmos][query result]', _res); return; }; } else { throw new Error('Invalid Cosmos config: must provide tx params or queryType'); } } else if (type === 'http' && config) { const cfg = JSON.parse(config); taskFn = (0, queries_2.makeHttpTaskFn)(cfg); } else { // Par défaut, tâche factice taskFn = async () => { console.log('Task executed:', { type, config }); }; } const taskId = jobs.schedule(taskFn, { runAt, interval, priority, retriesLeft, type, config, webhookUrl, maxRuns, maxDurationMs, onEnd }); reply.code(201).send({ id: taskId }); }); // DELETE /task/:id: delete a task by id app.delete('/task/:id', async (request, reply) => { const { id } = request.params; db.prepare('DELETE FROM tasks WHERE id = ?').run(id); reply.code(204).send(); }); // Méthode utilitaire pour arrêter proprement le serveur et le scheduler async function stopServer() { await app.close(); jobs.stop(); jobs['db'].close(); console.log('Serveur et scheduler arrêtés proprement.'); } // Optionnel : gestion du signal SIGTERM/SIGINT process.on('SIGTERM', async () => { await stopServer(); process.exit(0); }); process.on('SIGINT', async () => { await stopServer(); process.exit(0); }); if (require.main === module) { (async () => { await jobs.start(); await app.listen({ port: 9000 }); console.log('Fastify server running on http://localhost:9000'); })(); }