diff --git a/docs/runtime/file-io.mdx b/docs/runtime/file-io.mdx index b0573d95db79..51c1bbfd724b 100644 --- a/docs/runtime/file-io.mdx +++ b/docs/runtime/file-io.mdx @@ -78,15 +78,16 @@ await Bun.file("logs.json").delete(); ## Writing files (`Bun.write()`) -`Bun.write(destination, data): Promise` +`Bun.write(destination, data, options?): Promise` -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: @@ -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 +``` + +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` @@ -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 +``` + To incrementally write to the file, call `.write()`. ```ts @@ -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; } @@ -292,6 +336,10 @@ interface BunFile { arrayBuffer(): Promise; json(): Promise; bytes(): Promise; + write( + input: string | Blob | ArrayBuffer | SharedArrayBuffer | TypedArray | Response, + options?: { createPath?: boolean; type?: string }, + ): Promise; writer(params?: { highWaterMark?: number }): FileSink; exists(): Promise; delete(): Promise; diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2ded9f1ad305..4ac426b9966c 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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; /** @@ -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?: { /** @@ -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?: { /** @@ -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; @@ -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; + /** + * 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; + }, ): Promise; /** diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts index 69db093b1310..95e795c93bdb 100644 --- a/test/integration/bun-types/bun-types.test.ts +++ b/test/integration/bun-types/bun-types.test.ts @@ -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[]'.", }, { diff --git a/test/integration/bun-types/fixture/globals.ts b/test/integration/bun-types/fixture/globals.ts index a851e74717d9..4bfac830d531 100644 --- a/test/integration/bun-types/fixture/globals.ts +++ b/test/integration/bun-types/fixture/globals.ts @@ -40,6 +40,14 @@ expectAssignable({ windowBits: -11 }); // Other expectType>(Bun.write("test.json", "lol")); expectType>(Bun.write("test.json", new ArrayBuffer(32))); +expectType>(Bun.write("test.json", Bun.file("input.json"), { createPath: false, mode: 0o600 })); +expectType>(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>(Bun.write(1, "lol")); +expectType>(Bun.write(1, new Response("lol"))); +expectType>(Bun.write(1, Bun.file("test.json"))); expectType(Bun.pathToFileURL("/foo/bar.txt")); expectType(Bun.fileURLToPath(new URL("file:///foo/bar.txt"))); diff --git a/test/js/bun/bun-object/write.spec.ts b/test/js/bun/bun-object/write.spec.ts index c4978f18eb88..428736ee9de8 100644 --- a/test/js/bun/bun-object/write.spec.ts +++ b/test/js/bun/bun-object/write.spec.ts @@ -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"; @@ -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"); }); - // 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); });