Fundierte Artikel über Technologien, die das Kommende formen.

Der Fall für ein virtuelles Dateisystem in Node.js

Node.js hängt eng am echten Dateisystem. Eine VFS-Schicht würde besseres Testen, Sandboxing und Edge-Deployment ermöglichen.

Mechanische Arme greifen Papierordner, während ein geisterhafter holografischer Ordner vorbeischwebt

Versuch mal, eine Node.js-Anwendung ohne echtes Dateisystem laufen zu lassen, und schau, wie schnell alles auseinanderfällt. require() liest Dateien. fs.readFile() liest Dateien. Template-Engines lesen Dateien. Config-Loader lesen Dateien. Logging-Bibliotheken schreiben Dateien. Das gesamte Node.js-Ökosystem geht davon aus, dass ein POSIX-ähnliches Dateisystem existiert, beschreibbar ist und der primäre Weg ist, auf Code und Daten zuzugreifen.

Das war 2010 in Ordnung, als Node.js auf Servern mit lokalen Festplatten lief. Inzwischen wird es zunehmend problematisch, weil wir JavaScript dort ausführen wollen, wo es kein Dateisystem gibt, es nur lesbar ist oder man ihm nicht trauen sollte: Edge-Runtimes, WebAssembly-Sandboxes, Serverless-Funktionen und browserbasierte Entwicklungsumgebungen.

Wo die Dateisystem-Annahme bricht

Edge-Runtimes. Cloudflare Workers, Deno Deploy und Vercel Edge Functions führen JavaScript am Edge aus, also auf Rechnern nah am Nutzer, mit minimaler Infrastruktur. Diese Umgebungen bieten oft kein beschreibbares Dateisystem oder nur ein begrenztes In-Memory-Dateisystem, das zwischen Requests nicht persistiert. Node.js-Module, die fs.writeFileSync aufrufen, stürzen ab. Module, die mit fs.existsSync nach Config-Dateien suchen, verhalten sich unvorhersehbar.

WebAssembly. Node.js innerhalb einer WebAssembly-Sandbox auszuführen erfordert, dass Dateisystemoperationen auf das virtuelle Dateisystem der Wasm-Laufzeit (typischerweise WASI) abgebildet werden. Diese Abbildung ist unvollkommen: Dateiberechtigungen, Symlinks und plattformspezifische Pfade lassen sich nicht sauber übersetzen. Eine VFS-Abstraktion würde diese Abbildung explizit machen statt improvisiert.

Testing. Unit-Tests für Code, der Konfigurationsdateien liest, Templates lädt oder Logs schreibt, erfordern entweder das Mocken des fs-Moduls (fragil, weil verschiedene Bibliotheken unterschiedliche fs-APIs nutzen) oder das Anlegen temporärer Verzeichnisse mit Test-Fixtures (langsam, instabil und hinterlässt Müll auf der Platte). Ein virtuelles Dateisystem würde Tests komplett im Speicher, deterministisch und ohne Zugriff auf das echte Dateisystem laufen lassen.

Security-Sandboxing. Wenn man nicht vertrauenswürdigen Code ausführt, etwa Plugins, Nutzerskripte oder CI/CD-Build-Schritte, will man den Dateisystemzugriff kontrollieren. Bisher erfordert das OS-Level-Sandboxing (Container, Namespaces) oder Monkey-Patching des fs-Moduls. Ein VFS würde feingranulare Richtlinien ermöglichen: Dieser Code darf /app lesen, aber nicht /etc, und darf nach /tmp schreiben, aber nicht nach /app.

Wie ein VFS aussehen würde

Ein virtuelles Dateisystem ist kein neues Konzept. Betriebssysteme nutzen VFS-Schichten seit den 1980er-Jahren, Linux' VFS etwa lässt ext4, NFS und procfs hinter derselben API koexistieren. Die Idee für Node.js ist dieselbe: die Dateisystem-Schnittstelle (fs.readFile, require() usw.) von der Implementierung des Dateisystems entkoppeln.

