Skip to content
Warlock.js v4

Introduction

@warlock.js/fs is a thin opinionated wrapper around node:fs and node:fs/promises. It exists because nobody likes writing this every time they want to write a JSON file:

import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");

That’s the shape of half the boilerplate in any Node codebase. So:

import { putJsonFileAsync } from "@warlock.js/fs";
await putJsonFileAsync(filePath, value);

Same effect. No setup. Parent directories created. JSON pretty-printed. UTF-8. Done.

Without this package, a typical Node app reaches for a fleet of single-purpose libraries — fs-extra for outputFile, mkdirp for recursive mkdir, rimraf for recursive delete, write-file-atomic for atomic writes, hasha or md5-file for hashing, jsonfile for JSON helpers. Each one is fine. The stack of them is not.

@warlock.js/fs covers the ground all of those cover, in one place, with zero runtime deps beyond Node’s standard library.

CapabilityRaw node:fs@warlock.js/fs
Write a file, creating parent dirsmkdir({recursive: true}) + writeFileputFileAsync(path, content)
Write JSONwriteFile(path, JSON.stringify(v, null, 2))putJsonFileAsync(path, v)
Atomic write (no half-written reads)Hand-roll temp-file + rename + cleanupatomicWriteAsync(path, content)
Recursive delete (no-throw on missing)rm({recursive, force}) + filter ENOENTremoveDirectoryAsync(path)
Recursive mkdirmkdir({recursive: true})ensureDirectoryAsync(path)
Stream-hash a fileHand-roll createHash + createReadStreamhashFileAsync(path)
Hash an in-memory stringcreateHash('sha256').update(s).digest('hex')hashString(s)
Exists-and-is-a-filestat(path).isFile() + try/catch ENOENTfileExistsAsync(path)
List only files in a dirreaddir + stat each + filterlistFilesAsync(path)

The pattern: every helper does the obvious right thing for the common case. You drop down to node:fs directly when you need something exotic (symlinks, watching, FIFOs, low-level FD operations).

Two suffixes. That’s it.

  • *Async — returns a Promise. Use this everywhere in a running server / app.
  • bare name — synchronous, blocking. Use this in CLI tools, config loaders, code generators — anywhere that runs once and there’s nothing else for the event loop to do.

There is no *Sync suffix. The sync calls are the bare names. Reason: if both halves had a suffix, you’d have to remember which is which. With this convention, sync is the default and async is decorated — the decoration matches its surface area (Promise, await, etc).

// async — the everyday choice in a server
const content = await getFileAsync("./config.toml");
// sync — fine in a CLI, blocking is acceptable here
const content = getFile("./config.toml");

Reach for @warlock.js/fs when:

  • You’re writing files and want parent-dir creation for free.
  • You want atomic writes for config / manifest / state files.
  • You need a content hash for cache invalidation or ETag generation.
  • You’re doing the “ensure dir, list files, copy, delete” dance.

Stay on node:fs when:

  • You need streaming reads of a partial file.
  • You’re watching files (fs.watch).
  • You need low-level descriptor operations (open, read, pwrite).
  • You’re working with symlinks, FIFOs, or permission bits.

Both can coexist in the same file. Use whichever reads clearer for the task at hand.

  • Installation — install the package.
  • Your first write — five-minute walkthrough that ensures a directory, writes a JSON file, reads it back.