All files / src/adapters node-fs.ts

100% Statements 103/103
95.12% Branches 39/41
100% Functions 15/15
100% Lines 103/103

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 1321x 1x 1x 1x 1x               1x 1x 28x   1x 28x 28x 28x     1x 28x 28x 28x   1x 337x 337x 337x   1x 42x 42x 42x 42x 42x 2x 2x 40x 72x 72x 72x 40x 42x   1x 63x 63x   1x 75x 75x 75x 75x   1x 3x 3x     1x 16x 16x 16x 16x     1x 7x 7x 7x 7x 7x 7x 7x 7x   7x 5x 5x 5x 5x 7x 7x 7x 7x 7x   1x 3x   3x 2x 3x 2x 2x 3x   1x 3x 3x 3x 3x   1x 4x 4x   1x 5x 5x 5x 5x   1x 107x 107x 76x 107x 107x     107x 107x 107x 31x 31x 107x 1x  
import { createReadStream, createWriteStream, promises as fs } from 'node:fs';
import * as nodePath from 'node:path';
import { Readable, Writable } from 'node:stream';
import { dirname, normalizePath } from '../path.js';
import { chunked } from '../stream.js';
import type { ByteRange, VFSAdapter, VFSListEntry, VFSStat } from '../types.js';
 
/**
 * Node.js filesystem backend. Useful for CLI tooling and server-side peers,
 * and it is what makes the engine testable against a real filesystem rather
 * than only against the in-memory adapter.
 */
export class NodeFsAdapter implements VFSAdapter {
  readonly name: string;
  readonly root: string;
 
  constructor(root: string, name = nodePath.basename(root)) {
    this.root = nodePath.resolve(root);
    this.name = name;
  }
 
  /** Creates the folder if it does not exist yet. */
  static async open(root: string, name?: string): Promise<NodeFsAdapter> {
    await fs.mkdir(root, { recursive: true });
    return new NodeFsAdapter(root, name);
  }
 
  private resolve(path: string): string {
    const relative = normalizePath(path);
    return relative ? nodePath.join(this.root, relative) : this.root;
  }
 
  async list(path: string): Promise<VFSListEntry[]> {
    const base = normalizePath(path);
    let entries;
    try {
      entries = await fs.readdir(this.resolve(base), { withFileTypes: true });
    } catch {
      return [];
    }
    return entries.map((entry) => ({
      name: entry.name,
      path: base ? `${base}/${entry.name}` : entry.name,
      kind: entry.isDirectory() ? ('directory' as const) : ('file' as const),
    }));
  }
 
  async read(path: string): Promise<Uint8Array> {
    return new Uint8Array(await fs.readFile(this.resolve(path)));
  }
 
  async write(path: string, data: Uint8Array): Promise<void> {
    const target = this.resolve(path);
    await fs.mkdir(nodePath.dirname(target), { recursive: true });
    await fs.writeFile(target, data);
  }
 
  async mkdir(path: string): Promise<void> {
    await fs.mkdir(this.resolve(path), { recursive: true });
  }
 
  /** Real append: the commit log grows by the bytes added, not by its size. */
  async append(path: string, data: Uint8Array): Promise<void> {
    const target = this.resolve(path);
    await fs.mkdir(nodePath.dirname(target), { recursive: true });
    await fs.appendFile(target, data);
  }
 
  /** Positional read: seeks straight to `start`, never reads the rest. */
  async readRange(path: string, range: ByteRange = {}): Promise<Uint8Array> {
    const handle = await fs.open(this.resolve(path), 'r');
    try {
      const { size } = await handle.stat();
      const start = Math.min(Math.max(range.start ?? 0, 0), size);
      const end = Math.min(range.end ?? size, size);
      const length = Math.max(0, end - start);
      const out = new Uint8Array(length);
      let filled = 0;
      // A single read() can come up short on some filesystems.
      while (filled < length) {
        const { bytesRead } = await handle.read(out, filled, length - filled, start + filled);
        if (bytesRead === 0) break;
        filled += bytesRead;
      }
      return filled === length ? out : out.subarray(0, filled);
    } finally {
      await handle.close();
    }
  }
 
  async readStream(path: string, range: ByteRange = {}): Promise<ReadableStream<Uint8Array>> {
    const start = range.start ?? 0;
    // node's `end` is inclusive, ours is not
    if (range.end !== undefined && range.end <= start) return chunked(new Uint8Array());
    const options: { start: number; end?: number } = { start };
    if (range.end !== undefined) options.end = range.end - 1;
    const stream = createReadStream(this.resolve(path), options);
    return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;
  }
 
  async writeStream(path: string): Promise<WritableStream<Uint8Array>> {
    const target = this.resolve(path);
    await fs.mkdir(nodePath.dirname(target), { recursive: true });
    return Writable.toWeb(createWriteStream(target)) as unknown as WritableStream<Uint8Array>;
  }
 
  async delete(path: string): Promise<void> {
    await fs.rm(this.resolve(path), { recursive: true, force: true });
  }
 
  async rename(oldPath: string, newPath: string): Promise<void> {
    const target = normalizePath(newPath);
    await fs.mkdir(this.resolve(dirname(target)), { recursive: true });
    await fs.rename(this.resolve(oldPath), this.resolve(target));
  }
 
  async stat(path: string): Promise<VFSStat | null> {
    try {
      const stat = await fs.stat(this.resolve(path));
      return {
        kind: stat.isDirectory() ? 'directory' : 'file',
        size: stat.size,
        // ms precision: sub-millisecond values would not survive JSON round
        // trips identically on every platform
        mtime: Math.floor(stat.mtimeMs),
      };
    } catch {
      return null;
    }
  }
}