> ## Content Index
> Fetch the complete content index at: https://sudosecurity.org/llms.txt
> Use this file to discover other available public pages before exploring further.

# Bun v1.3.11: Cron Jobs, ANSI Slicing, Markdown Rendering, and What Changed Under the Hood
- URL: https://sudosecurity.org/bun-v1-3-11-released/
- Published: 2026-03-19T01:02:00.000Z
- Updated: 2026-10-16T05:46:00.000Z
- Description: A production deep dive into Bun v1.3.11 — covering Bun.cron for OS-level scheduling, Bun.sliceAnsi for terminal-aware string slicing, enriched Markdown rendering callbacks, path-ignore patterns for test discovery, and the dgram UDP fixes that unblocked real-world libraries on macOS.
- Author: Traven
- Tags: Web Development, Bun, Backend Development, Runtimes, TypeScript, Node.js, News

Bun v1.3.11 shipped on March 18, 2026, and it is not a release you skip. The headline items are `Bun.cron` — a built-in OS-level cron scheduler that works the same way on Linux, macOS, and Windows — and `Bun.sliceAnsi`, a native replacement for two of the most depended-upon terminal string packages in the npm ecosystem. Below those is a deep list of Node.js compatibility fixes, dgram UDP correctness fixes on macOS that unblocked DHT libraries, and richer metadata for `Bun.markdown.render()` callbacks.

This article walks through every significant change with production-grade code and a full technical explanation of what each piece does and why it matters.

## `Bun.cron`: OS-Level Cron Scheduling

Before this release, scheduling recurring background work in a Bun application meant one of three things: reaching for a Node.js library like `node-cron` or `croner`, shelling out to `crontab` yourself, or running a separate process. `Bun.cron` eliminates all three. It registers jobs directly with the operating system scheduler — `crontab` on Linux, `launchd` on macOS, Task Scheduler on Windows — using a single, cross-platform API.

### Registering a Job

```typescript
import path from "path";

const WORKER_PATH = path.resolve(import.meta.dir, "workers", "report.ts");

async function registerScheduledJobs(): Promise<void> {
  await Bun.cron(WORKER_PATH, "30 2 * * MON", "weekly-report");
  await Bun.cron(WORKER_PATH, "0 * * * *", "hourly-health-check");
  await Bun.cron(WORKER_PATH, "@daily", "daily-cleanup");
}

registerScheduledJobs().catch((err) => {
  console.error("Failed to register cron jobs:", err);
  process.exit(1);
});

```

`Bun.cron` takes three arguments: the path to the script to execute, the cron expression defining the schedule, and a unique job title. The title is the job's identity in the OS scheduler. Calling `Bun.cron` a second time with the same title overwrites the existing job in place, which means you can safely re-run your startup script after a deploy without creating duplicate entries.

The path must be absolute or resolvable from the current working directory. Using `import.meta.dir` anchors the path to the source file's location, which is the right pattern in production where the process may run from a different working directory than your source tree.

The function is `async` and returns a Promise that resolves when the OS scheduler confirms registration. On Linux this means `crontab` has been updated. On macOS it means a `launchd` plist has been written and loaded. On Windows it means a Task Scheduler entry exists. Errors surface as rejections — if `crontab` is unavailable or the user lacks scheduling permissions, the Promise rejects with an actionable error message rather than silently failing.

### The Worker Script

```typescript
import { Pool } from "pg";
import { createLogger } from "winston";

const logger = createLogger({ level: "info" });

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  idleTimeoutMillis: 10_000,
});

interface ScheduledController {
  cron: string;
  scheduledTime: number;
}

async function runWeeklyReport(scheduledTime: number): Promise<void> {
  const week = new Date(scheduledTime);

  const result = await pool.query<{ count: number; revenue: number }>(
    `SELECT
       COUNT(*)::int AS count,
       COALESCE(SUM(amount), 0)::numeric AS revenue
     FROM orders
     WHERE created_at >= date_trunc('week', $1::timestamptz) - INTERVAL '7 days'
       AND created_at <  date_trunc('week', $1::timestamptz)
       AND status = 'completed'`,
    [week],
  );

  logger.info({
    week: week.toISOString(),
    orders: result.rows[0].count,
    revenue: result.rows[0].revenue,
  }, "Weekly report generated");
}

async function runHourlyHealthCheck(_scheduledTime: number): Promise<void> {
  const client = await pool.connect();
  try {
    await client.query("SELECT 1");
    logger.info("Health check passed");
  } finally {
    client.release();
  }
}

async function runDailyCleanup(_scheduledTime: number): Promise<void> {
  await pool.query(
    `DELETE FROM sessions
     WHERE expires_at < NOW() - INTERVAL '1 day'`,
  );
  logger.info("Expired sessions cleaned");
}

const handlers: Record<string, (scheduledTime: number) => Promise<void>> = {
  "30 2 * * 1": runWeeklyReport,
  "0 * * * *": runHourlyHealthCheck,
  "@daily": runDailyCleanup,
};

export default {
  async scheduled(controller: ScheduledController): Promise<void> {
    const handler = handlers[controller.cron];

    if (!handler) {
      logger.error({ cron: controller.cron }, "No handler registered for cron expression");
      return;
    }

    try {
      await handler(controller.scheduledTime);
    } catch (err) {
      logger.error({ cron: controller.cron, err }, "Scheduled job failed");
      throw err;
    }
  },
};

```

