Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
b7d7d5b99d feat: add Bun.dir() and Bun.Directory for lazy directory reading
Implements a lazy directory API similar to Bun.file() that doesn't open
the directory until files() or filesSync() is called.

- Bun.dir(path) creates a lazy Directory handle
- Bun.Directory class can be constructed with new Bun.Directory(path)
- Directory.files() returns Promise<Dirent[]>
- Directory.filesSync() returns Dirent[] synchronously
- Directory.path and Directory.name properties

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 01:17:56 +00:00
8 changed files with 510 additions and 0 deletions

View File

@@ -36,6 +36,7 @@ pub const dns = @import("./api/bun/dns.zig");
pub const FFI = @import("./api/ffi.zig").FFI;
pub const HTMLRewriter = @import("./api/html_rewriter.zig");
pub const FileSystemRouter = @import("./api/filesystem_router.zig").FileSystemRouter;
pub const Directory = @import("./api/Directory.zig");
pub const Glob = @import("./api/glob.zig");
pub const H2FrameParser = @import("./api/bun/h2_frame_parser.zig").H2FrameParser;
pub const JSBundler = @import("./api/JSBundler.zig").JSBundler;

View File

@@ -17,6 +17,7 @@ pub const BunObject = struct {
pub const createParsedShellScript = toJSCallback(bun.shell.ParsedShellScript.createParsedShellScript);
pub const createShellInterpreter = toJSCallback(bun.shell.Interpreter.createShellInterpreter);
pub const deflateSync = toJSCallback(JSZlib.deflateSync);
pub const dir = toJSCallback(Bun.constructDirectory);
pub const file = toJSCallback(WebCore.Blob.constructBunFile);
pub const gunzipSync = toJSCallback(JSZlib.gunzipSync);
pub const gzipSync = toJSCallback(JSZlib.gzipSync);
@@ -50,6 +51,7 @@ pub const BunObject = struct {
// --- Lazy property callbacks ---
pub const CryptoHasher = toJSLazyPropertyCallback(Crypto.CryptoHasher.getter);
pub const CSRF = toJSLazyPropertyCallback(Bun.getCSRFObject);
pub const Directory = toJSLazyPropertyCallback(Bun.getDirectoryConstructor);
pub const FFI = toJSLazyPropertyCallback(Bun.FFIObject.getter);
pub const FileSystemRouter = toJSLazyPropertyCallback(Bun.getFileSystemRouter);
pub const Glob = toJSLazyPropertyCallback(Bun.getGlobConstructor);
@@ -115,6 +117,7 @@ pub const BunObject = struct {
// --- Lazy property callbacks ---
@export(&BunObject.CryptoHasher, .{ .name = lazyPropertyCallbackName("CryptoHasher") });
@export(&BunObject.CSRF, .{ .name = lazyPropertyCallbackName("CSRF") });
@export(&BunObject.Directory, .{ .name = lazyPropertyCallbackName("Directory") });
@export(&BunObject.FFI, .{ .name = lazyPropertyCallbackName("FFI") });
@export(&BunObject.FileSystemRouter, .{ .name = lazyPropertyCallbackName("FileSystemRouter") });
@export(&BunObject.MD4, .{ .name = lazyPropertyCallbackName("MD4") });
@@ -153,6 +156,7 @@ pub const BunObject = struct {
@export(&BunObject.createParsedShellScript, .{ .name = callbackName("createParsedShellScript") });
@export(&BunObject.createShellInterpreter, .{ .name = callbackName("createShellInterpreter") });
@export(&BunObject.deflateSync, .{ .name = callbackName("deflateSync") });
@export(&BunObject.dir, .{ .name = callbackName("dir") });
@export(&BunObject.file, .{ .name = callbackName("file") });
@export(&BunObject.gunzipSync, .{ .name = callbackName("gunzipSync") });
@export(&BunObject.gzipSync, .{ .name = callbackName("gzipSync") });
@@ -1269,6 +1273,16 @@ pub fn getGlobConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc
return jsc.API.Glob.js.getConstructor(globalThis);
}
pub fn getDirectoryConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return api.Directory.js.getConstructor(globalThis);
}
/// Bun.dir(path) - creates a lazy Directory handle
pub fn constructDirectory(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const dir = try api.Directory.constructor(globalObject, callframe);
return dir.toJS(globalObject);
}
pub fn getS3ClientConstructor(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return jsc.WebCore.S3Client.js.getConstructor(globalThis);
}

View File

@@ -0,0 +1,30 @@
import { define } from "../../codegen/class-definitions";
export default [
define({
name: "Directory",
construct: true,
finalize: true,
configurable: false,
klass: {},
JSType: "0b11101110",
proto: {
path: {
getter: "getPath",
cache: true,
},
name: {
getter: "getName",
cache: true,
},
files: {
fn: "files",
length: 0,
},
filesSync: {
fn: "filesSync",
length: 0,
},
},
}),
];

View File

@@ -0,0 +1,298 @@
const Directory = @This();
const std = @import("std");
const bun = @import("bun");
const jsc = bun.jsc;
const strings = bun.strings;
const Environment = bun.Environment;
pub const js = jsc.Codegen.JSDirectory;
pub const toJS = js.toJS;
pub const fromJS = js.fromJS;
pub const fromJSDirect = js.fromJSDirect;
const DirIterator = bun.DirIterator;
const Dirent = bun.jsc.Node.Dirent;
/// The path to the directory. This is stored as a string and the directory
/// is NOT opened until files() or filesSync() is called - this is the lazy
/// loading pattern similar to Bun.file().
path: bun.String,
/// Construct a Directory from JavaScript arguments.
/// Called when: `new Bun.Directory(path)` or `Bun.dir(path)`
pub fn constructor(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!*Directory {
const alloc = bun.default_allocator;
const arguments = callframe.arguments_old(1).slice();
if (arguments.len == 0) {
return globalObject.throwInvalidArguments("Expected directory path string", .{});
}
const path_arg = arguments[0];
if (!path_arg.isString()) {
return globalObject.throwInvalidArgumentTypeValue("path", "string", path_arg);
}
var path_string = try bun.String.fromJS(path_arg, globalObject);
if (path_string.isEmpty()) {
return globalObject.throwInvalidArguments("Path cannot be empty", .{});
}
// Store the path - we don't open the directory yet (lazy loading)
const dir = bun.handleOom(alloc.create(Directory));
dir.* = .{ .path = path_string };
return dir;
}
/// Called when the object is garbage collected
pub fn finalize(this: *Directory) callconv(.c) void {
const alloc = bun.default_allocator;
this.path.deref();
alloc.destroy(this);
}
/// Get the path property
pub fn getPath(this: *Directory, globalObject: *jsc.JSGlobalObject) jsc.JSValue {
return this.path.toJS(globalObject);
}
/// Get the name property (basename of the directory)
pub fn getName(this: *Directory, globalObject: *jsc.JSGlobalObject) jsc.JSValue {
const path_slice = this.path.toUTF8(bun.default_allocator);
defer path_slice.deinit();
const basename = std.fs.path.basename(path_slice.slice());
return bun.String.cloneUTF8(basename).toJS(globalObject);
}
/// Async version of files() - returns Promise<Dirent[]>
pub fn files(this: *Directory, globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const vm = globalObject.bunVM();
// Create a strong reference to the path for the async task
this.path.ref();
var task = FilesTask.new(.{
.path = this.path,
.vm = vm,
});
task.promise = jsc.JSPromise.Strong.init(globalObject);
task.any_task = jsc.AnyTask.New(FilesTask, &FilesTask.runFromJS).init(task);
task.ref.ref(vm);
jsc.WorkPool.schedule(&task.task);
return task.promise.value();
}
/// Sync version of files() - returns Dirent[]
pub fn filesSync(this: *Directory, globalObject: *jsc.JSGlobalObject, _: *jsc.CallFrame) bun.JSError!jsc.JSValue {
const path_slice = this.path.toUTF8(bun.default_allocator);
defer path_slice.deinit();
return readDirectoryEntries(globalObject, path_slice.slice());
}
/// Read directory entries synchronously and return as JS array of Dirent objects
fn readDirectoryEntries(globalObject: *jsc.JSGlobalObject, path: []const u8) bun.JSError!jsc.JSValue {
const path_z = bun.default_allocator.dupeZ(u8, path) catch return globalObject.throw("Out of memory", .{});
defer bun.default_allocator.free(path_z);
// Open the directory
const flags = bun.O.DIRECTORY | bun.O.RDONLY;
const fd = switch (bun.sys.open(path_z, flags, 0)) {
.err => |err| {
const js_err = err.withPath(path).toJS(globalObject);
return globalObject.throwValue(js_err);
},
.result => |fd_result| fd_result,
};
defer fd.close();
// Use the directory iterator
var iterator = DirIterator.iterate(fd, .u8);
// Collect entries
var entries = std.ArrayListUnmanaged(Dirent){};
defer {
for (entries.items) |*item| {
item.deref();
}
entries.deinit(bun.default_allocator);
}
var dirent_path = bun.String.cloneUTF8(path);
defer dirent_path.deref();
var entry = iterator.next();
while (switch (entry) {
.err => |err| {
const js_err = err.withPath(path).toJS(globalObject);
return globalObject.throwValue(js_err);
},
.result => |ent| ent,
}) |current| : (entry = iterator.next()) {
const utf8_name = current.name.slice();
dirent_path.ref();
entries.append(bun.default_allocator, .{
.name = bun.String.cloneUTF8(utf8_name),
.path = dirent_path,
.kind = current.kind,
}) catch return globalObject.throw("Out of memory", .{});
}
// Convert to JS array
var array = try jsc.JSValue.createEmptyArray(globalObject, entries.items.len);
var previous_jsstring: ?*jsc.JSString = null;
for (entries.items, 0..) |*item, i| {
const js_dirent = try item.toJS(globalObject, &previous_jsstring);
try array.putIndex(globalObject, @truncate(i), js_dirent);
}
return array;
}
/// Async task for reading directory entries
const FilesTask = struct {
task: jsc.WorkPoolTask = .{ .callback = &workPoolCallback },
promise: jsc.JSPromise.Strong = .{},
vm: *jsc.VirtualMachine,
path: bun.String,
any_task: jsc.AnyTask = undefined,
ref: bun.Async.KeepAlive = .{},
result: Result = undefined,
const Result = union(enum) {
success: []Dirent,
err: bun.sys.Error,
};
pub const new = bun.TrivialNew(@This());
fn workPoolCallback(task_ptr: *jsc.WorkPoolTask) void {
const this: *FilesTask = @fieldParentPtr("task", task_ptr);
defer this.vm.enqueueTaskConcurrent(jsc.ConcurrentTask.create(this.any_task.task()));
const path_slice = this.path.toUTF8(bun.default_allocator);
defer path_slice.deinit();
const path_z = bun.default_allocator.dupeZ(u8, path_slice.slice()) catch {
this.result = .{ .err = bun.sys.Error.fromCode(.NOMEM, .open) };
return;
};
defer bun.default_allocator.free(path_z);
// Open the directory
const flags = bun.O.DIRECTORY | bun.O.RDONLY;
const fd = switch (bun.sys.open(path_z, flags, 0)) {
.err => |err| {
this.result = .{ .err = err };
return;
},
.result => |fd_result| fd_result,
};
defer fd.close();
// Use the directory iterator
var iterator = DirIterator.iterate(fd, .u8);
// Collect entries
var entries = std.ArrayListUnmanaged(Dirent){};
var dirent_path = bun.String.cloneUTF8(path_slice.slice());
defer dirent_path.deref();
var entry = iterator.next();
while (switch (entry) {
.err => |err| {
for (entries.items) |*item| {
item.deref();
}
entries.deinit(bun.default_allocator);
this.result = .{ .err = err };
return;
},
.result => |ent| ent,
}) |current| : (entry = iterator.next()) {
const utf8_name = current.name.slice();
dirent_path.ref();
entries.append(bun.default_allocator, .{
.name = bun.String.cloneUTF8(utf8_name),
.path = dirent_path,
.kind = current.kind,
}) catch {
for (entries.items) |*item| {
item.deref();
}
entries.deinit(bun.default_allocator);
this.result = .{ .err = bun.sys.Error.fromCode(.NOMEM, .open) };
return;
};
}
this.result = .{ .success = entries.toOwnedSlice(bun.default_allocator) catch &.{} };
}
pub fn runFromJS(this: *FilesTask) bun.JSTerminated!void {
defer this.deinit();
if (this.vm.isShuttingDown()) {
return;
}
const globalObject = this.vm.global;
const promise = this.promise.swap();
switch (this.result) {
.err => |err| {
const js_err = err.toJS(globalObject);
try promise.reject(globalObject, js_err);
},
.success => |entries| {
defer bun.default_allocator.free(entries);
// Convert to JS array
var array = jsc.JSValue.createEmptyArray(globalObject, entries.len) catch {
for (entries) |*item| {
@constCast(item).deref();
}
try promise.reject(globalObject, globalObject.createErrorInstance("Out of memory", .{}));
return;
};
var previous_jsstring: ?*jsc.JSString = null;
for (entries, 0..) |*item, i| {
const js_dirent = @constCast(item).toJS(globalObject, &previous_jsstring) catch {
for (entries[i..]) |*remaining| {
@constCast(remaining).deref();
}
// An exception is already pending from toJS, so we just return
return error.JSTerminated;
};
array.putIndex(globalObject, @truncate(i), js_dirent) catch {
for (entries[i + 1 ..]) |*remaining| {
@constCast(remaining).deref();
}
return error.JSTerminated;
};
}
try promise.resolve(globalObject, array);
},
}
}
fn deinit(this: *FilesTask) void {
this.ref.unref(this.vm);
this.path.deref();
this.promise.deinit();
bun.destroy(this);
}
};
comptime {
_ = js;
}

View File

@@ -5,6 +5,7 @@
#define FOR_EACH_GETTER(macro) \
macro(CSRF) \
macro(CryptoHasher) \
macro(Directory) \
macro(FFI) \
macro(FileSystemRouter) \
macro(Glob) \
@@ -44,6 +45,7 @@
macro(createParsedShellScript) \
macro(createShellInterpreter) \
macro(deflateSync) \
macro(dir) \
macro(file) \
macro(fs) \
macro(gc) \

View File

@@ -742,6 +742,8 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
deepEquals functionBunDeepEquals DontDelete|Function 2
deepMatch functionBunDeepMatch DontDelete|Function 2
deflateSync BunObject_callback_deflateSync DontDelete|Function 1
dir BunObject_callback_dir DontDelete|Function 1
Directory BunObject_lazyPropCb_wrap_Directory DontDelete|PropertyCallback
dns constructDNSObject ReadOnly|DontDelete|PropertyCallback
enableANSIColors BunObject_lazyPropCb_wrap_enableANSIColors DontDelete|PropertyCallback
env constructEnvObject ReadOnly|DontDelete|PropertyCallback

View File

@@ -24,6 +24,7 @@ pub const Classes = struct {
pub const ExpectTypeOf = jsc.Expect.ExpectTypeOf;
pub const ScopeFunctions = jsc.Jest.bun_test.ScopeFunctions;
pub const DoneCallback = jsc.Jest.bun_test.DoneCallback;
pub const Directory = api.Directory;
pub const FileSystemRouter = api.FileSystemRouter;
pub const Glob = api.Glob;
pub const ShellInterpreter = api.Shell.Interpreter;

162
test/js/bun/bun-dir.test.ts Normal file
View File

@@ -0,0 +1,162 @@
import { describe, expect, test } from "bun:test";
import { tempDir } from "harness";
import * as fs from "node:fs";
import * as path from "node:path";
describe("Bun.dir()", () => {
test("creates a lazy Directory object", () => {
const dir = Bun.dir("/tmp");
expect(dir).toBeDefined();
expect(dir).toBeInstanceOf(Bun.Directory);
});
test("has path property", () => {
const dir = Bun.dir("/tmp");
expect(dir.path).toBe("/tmp");
});
test("has name property (basename)", () => {
const dir = Bun.dir("/some/nested/folder");
expect(dir.name).toBe("folder");
});
test("is lazy - doesn't open directory until files() is called", () => {
// This should NOT throw even though the path doesn't exist
const dir = Bun.dir("/this/path/does/not/exist");
expect(dir.path).toBe("/this/path/does/not/exist");
expect(dir.name).toBe("exist");
});
test("throws when path is not a string", () => {
// @ts-expect-error - intentionally passing wrong type
expect(() => Bun.dir(123)).toThrow();
// @ts-expect-error - intentionally passing wrong type
expect(() => Bun.dir(null)).toThrow();
});
test("throws when path is empty", () => {
expect(() => Bun.dir("")).toThrow();
});
});
describe("Bun.Directory constructor", () => {
test("can be constructed with new", () => {
const dir = new Bun.Directory("/tmp");
expect(dir).toBeInstanceOf(Bun.Directory);
expect(dir.path).toBe("/tmp");
});
});
describe("Directory.filesSync()", () => {
test("returns array of Dirent objects", () => {
using dir_path = tempDir("bun-dir-test", {
"file1.txt": "hello",
"file2.txt": "world",
});
// Create subdirectory manually
fs.mkdirSync(path.join(String(dir_path), "subdir"), { recursive: true });
const dir = Bun.dir(String(dir_path));
const entries = dir.filesSync();
expect(Array.isArray(entries)).toBe(true);
expect(entries.length).toBe(3);
// Check that entries are Dirent-like objects
for (const entry of entries) {
expect(typeof entry.name).toBe("string");
expect(typeof entry.isFile).toBe("function");
expect(typeof entry.isDirectory).toBe("function");
}
// Check specific entries
const names = entries.map(e => e.name).sort();
expect(names).toEqual(["file1.txt", "file2.txt", "subdir"]);
// Check file types
const file1 = entries.find(e => e.name === "file1.txt");
expect(file1?.isFile()).toBe(true);
expect(file1?.isDirectory()).toBe(false);
const subdir = entries.find(e => e.name === "subdir");
expect(subdir?.isDirectory()).toBe(true);
expect(subdir?.isFile()).toBe(false);
});
test("throws for non-existent directory", () => {
const dir = Bun.dir("/this/path/definitely/does/not/exist");
expect(() => dir.filesSync()).toThrow();
});
test("returns empty array for empty directory", () => {
using dir_path = tempDir("bun-dir-empty-test", {});
const dir = Bun.dir(String(dir_path));
const entries = dir.filesSync();
expect(Array.isArray(entries)).toBe(true);
expect(entries.length).toBe(0);
});
});
describe("Directory.files()", () => {
test("returns Promise that resolves to array of Dirent objects", async () => {
using dir_path = tempDir("bun-dir-async-test", {
"async1.txt": "async content 1",
"async2.txt": "async content 2",
});
const dir = Bun.dir(String(dir_path));
const promise = dir.files();
expect(promise).toBeInstanceOf(Promise);
const entries = await promise;
expect(Array.isArray(entries)).toBe(true);
expect(entries.length).toBe(2);
const names = entries.map(e => e.name).sort();
expect(names).toEqual(["async1.txt", "async2.txt"]);
});
test("rejects for non-existent directory", async () => {
const dir = Bun.dir("/this/path/definitely/does/not/exist/async");
await expect(dir.files()).rejects.toThrow();
});
test("can be called multiple times on same Directory", async () => {
using dir_path = tempDir("bun-dir-multi-test", {
"multi.txt": "content",
});
const dir = Bun.dir(String(dir_path));
// Call files() multiple times
const [entries1, entries2] = await Promise.all([dir.files(), dir.files()]);
expect(entries1.length).toBe(1);
expect(entries2.length).toBe(1);
expect(entries1[0].name).toBe("multi.txt");
expect(entries2[0].name).toBe("multi.txt");
});
});
describe("Directory Dirent properties", () => {
test("Dirent has parentPath/path property", () => {
using dir_path = tempDir("bun-dir-parent-test", {
"test.txt": "test",
});
const dir = Bun.dir(String(dir_path));
const entries = dir.filesSync();
expect(entries.length).toBe(1);
const entry = entries[0];
// Check path/parentPath
expect(entry.path).toBe(String(dir_path));
expect(entry.parentPath).toBe(String(dir_path));
});
});