Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
# Build artifacts
/build
node_modules/
test_output.log
test_timegm
test_timegm.cc
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ if(WEBVIEW_BUILD)
if(WEBVIEW_BUILD_TESTS)
include(CTest)
add_subdirectory(test_driver)

add_executable(cron_test metascript/tests/cron_test.cc)
target_include_directories(cron_test PRIVATE core/include metascript)
add_test(NAME cron_test COMMAND cron_test)
endif()

add_subdirectory(core)
Expand Down
25 changes: 25 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 94 additions & 0 deletions examples/metascript_cron.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#include "../metascript/runtime.hh"
#include <iostream>

const char* html = R"html(
<!DOCTYPE html>
<html>
<head>
<title>MetaScript Cron Demo</title>
<style>
body { font-family: sans-serif; padding: 20px; }
pre { background: #f0f0f0; padding: 10px; }
.success { color: green; }
.error { color: red; }
</style>
</head>
<body>
<h1>MetaScript Cron Demo</h1>

<div>
<h3>Parse Cron Expression</h3>
<input type="text" id="expr" value="*/15 * * * *">
<button onclick="parseCron()">Parse</button>
<p id="parseResult"></p>
</div>

<hr>

<div>
<h3>Register Cron Job</h3>
<p>This will register a job that runs <code>worker.ts</code> every Monday at 2:30 AM.</p>
<button onclick="registerJob()">Register Job</button>
<button onclick="removeJob()">Remove Job</button>
<p id="registerResult"></p>
</div>

<script>
async function parseCron() {
const expr = document.getElementById('expr').value;
try {
const next = await Meta.cron.parse(expr);
document.getElementById('parseResult').textContent = 'Next: ' + (next ? next.toISOString() : 'null');
document.getElementById('parseResult').className = 'success';
} catch (e) {
document.getElementById('parseResult').textContent = 'Error: ' + e.message;
document.getElementById('parseResult').className = 'error';
}
}

async function registerJob() {
try {
await Meta.cron("./worker.ts", "30 2 * * MON", "weekly-report");
document.getElementById('registerResult').textContent = 'Job "weekly-report" registered!';
document.getElementById('registerResult').className = 'success';
} catch (e) {
document.getElementById('registerResult').textContent = 'Error: ' + e.message;
document.getElementById('registerResult').className = 'error';
}
}

async function removeJob() {
try {
await Meta.cron.remove("weekly-report");
document.getElementById('registerResult').textContent = 'Job "weekly-report" removed!';
document.getElementById('registerResult').className = 'success';
} catch (e) {
document.getElementById('registerResult').textContent = 'Error: ' + e.message;
document.getElementById('registerResult').className = 'error';
}
}
</script>
</body>
</html>
)html";

int main(int argc, char** argv) {
// Handle CLI for scheduled execution
int res = metascript::runtime::handle_cli(argc, argv);
if (res != -1) {
return res;
}

try {
metascript::runtime rt(true);
rt.set_title("MetaScript Cron Demo");
rt.set_size(600, 500, WEBVIEW_HINT_NONE);
rt.set_html(html);
rt.run();
} catch (const std::exception& e) {
std::cerr << "Fatal error: " << e.what() << std::endl;
return 1;
}

return 0;
}
8 changes: 8 additions & 0 deletions examples/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
scheduled(controller) {
console.log("Cron Job Fired!");
console.log("Cron Expression:", controller.cron);
console.log("Type:", controller.type);
console.log("Scheduled Time:", new Date(controller.scheduledTime).toISOString());
},
};
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log("Hello via Bun!");
232 changes: 232 additions & 0 deletions metascript/cron_parser.hh
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
#ifndef METASCRIPT_CRON_PARSER_HH
#define METASCRIPT_CRON_PARSER_HH

#include <algorithm>
#include <chrono>
#include <ctime>
#include <iomanip>
#include <iostream>
#include <map>
#include <set>
#include <sstream>
#include <string>
#include <vector>

#ifdef _WIN32
#define timegm _mkgmtime
#endif