When the OS fires a scheduled job, Bun imports the script and calls `default.scheduled()`. This follows the Cloudflare Workers Cron Triggers API — the same interface used in edge workers — so scripts written for Cloudflare Workers work without modification. `controller.cron` contains the normalized cron expression that triggered the invocation (note that named weekdays are normalized to numbers: `MON` becomes `1`). `controller.scheduledTime` is the Unix timestamp in milliseconds for the scheduled fire time, not the wall-clock time the script actually started. These differ when the scheduler fires late.

The `handlers` map routes each expression to the right function. Throwing inside `scheduled` propagates the error to the OS scheduler, which records it in the platform's log — `journalctl` on Linux, the log files in `/tmp` on macOS, Event Viewer on Windows. Swallowing errors with an empty `catch` makes production debugging very difficult. Re-throwing after logging is the right pattern.

The worker holds its own database pool rather than sharing one with the main server process, because the OS spawns the worker as a separate Bun process. Each job invocation is an independent process. This is not a design flaw — it means a hung job cannot block the scheduler from firing the next run, and a crashing job does not take down the API server.

### Parsing Cron Expressions

```typescript
interface CronSchedule {
  expression: string;
  label: string;
  nextRuns: Date[];
}

function previewCronSchedule(
  expression: string,
  label: string,
  count = 5,
): CronSchedule {
  const nextRuns: Date[] = [];
  let cursor: Date | null = new Date();

  for (let i = 0; i < count; i++) {
    const next = Bun.cron.parse(expression, cursor);
    if (next === null) break;
    nextRuns.push(next);
    cursor = new Date(next.getTime() + 1000);
  }

  return { expression, label, nextRuns };
}

function validateCronExpression(expression: string): boolean {
  return Bun.cron.parse(expression) !== null;
}

const schedules = [
  previewCronSchedule("30 2 * * MON", "weekly-report"),
  previewCronSchedule("0 9 * * MON-FRI", "weekday-digest"),
  previewCronSchedule("@yearly", "annual-audit"),
];

for (const schedule of schedules) {
  console.log(`\n${schedule.label} (${schedule.expression})`);
  for (const date of schedule.nextRuns) {
    console.log(" ", date.toLocaleString("en-US", { timeZone: "UTC" }));
  }
}

```

`Bun.cron.parse` takes a cron expression and an optional `Date` representing the start of the search window. It returns the next `Date` after that window when the expression would fire, or `null` if no match exists within approximately four years. A `null` return catches logically impossible expressions like `0 0 30 2 *` (February 30th) before they ever reach the OS scheduler.

Chaining calls by advancing `cursor` by one second after each result produces a sequence of upcoming fire times. This is useful for displaying a preview to operators before enabling a job, or for writing unit tests that assert your cron expression matches the human-readable description in your documentation.

`Bun.cron.parse` is a pure function — it makes no system calls and involves no I/O. Running it at application startup to validate all configured expressions costs microseconds and catches misconfigured schedules before they silently never fire.

### Removing a Job

```typescript
async function deregisterJob(title: string): Promise<void> {
  try {
    await Bun.cron.remove(title);
    console.log(`Removed job: ${title}`);
  } catch (err) {
    console.error(`Failed to remove job "${title}":`, err);
    throw err;
  }
}

async function rotateCronJob(
  scriptPath: string,
  newExpression: string,
  title: string,
): Promise<void> {
  await Bun.cron(scriptPath, newExpression, title);
}

```

`Bun.cron.remove` deletes the scheduled job from the OS by title. On Linux it removes the matching line from `crontab`. On macOS it unloads and deletes the `launchd` plist. On Windows it removes the Task Scheduler entry.

`rotateCronJob` shows the correct way to update an expression for an existing job: re-register with the same title. Because the OS scheduler overwrites in place, there is no window between the remove and the re-add where the job is absent. Calling `Bun.cron.remove` followed by `Bun.cron` would introduce a gap; calling `Bun.cron` alone does not.

---

