node:fs

Node.js fs module, resolvable via both ESM import and CommonJS require, with or without the node: prefix. Elide ships its own implementation of node:fs (it is no longer served by the embedding engine): the namespace is defined in WebIDL, backed by a host filesystem layer built on java.nio, and registered like the other Elide-native Node modules (node:path, node:os, node:crypto).

The synchronous, callback, promise, stream, and watch APIs are complete. Callback forms, fs.promises / node:fs/promises, FileHandle, AbortSignal cancellation, file streams (createReadStream / createWriteStream), and filesystem watching (watch / watchFile / unwatchFile) are all supported.

How it works

  • The surface is declared in protocol/webidl/node/fs.webidl; the generated base JSFsNamespaceBase is implemented by FsNamespace (packages/base/main/dev/elide/lang/javascript/node/fs/).
  • Operations marshal their arguments and call the host layer FsPrimitives (.../lang/javascript/fs/host/), built over java.nio.file.Files and FileChannel.
  • Asynchronous forms run on FsPrimitivesAsync (the same java.nio layer over ForkJoinPool.commonPool, returning CompletableFuture); results are delivered back to the guest on the event-loop tick, where they settle a native guest promise or invoke the callback. This works in both one-shot elide run and long-lived server modes.
  • NodeFsModule and NodeFsPromisesModule register fs and fs/promises in JavaScriptLang.initAllModules(), so require/import of either (with or without the node: prefix) resolve through Elide's module registry.
  • File streams are built on the node:stream Readable / Writable constructors: the host drives the _read / _write hooks from FsPrimitivesAsync. Watching uses the native backends (inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows, with a JDK WatchService fallback), installed at runtime startup; events cross from a backend daemon thread onto the event loop.
  • A capability sandbox (FsPolicy) gates filesystem access for every guest language at one point: node:fs consults it directly (it uses java.nio, not the GraalVM FileSystem), and the polyglot guests go through a gated ElideGuestFileSystem that consults the same policy — so JS, Python, and the JVM share one sandbox. The default policy grants everything (IOAccess.ALL), so behavior is unchanged until a sandbox is configured.

Synchronous API

  • Content and descriptors: readFileSync, writeFileSync, appendFileSync, openSync, closeSync, readSync, writeSync, fsyncSync, fdatasyncSync, ftruncateSync, truncateSync, readvSync, writevSync. readFileSync / writeFileSync accept either a path or a numeric file descriptor; readSync fills a Buffer, TypedArray, or DataView.
  • Namespace and links: mkdirSync (with recursive, returning the first created path), rmSync, rmdirSync, unlinkSync, renameSync, copyFileSync, cpSync, linkSync, symlinkSync, readlinkSync, readdirSync (with withFileTypes and recursive), opendirSync (returns a Dir), globSync (with withFileTypes and exclude), realpathSync (plus the realpathSync.native alias), mkdtempSync, mkdtempDisposableSync.
  • Metadata and access: statSync, lstatSync, fstatSync, statfsSync (all support { bigint: true }), existsSync, accessSync.
  • Permissions, ownership, times: chmodSync / lchmodSync / fchmodSync, chownSync / lchownSync / fchownSync, utimesSync / lutimesSync / futimesSync (time values may be numbers in seconds, numeric strings, or Date).
  • Paths may be passed as a string, a Buffer, or a file: URL object.
  • fs.constants exposes the access (F_OK/R_OK/W_OK/X_OK), copy (COPYFILE_), open (O_), file-type (S_IF), and mode (S_I) constants.

Objects

  • Stats / BigIntStats — the is*() predicates (isFile, isDirectory, isSymbolicLink, isBlockDevice, isCharacterDevice, isFIFO, isSocket), the numeric fields (size, mode, uid, gid, …), *Ms (and, in bigint mode, *Ns) timestamps, and Date accessors (atime/mtime/ctime/ birthtime).
  • Direntname, parentPath, path, and the is*() predicates.
  • StatFs — filesystem capacity and type (type, bsize, blocks, bfree, bavail, files, ffree).
  • Dirpath, readSync() (returns the next Dirent or null), closeSync().

