import { mkdir, readdir, rm, stat, statfs } from 'node:fs/promises'; import { createReadStream, createWriteStream } from 'node:fs'; import { dirname, join, posix, sep } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { Storage, StorageEntry, StorageUsage } from './index'; export class LocalStorage implements Storage { constructor(private readonly root: string) {} private resolve(logicalPath: string): string { // Normalise to OS-native separators. We reject leading "/" and any ".." // segment to prevent writes outside the root. const parts = logicalPath.split('/').filter((p) => p.length > 0); for (const p of parts) { if (p === '..' || p === '.') throw new Error(`Illegal path segment in ${logicalPath}`); } return join(this.root, ...parts); } private toLogical(absPath: string): string { const rel = absPath.startsWith(this.root) ? absPath.slice(this.root.length) : absPath; return rel.split(sep).filter((p) => p.length > 0).join('/'); } async put( path: string, data: Uint8Array | ReadableStream ): Promise { const abs = this.resolve(path); await mkdir(dirname(abs), { recursive: true }); if (data instanceof Uint8Array) { await Bun.write(abs, data); return; } // Stream: pipe through Node's fs writer to keep memory bounded. const writer = createWriteStream(abs); await pipeline(Readable.fromWeb(data as never), writer); } async get(path: string): Promise | null> { const abs = this.resolve(path); try { await stat(abs); } catch { return null; } const nodeStream = createReadStream(abs); return Readable.toWeb(nodeStream) as ReadableStream; } async exists(path: string): Promise { try { await stat(this.resolve(path)); return true; } catch { return false; } } async delete(path: string): Promise { await rm(this.resolve(path), { force: true, recursive: true }); } async stat(path: string): Promise<{ size: number; mtime: Date } | null> { try { const s = await stat(this.resolve(path)); return { size: s.size, mtime: s.mtime }; } catch { return null; } } async *list(prefix: string): AsyncIterable { const absPrefix = this.resolve(prefix); let entries; try { entries = await readdir(absPrefix, { withFileTypes: true }); } catch { return; } for (const e of entries) { const childAbs = join(absPrefix, e.name); if (e.isDirectory()) { yield* this.list(posix.join(prefix, e.name)); } else if (e.isFile()) { const s = await stat(childAbs); yield { path: this.toLogical(childAbs), size: s.size, mtime: s.mtime }; } } } async sizeOf(prefix: string): Promise { let total = 0; for await (const e of this.list(prefix)) total += e.size; return total; } async usage(): Promise { // Ensure root exists so statfs works on a fresh deployment. await mkdir(this.root, { recursive: true }); const used = await this.sizeOf(''); let freeBytes: number | null = null; let totalBytes: number | null = null; try { const fs = await statfs(this.root); freeBytes = Number(fs.bavail) * Number(fs.bsize); totalBytes = Number(fs.blocks) * Number(fs.bsize); } catch { // statfs may not be supported on all platforms; leave null. } return { usedBytes: used, freeBytes, totalBytes }; } }