namespace metascript {

class cron_parser {
public:
struct cron_fields {
std::set<int> minutes;
std::set<int> hours;
std::set<int> days_of_month;
std::set<int> months;
std::set<int> days_of_week;
bool dom_restricted = false;
bool dow_restricted = false;
};

static cron_fields parse_expression(const std::string &expression) {
std::string expr = expression;
if (expr.empty())
return {};

if (expr[0] == '@') {
if (expr == "@yearly" || expr == "@annually")
expr = "0 0 1 1 *";
else if (expr == "@monthly")
expr = "0 0 1 * *";
else if (expr == "@weekly")
expr = "0 0 * * 0";
else if (expr == "@daily" || expr == "@midnight")
expr = "0 0 * * *";
else if (expr == "@hourly")
expr = "0 * * * *";
}

std::vector<std::string> parts;
std::stringstream ss(expr);
std::string part;
while (ss >> part) {
parts.push_back(part);
}

if (parts.size() != 5) {
throw std::runtime_error("Invalid cron expression: must have 5 fields");
}

cron_fields fields;
fields.minutes = parse_field(parts[0], 0, 59);
fields.hours = parse_field(parts[1], 0, 23);
fields.days_of_month = parse_field(parts[2], 1, 31);
fields.months = parse_field(parts[3], 1, 12, month_names);
fields.days_of_week = parse_field(parts[4], 0, 7, day_names);

// Standard cron behavior: if both dom and dow are restricted, it's an OR
// match.
fields.dom_restricted = (parts[2] != "*");
fields.dow_restricted = (parts[4] != "*");

// Normalize days of week (0 and 7 are both Sunday)
if (fields.days_of_week.count(7)) {
fields.days_of_week.insert(0);
fields.days_of_week.erase(7);
}

return fields;
}

static std::time_t parse(const std::string &expression,
std::time_t relative_time = std::time(nullptr)) {
cron_fields fields = parse_expression(expression);
return next_occurrence(fields, relative_time);
}

static std::time_t next_occurrence(const cron_fields &fields,
std::time_t relative_time) {
// Start from the next minute
std::time_t current = relative_time + 60;
std::tm tm_buf;
std::tm *t = gmtime_portable(&current, &tm_buf);
t->tm_sec = 0;
current = timegm(t);

// Limit search to ~4 years
std::time_t end = relative_time + (4 * 366 * 24 * 60 * 60);

while (current < end) {
t = gmtime_portable(&current, &tm_buf);

if (fields.months.find(t->tm_mon + 1) == fields.months.end()) {
t->tm_mon++;
t->tm_mday = 1;
t->tm_hour = 0;
t->tm_min = 0;
current = timegm(t);
continue;
}

bool dom_match = fields.days_of_month.find(t->tm_mday) != fields.days_of_month.end();
bool dow_match = fields.days_of_week.find(t->tm_wday) != fields.days_of_week.end();

bool date_match = false;
if (fields.dom_restricted && fields.dow_restricted) {
date_match = dom_match || dow_match;
} else {
date_match = dom_match && dow_match;
}

if (!date_match) {
t->tm_mday++;
t->tm_hour = 0;
t->tm_min = 0;
current = timegm(t);
continue;
}

if (fields.hours.find(t->tm_hour) == fields.hours.end()) {
t->tm_hour++;
t->tm_min = 0;
current = timegm(t);
continue;
}

if (fields.minutes.find(t->tm_min) == fields.minutes.end()) {
t->tm_min++;
current = timegm(t);
continue;
}

return current;
}

return 0; // Not found
}

private:
static std::tm* gmtime_portable(const std::time_t* timep, std::tm* result) {
#ifdef _WIN32
gmtime_s(result, timep);
return result;
#else
return gmtime_r(timep, result);
#endif
}

private:
static inline std::map<std::string, int> month_names = {
{"JAN", 1}, {"FEB", 2}, {"MAR", 3}, {"APR", 4}, {"MAY", 5}, {"JUN", 6},
{"JUL", 7}, {"AUG", 8}, {"SEP", 9}, {"OCT", 10}, {"NOV", 11}, {"DEC", 12},
{"JANUARY", 1}, {"FEBRUARY", 2}, {"MARCH", 3}, {"APRIL", 4}, {"MAY", 5}, {"JUNE", 6},
{"JULY", 7}, {"AUGUST", 8}, {"SEPTEMBER", 9}, {"OCTOBER", 10}, {"NOVEMBER", 11}, {"DECEMBER", 12}
};

static inline std::map<std::string, int> day_names = {
{"SUN", 0}, {"MON", 1}, {"TUE", 2}, {"WED", 3}, {"THU", 4}, {"FRI", 5}, {"SAT", 6},
{"SUNDAY", 0}, {"MONDAY", 1}, {"TUESDAY", 2}, {"WEDNESDAY", 3}, {"THURSDAY", 4}, {"FRIDAY", 5}, {"SATURDAY", 6}
};

static std::set<int> parse_field(const std::string &field, int min_val,
int max_val,
const std::map<std::string, int> &names = {}) {
std::set<int> result;
std::stringstream ss(field);
std::string item;
while (std::getline(ss, item, ',')) {
if (item == "*") {
for (int i = min_val; i <= max_val; ++i)
result.insert(i);
} else {
size_t slash = item.find('/');
int step = 1;
std::string range_part = item;
if (slash != std::string::npos) {
step = std::stoi(item.substr(slash + 1));
range_part = item.substr(0, slash);
}

int start, end;
if (range_part == "*") {
start = min_val;
end = max_val;
} else {
size_t dash = range_part.find('-');
if (dash != std::string::npos) {
start = parse_value(range_part.substr(0, dash), names);
end = parse_value(range_part.substr(dash + 1), names);
} else {
start = end = parse_value(range_part, names);
}
}

for (int i = start; i <= end; i += step) {
result.insert(i);
}
}
}
return result;
}

static int parse_value(const std::string &val,
const std::map<std::string, int> &names) {
if (val.empty()) return 0;
if (std::isdigit(val[0]) || (val.size() > 1 && val[0] == '-')) {
return std::stoi(val);
}
std::string upper_val = val;
std::transform(upper_val.begin(), upper_val.end(), upper_val.begin(),
::toupper);
if (names.count(upper_val)) {
return names.at(upper_val);
}
throw std::runtime_error("Invalid value in cron expression: " + val);
}
};

} // namespace metascript

#endif // METASCRIPT_CRON_PARSER_HH
Loading