Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
35 changes: 25 additions & 10 deletions docs/runtime/cron.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Bun.cron("0 * * * *", async () => {
**Parse a cron expression to find the next matching time:**

```ts
// Next weekday at 9:30 AM UTC
// Next weekday at 9:30 AM local time
const next = Bun.cron.parse("30 9 * * MON-FRI");
```

Expand All @@ -32,7 +32,7 @@ await Bun.cron("./worker.ts", "30 2 * * MON", "weekly-report");

## `Bun.cron.parse()`

Parse a cron expression and return the next matching `Date` in UTC.
Parse a cron expression and return the next matching `Date` in the system's local time zone.

```ts
const next = Bun.cron.parse("*/15 * * * *");
Expand All @@ -41,10 +41,11 @@ console.log(next); // => next quarter-hour boundary

### Parameters

| Parameter | Type | Description |
| -------------- | ---------------- | -------------------------------------------------------- |
| `expression` | `string` | A 5-field cron expression or predefined nickname |
| `relativeDate` | `Date \| number` | Starting point for the search (defaults to `Date.now()`) |
| Parameter | Type | Description |
| -------------- | ----------------- | ------------------------------------------------------------------------------ |
| `expression` | `string` | A 5-field cron expression or predefined nickname |
| `relativeDate` | `Date \| number` | Starting point for the search (defaults to `Date.now()`) |
| `options` | `{ tz?: string }` | IANA time-zone name to interpret the schedule in (defaults to the system zone) |

### Returns

Expand Down Expand Up @@ -113,14 +114,27 @@ Both `0` and `7` mean Sunday in the weekday field.

```ts
const next = Bun.cron.parse("@daily");
console.log(next); // => next UTC midnight
console.log(next); // => next local midnight
```

### Time zone

`Bun.cron.parse()` and the in-process `Bun.cron(schedule, handler)` interpret schedules in **UTC**. There is no DST to handle — `0 9 * * *` always means 9:00 UTC.
Schedules are interpreted in the system's **local time zone** — the same way crontab, launchd, and Windows Task Scheduler read them. The OS-level form and the in-process callback form fire at the same wall-clock time.

The OS-level `Bun.cron(path, schedule, title)` uses the **system's local time zone**, because that's how crontab, launchd, and Windows Task Scheduler work. To make the two forms agree, run the process with `TZ=UTC`.
To override, pass an IANA time-zone name as `{ tz }` to `Bun.cron.parse()` or the in-process `Bun.cron(schedule, handler, options)`:

```ts
// 09:00 UTC, regardless of the server's TZ
Bun.cron.parse("0 9 * * *", Date.now(), { tz: "UTC" });

// Fire at 09:00 New York time
Bun.cron("0 9 * * *", handler, { tz: "America/New_York" });
```

DST transitions:

- **Spring-forward** — a schedule that lands in the missing hour fires that day, shifted forward by the gap (e.g. `30 2 * * *` runs at 3:30 on the spring-forward day). For multi-minute patterns inside the gap (`*/15 2 * * *`), only the first match fires.
- **Fall-back** — a fixed-time schedule in the duplicated hour (`30 1 * * *`) fires once, at the first occurrence. A schedule whose minute or hour field is `*` (`0 * * * *`, `* * * * *`) fires through **both** occurrences — once per real-time minute, matching crontab on Linux.

### Day-of-month and day-of-week interaction

Expand Down Expand Up @@ -161,8 +175,9 @@ In-process scheduling is the lightweight option for long-running servers and wor
| ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schedule` | `string` | A [cron expression](#cron-expression-syntax) or nickname like `"@hourly"`. |
| `handler` | `(this: CronJob) => unknown` | Called on each fire. May return a Promise — the next fire is not scheduled until it settles. Inside a `function` callback, `this` is the `CronJob` (so `this.stop()` works). |
| `options` | `{ tz?: string }` | IANA time-zone name to interpret the schedule in (defaults to the system zone). |

Returns a [`CronJob`](#the-cronjob-handle) synchronously. Throws a `TypeError` if the expression is invalid or has no future occurrences, like `"0 0 30 2 *"` (February 30th).
Returns a [`CronJob`](#the-cronjob-handle) synchronously. Throws a `TypeError` if the expression is invalid, the time-zone name is unknown, or the expression has no future occurrences, like `"0 0 30 2 *"` (February 30th).

### No-overlap guarantee

Expand Down
37 changes: 29 additions & 8 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7606,6 +7606,17 @@ declare module "bun" {
unref(): CronJob;
}

/**
* Options for the in-process {@link Bun.cron} callback overload and {@link Bun.cron.parse}.
*/
interface CronOptions {
/**
* IANA time-zone name to interpret the schedule in (e.g. `"UTC"`,
* `"America/New_York"`). Defaults to the system's local time zone.
*/
tz?: string;
}
Comment thread
robobun marked this conversation as resolved.
Resolved

/**
* Schedule cron jobs.
*
Expand Down Expand Up @@ -7647,8 +7658,7 @@ declare module "bun" {
*
* ### Cron expression syntax
*
* Five fields: `minute hour day-of-month month day-of-week`. Schedules are
* interpreted in **UTC** — `0 9 * * *` fires at 9:00 UTC, regardless of `TZ`.
* Five fields: `minute hour day-of-month month day-of-week`.
*
* | Field | Values | Special chars |
* |-------|--------|---------------|
Expand Down Expand Up @@ -7702,7 +7712,7 @@ declare module "bun" {
* @see {@link CronJob} for the returned handle.
* @see {@link Bun.cron.parse} to preview the next fire time.
*/
(schedule: CronWithAutocomplete, handler: (this: CronJob) => unknown): CronJob;
(schedule: CronWithAutocomplete, handler: (this: CronJob) => unknown, options?: CronOptions): CronJob;
/**
* Register an **OS-level** cron job that runs a JavaScript/TypeScript module on a schedule.
*
Expand Down Expand Up @@ -7802,7 +7812,10 @@ declare module "bun" {
*/
remove(title: string): Promise<void>;
/**
* Parse a cron expression and return the next matching `Date` in UTC.
* Parse a cron expression and return the next matching `Date` in the
* system's local time zone — the same way crontab, launchd, and Windows
* Task Scheduler interpret schedules. Pass `{ tz: "UTC" }` (or any IANA
* time-zone name) to override.
*
* Supports the same syntax as {@link Bun.cron} — 5-field expressions, named
* days/months, and predefined nicknames like `@daily`.
Expand All @@ -7811,23 +7824,31 @@ declare module "bun" {
* matching uses OR logic per [POSIX cron](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html):
* a date matches if **either** field matches.
*
* DST: spring-forward times shift forward by the gap; in the fall-back
* duplicated hour, fixed-time schedules fire once (first occurrence) while
* schedules with `*` minute or hour fire through both occurrences.
*
* @param expression - A cron expression or nickname (e.g. `"0,15,30,45 * * * *"`, `"0 9 * * MON-FRI"`, `"@hourly"`)
* @param relativeDate - Starting point for the search (defaults to `Date.now()`). Accepts a `Date` or milliseconds since epoch.
* @returns The next `Date` matching the expression in UTC, or `null` if no match exists within 8 years (e.g. `"0 0 30 2 *"` — Feb 30 never occurs)
* @throws If the expression is invalid or `relativeDate` is `NaN`/`Infinity`
* @param options - `{ tz?: string }` — IANA time-zone name to interpret the schedule in (defaults to the system's local zone).
* @returns The next `Date` matching the expression, or `null` if no match exists within 8 years (e.g. `"0 0 30 2 *"` — Feb 30 never occurs)
* @throws If the expression is invalid, `relativeDate` is `NaN`/`Infinity`, or `options.tz` is not a valid IANA name
*
* @example
* ```ts
* // Next weekday at 09:30 UTC
* // Next weekday at 09:30 local time
* const next = Bun.cron.parse("30 9 * * MON-FRI");
*
* // 09:00 in New York, regardless of the server's TZ
* const ny = Bun.cron.parse("0 9 * * *", Date.now(), { tz: "America/New_York" });
*
* // Chain calls to get a sequence
* const from = new Date();
* const first = Bun.cron.parse("@hourly", from);
* const second = first ? Bun.cron.parse("@hourly", first) : null;
* ```
*/
parse(expression: CronWithAutocomplete, relativeDate?: Date | number): Date | null;
parse(expression: CronWithAutocomplete, relativeDate?: Date | number, options?: CronOptions): Date | null;
};

/** Utility type for any process from {@link Bun.spawn()} with both stdout and stderr set to `"pipe"` */
Expand Down
121 changes: 118 additions & 3 deletions src/jsc/JSGlobalObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ impl JSGlobalObject {
JSValue::ZERO
}

pub fn gregorian_date_time_to_ms_utc(
#[allow(clippy::too_many_arguments)]
fn gregorian_date_time_to_ms_impl(
&self,
year: i32,
month: i32,
Expand All @@ -130,10 +131,33 @@ impl JSGlobalObject {
minute: i32,
second: i32,
millisecond: i32,
local: bool,
) -> JsResult<f64> {
crate::mark_binding();
crate::cpp::Bun__gregorianDateTimeToMS(
self,
year,
month,
day,
hour,
minute,
second,
millisecond,
local,
)
}

pub fn gregorian_date_time_to_ms_utc(
&self,
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: i32,
millisecond: i32,
) -> JsResult<f64> {
self.gregorian_date_time_to_ms_impl(
year,
month,
day,
Expand All @@ -145,7 +169,29 @@ impl JSGlobalObject {
)
}

pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime {
pub fn gregorian_date_time_to_ms(
&self,
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: i32,
millisecond: i32,
) -> JsResult<f64> {
self.gregorian_date_time_to_ms_impl(
year,
month,
day,
hour,
minute,
second,
millisecond,
true,
)
}

fn ms_to_gregorian_date_time_impl(&self, ms: f64, local: bool) -> GregorianDateTime {
crate::mark_binding();
let mut dt = GregorianDateTime::default();
// SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live
Expand All @@ -154,7 +200,7 @@ impl JSGlobalObject {
crate::cpp::raw::Bun__msToGregorianDateTime(
self.as_ptr(),
ms,
false,
local,
&raw mut dt.year,
&raw mut dt.month,
&raw mut dt.day,
Expand All @@ -167,6 +213,75 @@ impl JSGlobalObject {
dt
}

pub fn ms_to_gregorian_date_time_utc(&self, ms: f64) -> GregorianDateTime {
self.ms_to_gregorian_date_time_impl(ms, false)
}

pub fn ms_to_gregorian_date_time(&self, ms: f64) -> GregorianDateTime {
self.ms_to_gregorian_date_time_impl(ms, true)
}

pub fn ms_to_gregorian_date_time_in_zone(&self, ms: f64, tz_id: u32) -> GregorianDateTime {
crate::mark_binding();
let mut dt = GregorianDateTime::default();
// SAFETY: FFI — &self is a valid JSGlobalObject*; out-param pointers are to live
// stack locals (`dt` fields) and remain valid for the duration of the call.
unsafe {
crate::cpp::raw::Bun__msToGregorianDateTimeInZone(
self.as_ptr(),
ms,
tz_id,
&raw mut dt.year,
&raw mut dt.month,
&raw mut dt.day,
&raw mut dt.hour,
&raw mut dt.minute,
&raw mut dt.second,
&raw mut dt.weekday,
);
}
dt
}

#[allow(clippy::too_many_arguments)]
pub fn gregorian_date_time_to_ms_in_zone(
&self,
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: i32,
millisecond: i32,
tz_id: u32,
) -> f64 {
crate::mark_binding();
// SAFETY: FFI — &self is a valid JSGlobalObject*.
unsafe {
crate::cpp::raw::Bun__gregorianDateTimeToMSInZone(
self.as_ptr(),
year,
month,
day,
hour,
minute,
second,
millisecond,
tz_id,
)
}
}

/// Resolve an IANA time-zone name to a `TimeZoneID` suitable for
/// `*_in_zone`. Returns `None` if the name is not recognised.
pub fn resolve_time_zone_id(name: &[u8]) -> Option<u32> {
crate::mark_binding();
// SAFETY: FFI — `name` is a valid byte slice; len matches. The C++ side reads
// only `len` bytes and does not retain the pointer.
let id = unsafe { crate::cpp::raw::Bun__resolveTimeZoneID(name.as_ptr(), name.len()) };
if id == u32::MAX { None } else { Some(id) }
}

pub fn throw_todo(&self, msg: &[u8]) -> JsError {
let err = self.create_error_instance(format_args!("{}", bstr::BStr::new(msg)));
if err.is_empty() {
Expand Down
45 changes: 45 additions & 0 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@
#include "wtf/text/StringView.h"
#include "wtf/text/WTFString.h"
#include "wtf/GregorianDateTime.h"
#include "JavaScriptCore/IntlObject.h"
#include "JavaScriptCore/ISO8601.h"
#include "JavaScriptCore/JSCTimeZone.h"
#include "JavaScriptCore/TemporalCoreTypes.h"
#include "JavaScriptCore/TemporalEnums.h"
#include "JavaScriptCore/TimeZoneICUBridge.h"

#include "JavaScriptCore/FunctionPrototype.h"
#include "JSFetchHeaders.h"
Expand Down Expand Up @@ -5728,6 +5734,45 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__msToGregorianDateTime(JSC::JSGlobal
*weekday = dt.weekDay();
}

extern "C" [[ZIG_EXPORT(nothrow)]] uint32_t Bun__resolveTimeZoneID(const uint8_t* name, size_t len)
{
auto id = JSC::intlResolveTimeZoneID(StringView { std::span(reinterpret_cast<const Latin1Character*>(name), len) });
return id ? *id : std::numeric_limits<uint32_t>::max();
}
Comment thread
robobun marked this conversation as resolved.
Resolved

extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__msToGregorianDateTimeInZone(JSC::JSGlobalObject* globalObject, double ms, uint32_t tzID,
int* year, int* month, int* day, int* hour, int* minute, int* second, int* weekday)
{
UNUSED_PARAM(globalObject);
auto tz = JSC::TimeZone::fromID(tzID);
auto exact = JSC::ISO8601::ExactTime::fromEpochMilliseconds(static_cast<int64_t>(ms));
auto off = JSC::TemporalCore::getOffsetNanosecondsFor(tz, exact);
int64_t offNs = off ? *off : 0;
auto dt = JSC::TemporalCore::exactTimeToLocalDateAndTime(exact, offNs);
*year = dt.date.year();
*month = dt.date.month();
*day = dt.date.day();
*hour = static_cast<int>(dt.time.hour());
*minute = static_cast<int>(dt.time.minute());
*second = static_cast<int>(dt.time.second());
// ISO8601::dayOfWeek: 1=Mon..7=Sun; GregorianDateTime consumers expect 0=Sun..6=Sat.
*weekday = JSC::ISO8601::dayOfWeek(dt.date) % 7;
}

extern "C" [[ZIG_EXPORT(nothrow)]] double Bun__gregorianDateTimeToMSInZone(JSC::JSGlobalObject* globalObject,
int year, int month, int day, int hour, int minute, int second, int millisecond, uint32_t tzID)
{
UNUSED_PARAM(globalObject);
auto tz = JSC::TimeZone::fromID(tzID);
JSC::ISO8601::PlainDate date { year, static_cast<unsigned>(month), static_cast<unsigned>(day) };
JSC::ISO8601::PlainTime time { static_cast<unsigned>(hour), static_cast<unsigned>(minute),
static_cast<unsigned>(second), static_cast<unsigned>(millisecond), 0, 0 };
auto r = JSC::TemporalCore::getEpochNanosecondsFor(tz, date, time, JSC::TemporalDisambiguation::Compatible);
if (!r)
return std::numeric_limits<double>::quiet_NaN();
return static_cast<double>(r->epochMilliseconds());
}

extern "C" EncodedJSValue JSC__JSValue__dateInstanceFromNumber(JSC::JSGlobalObject* globalObject, double unixTimestamp)
{
auto& vm = JSC::getVM(globalObject);
Expand Down
Loading
Loading