Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/runtime/bun-apis.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Click the link in the right column to jump to the associated documentation.
| Node-API | [`Node-API`](/runtime/node-api) |
| `import.meta` | [`import.meta`](/runtime/module-resolution#import-meta) |
| Utilities | [`Bun.version`](/runtime/utils#bun-version), [`Bun.revision`](/runtime/utils#bun-revision), [`Bun.env`](/runtime/utils#bun-env), [`Bun.main`](/runtime/utils#bun-main) |
| Sleep & Timing | [`Bun.sleep()`](/runtime/utils#bun-sleep), [`Bun.sleepSync()`](/runtime/utils#bun-sleepsync), [`Bun.nanoseconds()`](/runtime/utils#bun-nanoseconds) |
| Sleep & Timing | [`Bun.sleep()`](/runtime/utils#bun-sleep), [`Bun.sleepSync()`](/runtime/utils#bun-sleepsync), [`Bun.nanoseconds()`](/runtime/utils#bun-nanoseconds), [`Bun.ms()`](/runtime/utils#bun-ms) |
| Random & UUID | [`Bun.randomUUIDv7()`](/runtime/utils#bun-randomuuidv7) |
| System & Environment | [`Bun.which()`](/runtime/utils#bun-which) |
| Comparison & Inspection | [`Bun.peek()`](/runtime/utils#bun-peek), [`Bun.deepEquals()`](/runtime/utils#bun-deepequals), `Bun.deepMatch`, [`Bun.inspect()`](/runtime/utils#bun-inspect) |
Expand Down
31 changes: 31 additions & 0 deletions docs/runtime/utils.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,37 @@ Bun.nanoseconds();

---

## `Bun.ms()`

Convert between a duration string and a number of milliseconds, modeled on the [`ms`](https://www.npmjs.com/package/ms) package.

Pass a string to parse it into milliseconds:

```ts
Bun.ms("2 days"); // => 172800000
Bun.ms("1h"); // => 3600000
Bun.ms("-1.5h"); // => -5400000
Bun.ms("nonsense"); // => undefined
```

An unparseable (but non-empty) string returns `undefined`, so you can fall back on a default. Note that an empty string throws, so default the string itself rather than passing `""`:

```ts
const timeout = Bun.ms(process.env.TIMEOUT || "30s") ?? 30_000;
```

Pass a number to format it into a human-readable string. Use `{ long: true }` for the verbose form:

```ts
Bun.ms(60000); // => "1m"
Bun.ms(60000, { long: true }); // => "1 minute"
Bun.ms(172800000); // => "2d"
```

Supported units (case-insensitive): years (`y`), weeks (`w`), days (`d`), hours (`h`/`hr`), minutes (`m`/`min`), seconds (`s`/`sec`), and milliseconds (`ms`). Formatting emits days at most — never weeks or years.

---

## `Bun.readableStreamTo*()`

Bun implements a set of convenience functions for asynchronously consuming the body of a `ReadableStream` and converting it to various binary formats.
Expand Down
47 changes: 47 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4792,6 +4792,53 @@ declare module "bun" {
*/
function nanoseconds(): number;

interface MsOptions {
/**
* Use the long, human-friendly format when converting a number to a string
* (e.g. `"1 minute"` instead of `"1m"`).
*
* @default false
*/
long?: boolean;
}

/**
* Convert a time duration between a string and a number of milliseconds,
* matching the [`ms`](https://www.npmjs.com/package/ms) package.
*
* Returns `undefined` when the string can't be parsed (matching `ms`), so
* `Bun.ms(input) ?? fallback` works.
*
* @category Utilities
* @example
* ```ts
* Bun.ms("2 days"); // 172800000
* Bun.ms("1h"); // 3600000
* Bun.ms("-1.5h"); // -5400000
* Bun.ms("nonsense"); // undefined
* ```
* @param value A duration string such as `"2 days"`, `"1h"`, or `"30m"`.
* @returns The number of milliseconds, or `undefined` if the string is not a
* valid duration.
*/
function ms(value: string): number | undefined;
/**
* Convert a number of milliseconds to a human-readable duration string,
* matching the [`ms`](https://www.npmjs.com/package/ms) package.
*
* @category Utilities
* @example
* ```ts
* Bun.ms(60000); // "1m"
* Bun.ms(60000, { long: true }); // "1 minute"
* Bun.ms(172800000); // "2d"
* ```
* @param value A number of milliseconds.
* @param options Formatting options. Set `long: true` for the verbose form.
* @returns A compact (e.g. `"1m"`) or long (e.g. `"1 minute"`) duration string.
*/
function ms(value: number, options?: MsOptions): string;

/**
* Show precise statistics about memory usage of your application
*
Expand Down
76 changes: 76 additions & 0 deletions src/bun_core/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,82 @@ pub fn parse_ms(input: &[u8]) -> Option<f64> {
Some(value * multiplier)
}

/// `Math.round` with JavaScript's tie-breaking (round half toward +∞), so
/// `format_ms` matches the npm `ms` package exactly. Rust's `f64::round`
/// rounds half away from zero (`-2.5 → -3`), whereas JS rounds `-2.5 → -2`.
///
/// Returns an `f64` (like JS `Math.round`) so huge magnitudes don't saturate
/// an integer cast — the result is stringified with JS number semantics.
fn js_math_round(x: f64) -> f64 {
let ceil = x.ceil();
if (ceil - x) > 0.5 { ceil - 1.0 } else { ceil }
}

/// Append `number` to `out` using JavaScript `Number.prototype.toString`
/// semantics (WTF's dtoa, the same routine JSC uses), so e.g. `1.5` → `"1.5"`
/// and `1e30` → `"1e+30"` — matching what the npm `ms` package produces from
/// string concatenation.
fn write_js_number(out: &mut String, number: f64) {
let mut buf = [0u8; 124];
let bytes = FormatDouble::dtoa(&mut buf, number);
// SAFETY: WTF's dtoa only ever emits ASCII (`0-9`, `.`, `e`, `+`, `-`,
// `Infinity`, `NaN`), which is always valid UTF-8.
out.push_str(unsafe { core::str::from_utf8_unchecked(bytes) });
}

/// Format a number of milliseconds as a human-readable duration string,
/// matching the npm `ms` package. `long` selects the verbose form
/// (`"1 minute"` / `"2 minutes"`) over the compact form (`"1m"`).
///
/// Note: like the `ms` package, formatting only uses days/hours/minutes/
/// seconds/milliseconds — it never emits weeks or years (those are only
/// accepted when *parsing*). The result is written into `out` (cleared
/// first) to avoid an allocation in the common case.
pub fn format_ms(ms: f64, long: bool, out: &mut String) {
const MS_PER_S: f64 = crate::time::MS_PER_S as f64;
const MS_PER_MIN: f64 = 60.0 * MS_PER_S;
const MS_PER_HOUR: f64 = 60.0 * MS_PER_MIN;
const MS_PER_DAY: f64 = crate::time::MS_PER_DAY as f64;

out.clear();

let abs_ms = ms.abs();

// (threshold, short suffix, long singular unit) in descending order.
// Matches the `ms` package: the largest unit emitted is days.
const UNITS: [(f64, &str, &str); 4] = [
(MS_PER_DAY, "d", "day"),
(MS_PER_HOUR, "h", "hour"),
(MS_PER_MIN, "m", "minute"),
(MS_PER_S, "s", "second"),
];

for (unit_ms, short, long_unit) in UNITS {
if abs_ms >= unit_ms {
// The rounded count is kept as an `f64` and stringified with JS
// number semantics (`dtoa`), matching `Math.round(ms/unit) + unit`.
write_js_number(out, js_math_round(ms / unit_ms));
if long {
// The npm `ms` package pluralizes once the magnitude reaches
// 1.5× the unit.
out.push(' ');
out.push_str(long_unit);
if abs_ms >= unit_ms * 1.5 {
out.push('s');
}
} else {
out.push_str(short);
}
return;
}
}

// Sub-second: the npm `ms` package concatenates the raw number
// (`ms + 'ms'`), so `1.5` → "1.5ms" — not truncated.
write_js_number(out, ms);
out.push_str(if long { " ms" } else { "ms" });
}

// ───────────────────────────────────────────────────────────────────────────
// Latin-1 formatting
// ───────────────────────────────────────────────────────────────────────────
Expand Down
8 changes: 4 additions & 4 deletions src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1276,10 +1276,10 @@ pub use crate::fmt::{
buf_print, buf_print_infallible, buf_print_len, buf_print_z, buf_print_z_infallible, bytes,
bytes_to_hex_lower, bytes_to_hex_lower_string, count, count_float, count_int, digit_count,
digit_count_i64, digit_count_u64, double, fast_digit_count, fmt_os_path, fmt_path, fmt_path_u8,
fmt_path_u16, format_ip, format_latin1, format_utf16_type, hex_byte_lower, hex_byte_upper,
hex_char_lower, hex_char_upper, hex_digit_value, hex_lower, hex_pair_value, hex_u8, hex_u16,
hex_upper, hex2_lower, hex2_upper, hex4_lower, hex4_upper, int_as_bytes, parse_ascii,
parse_f32, parse_f64, parse_hex_prefix, parse_hex_to_int, parse_hex4,
fmt_path_u16, format_ip, format_latin1, format_ms, format_utf16_type, hex_byte_lower,
hex_byte_upper, hex_char_lower, hex_char_upper, hex_digit_value, hex_lower, hex_pair_value,
hex_u8, hex_u16, hex_upper, hex2_lower, hex2_upper, hex4_lower, hex4_upper, int_as_bytes,
parse_ascii, parse_f32, parse_f64, parse_hex_prefix, parse_hex_to_int, parse_hex4,
parse_int as parse_int_radix, parse_ms, parse_num, print_int, quote, raw, s, size, size_f64,
size_i64, truncated_hash32, truncated_hash32_bytes, utf16,
};
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/BunObject+exports.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
macro(jest) \
macro(listen) \
macro(mmap) \
macro(ms) \
macro(nanoseconds) \
macro(openInEditor) \
macro(registerMacro) \
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/BunObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
udpSocket BunObject_callback_udpSocket DontDelete|Function 1
main bunObjectMain DontDelete|CustomAccessor
mmap BunObject_callback_mmap DontDelete|Function 1
ms BunObject_callback_ms DontDelete|Function 1
nanoseconds functionBunNanoseconds DontDelete|Function 0
openInEditor BunObject_callback_openInEditor DontDelete|Function 1
origin BunObject_lazyPropCb_wrap_origin DontEnum|ReadOnly|DontDelete|PropertyCallback
Expand Down
68 changes: 68 additions & 0 deletions src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ pub mod bun_object {
BunObject_callback_jest => Jest::call,
BunObject_callback_listen => super::static_adapters::listener_listen,
BunObject_callback_mmap => super::mmap_file,
BunObject_callback_ms => super::ms,
BunObject_callback_nanoseconds => super::nanoseconds,
BunObject_callback_openInEditor => super::open_in_editor,
BunObject_callback_registerMacro => super::register_macro,
Expand Down Expand Up @@ -1557,6 +1558,73 @@ pub(crate) fn index_of_line(

pub use crate::crypto as crypto_mod;

/// `Bun.ms(value, options?)` — a drop-in for the npm `ms` package.
///
/// - `Bun.ms("2 days")` → `172800000` (parse a duration string to ms).
/// - `Bun.ms(60000)` → `"1m"` (format ms to a compact string).
/// - `Bun.ms(60000, { long: true })` → `"1 minute"` (verbose form).
#[bun_jsc::host_fn]
pub(crate) fn ms(global_this: &JSGlobalObject, callframe: &CallFrame) -> JsResult<JSValue> {
let arguments = callframe.arguments_old::<2>();
let args = arguments.slice();

let Some(&value) = args.first() else {
return Err(global_this.throw_type_error(format_args!(
"ms() expects a string like \"2 days\" or a number of milliseconds"
)));
};

// String input → parse to milliseconds. Use `is_string_literal` (primitive
// `string` only) rather than `is_string`, because the `ms` package checks
// `typeof val === "string"` — a boxed `new String(...)` is `"object"` and
// takes the throw path below.
if value.is_string_literal() {
let slice = value.to_slice(global_this)?;
// The `ms` package only calls `parse()` for non-empty strings; an empty
// string falls through to its `throw`.
if slice.slice().is_empty() {
return Err(global_this.throw_type_error(format_args!(
"ms(): value must be a non-empty string or a finite number"
)));
}
Comment thread
robobun marked this conversation as resolved.
// Matching `ms`: a non-empty string that doesn't parse returns
// `undefined` (its regex simply doesn't match), rather than throwing.
match bun_core::parse_ms(slice.slice()) {
Some(ms) => Ok(JSValue::js_number(ms)),
Comment thread
robobun marked this conversation as resolved.
None => Ok(JSValue::UNDEFINED),
}
} else if value.is_number() {
// Numeric input → format to a human-readable string. The `ms` package
// does a strict `typeof val === 'number' && isFinite(val)` check (no
// coercion), so only real finite numbers reach the formatter.
let num = value.as_number();
if !num.is_finite() {
return Err(global_this.throw_type_error(format_args!(
"ms(): value must be a finite number of milliseconds"
)));
}

let mut long = false;
if let Some(&opts) = args.get(1) {
if opts.is_object() {
if let Some(long_val) = opts.get_truthy(global_this, "long")? {
long = long_val.to_boolean();
}
}
}

let mut out = String::new();
bun_core::format_ms(num, long, &mut out);
bun_string_jsc::create_utf8_for_js(global_this, out.as_bytes())
} else {
// Matches the `ms` package, which throws on anything that is neither a
// non-empty string nor a finite number.
Err(global_this.throw_type_error(format_args!(
"ms(): value must be a string like \"2 days\" or a number of milliseconds"
)))
}
}

#[bun_jsc::host_fn]
pub(crate) fn nanoseconds(global_this: &JSGlobalObject, _: &CallFrame) -> JsResult<JSValue> {
// SAFETY: bun_vm() returns the live thread-local VM for a Bun-owned global.
Expand Down
Loading
Loading