> If you're enjoying what you've read so far, we recommend [Node.js vs Deno vs Bun: Picking the Right JavaScript Runtime in 2026](https://sudosecurity.org/node-js-vs-deno-vs-bun/) as your next read.

---

## `Bun.sliceAnsi`: Terminal-Aware String Slicing

Terminal applications and CLI tools routinely need to truncate strings to fit a column width, or extract a slice from a larger string, while keeping ANSI color codes and emoji intact. Two npm packages — `slice-ansi` and `cli-truncate` — handle this use case and together account for tens of millions of weekly downloads. `Bun.sliceAnsi` replaces both with a single native built-in.

### CLI Output Formatting

```typescript
import { createServer, IncomingMessage, ServerResponse } from "http";

interface LogEntry {
  timestamp: Date;
  level: "INFO" | "WARN" | "ERROR";
  requestId: string;
  message: string;
  durationMs: number;
}

const LEVEL_COLORS: Record<LogEntry["level"], string> = {
  INFO: "\x1b[32m",
  WARN: "\x1b[33m",
  ERROR: "\x1b[31m",
};

const RESET = "\x1b[0m";
const DIM = "\x1b[2m";
const BOLD = "\x1b[1m";

function formatLogLine(entry: LogEntry, terminalWidth: number): string {
  const levelColor = LEVEL_COLORS[entry.level];
  const coloredLevel = `${levelColor}${BOLD}${entry.level.padEnd(5)}${RESET}`;

  const ts = entry.timestamp.toISOString().slice(11, 23);
  const coloredTs = `${DIM}${ts}${RESET}`;

  const reqId = `${DIM}[${entry.requestId.slice(0, 8)}]${RESET}`;
  const duration = `${DIM}${entry.durationMs}ms${RESET}`;

  const prefix = `${coloredTs} ${coloredLevel} ${reqId} `;
  const suffix = ` ${duration}`;

  const prefixWidth = ts.length + 1 + entry.level.length + 1 + 1 + 8 + 1 + 1;
  const suffixWidth = entry.durationMs.toString().length + 2 + 1;
  const messageWidth = Math.max(terminalWidth - prefixWidth - suffixWidth, 10);

  const truncatedMessage = Bun.sliceAnsi(entry.message, 0, messageWidth, "…");

  return `${prefix}${truncatedMessage}${suffix}`;
}

function formatTable(
  rows: Array<{ label: string; value: string }>,
  maxLabelWidth: number,
  maxValueWidth: number,
): string {
  return rows
    .map(({ label, value }) => {
      const truncatedLabel = Bun.sliceAnsi(label, 0, maxLabelWidth, "…");
      const truncatedValue = Bun.sliceAnsi(value, 0, maxValueWidth, "…");
      return `  ${truncatedLabel.padEnd(maxLabelWidth)}  ${truncatedValue}`;
    })
    .join("\n");
}

function createRequestLogger(terminalWidth: number) {
  return function logRequest(entry: LogEntry): void {
    const line = formatLogLine(entry, terminalWidth);
    process.stdout.write(line + "\n");
  };
}

const logger = createRequestLogger(process.stdout.columns ?? 120);

const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  const start = performance.now();
  const requestId = crypto.randomUUID();

  res.on("finish", () => {
    const level = res.statusCode >= 500 ? "ERROR" : res.statusCode >= 400 ? "WARN" : "INFO";
    logger({
      timestamp: new Date(),
      level,
      requestId,
      message: `${req.method} ${req.url} ${res.statusCode}`,
      durationMs: Math.round(performance.now() - start),
    });
  });

  res.writeHead(200);
  res.end("ok");
});

server.listen(3000);

```

`Bun.sliceAnsi(str, start, end, ellipsis)` slices `str` by terminal column width from `start` to `end`. When an `ellipsis` argument is provided, `Bun.sliceAnsi` operates in truncation mode: if the string exceeds `end` columns, it truncates to `end - ellipsis_width` columns and appends the ellipsis. Without the ellipsis argument it operates in plain slice mode, returning exactly the columns between `start` and `end`.

The function measures width in terminal columns, not character code units. A single emoji like `🎉` occupies two terminal columns. A zero-width joiner sequence like `👨‍👩‍👧‍👦` is a single grapheme cluster that also occupies two columns. ASCII characters occupy one column each. `Bun.sliceAnsi` handles all of these correctly, whereas a naïve `str.slice(0, n)` splits multi-byte sequences mid-character and produces garbled output or broken ANSI codes.

The terminal width calculation in `formatLogLine` separates the fixed-width structural elements (timestamp, level, request ID, duration) from the variable-width message. The message width is what remains after subtracting the structural widths. This pattern keeps log lines within the terminal viewport regardless of message length, while preserving ANSI color codes in both the truncated message and the surrounding elements.

The ellipsis is emitted inside active SGR styles. If the message was colored red before truncation, the ellipsis inherits that red color. After the ellipsis, styles are reset correctly. This means a truncated error message does not leave a dangling escape sequence that colors everything that follows it on the terminal.

### Slicing ANSI Strings Safely

```typescript
interface ProgressBar {
  label: string;
  current: number;
  total: number;
  width: number;
}

function renderProgressBar(bar: ProgressBar): string {
  const GREEN = "\x1b[32m";
  const GRAY = "\x1b[90m";
  const RESET = "\x1b[0m";
  const BOLD = "\x1b[1m";

  const ratio = Math.min(bar.current / bar.total, 1);
  const filledCount = Math.round(bar.width * ratio);
  const emptyCount = bar.width - filledCount;

  const filled = `${GREEN}${"█".repeat(filledCount)}${RESET}`;
  const empty = `${GRAY}${"░".repeat(emptyCount)}${RESET}`;
  const pct = `${BOLD}${Math.round(ratio * 100)}%${RESET}`;

  const fullBar = `${filled}${empty} ${pct}`;

  const labelWidth = 20;
  const truncatedLabel = Bun.sliceAnsi(bar.label, 0, labelWidth, "…");

  return `${truncatedLabel.padEnd(labelWidth)} ${fullBar}`;
}

function renderProgressTable(bars: ProgressBar[]): string {
  return bars.map(renderProgressBar).join("\n");
}

const jobs: ProgressBar[] = [
  { label: "Uploading assets to CDN", current: 450, total: 600, width: 30 },
  { label: "Running database migrations with very long description", current: 12, total: 15, width: 30 },
  { label: "Compiling TypeScript — 🎉 almost done", current: 599, total: 600, width: 30 },
  { label: "Sending notifications", current: 1000, total: 1000, width: 30 },
];

process.stdout.write(renderProgressTable(jobs) + "\n");

```

`Bun.sliceAnsi` handles the `label` truncation here, capping it at 20 columns regardless of what it contains — plain ASCII, multibyte UTF-8, ANSI hyperlinks, or any combination. The `padEnd` call then pads the visible width to exactly 20 columns so the progress bars align vertically. Without ANSI-aware slicing, `padEnd` would measure the string's byte length rather than its visible width, producing misaligned output whenever a label contains non-ASCII characters or escape sequences.

The negative-index variant works as well: `Bun.sliceAnsi(str, -n, undefined, "…")` extracts the last `n` columns, replacing the beginning with an ellipsis. This is useful for path truncation in file explorers and log viewers where the filename at the end of the path is more informative than the leading directories.

## `Bun.markdown.render()`: Richer List Metadata

`Bun.markdown` is Bun's built-in CommonMark parser. Its `render()` method accepts a Markdown string and a renderer object whose callbacks are invoked for each node type. In v1.3.11, the `listItem` callback now receives `index`, `depth`, `ordered`, and `start` in its metadata, and the `list` callback receives `depth`. Previously, `listItem` only passed `checked` (and only for task list items), making it impossible to build custom list renderers without post-processing.

### Documentation Generator with Custom List Rendering

```typescript
interface ListItemMeta {
  index: number;
  depth: number;
  ordered: boolean;
  start?: number;
  checked?: boolean;
}

interface ListMeta {
  depth: number;
}

type Renderer = Parameters<typeof Bun.markdown.render>[1];

function buildNumberingMarker(
  depth: number,
  n: number,
  ordered: boolean,
): string {
  if (!ordered) {
    const bullets = ["•", "◦", "▸"];
    return bullets[Math.min(depth, bullets.length - 1)];
  }

  switch (depth) {
    case 0:
      return `${n}.`;
    case 1:
      return `${String.fromCharCode(96 + n)}.`;
    case 2: {
      const romanNumerals = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x"];
      return `${romanNumerals[Math.min(n - 1, romanNumerals.length - 1)]}.`;
    }
    default:
      return `${n}.`;
  }
}

function buildHtmlRenderer(options: { wrapCodeBlocks?: boolean } = {}): Renderer {
  return {
    listItem(children: string, meta: ListItemMeta): string {
      const n = (meta.start ?? 1) + meta.index;
      const marker = buildNumberingMarker(meta.depth, n, meta.ordered);
      const indent = "  ".repeat(meta.depth);

      if (meta.checked !== undefined) {
        const checkbox = meta.checked
          ? '<input type="checkbox" checked disabled>'
          : '<input type="checkbox" disabled>';
        return `${indent}<li class="task-item">${checkbox} ${children.trim()}</li>\n`;
      }

      return `${indent}<li data-marker="${marker}" data-depth="${meta.depth}">${children.trim()}</li>\n`;
    },

    list(children: string, meta: ListMeta): string {
      const depthClass = `list-depth-${meta.depth}`;
      return `<ul class="${depthClass}">\n${children}</ul>\n`;
    },

    code(source: string, language?: string): string {
      if (!options.wrapCodeBlocks) {
        return `<pre><code class="language-${language ?? "text"}">${source}</code></pre>\n`;
      }
      return (
        `<div class="code-block" data-language="${language ?? "text"}">\n` +
        `  <pre><code>${source}</code></pre>\n` +
        `</div>\n`
      );
    },

    heading(children: string, level: number): string {
      const id = children
        .toLowerCase()
        .replace(/[^a-z0-9\s-]/g, "")
        .trim()
        .replace(/\s+/g, "-");
      return `<h${level} id="${id}">${children}</h${level}>\n`;
    },
  };
}

const input = `
# API Reference

## Endpoints

1. Authentication
   1. POST /auth/login
      1. Returns JWT
      2. Accepts email and password
   2. POST /auth/refresh
2. Users
   a. GET /users
   b. POST /users

## Features

- Open source
  - MIT licensed
    - Free for commercial use
  - Community maintained
- Fast

## Checklist

- [x] TypeScript support
- [ ] GraphQL adapter
- [x] REST API
`;

const renderer = buildHtmlRenderer({ wrapCodeBlocks: true });
const html = Bun.markdown.render(input, renderer);
console.log(html);

```

Each `listItem` callback invocation now receives its full structural context. `meta.index` is zero-based within the parent list, so the first item in any list is always `0` regardless of nesting. `meta.start` holds the user-specified start number from the Markdown source (`1. First` has `start: 1`; `3. Third` has `start: 3`). The actual display number is therefore `(meta.start ?? 1) + meta.index`.

`meta.depth` is the nesting level of the parent list, with `0` meaning top-level. This drives the `buildNumberingMarker` function, which applies Arabic numerals at depth 0, lowercase letters at depth 1, and Roman numerals at depth 2\. Previously this information was unavailable without parsing the rendered HTML or tracking state externally.

`meta.ordered` distinguishes ordered lists (`1. Item`) from unordered lists (`- Item`), letting the same callback render both with different marker styles and CSS classes.

The `checked` property is only present for task list items (`- [x]` or `- [ ]`). For all other items it is `undefined`. As of this release, the meta object is always passed to `listItem` — in prior versions it was only passed for task list items. This makes the callback signature consistent and allows type-safe handling without checking whether `meta` exists.

The meta objects use cached JSC (JavaScriptCore) structures internally, adding approximately 0.7 nanoseconds per object creation. For a document with 500 list items this is 350 nanoseconds of overhead — negligible for any real workload.

### Generating Structured Documentation from Markdown

```typescript
import { writeFile } from "fs/promises";
import path from "path";
import { glob } from "glob";

interface TableOfContentsEntry {
  title: string;
  id: string;
  level: number;
}

interface RenderResult {
  html: string;
  toc: TableOfContentsEntry[];
}

function renderWithToc(markdown: string): RenderResult {
  const toc: TableOfContentsEntry[] = [];

  const html = Bun.markdown.render(markdown, {
    heading(children: string, level: number): string {
      const id = children
        .toLowerCase()
        .replace(/[^a-z0-9\s-]/g, "")
        .trim()
        .replace(/\s+/g, "-");

      if (level <= 3) {
        toc.push({ title: children, id, level });
      }

      return `<h${level} id="${id}">${children}</h${level}>\n`;
    },

    listItem(children: string, meta: ListItemMeta): string {
      const indent = "  ".repeat(meta.depth);
      const n = (meta.start ?? 1) + meta.index;

      if (meta.checked !== undefined) {
        const cls = meta.checked ? "done" : "todo";
        return `${indent}<li class="task ${cls}">${children.trim()}</li>\n`;
      }

      if (meta.ordered) {
        return `${indent}<li value="${n}">${children.trim()}</li>\n`;
      }

      return `${indent}<li>${children.trim()}</li>\n`;
    },

    list(children: string, meta: ListMeta): string {
      return `<ul class="depth-${meta.depth}">\n${children}</ul>\n`;
    },
  });

  return { html, toc };
}

function buildTocHtml(entries: TableOfContentsEntry[]): string {
  if (entries.length === 0) return "";

  const items = entries
    .map(({ title, id, level }) => {
      const indent = "  ".repeat(level - 1);
      return `${indent}<li><a href="#${id}">${title}</a></li>`;
    })
    .join("\n");

  return `<nav class="toc">\n<ul>\n${items}\n</ul>\n</nav>\n`;
}

async function processDocumentationFiles(
  docsDir: string,
  outputDir: string,
): Promise<void> {
  const files = await glob("**/*.md", { cwd: docsDir });

  await Promise.all(
    files.map(async (file) => {
      const inputPath = path.join(docsDir, file);
      const outputPath = path.join(
        outputDir,
        file.replace(/\.md$/, ".html"),
      );

      const markdown = await Bun.file(inputPath).text();
      const { html, toc } = renderWithToc(markdown);
      const tocHtml = buildTocHtml(toc);

      const page = `<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Docs</title></head>
<body>
${tocHtml}
<main>
${html}
</main>
</body>
</html>`;

      await Bun.write(outputPath, page);
    }),
  );
}

await processDocumentationFiles("./docs", "./dist/docs");

```

`renderWithToc` uses the `heading` callback to intercept heading nodes as they render, extracting the text and computed ID into a table of contents array. Because callbacks fire in document order, `toc` is populated in the order headings appear, which is exactly the order they should appear in the navigation.

`Bun.file(inputPath).text()` is Bun's native async file read — faster than `fs/promises.readFile` and with less boilerplate. `Bun.write(outputPath, page)` writes the output with the same zero-dependency style. `Promise.all` processes all files concurrently, which on a 100-file documentation site reduces total processing time from the sum of all file I/O operations to roughly the time of the single slowest file.

The `listItem` callback's `value="${n}"` on ordered list items tells the browser the correct display number for items with a non-default `start`. Without `value`, a list starting at 3 would render as 1, 2, 3 in the browser even though the Markdown source says `3. Item`.

## `--path-ignore-patterns` for `bun test`

Large monorepos and projects with vendored code frequently have `*.test.ts` files in directories that should not participate in the main test suite. Previously `bun test` would discover and attempt to run these files. `--path-ignore-patterns` fixes this with glob-based exclusion baked directly into the test runner's file scanner.

### Configuration via `bunfig.toml`

```toml
[test]
pathIgnorePatterns = [
  "vendor/**",
  "node_modules/**",
  "submodules/**",
  "fixtures/**",
  "**/test-data/**",
  "**/e2e/**",
  "scripts/**"
]

```

```typescript
import { describe, test, expect, mock } from "bun:test";
import path from "path";

interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
  ssl: boolean;
}

function parseConnectionString(url: string): DatabaseConfig {
  const parsed = new URL(url);
  return {
    host: parsed.hostname,
    port: parseInt(parsed.port, 10) || 5432,
    database: parsed.pathname.slice(1),
    ssl: parsed.searchParams.get("ssl") === "true",
  };
}

describe("parseConnectionString", () => {
  test("parses a standard postgres URL", () => {
    const config = parseConnectionString(
      "postgres://localhost:5432/mydb?ssl=false",
    );
    expect(config).toEqual({
      host: "localhost",
      port: 5432,
      database: "mydb",
      ssl: false,
    });
  });

  test("defaults port to 5432 when omitted", () => {
    const config = parseConnectionString("postgres://db.example.com/mydb");
    expect(config.port).toBe(5432);
  });

  test("handles SSL flag", () => {
    const config = parseConnectionString(
      "postgres://localhost/mydb?ssl=true",
    );
    expect(config.ssl).toBe(true);
  });
});

```

The `pathIgnorePatterns` option in `bunfig.toml` accepts an array of glob patterns. When `bun test` scans for test files, it prunes any directory whose path matches one of these patterns before traversing into it. This is important: pruning happens at the directory level, not after discovering files inside it. A pattern like `vendor/**` prevents Bun from descending into `vendor/` at all, not just from running files found inside it. For a `vendor/` directory with 10,000 files, this avoids the entire filesystem traversal.

Patterns passed via `--path-ignore-patterns` on the command line take precedence over and completely replace the `bunfig.toml` value. They do not merge. If you have three patterns in `bunfig.toml` and pass one via the CLI, only the CLI pattern applies. This design keeps CI configuration explicit: a CI script that passes `--path-ignore-patterns` always produces the same file set regardless of what `bunfig.toml` contains.

## `dgram` UDP Socket Fixes on macOS

Three bugs in Bun's `dgram` implementation caused UDP sockets to fail silently on macOS. These bugs affected real libraries in production: `k-rpc`, which is used by `bittorrent-dht` and by any application implementing the BitTorrent DHT protocol, depends on implicit bind-on-send behavior that was broken.

### UDP Peer Discovery with `reusePort` and Implicit Binding

```typescript
import dgram from "dgram";
import { EventEmitter } from "events";

interface PeerAddress {
  address: string;
  port: number;
}

interface DHTMessage {
  type: "ping" | "pong" | "find_node" | "announce";
  nodeId: string;
  payload?: unknown;
}

class UDPPeerDiscovery extends EventEmitter {
  private readonly socket: dgram.Socket;
  private bound = false;
  private readonly nodeId: string;

  constructor() {
    super();
    this.nodeId = crypto.randomUUID();

    this.socket = dgram.createSocket({
      type: "udp4",
      reusePort: true,
    });

    this.socket.on("message", (msg, rinfo) => {
      this.handleMessage(msg, { address: rinfo.address, port: rinfo.port });
    });

    this.socket.on("error", (err) => {
      this.emit("error", err);
    });
  }

  private handleMessage(msg: Buffer, sender: PeerAddress): void {
    let parsed: DHTMessage;

    try {
      parsed = JSON.parse(msg.toString("utf8")) as DHTMessage;
    } catch {
      return;
    }

    this.emit("message", parsed, sender);

    if (parsed.type === "ping") {
      const pong: DHTMessage = { type: "pong", nodeId: this.nodeId };
      this.send(pong, sender.port, sender.address);
    }
  }

  send(message: DHTMessage, port: number, address: string): void {
    const payload = Buffer.from(JSON.stringify(message));

    this.socket.send(payload, 0, payload.length, port, address, (err) => {
      if (err) {
        this.emit("error", err);
      }
    });
  }

  bind(port: number): Promise<void> {
    return new Promise((resolve, reject) => {
      this.socket.once("error", reject);
      this.socket.bind(port, () => {
        this.bound = true;
        this.socket.removeListener("error", reject);
        resolve();
      });
    });
  }

  ping(target: PeerAddress): void {
    const message: DHTMessage = { type: "ping", nodeId: this.nodeId };
    this.send(message, target.port, target.address);
  }

  getAddress(): dgram.AddressInfo {
    return this.socket.address();
  }

  close(): Promise<void> {
    return new Promise((resolve) => {
      this.socket.close(resolve);
    });
  }
}

const discovery = new UDPPeerDiscovery();

discovery.on("message", (msg: DHTMessage, sender: PeerAddress) => {
  console.log(`Received ${msg.type} from ${sender.address}:${sender.port}`);
});

discovery.on("error", (err: Error) => {
  console.error("UDP error:", err);
});

await discovery.bind(6881);

const loopbackTarget: PeerAddress = { address: "127.0.0.1", port: 6882 };
discovery.ping(loopbackTarget);

console.log("Listening on:", discovery.getAddress());

```

Three fixes land in v1.3.11 that make this code work correctly on macOS.

`reusePort: true` in `dgram.createSocket` options sets `SO_REUSEPORT` on the socket, allowing multiple processes to bind to the same UDP port. DHT implementations use this to run multiple node instances on the same machine. Before this fix, Bun's implementation of `reusePort` was guarded by a Linux-only code path — the option was silently ignored on macOS. Now `SO_REUSEPORT` is applied on any platform that supports it, matching Node.js behavior.

Implicit bind-on-send is the behavior where calling `send()` on an unbound socket automatically binds it to `0.0.0.0` on an ephemeral port before sending. The DHT library `k-rpc` relies on this — it calls `send` before explicitly calling `bind`. On macOS this failed because Bun's error handling used hardcoded Linux errno values (`92` for `ENOPROTOOPT`) rather than the C macro `ENOPROTOOPT`, whose value on macOS is `42`. The mismatch caused IPv4 fallback paths inside the bind logic to never trigger on macOS. The fix uses the macro rather than the raw number.

The socket file descriptor leak occurred when `setsockopt` for `reusePort` failed. The socket was opened, the option set failed, but the file descriptor was neither closed nor returned to the caller with a useful error. Production systems hit this when they tried to create many sockets and eventually exhausted their file descriptor limit. The fix closes the socket and propagates the errno correctly, producing a specific error message instead of a generic `Failed to bind socket`.

---

> Still with us? We think you'll like [What is Bun? The JavaScript Runtime That Made npm Install Fast](https://sudosecurity.org/what-is-bun/) as your next read.

---

## Notable Bug Fixes with Production Impact

### TLS and Custom DNS Lookups (axios / got)

A bug in Bun's HTTP client broke TLS certificate verification when a custom `lookup` function was provided — this is the mechanism axios, got, and other HTTP libraries use to implement custom DNS resolvers. When the custom lookup resolved a hostname to an IP address, Bun used the resolved IP for the TLS Server Name Indication header and certificate validation. The server's certificate is issued to the hostname, not the IP address, so verification failed with `ERR_TLS_CERT_ALTNAME_INVALID`. The fix retains the original hostname for TLS and only uses the resolved address for the TCP connection.

### PostgreSQL Batch Insert Limits

`bun:sql` previously panicked with an internal type error when a query exceeded PostgreSQL's 65,535 parameter limit — a limit you hit when batch-inserting, for example, 7,000 rows with 10 columns each (70,000 parameters). The panic produced no useful information. The fix converts this to a typed `PostgresError` with code `ERR_POSTGRES_TOO_MANY_PARAMETERS` and a hint to reduce batch size. Production code can now catch this error and split the batch.

```typescript
import { sql } from "bun:sql";

interface Row {
  userId: number;
  action: string;
  metadata: Record<string, unknown>;
  createdAt: Date;
}

async function batchInsertAuditLog(rows: Row[]): Promise<void> {
  const COLUMNS = 4;
  const MAX_PG_PARAMS = 65_535;
  const MAX_BATCH = Math.floor(MAX_PG_PARAMS / COLUMNS);

  for (let i = 0; i < rows.length; i += MAX_BATCH) {
    const batch = rows.slice(i, i + MAX_BATCH);

    await sql`
      INSERT INTO audit_log (user_id, action, metadata, created_at)
      SELECT * FROM json_populate_recordset(
        null::audit_log,
        ${JSON.stringify(batch.map((r) => ({
          user_id: r.userId,
          action: r.action,
          metadata: r.metadata,
          created_at: r.createdAt,
        })))}
      )
    `;
  }
}

```

The `MAX_BATCH` calculation divides 65,535 by the number of columns, giving the maximum number of rows per insert. Inserting as a JSON array and using `json_populate_recordset` sidesteps the parameter limit entirely — the entire batch is one parameter regardless of row count. Both patterns are correct; the per-column calculation is the simpler migration when changing existing code.

### HTTP/2 Window Stalling

`node:http2` client streams stalled after receiving 65,535 bytes when `setLocalWindowSize()` was in use. The method updated Bun's internal window size counter but never sent a `WINDOW_UPDATE` frame to the server, so the server believed the client's receive window was exhausted and stopped sending data. This is the RFC 7540 flow control mechanism working as designed — the server is not buggy, the client was. The fix sends the `WINDOW_UPDATE` frame when `setLocalWindowSize` is called.

### `spawnSync` Draining Microtask Queue

`spawnSync` is supposed to be a blocking call that runs a subprocess and returns when it exits. A bug caused it to drain the global microtask queue during the wait, which meant Promise callbacks — user JavaScript — could execute inside what callers expected to be a synchronous, non-interruptible block. Code that depended on nothing running between the `spawnSync` call and its return was silently broken. The fix prevents microtask queue drainage during synchronous subprocess execution.

### `process.off` Signal Handler Bug

Calling `process.off("SIGTERM", handler)` when multiple listeners were registered for the same signal incorrectly uninstalled the OS-level signal handler, not just the JavaScript listener. Remaining JavaScript listeners registered with `process.on("SIGTERM", ...)` would stop receiving the signal. In production, this meant a graceful shutdown handler registered before a third-party library's handler could be silently deregistered when the library removed its own listener. The fix correctly tracks listener counts and only uninstalls the OS handler when the last listener is removed.

### CRLF Injection in `writeEarlyHints`

`ServerResponse.prototype.writeEarlyHints` wrote header names and values to the raw socket without sanitization. A header value containing `\r\n` followed by a new header line would be written verbatim, allowing HTTP response splitting — an attacker who controlled a header value could inject arbitrary response headers into early hint responses. The fix validates header names and values before writing them, stripping carriage returns and line feeds.

## 4 MB Smaller on Linux x64

Bun's Linux x64 binary is 4 MB smaller in this release because the Bun team deleted CMake from the build dependencies and eliminated the compiled CMake artifacts from the distributed binary. CMake is a build system generator — it is needed to build Bun's C++ dependencies, but its output is not needed at runtime. Moving the build system configuration to Zig's native build system eliminates the need to vendor CMake binaries.

For production deployments where Bun is packaged in Docker images, 4 MB less in the binary directly reduces layer sizes. A typical Alpine-based image that previously included Bun at \~95 MB now includes it at \~91 MB. For base images that are pulled millions of times across a fleet, this reduction multiplies significantly in total network transfer. It also reduces cold start times in serverless environments where the binary must be loaded from storage before execution begins.

## The Cnclusion

Bun v1.3.11 is one of the more substantial maintenance releases in recent history. The surface-level additions — `Bun.cron` and `Bun.sliceAnsi` — replace entire categories of npm dependencies with zero-configuration native implementations. The bug fixes reach into dgram, TLS, http2, crypto, the bundler's barrel optimization, and the test runner. Any production Bun application benefits from the upgrade, and several of the fixes address correctness issues rather than performance: the `process.off` signal handler bug and the `spawnSync` microtask drainage bug both affect correctness under conditions that are easy to hit in real services.