Skip to content
52 changes: 50 additions & 2 deletions docs/runtime/file-io.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,16 @@ await Bun.file("logs.json").delete();

## Writing files (`Bun.write()`)

`Bun.write(destination, data): Promise<number>`
`Bun.write(destination, data, options?): Promise<number>`

The `Bun.write` function is a multi-tool for writing payloads of all kinds to disk.
The `Bun.write` function is a multi-tool for writing payloads of all kinds to disk. It resolves with the number of bytes written.

The first argument is the `destination`, which can be any of the following:

- `string`: A path to a location on the file system. Use the `"path"` module to manipulate paths.
- `URL`: A `file://` descriptor.
- `BunFile`: A file reference.
- `number`: A file descriptor, such as the one that `fs.openSync()` returns.

The second argument is the data to write. It can be any of the following:

Expand Down Expand Up @@ -152,6 +153,33 @@ const response = await fetch("https://bun.com");
await Bun.write("index.html", response);
```

By default, `Bun.write` creates the destination's parent directories if they do not exist. Pass `createPath: false` to get an `ENOENT` error instead.

```ts
await Bun.write("out/nested/dir/file.txt", "data"); // creates out/nested/dir/
await Bun.write("out/other/file.txt", "data", { createPath: false }); // rejects with ENOENT
```

### Overwriting an existing file

When the destination is a path or a `Bun.file(path)`, `Bun.write` replaces the file's contents: after the write, the file holds exactly the bytes of `data`, even if it was longer before. Bun writes the file in place, so the file keeps its inode and hard links. The file keeps its permissions too, unless the `mode` option sets them. Bun applies `mode` when `data` is a `BunFile`, and ignores `mode` when `data` is a string or a `TypedArray`.

When the destination is a file descriptor (a number or `Bun.file(fd)`), `Bun.write` writes at the descriptor's current position and leaves the rest of the file as it is. One exception: empty `data` that is not a file or a stream, such as an empty string or `TypedArray`, truncates the file to zero length. Open the descriptor with the flags you need (for example `"r+"` to write into an existing file) and close it yourself. To append to a file, use a [`FileSink`](#incremental-writing-with-filesink) on a descriptor that you open in append mode.

`Bun.write` is not atomic. Another process can observe a partially written file, and two writes to the same path at the same time can interleave their bytes. This is not specific to Bun. It is how writes to a shared path work. When readers must never see a partial file, write to a temporary path in the same directory and rename it over the destination:

```ts
import { rename } from "node:fs/promises";

const tmp = `config.json.${crypto.randomUUID()}.tmp`; // unique per write, same directory as the destination
await Bun.write(tmp, JSON.stringify(config));
await rename(tmp, "config.json"); // atomic on the same filesystem
```
Comment thread
robobun marked this conversation as resolved.
Resolved

Two writes to the same path that overlap are not ordered either. The write that you start first can finish last, and then the file holds the older data. Bun can complete a small string or `TypedArray` write before `Bun.write` returns. Bun completes other writes later, in no fixed order. Code that overlaps writes can therefore work while the data is small and lose an update when the data grows.

Start a write to a path after the previous write to that path settles. In a server, requests overlap, so an `await` in each request handler is not enough. The rename above keeps the file whole, but it does not order two replacements.

---

## Incremental writing with `FileSink`
Expand All @@ -163,6 +191,18 @@ const file = Bun.file("output.txt");
const writer = file.writer();
```

Calling `.writer()` opens the file right away, and creates it if it does not exist (the parent directory must exist). Writing starts at the beginning of the file. `FileSink` has no append mode. To append, open the file in append mode with `node:fs` and create the writer from the file descriptor:

```ts
import { openSync, closeSync } from "node:fs";

const fd = openSync("app.log", "a");
const writer = Bun.file(fd).writer();
writer.write("one more line\n");
await writer.end();
closeSync(fd); // the writer does not close a descriptor that you opened
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
```

To incrementally write to the file, call `.write()`.

