UNPKG

partywhen

Version:

A library for scheduling and running tasks in Cloudflare Workers

340 lines (339 loc) 9.52 kB
import cronParser from "cron-parser"; import { Server } from "partyserver"; //#region src/index.ts var Scheduler = class extends Server { constructor(state, env) { super(state, env); this.ctx.blockConcurrencyWhile(async () => { this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, description TEXT, payload TEXT, callback TEXT, type TEXT NOT NULL CHECK(type IN ('scheduled', 'delayed', 'cron', 'no-schedule')), time INTEGER, delayInSeconds INTEGER, cron TEXT, created_at INTEGER DEFAULT (unixepoch()) ) `); await this.alarm(); }); } status() { return { status: "reachable", timestamp: Date.now(), diskUsage: this.ctx.storage.sql.databaseSize }; } async fetch(_request) { return new Response("Hello World!"); } getAllTasks() { return this.querySql([{ sql: "SELECT * FROM tasks" }]); } async scheduleNextAlarm() { const { result } = this.querySql([{ sql: ` SELECT time FROM tasks WHERE time > ? AND type != 'no-schedule' ORDER BY time ASC LIMIT 1 `, params: [Math.floor(Date.now() / 1e3)] }]); if (!result) return; if (result.length > 0 && "time" in result[0]) { const nextTime = result[0].time * 1e3; await this.ctx.storage.setAlarm(nextTime); } } async scheduleTask(task) { const { id } = task; console.log("scheduling task", task); if ("time" in task && task.time) { const timestamp = Math.floor(task.time.getTime() / 1e3); this.querySql([{ sql: ` INSERT OR REPLACE INTO tasks (id, description, payload, callback, type, time) VALUES (?, ?, ?, ?, 'scheduled', ?) `, params: [ id, task.description || null, JSON.stringify(task.payload || null), JSON.stringify(task.callback || null), timestamp ] }]); await this.scheduleNextAlarm(); return { id, description: task.description, payload: task.payload, callback: task.callback, time: task.time, type: "scheduled" }; } else if ("delayInSeconds" in task && task.delayInSeconds) { const time = new Date(Date.now() + task.delayInSeconds * 1e3); const timestamp = Math.floor(time.getTime() / 1e3); this.querySql([{ sql: ` INSERT OR REPLACE INTO tasks (id, description, payload, callback, type, delayInSeconds, time) VALUES (?, ?, ?, ?, 'delayed', ?, ?) `, params: [ id, task.description || null, JSON.stringify(task.payload || null), JSON.stringify(task.callback || null), task.delayInSeconds, timestamp ] }]); await this.scheduleNextAlarm(); return { id, description: task.description, payload: task.payload, callback: task.callback, delayInSeconds: task.delayInSeconds, time, type: "delayed" }; } else if ("cron" in task && task.cron) { const nextExecutionTime = this.getNextCronTime(task.cron); const timestamp = Math.floor(nextExecutionTime.getTime() / 1e3); this.querySql([{ sql: ` INSERT OR REPLACE INTO tasks (id, description, payload, callback, type, cron, time) VALUES (?, ?, ?, ?, 'cron', ?, ?) `, params: [ id, task.description || null, JSON.stringify(task.payload || null), JSON.stringify(task.callback || null), task.cron, timestamp ] }]); await this.scheduleNextAlarm(); return { id, description: task.description, payload: task.payload, callback: task.callback, cron: task.cron, time: nextExecutionTime, type: "cron" }; } else { const time = /* @__PURE__ */ new Date(864e13); const timestamp = Math.floor(time.getTime() / 1e3); this.querySql([{ sql: ` INSERT OR REPLACE INTO tasks (id, description, payload, callback, type, time) VALUES (?, ?, ?, ?, 'no-schedule', ?) `, params: [ id, task.description || null, JSON.stringify(task.payload || null), JSON.stringify(task.callback || null), timestamp ] }]); return { id, description: task.description, payload: task.payload, callback: task.callback, time, type: "no-schedule" }; } } async alarm() { const now = Math.floor(Date.now() / 1e3); const { result: tasks } = this.querySql([{ sql: "SELECT * FROM tasks WHERE time <= ?", params: [now] }]); for (const row of tasks || []) { const task = this.rowToTask(row); await this.executeTask(task); if (task.type === "cron") { const nextExecutionTime = this.getNextCronTime(task.cron); const nextTimestamp = Math.floor(nextExecutionTime.getTime() / 1e3); this.querySql([{ sql: "UPDATE tasks SET time = ? WHERE id = ?", params: [nextTimestamp, task.id] }]); } else this.querySql([{ sql: "DELETE FROM tasks WHERE id = ?", params: [task.id] }]); } await this.scheduleNextAlarm(); } rowToTask(row) { const base = { id: row.id, description: row.description, payload: row.payload ? JSON.parse(row.payload) : void 0, callback: row.callback ? JSON.parse(row.callback) : void 0 }; switch (row.type) { case "scheduled": return { ...base, time: /* @__PURE__ */ new Date(row.time * 1e3), type: "scheduled" }; case "delayed": return { ...base, delayInSeconds: row.delayInSeconds, time: /* @__PURE__ */ new Date(row.time * 1e3), type: "delayed" }; case "cron": return { ...base, cron: row.cron, time: /* @__PURE__ */ new Date(row.time * 1e3), type: "cron" }; case "no-schedule": return { ...base, time: /* @__PURE__ */ new Date(row.time * 1e3), type: "no-schedule" }; default: throw new Error(`Unknown task type: ${row.type}`); } } async executeTask(task) { console.log(`Executing task ${task.id}:`, task); if ("callback" in task && task.callback) { const { type } = task.callback; if (type === "webhook") { const response = await fetch(task.callback.url, { method: "POST", body: JSON.stringify(task), headers: { "Content-Type": "application/json" } }); if (!response.ok) { const text = await response.text(); throw new Error(`Webhook failed with status ${response.status} and body ${text}`); } const responseBody = await response.text(); console.log(`Webhook response body: ${responseBody}`); } else if (type === "durable-object") { const id = this.env[task.callback.namespace].idFromName(task.callback.name); this.env[task.callback.namespace].get(id)[task.callback.function](task).catch((e) => { console.error("Error executing durable object function:", e); }); } else if (type === "service") this.env[task.callback.service][task.callback.function](task); else if (type === "self") this[task.callback.function](task); else console.error("unknown callback type", task); } else console.error("missing callback", task); } getNextCronTime(cronExpression) { return cronParser.parse(cronExpression).next().toDate(); } async query(criteria = {}) { let query = "SELECT * FROM tasks WHERE 1=1"; const params = []; if (criteria.id) { query += " AND id = ?"; params.push(criteria.id); } if (criteria.description) { query += " AND description = ?"; params.push(criteria.description); } if (criteria.type) { query += " AND type = ?"; params.push(criteria.type); } if (criteria.timeRange) { query += " AND time >= ? AND time <= ?"; const start = criteria.timeRange.start || /* @__PURE__ */ new Date(0); const end = criteria.timeRange.end || /* @__PURE__ */ new Date(999999999999999); params.push(Math.floor(start.getTime() / 1e3), Math.floor(end.getTime() / 1e3)); } const { result } = this.querySql([{ sql: query, params }]); return result?.map((row) => this.rowToTask(row)) || []; } async cancelTask(id) { this.querySql([{ sql: "DELETE FROM tasks WHERE id = ?", params: [id] }]); await this.scheduleNextAlarm(); return true; } querySql(qs, isRaw = false) { try { if (!qs.length) throw new Error("No query found to run"); const queries = qs?.map((item) => { const { sql, params } = item; if (!sql?.trim()) throw new Error("Empty 'sql' field in transaction"); return { sql, params }; }) || []; let result; if (queries.length > 1) result = this.executeTransaction(queries, isRaw); else { const [query] = queries; result = this.executeQuery(query.sql, query.params, isRaw); } return { error: null, status: 200, result }; } catch (error) { return { result: null, error: error.message ?? "Operation failed.", status: 500 }; } } executeTransaction(queries, isRaw) { return this.ctx.storage.transactionSync(() => { const results = []; for (const queryObj of queries) { const { sql, params } = queryObj; const result = this.executeQuery(sql, params, isRaw); results.push(result); } return results; }); } executeQuery(sql, params, isRaw) { const cursor = params?.length ? this.ctx.storage.sql.exec(sql, ...params) : this.ctx.storage.sql.exec(sql); let result; if (isRaw) result = { columns: cursor.columnNames, rows: cursor.raw().toArray(), meta: { rows_read: cursor.rowsRead, rows_written: cursor.rowsWritten } }; else result = cursor.toArray(); return result; } }; //#endregion export { Scheduler }; //# sourceMappingURL=index.js.map