// Hypothetical VFS API
import { createVFS, MemoryFS, ReadOnlyFS, OverlayFS } from 'node:vfs';
// In-memory filesystem for testing
const testFS = new MemoryFS({
'/app/config.json': '{"port": 3000}',
'/app/templates/index.html': '<h1>Hello</h1>',
});
// Read-only view of the real filesystem
const readOnly = new ReadOnlyFS('/');
// Overlay: reads from real FS, writes go to memory
const sandbox = new OverlayFS(readOnly, new MemoryFS());
// Run code with a specific filesystem
const vfs = createVFS(sandbox);
vfs.run(() => {
// Inside this context:
// fs.readFileSync('/etc/hosts') → reads real file
// fs.writeFileSync('/tmp/log.txt') → writes to memory overlay
// require('./module') → resolves against the VFS
const app = require('./app');
app.start();
});

Die entscheidende Erkenntnis ist, dass das VFS jeden Dateisystemzugriff abfängt, nicht nur explizite fs-Aufrufe, sondern auch require(), import(), __dirname und process.cwd(). Genau das macht es nützlich: Man kann den gesamten Dateisystemzugriff einer Anwendung umleiten, ohne die Anwendung selbst zu ändern.

Das Problem der Modulauflösung

Die tiefste Verflechtung zwischen Node.js und dem Dateisystem ist die Modulauflösung. Wenn man require('express') schreibt, läuft Node.js den Verzeichnisbaum nach oben, sucht nach node_modules/express-Verzeichnissen, liest package.json-Dateien, löst Symlinks auf und folgt dem Feld main oder exports. Dabei werden für ein einziges require Dutzende Dateisystemaufrufe ausgeführt.

Yarn PnP (Plug'n'Play) hat das teilweise gelöst, indem es node_modules durch eine Manifestdatei ersetzt, die Modulnamen auf ZIP-Archive abbildet. Das ist schneller (kein Verzeichnisdurchlauf) und deterministischer (keine Überraschungen durch Hoisting), hat aber ein Monkey-Patching der Modulauflösung von Node erfordert, was Tools kaputt gemacht hat, die von der Existenz von node_modules ausgingen.

Ein richtiges VFS würde Yarn PnPs Ansatz zu einem Feature erster Klasse machen. Die Modulauflösung liefe über das VFS, das beliebige Zuordnungen umsetzen könnte: ZIP-Archive, In-Memory-Module, Remote-URLs oder den klassischen Verzeichnisdurchlauf von node_modules. Verschiedene Strategien für verschiedene Umgebungen, dieselbe API für den Anwendungscode.

Vorarbeiten anderer Projekte

Andere Runtimes haben Varianten dieses Problems bereits gelöst.

  • Deno lädt Module standardmäßig über URLs und cacht sie in einem verwalteten Verzeichnis. Es gibt kein node_modules und keine dateisystembasierte Modulauflösung. Die Runtime bestimmt, wo und wie Module gespeichert werden.
  • Bun nutzt einen globalen Modul-Cache mit Hardlinks, was den Dateisystem-Overhead reduziert. Seine Modulauflösung ist im Vergleich zu Node.js stark optimiert.
  • Gos io/fs-Interface (seit Go 1.16) definiert eine Dateisystem-Schnittstelle, die vom OS-Dateisystem, eingebetteten Dateien (embed.FS), ZIP-Archiven und In-Memory-Dateisystemen implementiert wird. Standardbibliotheksfunktionen akzeptieren das Interface fs.FS und sind dadurch ohne echte Dateien testbar.
  • Javas NIO-FileSystem-Abstraktion unterstützt austauschbare Dateisystem-Provider, darunter In-Memory-Implementierungen für Tests und ZIP-basierte Dateisysteme.
  • Das .NET-IFileSystem-Muster ist in .NET-Anwendungen für Testbarkeit weit verbreitet, auch wenn es eher eine Community-Konvention als ein Runtime-Feature ist.

Der Go-Ansatz ist besonders lehrreich. Durch ein kleines Interface (Open, Read, Stat), das durchgängig in der Standardbibliothek genutzt wird, hat Go Dateisystemabstraktion mühelos gemacht, ohne bestehenden Code zu brechen. Node.js könnte dasselbe tun, indem es ein VFS-Interface definiert und Standardbibliothek und Modul-Loader schrittweise darauf umstellt.