```ts
Expand Down Expand Up @@ -280,6 +320,10 @@ interface Bun {
write(
destination: string | number | BunFile | URL,
input: string | Blob | ArrayBuffer | SharedArrayBuffer | TypedArray | Response,
options?: {
createPath?: boolean; // default true
mode?: number; // applied when input is a BunFile
},
): Promise<number>;
}

Expand All @@ -292,6 +336,10 @@ interface BunFile {
arrayBuffer(): Promise<ArrayBuffer>;
json(): Promise<any>;
bytes(): Promise<Uint8Array>;
write(
input: string | Blob | ArrayBuffer | SharedArrayBuffer | TypedArray | Response,
options?: { createPath?: boolean; type?: string },
): Promise<number>;
writer(params?: { highWaterMark?: number }): FileSink;
exists(): Promise<boolean>;
delete(): Promise<void>;
Expand Down
75 changes: 63 additions & 12 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2140,20 +2140,37 @@ declare module "bun" {
*
* If `destination` exists, it must be a regular file or symlink to a file. If `destination`'s directory does not exist, it is created by default.
*
* An existing file is overwritten in place and truncated to the new length. It keeps its inode and hard links, and
* it keeps its permissions unless `mode` sets them. A file descriptor destination (a number or `Bun.file(fd)`) is
* written at its current position and is not truncated, except that an empty `input` that is not a file or a
* stream truncates it to zero length.
*
* The write is not atomic: concurrent writers to one path can interleave. For an atomic replace, write to a
* temporary path and `rename()` it over the destination. Writes to one path that overlap are not ordered either:
* an earlier write can finish after a later one. Start a write to a path after the previous write to it settles.
*
* @category File System
*
* @param destination The file or file path to write to
* @param destination The file, file path, or file descriptor to write to
* @param input The data to copy into `destination`
* @param options Options for the write
*
* @returns A promise that resolves with the number of bytes written.
*/
function write(
destination: BunFile | S3File | PathLike,
destination: BunFile | S3File | PathLike | number,
input: Blob | NodeJS.TypedArray | ArrayBufferLike | string | BlobPart[] | Archive | ReadableStream,
options?: {
/**
* If writing to a PathLike, set the permissions of the file.
* The permissions of the destination file, for example `0o600`.
*
* `mode` takes effect when `input` is a `BunFile`: the destination gets
* exactly this mode, whether Bun creates it or it already exists. When
* `input` is a `ReadableStream`, Bun uses `mode` only to create a file
* that does not exist, and the process umask applies.
*
* `mode` has no effect when `input` is a string, a `TypedArray`, an
* `ArrayBuffer`, an `Archive`, or a `Blob` that holds data.
*/
mode?: number;
/**
Expand All @@ -2171,16 +2188,19 @@ declare module "bun" {
* Persist a {@link Response} or {@link Request} body to disk. The body is
* streamed into the file as it arrives.
*
* @param destination The file to write to. If the file doesn't exist, it is
* created; if it does, it is overwritten. If `input` is smaller than
* `destination`, `destination` is truncated.
* @param destination The file or file descriptor to write to. A
* `Bun.file(path)` that doesn't exist is created. One that exists is
* overwritten, and truncated if `input` is smaller. A file descriptor (a
* number or `Bun.file(fd)`) is written at its current position and is not
* truncated, except that an empty body that is not a file truncates it to
* zero length.
* @param input The `Response` or `Request` whose body is written
* @param options Options for the write
*
* @returns A promise that resolves with the number of bytes written.
*/
function write(
destination: BunFile,
destination: BunFile | number,
input: Response | Request,
options?: {
/**
Expand Down Expand Up @@ -2230,15 +2250,17 @@ declare module "bun" {
* [`clonefile()`](https://www.manpagez.com/man/2/clonefile/) and falls
* back to [`fcopyfile()`](https://www.manpagez.com/man/2/fcopyfile/).
*
* @param destination The file to write to. If the file doesn't exist, it is
* created; if it does, it is overwritten. If `input` is smaller than
* `destination`, `destination` is truncated.
* @param destination The file or file descriptor to write to. A
* `Bun.file(path)` that doesn't exist is created. One that exists is
* overwritten, and truncated if `input` is smaller. A file descriptor (a
* number or `Bun.file(fd)`) is written at its current position and is not
* truncated.
* @param input The file to copy from
* @returns A promise that resolves with the number of bytes written.
*/

function write(
destination: BunFile,
destination: BunFile | number,
input: BunFile,
options?: {
/**
Expand Down Expand Up @@ -2714,6 +2736,19 @@ declare module "bun" {

/**
* Incremental writer for files and pipes.
*
* For a path-backed `BunFile`, this opens the file immediately and creates
* it if it does not exist (the parent directory must exist). Writes start
* at the beginning of the file. There is no append mode: to append, open
* the file with `fs.openSync(path, "a")` and call `Bun.file(fd).writer()`.
* The writer does not close a file descriptor that you passed in.
*
* @example
* ```ts
* const writer = Bun.file("output.txt").writer();
* writer.write("hello\n");
* await writer.end();
* ```
*/
writer(options?: { highWaterMark?: number }): FileSink;
Comment on lines 2737 to 2753

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: nit, pre-existing: TypeScript users still get a highWaterMark option on BunFile.writer() that the runtime never reads, while this PR removes the same unread key from BunFile.write(). The rewritten JSDoc at bun.d.ts:2737-2753 keeps writer(options?: { highWaterMark?: number }), but Blob.rs:1741-1754 parses the options with the FileSink tag, which reads only path and fd (streams.rs:205-248), and FileSink.rs has no high-water or chunk-size field. Fix: make the declaration and docs match the runtime: either drop highWaterMark from writer() (and the file.writer({ highWaterMark: 1024 * 1024 }); // 1MB example at docs/runtime/file-io.mdx:226 and the reference block at file-io.mdx:343), or state in the JSDoc that it currently has no effect.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls Bun.file("out.txt").writer({ highWaterMark: 1024 * 1024 }) as docs/runtime/file-io.mdx:226 shows. Blob.rs:1741-1745 turns the options object into streams::Start::from_js_with_tag::<{ StartTag::FileSink }>, whose FileSink arm (streams.rs:205-248) only looks at path and fd and otherwise returns default FileSinkOptions. file_sink::Options (FileSink.rs:163-170) has input_path, mode, truncate, mkdirp and nothing else, and FileSink.rs contains no highWaterMark, high_water_mark or chunk_size reference. So the buffer size the user configured changes nothing. This is the same situation the PR fixes for BunFile.write() (it deletes { highWaterMark?: number } there because do_write never reads it), but the sibling writer() declaration the PR rewrites the JSDoc for keeps the unread option. The base branch has the same declaration, so this is pre-existing; the PR's stated rule that declarations mirror the runtime is what makes it in scope.

Verification: pre-existing (nit). Trigger: any TypeScript caller writing Bun.file(p).writer({ highWaterMark: n }). packages/bun-types/bun.d.ts:2753 still declares writer(options?: { highWaterMark?: number }): FileSink;. The FileSink arm at src/runtime/webcore/streams.rs:205-248 reads only path and fd. The base branch has the identical writer signature and docs, so merging makes nothing worse.


Expand Down Expand Up @@ -2759,7 +2794,23 @@ declare module "bun" {
*/
write(
data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer | Request | Response | BunFile | ReadableStream,
options?: { highWaterMark?: number },
options?: {
/**
* If `true`, create the parent directory if it doesn't exist.
*
* If `false`, the write rejects when the directory doesn't exist.
*
* @default true
*/
createPath?: boolean;
Comment on lines +2798 to +2805

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional), pre-existing: a caller who passes the documented default createPath: true to Bun.file(fd).write() or Bun.write(Bun.file(fd), ...) gets a thrown TypeError, which this new JSDoc does not mention. write_file_internal at Blob.rs:4663-4674 throws "Cannot create a directory for a file descriptor" whenever createPath is explicitly true and the destination blob is fd-backed, while a bare number destination (now typed) silently ignores it. Fix: state on the createPath docs (bun.d.ts:2798-2805, 2176-2183, 2206-2213, 2280-2287) that the option applies to path destinations only and that true throws for Bun.file(fd), or make the runtime treat true as a no-op for every fd destination.

Why this was flagged

A caller writes await Bun.file(fd).write(data, { createPath: true }) because bun.d.ts:2798-2805 documents createPath with @ default true. do_write at Blob.rs:1283-1294 stores Some(true) in mkdirp_if_not_exists and passes a PathOrBlob::Blob whose store pathlike is PathOrFileDescriptor::Fd. write_file_internal at Blob.rs:4663-4674 matches exactly that shape and returns throw_invalid_arguments("Cannot create a directory for a file descriptor"), a synchronous TypeError, before any byte is written. Bun.write(Bun.file(fd), data, { createPath: true }) takes the same path via Blob.rs:5090-5098. Bun.write(fd, data, { createPath: true }) with a plain number arrives as PathOrBlob::Path(Fd) and is not caught by that check, so it writes normally. The runtime did this on the base branch too, but the base types did not accept a number destination and the BunFile.write() options type had no createPath, so the contradiction between the documented default and the throw is new to readers of this PR's JSDoc.

Verification: nit. Trigger: a TypeScript caller passes the documented default explicitly, Bun.file(fd).write(data, { createPath: true }), where the BunFile is fd-backed. write_file_internal at Blob.rs:4663-4673 returns throw_invalid_arguments("Cannot create a directory for a file descriptor") before any write. The new JSDoc this PR adds at packages/bun-types/bun.d.ts:2798-2805 says nothing about this.

/**
* Set the MIME type of this `BunFile`: the value of its `type`
* property, and the `Content-Type` of a `Response` made from the file.
* The file on disk does not record it. A string that is not a valid
* MIME type is ignored.
*/
type?: string;
},
Comment thread
robobun marked this conversation as resolved.
Resolved
): Promise<number>;

/**
Expand Down
2 changes: 1 addition & 1 deletion test/integration/bun-types/bun-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ describe("@types/bun integration test", () => {
},
{
code: 2353,
line: "globals.ts:307:5",
line: "globals.ts:315:5",
message: "Object literal may only specify known properties, and 'headers' does not exist in type 'string[]'.",
},
{
Expand Down
8 changes: 8 additions & 0 deletions test/integration/bun-types/fixture/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ expectAssignable<Bun.ZlibCompressionOptions>({ windowBits: -11 });
// Other
expectType<Promise<number>>(Bun.write("test.json", "lol"));
expectType<Promise<number>>(Bun.write("test.json", new ArrayBuffer(32)));
expectType<Promise<number>>(Bun.write("test.json", Bun.file("input.json"), { createPath: false, mode: 0o600 }));
expectType<Promise<number>>(Bun.file("test.json").write("lol", { createPath: false, type: "application/json" }));
// @ts-expect-error BunFile.write() does not read highWaterMark
Bun.file("test.json").write("lol", { highWaterMark: 1024 });
// A number destination is a file descriptor.
expectType<Promise<number>>(Bun.write(1, "lol"));
expectType<Promise<number>>(Bun.write(1, new Response("lol")));
expectType<Promise<number>>(Bun.write(1, Bun.file("test.json")));
expectType<URL>(Bun.pathToFileURL("/foo/bar.txt"));
expectType<string>(Bun.fileURLToPath(new URL("file:///foo/bar.txt")));

Expand Down
24 changes: 12 additions & 12 deletions test/js/bun/bun-object/write.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BunFile } from "bun";
import { tmpdirSync } from "harness";
import { tempDir, tmpdirSync } from "harness";
import { constants, promises as fs } from "node:fs";
import path from "node:path";

Expand All @@ -18,20 +18,20 @@ describe("Bun.write()", () => {
},
);

// FIXME
it.failing("Throws when `destination` is a number ", async () => {
// @ts-expect-error
await expect(() => Bun.write(1, "foo 1")).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
// @ts-expect-error
await expect(() => Bun.write(0, "foo 0")).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
// @ts-expect-error
await expect(() => Bun.write(-10, "foo -10")).toThrowWithCodeAsync(Error, "ERR_INVALID_ARG_TYPE");
// A number is a file descriptor. The caller owns it: Bun writes at its position and does not truncate the file.
it("Writes to the file descriptor when `destination` is a number", async () => {
using dir = tempDir("bun-write-fd", { "file.txt": "0123456789" });
const filepath = path.join(String(dir), "file.txt");
const handle = await fs.open(filepath, "r+");
try {
expect(await Bun.write(handle.fd, "abc")).toBe(3);
} finally {
await handle.close();
}
expect(await fs.readFile(filepath, "utf-8")).toBe("abc3456789");
});
Comment on lines +22 to 32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): the only runtime test for a number destination writes at position 0 with a string, so it cannot fail if Bun ignored the descriptor's position, and it exercises none of the other overloads this PR adds number to. The types now accept Bun.write(fd, Response) and Bun.write(fd, BunFile) (bun.d.ts:2203, 2263) and the docs promise a write "at the descriptor's current position" (file-io.mdx:167), but no test seeks before writing or uses those inputs. Fix: add cases alongside this one that open with "r+", move the position (for example handle.write of a prefix or fs.readSync with a position), then write a string, a Response and a Bun.file() through the number and assert exact file bytes.

Why this was flagged

test/js/bun/bun-object/write.spec.ts:22-32 opens "file.txt" holding "0123456789" with "r+" (position 0), writes "abc" and asserts "abc3456789". A runtime that always wrote at offset 0 and never honoured the descriptor position would pass this test, so the "writes at its position" comment at line 21 is not what the assertion proves; only no-truncation is proved. The PR changes bun.d.ts:2203 and bun.d.ts:2263 so TypeScript now accepts a number for Response/Request and BunFile inputs, which go through different runtime paths (WriteFile / WriteFileWaitFromLockedValueTask at Blob.rs:4946-4972, CopyFile at Blob.rs:4417-4425) than the string fast path at Blob.rs:4712-4717 that this test hits, and none of those paths has an automated test with a number destination. The base branch had an it.failing placeholder here; the PR replaces it with one case where REVIEW.md asks for every overload and the stated invariant.

Verification: nit. A future regression in numeric-fd handling (writing at offset 0 instead of the descriptor's position) would pass the suite. test/js/bun/bun-object/write.spec.ts:21-32 seeds "0123456789", opens with fs.open(filepath, "r+"), calls Bun.write(handle.fd, "abc") and asserts "abc3456789". An implementation that pwrite()s at offset 0 regardless of position produces the identical result.


// NOTE: if/when we ban fds, this will become ERR_INVALID_ARG_TYPE. When that
// happens, delete or update this test
it("Throws when given a negative number", () => {
// @ts-expect-error
expect(() => Bun.write(-1, "foo")).toThrow(RangeError);
});

Expand Down
Loading