Errors

Failures surface as Node SystemErrors: a thrown Error whose message reads : , '', carrying code (e.g. ENOENT, EEXIST, EISDIR), errno (negative, as Node reports it), syscall, and — for two-path operations — path / dest.

javascript
const fs = require("node:fs");

fs.writeFileSync("out.txt", "written by Elide\n");
const text = fs.readFileSync("out.txt", "utf8");

const st = fs.statSync("out.txt");
console.log(st.isFile(), st.size);

try {
  fs.readFileSync("does-not-exist");
} catch (err) {
  console.log(err.code, err.errno, err.syscall); // ENOENT -2 open
}

Asynchronous API (callbacks)

Every operation with a Node callback form is supported; the last argument is a Node-style (err, ...results) callback invoked on the event loop. The names are the base names (no Sync suffix): readFile, writeFile, appendFile, open, close, read, write, mkdir, rm, rmdir, unlink, rename, copyFile, cp, link, symlink, readlink, readdir, opendir, glob, realpath, mkdtemp, stat, lstat, fstat, statfs, access, chmod / lchmod / fchmod, chown / lchown / fchown, utimes / lutimes / futimes, truncate, ftruncate, fsync, fdatasync. fs.exists(path, cb) is also provided — its callback receives a single boolean, matching Node's legacy signature.

javascript
const fs = require("node:fs");

fs.writeFile("out.txt", "async write\n", (err) => {
  if (err) throw err;
  fs.readFile("out.txt", "utf8", (err, text) => {
    console.log(text.trimEnd());
  });
});

Promise API (fs.promises / node:fs/promises)

The promise API resolves via require('node:fs/promises'), import 'node:fs/promises', or require('fs').promises — all yield the same namespace. Each operation returns a Promise; failures reject with the same Node SystemError shape as the synchronous API.

javascript
import { readFile, writeFile } from "node:fs/promises";

await writeFile("out.txt", "via promises\n");
console.log((await readFile("out.txt", "utf8")).trimEnd());

FileHandle

fsPromises.open(path, flags, mode) resolves to a FileHandle. It exposes read / readv / write / writev, readFile / writeFile / appendFile, stat, truncate, chmod, chown, utimes, sync, datasync, and close, plus the EventEmitter surface (on / once / off / emit, emitting close) and Symbol.asyncDispose for await using.
javascript
import { open } from "node:fs/promises";

const fh = await open("out.txt", "r");
try {
  const { bytesRead, buffer } = await fh.read(Buffer.alloc(64), 0, 64, 0);
  console.log(buffer.subarray(0, bytesRead).toString("utf8"));
} finally {
  await fh.close();
}

Cancellation (AbortSignal)

readFile, writeFile, and appendFile accept a { signal } option in both the callback and promise forms. An already-aborted signal — or one aborted before the work completes — rejects (or calls back) with an AbortError whose code is ABORT_ERR.

File streams

fs.createReadStream(path[, options]) returns an fs.ReadStream (a stream.Readable); fs.createWriteStream(path[, options]) returns an fs.WriteStream (a stream.Writable). Both honour the usual options — flags, encoding, fd, mode, autoClose, emitClose, start, highWaterMark (and end for reads) — emit 'open' / 'ready' / 'close', expose path / bytesRead (or bytesWritten) / pending, support .pipe(), and read or write incrementally rather than buffering the whole file.
javascript
import { createReadStream, createWriteStream } from "node:fs";

createReadStream("big.log").pipe(createWriteStream("copy.log"));

The FileHandle stream helpers reuse the open descriptor: fileHandle.createReadStream(), createWriteStream(), readableWebStream() (a WHATWG ReadableStream), and readLines() (an async iterable of lines). Unlike the top-level streams these default to autoClose: false — the FileHandle owns the descriptor.