Die Herausforderung der Kompatibilität

Das größte Hindernis für ein VFS in Node.js ist nicht die Implementierung, sondern das Ökosystem. npm hat über zwei Millionen Pakete, und ein erheblicher Teil davon greift auf das Dateisystem in einer Weise zu, die POSIX-Semantik, echte Pfade und beschreibbare Verzeichnisse voraussetzt.

Ein VFS, das bestehende Pakete bricht, ist von Anfang an gescheitert. Es muss opt-in und abwärtskompatibel sein: Wenn man kein VFS explizit erstellt, funktioniert alles genau wie heute. Erstellt man ein VFS, sollte es Dateisystemaufrufe transparent abfangen, sodass saubere Pakete ohne Änderungen funktionieren.

„Sauber“ ist das entscheidende Kriterium. Pakete, die cp oder rm aufrufen, native Addons, die libc-open() direkt nutzen, oder sich auf /proc oder andere betriebssystemspezifische Dateisysteme verlassen, werden mit einem VFS nicht funktionieren. Das ist eine harte Grenze, denn jede Abstraktion leckt, sobald Code an ihr vorbei auf das darunterliegende System greift.

Was tatsächlich vorgeschlagen wird

Die Diskussion um ein VFS in Node.js ist nicht theoretisch, im Issue-Tracker von Node.js gibt es konkrete Vorschläge. Die aktuelle Richtung umfasst ein paar zentrale Ideen.

Erstens ein FileSystemProvider-Interface, an das das fs-Modul delegiert. Der Standard-Provider ist das echte Dateisystem. Eigene Provider implementieren dieselbe Schnittstelle für In-Memory-, Read-only-, Overlay- oder Remote-Dateisysteme.

Zweitens die Integration mit dem Modul-Loader. require() und import() würden Module über das VFS auflösen, was Strategien ermöglicht, die nicht von node_modules-Verzeichnissen abhängen.

Drittens eine Policy-Schicht. Das VFS könnte Zugriffskontrollen durchsetzen und verhindern, dass Code Pfade außerhalb eines definierten Bereichs liest oder beschreibt. Das würde Node.js eine eingebaute Sandboxing-Fähigkeit geben, ähnlich wie Deno mit den Flags --allow-read und --allow-write.

Ob das in Node.js 24, 26 oder überhaupt jemals kommt, hängt von der Kapazität des Node.js-Teams und davon ab, wie viel die Community von einer brechenden Änderung am Dateisystemzugriff hält. Der Druck ist aber real: Edge-Runtimes wachsen, WebAssembly-Zielplattformen vermehren sich, und die Lücke zwischen „JavaScript überall“ und „Node.js speziell auf Servern mit echten Dateisystemen“ wird zu einem Wettbewerbsnachteil.

Was man heute tun kann

Man muss nicht auf ein Runtime-VFS warten, um von Dateisystemabstraktion zu profitieren. Für neuen Code kapselt man den Dateisystemzugriff hinter einem Interface. Statt fs.readFile direkt aufzurufen, erstellt man ein FileStore-Interface, von dem der eigene Code abhängt. Implementiert wird es mit dem echten Dateisystem für Produktion und mit einer In-Memory-Map für Tests. Das ist klassische Dependency Inversion: unspektakulär, wirksam und mit jeder Runtime kompatibel.

Für bestehenden Code bieten Bibliotheken wie memfs und unionfs In-Memory- und Overlay-Dateisysteme, die das fs-Modul von Node patchen. Sie sind nicht perfekt, da native Addons und Child-Prozesse sie umgehen, aber für reinen JavaScript-Code funktionieren sie und machen Tests deutlich einfacher.

Das Dateisystem ist das letzte große Stück Node.js-Infrastruktur ohne saubere Abstraktionsgrenze. Streams haben Interfaces, HTTP hat Interfaces, sogar der Modul-Loader hat Hooks. Wenn das Dateisystem dieselbe Behandlung bekommt, wird Node.js eine wirklich portable Runtime statt einer serverspezifischen. Dafür lohnt es sich einzutreten.