-
Notifications
You must be signed in to change notification settings - Fork 2
Compat File
Cross-runtime file system operations with a unified API.
The File module provides a unified interface for file system operations across Deno, Bun, and Node.js runtimes. All operations have both async and sync variants.
- Cross-runtime compatibility - Works seamlessly across Deno, Bun, and Node.js
- Async & Sync variants - All operations available in both modes
- Directory filtering - Filter directory listings by file type, extension, or regex patterns
- Type-safe - Full TypeScript support with detailed type definitions
- Error handling - Specific error types for different failure scenarios
| Feature | Bun | Deno | Node.js | Workers |
|---|---|---|---|---|
| Read files | ✅ | ✅ | ✅ | ✅† |
| Write files | ✅ | ✅ | ✅ | ✅† |
| Low-level file handles | ✅ | ✅ | ✅ | ❌ |
| Path checks | ✅ | ✅ | ✅ | ✅† |
| File stats | ✅ | ✅ | ✅ | ✅† |
| JSON operations | ✅ | ✅ | ✅ | ❌ |
| Directory ops | ✅ | ✅ | ✅ | ❌ |
| Directory filtering | ✅ | ✅ | ✅ | ❌ |
| Remove files/dirs | ✅ | ✅ | ✅ | ✅†§ |
| Temp file/dir creation | ✅ | ✅ | ✅ | opt-in |
†Under /tmp only — see
Cloudflare Workers below.
§deleteFile / deleteFileSync only; remove and the directory
removers still throw.
Under nodejs_compat, workerd resolves node:fs and the path-based
operations run on it — but only under /tmp. Every other location is
refused by the platform itself (operation not permitted for a relative
path, no such file or directory for /var/...), so the path you pass
is the boundary and compat adds no guard of its own.
Workerd's /tmp is in-memory and does not survive the request that
created it — a file written in one request is already gone in the next.
Stage, read back and relay within a single request; never treat it as
storage.
Available on Workers:
-
readFile,readFileSync,readTextFile,readTextFileSync,readFileStream -
writeFile,writeFileSync,writeTextFile,writeTextFileSync -
stat,statSync,pathExists,pathExistsSync -
deleteFile,deleteFileSync
makeTempFile, makeTempFileSync, makeTempDir and makeTempDirSync
choose the location themselves, so the ephemerality would be invisible at
the call site. They keep throwing UnsupportedRuntimeError on Workers
unless you pass allowEphemeral: true:
import {
deleteFile,
makeTempFile,
readFile,
writeFile,
} from '@tundralibs/compat/file';
// Stage an upload, read it back, relay it, clean up — all in one request.
const scratch = await makeTempFile({ allowEphemeral: true, suffix: '.bin' });
await writeFile(scratch, new Uint8Array([1, 2, 3]));
const staged = await readFile(scratch);
await deleteFile(scratch);Everything else — directory operations, copyFile / moveFile /
renameFile, ensureFile, realPath, remove, the JSON helpers and the
openFile handle API — still throws UnsupportedRuntimeError.
Deno:
deno add @tundralibs/compatBun:
bunx jsr add @tundralibs/compatNode.js:
npx jsr add @tundralibs/compatEvery function below that takes a
pathvalidates it first: empty or whitespace-only strings and paths containing a null byte (\0) throwFileInvalidPathimmediately, before touching the filesystem. Paths longer than 4096 characters (260 on Windows) throw the same error. Path traversal (../..) and absolute paths are never blocked — this is a general-purpose primitive with no notion of an "allowed root". Code that must confine access to a directory has to resolve and check paths against its own root before calling in; a malicious../../etc/passwdpassed straight through from user input will be read without complaint.
Checks if a path exists.
async function pathExists(path: string): Promise<boolean>;Example:
import { pathExists } from '@tundralibs/compat/file';
const exists = await pathExists('./config.json');
console.log(exists); // true or falseSynchronous version of pathExists().
function pathExistsSync(path: string): boolean;Checks if a path points to a file.
async function isFile(path: string): Promise<boolean>;
function isFileSync(path: string): boolean;Example:
import { isFile } from '@tundralibs/compat/file';
const isRegularFile = await isFile('./document.pdf');Checks if a path points to a directory.
async function isDirectory(path: string): Promise<boolean>;
function isDirectorySync(path: string): boolean;Aliases: isDir(), isDirSync()
Resolves a path to its absolute, canonical form — following symlinks and
collapsing ./.. segments against the real filesystem. Unlike
path.resolve() from @tundralibs/compat/path (string-only, no I/O and no
symlink resolution), this one touches disk and the path must exist.
async function realPath(path: string): Promise<string>;
function realPathSync(path: string): string;Example:
import { realPath } from '@tundralibs/compat/file';
// Resolve a symlinked or relative path to its canonical absolute form.
const absolute = await realPath('./src');
console.log(absolute); // e.g. '/home/user/project/src'Reach for
realPathwhen you need the canonical location on disk (e.g. to compare two paths for identity, or to log where a symlink actually points). Reach forpath.resolve()instead when you just need a syntactically absolute path and the target may not exist yet —realPaththrowsFileNotFoundfor a missing path,path.resolve()never touches the filesystem.
Gets detailed file or directory information.
async function stat(path: string): Promise<FileInfo>;
function statSync(path: string): FileInfo;
interface FileInfo {
isFile: boolean;
isDirectory: boolean;
isSymlink: boolean;
size: number;
mtime: Date | null;
atime: Date | null;
birthtime: Date | null;
mode: number | null;
uid: number | null;
gid: number | null;
}Example:
import { stat } from '@tundralibs/compat/file';
const info = await stat('./data.txt');
console.log(`Size: ${info.size} bytes`);
console.log(`Modified: ${info.mtime}`);Reads a file as binary data.
async function readFile(path: string): Promise<Uint8Array>;
function readFileSync(path: string): Uint8Array;Example:
import { readFile } from '@tundralibs/compat/file';
const data = await readFile('./image.png');
console.log(data.length); // File size in bytesOpens a file as a ReadableStream<Uint8Array> without buffering it. start
and end are inclusive byte offsets; either may be omitted.
async function readFileStream(
path: string,
options?: { start?: number; end?: number },
): Promise<ReadableStream<Uint8Array>>;Example:
import { readFileStream } from '@tundralibs/compat/file';
const body = await readFileStream('./video.mp4', { start: 0, end: 1023 });
const response = new Response(body, { status: 206 });Reads a file as UTF-8 text.
async function readTextFile(path: string): Promise<string>;
function readTextFileSync(path: string): string;Example:
import { readTextFile } from '@tundralibs/compat/file';
const content = await readTextFile('./README.md');
console.log(content);Reads and parses a JSON file.
async function readJSONFile<T extends Record<string, unknown>>(
path: string,
): Promise<T>;
function readJSONFileSync<T extends Record<string, unknown>>(path: string): T;Example:
import { readJSONFile } from '@tundralibs/compat/file';
type Config = {
port: number;
host: string;
};
const config = await readJSONFile<Config>('./config.json');
console.log(config.port);Writes binary data to a file.
async function writeFile(
path: string,
data: Uint8Array,
options?: WriteOptions,
): Promise<void>;
function writeFileSync(
path: string,
data: Uint8Array,
options?: WriteOptions,
): void;
type WriteOptions = {
/** Whether to append to the file instead of overwriting. Defaults to false. */
append?: boolean;
/** Whether to create the file if it doesn't exist. Defaults to true. */
create?: boolean;
/** File mode (permissions), e.g., 0o644 for rw-r--r--. */
mode?: number;
};Behavior (identical across Deno, Bun, and Node):
- Unless
appendistrue, the target file is truncated to the written content — overwriting a longer file with shorter content leaves no stale trailing bytes. - With
create: falsethe file must already exist. A missing file throwsFileNotFound; the file is not created, even whenappend: true.
Example:
import { writeFile } from '@tundralibs/compat/file';
const data = new Uint8Array([1, 2, 3, 4, 5]);
await writeFile('./data.bin', data);Writes text to a file.
async function writeTextFile(
path: string,
content: string,
options?: WriteOptions,
): Promise<void>;
function writeTextFileSync(
path: string,
content: string,
options?: WriteOptions,
): void;Example:
import { writeTextFile } from '@tundralibs/compat/file';
await writeTextFile('./output.txt', 'Hello, World!');Writes an object as JSON to a file.
async function writeJSONFile(
path: string,
data: unknown,
options?: WriteOptions & { space?: number | string },
): Promise<void>;
function writeJSONFileSync(
path: string,
data: unknown,
options?: WriteOptions & { space?: number | string },
): void;Example:
import { writeJSONFile } from '@tundralibs/compat/file';
const config = { port: 3000, host: 'localhost' };
await writeJSONFile('./config.json', config, { space: 2 });Makes sure a file exists: a no-op (content untouched) if it's already there, or an empty file — plus any missing parent directories — if not.
async function ensureFile(
path: string,
options?: { mode?: number },
): Promise<void>;
function ensureFileSync(
path: string,
options?: { mode?: number },
): void;Example:
import { ensureFile } from '@tundralibs/compat/file';
// Creates ./data/cache/ and ./data/cache/session.json if either is
// missing; leaves an existing session.json's content untouched.
await ensureFile('./data/cache/session.json');Unlike
writeFile(path, data, { create: true }),ensureFilenever writes or truncates an existing file's content — it only ever creates an empty one when nothing is there. Use it to guarantee a file is present before opening it for append, not to reset it.
Throws: FileTypeMismatch if the path exists but is a directory.
Low-level file handle operations for fine-grained control over file I/O. Useful for high-performance scenarios like logging where you need control over buffering and disk syncing.
Key Features:
-
Type Safety:
AsyncFileHandleandSyncFileHandleare separate types that only expose appropriate methods - No Method Mixing: Async handles only have async methods, sync handles only have sync methods
- Cross-Runtime: Works identically across Deno, Bun, and Node.js with optimized implementations
- Resource Management: Proper file descriptor handling prevents resource leaks and GC issues
Opens a file and returns an async file handle with only async methods.
async function openFile(
path: string,
options: OpenOptions,
): Promise<AsyncFileHandle>;
interface OpenOptions {
read?: boolean;
write?: boolean;
append?: boolean;
create?: boolean;
truncate?: boolean;
mode?: number; // Unix permissions
}
type AsyncFileHandle = {
readonly path: string;
readonly closed: boolean;
write(data: Uint8Array): Promise<number>; // Always returns Promise
sync(): Promise<void>; // Always returns Promise
close(): void; // Sync cleanup
};Parameters:
-
path- File path to open -
options- Open options (read, write, append, create, truncate)
createvstruncate:createopens an existing file or makes a new one; on its own it does not clear existing content — writes overwrite from offset 0 and any trailing bytes remain. Passtruncate: trueto clear the file to zero length first. This behaviour is identical across Deno, Bun, and Node.js (openFileSyncfollows the same rules).
Returns: Promise resolving to an AsyncFileHandle
Throws:
-
FileNotFound- If file doesn't exist andcreateis false -
FileAccessDenied- If permission is denied -
FileInvalidPath- If path is invalid
Important: Always close the file handle when done to avoid resource leaks. Use try/finally blocks.
Technical Note: In Node.js,
openFile()keeps the fullFileHandleobject instead of extracting the file descriptor. This prevents garbage collection issues where the FileHandle's finalizer would attempt to close an already-closed descriptor, which could causeEBADFerrors.
Runtime Implementation:
-
Deno: Uses
Deno.FsFilewith native async methods - Bun: Uses numeric file descriptor with callback-based operations
-
Node.js: Uses
FileHandleobject fromfs.promises.open()for proper resource management
Example - Basic logging:
import { openFile } from '@tundralibs/compat/file';
const file = await openFile('./app.log', {
write: true,
create: true,
append: true,
});
try {
const encoder = new TextEncoder();
await file.write(encoder.encode('Log entry\n'));
await file.sync(); // Ensure data is written to disk
} finally {
file.close(); // Always close to release resources
}Example - High-performance buffered logging:
import { openFile } from '@tundralibs/compat/file';
const file = await openFile('./performance.log', {
write: true,
create: true,
append: true,
});
const buffer: Uint8Array[] = [];
let bufferSize = 0;
const MAX_BUFFER = 4096;
async function log(message: string) {
const data = new TextEncoder().encode(message + '\n');
buffer.push(data);
bufferSize += data.length;
if (bufferSize >= MAX_BUFFER) {
await flush();
}
}
async function flush() {
for (const data of buffer) {
await file.write(data);
}
await file.sync(); // Critical for data durability
buffer.length = 0;
bufferSize = 0;
}
// Usage
await log('Event 1');
await log('Event 2');
await flush(); // Flush remaining
file.close();Example - Truncate existing file:
import { openFile } from '@tundralibs/compat/file';
const file = await openFile('./output.txt', {
write: true,
create: true,
truncate: true, // Clear existing content
});
try {
await file.write(new TextEncoder().encode('Fresh content'));
} finally {
file.close();
}Synchronously opens a file and returns a sync file handle with only sync methods.
function openFileSync(
path: string,
options: OpenOptions,
): SyncFileHandle;
type SyncFileHandle = {
readonly path: string;
readonly closed: boolean;
write(data: Uint8Array): number; // Returns number directly (blocking)
sync(): void; // Returns void directly (blocking)
close(): void; // Sync cleanup
};Parameters:
-
path- File path to open -
options- Open options (read, write, append, create, truncate)
Returns: A SyncFileHandle
Throws:
-
FileNotFound- If file doesn't exist andcreateis false -
FileAccessDenied- If permission is denied -
FileInvalidPath- If path is invalid
Runtime Implementation:
-
Deno: Uses
Deno.FsFilewith native sync methods - Bun: Uses numeric file descriptor with sync operations
-
Node.js: Uses numeric file descriptor from
fs.openSync()
Example:
import { openFileSync } from '@tundralibs/compat/file';
const file = openFileSync('./config.txt', {
write: true,
create: true,
});
try {
const encoder = new TextEncoder();
file.write(encoder.encode('config=value\n'));
file.sync(); // Ensure data is persisted
} finally {
file.close();
}Type Safety and Method Separation:
The file handles use strict type separation to prevent accidentally mixing async and sync operations:
import { openFile, openFileSync } from '@tundralibs/compat/file';
declare const data: Uint8Array;
// ✅ Async handle - Only async methods available
const asyncFile = await openFile('./log.txt', { write: true, create: true });
const asyncBytes = await asyncFile.write(data); // Returns Promise<number>
await asyncFile.sync(); // Returns Promise<void>
// asyncFile.writeSync() doesn't exist! // ❌ Not available
// ✅ Sync handle - Only sync methods available
const syncFile = openFileSync('./log.txt', { write: true, create: true });
const syncBytes = syncFile.write(data); // Returns number directly (blocking)
syncFile.sync(); // Returns void directly (blocking)
// syncFile.write() never returns Promise // ❌ Always blockingWhy This Matters:
// ❌ This would be dangerous if allowed:
const file = await openFile('./log.txt', { write: true });
file.writeSync(data); // Would block event loop in async context!
// ✅ Instead, the type system prevents this:
const file = await openFile('./log.txt', { write: true });
// file.writeSync is not defined - TypeScript error!This design ensures you can't accidentally block the event loop by using sync operations on an async handle, or waste resources by trying to await sync operations.
Creates a directory.
async function makeDir(
path: string,
options?: { recursive?: boolean; mode?: number },
): Promise<void>;
function makeDirSync(
path: string,
options?: { recursive?: boolean; mode?: number },
): void;Example:
import { makeDir } from '@tundralibs/compat/file';
// Create nested directories
await makeDir('./data/logs/2024', { recursive: true });
makeDirthrowsFileAlreadyExistsif the directory is already there — even withrecursive: true, which only means "create missing parents", not "ignore an existing target". UseensureDir()below when "already there" should be a no-op instead of an error.
Makes sure a directory exists: a no-op if it's already there, or creates
it — and any missing parents — if not. This is makeDir with
recursive: true plus a swallowed "already exists" error, so it's the
one to reach for when you just want a directory to be present rather than
to detect whether you created it.
async function ensureDir(
path: string,
options?: { mode?: number },
): Promise<void>;
function ensureDirSync(
path: string,
options?: { mode?: number },
): void;Example:
import { ensureDir } from '@tundralibs/compat/file';
// Safe to call on every startup — creates the tree once, no-ops after.
await ensureDir('./data/logs/2024', { mode: 0o755 });Throws: FileTypeMismatch if the path exists but is a file, not a
directory.
Lists directory contents with optional filtering.
async function readDir(
path: string,
options?: ReadDirOptions,
): AsyncIterable<DirectoryEntry>;
function readDirSync(
path: string,
options?: ReadDirOptions,
): Iterable<DirectoryEntry>;
interface DirectoryEntry {
name: string;
path: string;
isFile: boolean;
isDirectory: boolean;
isSymlink: boolean;
}
interface ReadDirOptions {
/** Include files in the results (default: true) */
includeFiles?: boolean;
/** Include directories in the results (default: true) */
includeDirs?: boolean;
/** Array of RegExp patterns - only include entries matching at least one pattern */
match?: Array<RegExp>;
/** Array of RegExp patterns - exclude entries matching any pattern */
skip?: Array<RegExp>;
/** Array of file extensions to include (e.g., ['.ts', '.js']) - only applies to files */
exts?: Array<string>;
}Example:
import { readDir, readDirSync } from '@tundralibs/compat/file';
// List all entries
for await (const entry of readDir('./src')) {
console.log(`${entry.name} (${entry.isFile ? 'file' : 'dir'})`);
}
// Only TypeScript files
for await (const entry of readDir('./src', { exts: ['.ts'] })) {
console.log(entry.name);
}
// Skip test files
for await (const entry of readDir('./src', { skip: [/\.test\./] })) {
console.log(entry.name);
}
// Only directories
for await (const entry of readDir('./src', { includeFiles: false })) {
console.log(entry.name);
}
// Combined filters: TypeScript files matching pattern, excluding tests
for await (
const entry of readDir('./src', {
exts: ['.ts'],
match: [/^app/],
skip: [/\.test\./],
})
) {
console.log(entry.name);
}
// Synchronous version
for (const entry of readDirSync('./config', { exts: ['.json', '.yaml'] })) {
console.log(entry.name);
}Deletes a single file. Unlike remove() below, this refuses a directory
target instead of recursing into it — a FileTypeMismatch guard against
accidentally deleting a whole tree when you meant to delete one file.
async function deleteFile(path: string): Promise<void>;
function deleteFileSync(path: string): void;Example:
import { deleteFile } from '@tundralibs/compat/file';
await deleteFile('./temp.txt');Throws: FileTypeMismatch if path is a directory.
Removes a file or directory.
async function remove(path: string): Promise<void>;
function removeSync(path: string): void;Example:
import { remove } from '@tundralibs/compat/file';
// Remove file
await remove('./temp.txt');
// Remove directory and contents (directories are always removed recursively)
await remove('./temp-dir');Removes a directory. Empty-only unless recursive: true — the directory
counterpart to deleteFile()'s "don't recurse by accident" stance, just
inverted: here recursion is opt-in rather than always-on the way it is in
remove().
async function removeDir(
path: string,
options?: { recursive?: boolean },
): Promise<void>;
function removeDirSync(
path: string,
options?: { recursive?: boolean },
): void;Example:
import { removeDir } from '@tundralibs/compat/file';
await removeDir('./empty-cache-dir');
await removeDir('./build-output', { recursive: true });Without
recursive: true, a non-empty directory throws — verified asENOTEMPTYon both Deno and Node/Bun, which isn't in the mapped-error list, so it surfaces as a genericFileOperationError, notFileTypeMismatchorFileAlreadyExists. Reach foremptyDir()above instead when you want to clear contents but keep the directory itself.
Removes all contents of a directory while keeping the directory.
async function emptyDir(path: string): Promise<void>;
function emptyDirSync(path: string): void;Example:
import { emptyDir } from '@tundralibs/compat/file';
await emptyDir('./cache');Copies a file.
async function copyFile(
src: string,
dest: string,
): Promise<void>;
function copyFileSync(
src: string,
dest: string,
): void;Example:
import { copyFile } from '@tundralibs/compat/file';
await copyFile('./source.txt', './backup/source.txt');
copyFilesilently overwrites an existingdest— it maps directly ontoDeno.copyFile()/fs.promises.copyFile(), neither of which checks for an existing destination first. There is nooverwriteoption at the single-file level (unlikecopyDirbelow, which has one).
Recursively copies a directory and everything under it, creating the destination (and any missing parents) automatically.
async function copyDir(
src: string,
dest: string,
options?: { overwrite?: boolean },
): Promise<void>;
function copyDirSync(
src: string,
dest: string,
options?: { overwrite?: boolean },
): void;Example:
import { copyDir } from '@tundralibs/compat/file';
// Fails fast on the first file that already exists in dest.
await copyDir('./templates', './build/templates');
// Overwrite instead of failing.
await copyDir('./templates', './build/templates', { overwrite: true });Unlike
copyFile,copyDirdefaults to not overwriting: withoverwrite: false(the default) it throwsFileAlreadyExistson the first pre-existing file it walks into, leaving a partial copy behind — it does not roll back what it already copied. Passoverwrite: trueto copy over an existing tree, oremptyDir(dest)first for a clean copy.
**
moveFile/renameFile/moveDir/renameDirsilently overwrite an existing destination on POSIX (Linux/macOS) — verified against source: all four map straight ontoDeno.rename()/fs.promises.rename(), and POSIXrename(2)replaces an existing destination without error. None of them pre-checkdestthe waycopyDir/movedo. If you need "fail instead of clobber" semantics, checkpathExists(dest)yourself first, or use the genericmove()below, which does that check for you. (Windowsrenamesemantics are not verified here — treat them as unconfirmed rather than assuming POSIX behavior.)
Moves a file to a new path, including across directories. A thin wrapper over rename — see the overwrite callout above.
async function moveFile(src: string, dest: string): Promise<void>;
function moveFileSync(src: string, dest: string): void;Example:
import { moveFile } from '@tundralibs/compat/file';
await moveFile('./inbox/report.csv', './archive/2024/report.csv');Throws: FileNotFound if src doesn't exist.
Renames a file within its current directory. newName is a bare file
name, not a path — it's joined onto dirname(filePath) for you, so
passing a path with its own separators produces a nested/incorrect result
rather than an error.
async function renameFile(filePath: string, newName: string): Promise<void>;
function renameFileSync(filePath: string, newName: string): void;Example:
import { renameFile } from '@tundralibs/compat/file';
await renameFile('/data/reports/draft.csv', 'final.csv');
// Result: /data/reports/final.csvThrows: FileNotFound if filePath doesn't exist.
Directory counterparts of moveFile/renameFile — same rename-based
implementation, same silent-overwrite-on-POSIX caveat above, same
bare-name contract for renameDir's second argument.
async function moveDir(src: string, dest: string): Promise<void>;
function moveDirSync(src: string, dest: string): void;
async function renameDir(dirPath: string, newName: string): Promise<void>;
function renameDirSync(dirPath: string, newName: string): void;Example:
import { moveDir, renameDir } from '@tundralibs/compat/file';
await moveDir('./build/staging', './releases/v2');
await renameDir('/data/projects/old-name', 'new-name');
// renameDir result: /data/projects/new-nameThe one to reach for when you want a safe move: unlike the four
functions above, move checks dest first and throws FileAlreadyExists
if anything is already there — it never silently overwrites. It also
handles moving across filesystems/devices, where a plain rename fails
with EXDEV: on that specific error it transparently falls back to
copy-then-delete (copyFile+deleteFile, or copyDir+removeDir for a
directory src). Works for both files and directories — moveFile and
moveDir are separate functions because their JSDoc/signatures target one
kind each, but move inspects src via stat() and dispatches itself.
async function move(src: string, dest: string): Promise<void>;
function moveSync(src: string, dest: string): void;Example:
import { move } from '@tundralibs/compat/file';
// Same device: fast rename. Different device (e.g. src on a mounted
// volume): falls back to copy+delete automatically.
await move('/mnt/incoming/upload.bin', '/data/processed/upload.bin');
// Works for directories too.
await move('./staging-dir', './final-dir');Reach for
move()when the destination might already exist and you want that to be an error, or whensrc/destmight be on different filesystems (e.g./tmpvs. a mounted volume, or two separate Docker volumes). Reach formoveFile/moveDirwhen you specifically want rename-or-clobber semantics and both paths are guaranteed to be on the same device.
Throws: FileNotFound if src doesn't exist; FileAlreadyExists if
dest already exists.
type TempOptions = {
/** Directory to create the temp file/dir in. Defaults to the system temp directory. */
dir?: string;
/** Prefix for the generated name. */
prefix?: string;
/** Suffix for the generated name (e.g. an extension). */
suffix?: string;
/** Required to be `true` on Cloudflare Workers — see below. Ignored elsewhere. */
allowEphemeral?: boolean;
};Creates a new, empty, uniquely-named file and returns its path.
async function makeTempFile(options?: TempOptions): Promise<string>;
function makeTempFileSync(options?: TempOptions): string;Example:
import { makeTempFile } from '@tundralibs/compat/file';
const tempFile = await makeTempFile({ prefix: 'upload-', suffix: '.tmp' });
// e.g. '/tmp/upload-a1b2c3d4-....tmp'Creates a new, empty, uniquely-named directory and returns its path.
async function makeTempDir(options?: TempOptions): Promise<string>;
function makeTempDirSync(options?: TempOptions): string;Example:
import { makeTempDir, removeDir } from '@tundralibs/compat/file';
const workDir = await makeTempDir({ prefix: 'build-' });
try {
// ... write intermediate build output into workDir ...
} finally {
await removeDir(workDir, { recursive: true });
}On Deno,
dir/prefix/suffixpass straight through toDeno.makeTempFile/Deno.makeTempDir. On Bun/Node there is no native equivalent, so compat builds the name itself fromcrypto.randomUUID()(collision-free and unguessable — an earlierDate.now()+Math.random()scheme was a locally-guessable-path footgun and was replaced). Neither runtime path auto-cleans the result: you are responsible for deleting what you create, typically in afinallyblock viadeleteFile()/removeDir({ recursive: true }).On Cloudflare Workers, all four of these throw
UnsupportedRuntimeErrorunless you passallowEphemeral: true— see Cloudflare Workers above for why: the location is workerd's in-memory/tmp, which does not survive the request that created it, and unlike the path-based operations you never chose that location yourself, so the ephemerality would otherwise be invisible at the call site.
import {
pathExists,
readJSONFile,
writeJSONFile,
} from '@tundralibs/compat/file';
type AppConfig = {
port: number;
host: string;
debug: boolean;
};
async function loadConfig(path: string): Promise<AppConfig> {
const defaults: AppConfig = {
port: 3000,
host: 'localhost',
debug: false,
};
if (await pathExists(path)) {
return await readJSONFile<AppConfig>(path);
}
await writeJSONFile(path, defaults, { space: 2 });
return defaults;
}import {
isDirectory,
makeDir,
pathExists,
writeTextFile,
} from '@tundralibs/compat/file';
import * as path from '@tundralibs/compat/path';
async function safeWriteFile(
filePath: string,
content: string,
): Promise<void> {
const dir = path.dirname(filePath);
// Ensure directory exists
if (!await pathExists(dir)) {
await makeDir(dir, { recursive: true });
} else if (!await isDirectory(dir)) {
throw new Error(`${dir} exists but is not a directory`);
}
await writeTextFile(filePath, content);
}import { type AsyncFileHandle, openFile } from '@tundralibs/compat/file';
/**
* High-performance logger using low-level file handles
* with batched writes and explicit flushing.
*/
class PerformanceLogger {
private file: AsyncFileHandle | null = null;
private buffer: Uint8Array[] = [];
private bufferSize = 0;
private readonly encoder = new TextEncoder();
private readonly maxBufferSize = 4096;
async open(path: string): Promise<void> {
this.file = await openFile(path, {
write: true,
create: true,
append: true,
});
}
async log(message: string): Promise<void> {
if (!this.file) {
throw new Error('Logger not initialized');
}
const data = this.encoder.encode(
`[${new Date().toISOString()}] ${message}\n`,
);
this.buffer.push(data);
this.bufferSize += data.length;
// Auto-flush when buffer is full
if (this.bufferSize >= this.maxBufferSize) {
await this.flush();
}
}
async flush(): Promise<void> {
if (!this.file || this.buffer.length === 0) {
return;
}
// Write all buffered data
for (const data of this.buffer) {
await this.file.write(data);
}
// Sync to disk for durability
await this.file.sync();
// Clear buffer
this.buffer.length = 0;
this.bufferSize = 0;
}
async close(): Promise<void> {
if (this.file) {
await this.flush(); // Flush remaining data
this.file.close();
this.file = null;
}
}
}
// Usage
const logger = new PerformanceLogger();
await logger.open('./app.log');
try {
await logger.log('Application started');
await logger.log('Processing request');
await logger.log('Request completed');
} finally {
await logger.close(); // Always close to flush and release resources
}import { isFile, readDir, readTextFile } from '@tundralibs/compat/file';
import * as path from '@tundralibs/compat/path';
// Basic directory processing
async function processMarkdownFiles(dirPath: string): Promise<void> {
for await (const entry of readDir(dirPath)) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isFile && path.extname(entry.name) === '.md') {
const content = await readTextFile(fullPath);
console.log(`Processing ${entry.name} (${content.length} chars)`);
} else if (entry.isDirectory) {
await processMarkdownFiles(fullPath); // Recursive
}
}
}
// Using filters for more efficient processing
async function processSourceFiles(dirPath: string): Promise<void> {
// Only process .ts files, skip test files
for await (
const entry of readDir(dirPath, {
exts: ['.ts'],
skip: [/\.test\./, /\.spec\./],
})
) {
const content = await readTextFile(entry.path);
console.log(`Processing ${entry.name}`);
}
}
// Count files by type
async function countFilesByType(
dirPath: string,
): Promise<Record<string, number>> {
const counts: Record<string, number> = {};
for await (
const entry of readDir(dirPath, { includeFiles: true, includeDirs: false })
) {
const ext = path.extname(entry.name) || 'no-extension';
counts[ext] = (counts[ext] || 0) + 1;
}
return counts;
}
// Find configuration files
async function findConfigFiles(dirPath: string): Promise<string[]> {
const configFiles: string[] = [];
for await (
const entry of readDir(dirPath, {
exts: ['.json', '.yaml', '.yml', '.toml'],
match: [/config/, /settings/],
})
) {
configFiles.push(entry.path);
}
return configFiles;
}Converts a file:// URL to a platform-specific file path.
function fromFileUrl(url: string | URL): string;Parameters:
-
url- The file URL to convert (string or URL object)
Returns: The file path as a string
Throws: FileOperationError if the URL doesn't use the file: protocol
Example:
import { fromFileUrl } from '@tundralibs/compat/file';
// Basic conversion
const unixPath = fromFileUrl('file:///home/user/file.txt');
console.log(unixPath); // '/home/user/file.txt' on Unix
// With URL object
const url = new URL('file:///C:/Users/user/file.txt');
const windowsPath = fromFileUrl(url);
console.log(windowsPath); // 'C:\\Users\\user\\file.txt' on Windows
// With encoded characters
const decodedPath = fromFileUrl('file:///path/to/file%20with%20spaces.txt');
console.log(decodedPath); // '/path/to/file with spaces.txt'Converts a file path to a file:// URL.
function toFileUrl(filePath: string): URL;Parameters:
-
filePath- The file path to convert (relative or absolute)
Returns: A URL object with the file: protocol
Throws: FileOperationError if the path is invalid
Example:
import { fromFileUrl, toFileUrl } from '@tundralibs/compat/file';
// Basic conversion
const unixUrl = toFileUrl('/home/user/file.txt');
console.log(unixUrl.href); // 'file:///home/user/file.txt'
// Windows path
const windowsUrl = toFileUrl('C:\\Users\\user\\file.txt');
console.log(windowsUrl.href); // 'file:///C:/Users/user/file.txt'
// Relative path (converts to absolute)
const relativeUrl = toFileUrl('./file.txt');
console.log(relativeUrl.href); // 'file:///current/working/dir/file.txt'
// Round-trip conversion
const originalPath = '/home/user/file.txt';
const roundTripUrl = toFileUrl(originalPath);
const convertedPath = fromFileUrl(roundTripUrl);
console.log(originalPath === convertedPath); // trueUse Cases:
- Web Workers: Pass file paths to web workers that expect URLs
- Module Loading: Convert file paths to URLs for dynamic imports
- Cross-Platform: Normalize path representation across different operating systems
- APIs: Work with APIs that require file URLs instead of paths
All file operations throw specific error types:
-
FileNotFound- File or directory doesn't exist -
FileAccessDenied- Permission denied -
FileInvalidPath- Invalid path format: empty/whitespace, contains a null byte, or longer than 4096 characters (260 on Windows). Not thrown for path traversal (../..) or absolute paths — those are never rejected; see the callout at the top of API Reference. -
FileAlreadyExists- File already exists -
FileTypeMismatch- Path exists but is a different type than expected (e.g. directory where a file was expected) -
FileOperationError- Base class every error above extends, and also the catch-all for a runtime error code compat doesn't map to a more specific class (e.g.ENOTEMPTYfromremoveDir()withoutrecursive).error instanceof FileOperationErrorcatches all of the above at once.
Example:
import {
FileAccessDenied,
FileNotFound,
readTextFile,
} from '@tundralibs/compat/file';
try {
const content = await readTextFile('./config.json');
} catch (error) {
if (error instanceof FileNotFound) {
console.error('Config file not found');
} else if (error instanceof FileAccessDenied) {
console.error('Permission denied');
} else {
throw error;
}
}- Use async operations - Prefer async over sync for better performance
-
Check paths first - Use
pathExists()before operations when appropriate - Handle errors - Catch and handle specific error types
-
Use recursive options - Use
{ recursive: true }for nested paths - Validate paths - Sanitize user-provided paths before use
- Always close file handles - Use try/finally blocks to ensure handles are closed
- Batch writes with file handles - For high-performance scenarios, use file handles with buffering
-
Call sync() for critical data - Use
file.sync()after writing important data to ensure disk persistence
import { openFile } from '@tundralibs/compat/file';
declare const data: Uint8Array;
// ✅ Good - Always close in finally
const file = await openFile('./data.txt', { write: true, create: true });
try {
await file.write(data);
await file.sync(); // Ensure durability
} finally {
file.close(); // Always execute
}
// ❌ Bad - File might not close if error occurs
const unguardedFile = await openFile('./data.txt', {
write: true,
create: true,
});
await unguardedFile.write(data);
unguardedFile.close(); // Might not execute