Watching

fs.watch(filename[, options][, listener]) returns an fs.FSWatcher (an EventEmitter) backed by the native OS watcher. It emits 'change' with (eventType, filename), supports the recursive / persistent / encoding / signal options, ref() / unref(), close(), and is async-iterable. fsPromises.watch(filename[, options]) returns that async iterator directly.
javascript
const watcher = fs.watch("./src", { recursive: true }, (event, file) => {
  console.log(event, file);
});
// later: watcher.close();
fs.watchFile(filename[, options], listener) polls the file's status at an interval and invokes the listener with the current and previous Stats; fs.unwatchFile(filename[, listener]) stops it.

Capability sandbox

Elide can confine filesystem access with a per-path, per-operation capability policy, modeled on Deno's read/write scoping but enforced at a single host-side gate shared by every guest language:

  • Each capability (read, write) is either fully granted or restricted to a set of path roots; a path is allowed only when it is at or under a granted root.
  • Checks canonicalize the target (toRealPath) before matching, so .. and symlink escapes cannot evade the policy. The operation then runs against the resolved-ancestor path, so an ancestor symlink cannot be swapped between the check and the syscall (the remaining check→use window — a swap of the final component itself — needs openat/O_NOFOLLOW and is tracked as hardening).
  • A denied node:fs operation fails with a Node EACCES SystemError; a denied polyglot operation fails with the engine's SecurityException.
  • The same FsPolicy gates node:fs, node:fs/promises, streams, watchers, and the polyglot FileSystem (Python open, JS import, JVM file access) — one sandbox for the whole runtime.

The default policy grants all access, matching the historical behavior; a restricted policy is opt-in.

Enabling it from the command line

The sandbox is activated by global flags. They are honored wherever Elide builds a guest runtime — elide run, the implicit elide form, inline snippets (-s/--snippet), and the elide mcp server — and apply to every guest language:

  • --sandbox turns the sandbox on. With no grants it denies all filesystem access; pair it with --allow-read / --allow-write to open holes.
  • --allow-read[=PATHS] grants reads. Bare --allow-read grants every read; --allow-read=/a,/b scopes it to those roots. Repeatable, and any occurrence implies --sandbox.
  • --allow-write[=PATHS] grants writes, with the same bare-vs-scoped semantics.
  • --fs-audit logs each access decision to stderr.
bash
# Read-only access confined to the project directory:
 elide --sandbox --allow-read=. server.js

# Read anywhere, write only to a scratch dir:
 elide --allow-read --allow-write=/tmp/scratch build.js

Known differences from Node

Some details cannot be reproduced faithfully through java.nio and are documented rather than faked:

  • statfsSync reports bsize and the block counts from the JDK FileStore; the on-disk filesystem type and the inode counters (files / ffree) are reported as 0.
  • lchmodSync throws ENOSYS (it has no portable POSIX implementation, matching Node on Linux).
  • stat/lstat/readdir resolve the real file type (regular, directory, symlink, block/character device, FIFO, socket) from the raw st_mode on POSIX. On platforms without the unix attribute view (e.g. Windows) special files fall back to "unknown".
  • birthtime is best-effort on Linux: the JDK cannot read statx's creation time, so it mirrors the JDK's creationTime() (often the change time). ctime uses the real unix:ctime where available.
  • fs.watch uses one OS watch instance per watcher (Linux inotify is not multiplexed as libuv does), so a process that holds very many concurrent watchers can hit the kernel's max_user_instances limit. Always close() watchers you no longer need; dropped ones are reclaimed only best-effort on GC.
  • glob / globSync use a Node-style matcher: , * (globstar — a leading **/ matches top-level entries too), ?, character classes ([...]/[!...]), and brace alternation ({a,b}). Extended-glob groups (@(...), +(...), !(...)) are not supported.

Not yet implemented

  • fs.openAsBlob.

See also