UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

686 lines (640 loc) 17.9 kB
import { createReadStream } from "node:fs"; import { access, copyFile, cp as fsCp, mkdir as fsMkdir, readFile as fsReadFile, rm as fsRm, writeFile as fsWriteFile, readdir, rename, stat, } from "node:fs/promises"; import { basename, join } from "node:path"; import { PassThrough, Readable } from "node:stream"; import type { ReadableStream as NodeWebStream } from "node:stream/web"; import { fileURLToPath } from "node:url"; import { $inject, AlephaError, type FileLike, isFileLike, Json, type StreamLike, } from "alepha"; import { FileDetector } from "../services/FileDetector.ts"; import type { CpOptions, CreateFileOptions, FileSystemProvider, LsOptions, MkdirOptions, RmOptions, } from "./FileSystemProvider.ts"; /** * Node.js implementation of FileSystem interface. * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // Create from URL * const file1 = fs.createFile({ url: "file:///path/to/file.png" }); * * // Create from Buffer * const file2 = fs.createFile({ buffer: Buffer.from("hello"), name: "hello.txt" }); * * // Create from text * const file3 = fs.createFile({ text: "Hello, world!", name: "greeting.txt" }); * * // File operations * await fs.mkdir("/tmp/mydir", { recursive: true }); * await fs.cp("/src/file.txt", "/dest/file.txt"); * await fs.mv("/old/path.txt", "/new/path.txt"); * const files = await fs.ls("/tmp"); * await fs.rm("/tmp/file.txt"); * ``` */ export class NodeFileSystemProvider implements FileSystemProvider { protected detector = $inject(FileDetector); protected json = $inject(Json); public join(...paths: string[]): string { return join(...paths); } /** * Creates a FileLike object from various sources. * * @param options - Options for creating the file * @returns A FileLike object * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // From URL * const file1 = fs.createFile({ url: "https://example.com/image.png" }); * * // From Buffer * const file2 = fs.createFile({ * buffer: Buffer.from("hello"), * name: "hello.txt", * type: "text/plain" * }); * * // From text * const file3 = fs.createFile({ text: "Hello!", name: "greeting.txt" }); * * // From stream with detection * const stream = createReadStream("/path/to/file.png"); * const file4 = fs.createFile({ stream, name: "image.png" }); * ``` */ createFile(options: CreateFileOptions): FileLike { if ("path" in options) { const filePath = options.path; const filename = basename(filePath); return this.createFileFromUrl(`file://${filePath}`, { type: options.type, name: options.name || filename, }); } // Handle URL if ("url" in options) { return this.createFileFromUrl(options.url, { type: options.type, name: options.name, }); } if ("response" in options) { if (!options.response.body) { throw new AlephaError("Response has no body stream"); } const res = options.response; // guess size from content-length header if available const sizeHeader = res.headers.get("content-length"); const size = sizeHeader ? parseInt(sizeHeader, 10) : undefined; // guess name from content-disposition header if available let name = options.name; const contentDisposition = res.headers.get("content-disposition"); if (contentDisposition && !name) { const match = contentDisposition.match(/filename="?([^"]+)"?/); if (match) { name = match[1]; } } // guess type from content-type header if available const type = options.type || res.headers.get("content-type") || undefined; return this.createFileFromStream(options.response.body, { type, name, size, }); } // Handle Web File if ("file" in options) { return this.createFileFromWebFile(options.file, { type: options.type, name: options.name, size: options.size, }); } // Handle Buffer if ("buffer" in options) { return this.createFileFromBuffer(options.buffer, { type: options.type, name: options.name, }); } // Handle ArrayBuffer if ("arrayBuffer" in options) { return this.createFileFromBuffer(Buffer.from(options.arrayBuffer), { type: options.type, name: options.name, }); } // Handle text if ("text" in options) { return this.createFileFromBuffer(Buffer.from(options.text, "utf-8"), { type: options.type || "text/plain", name: options.name || "file.txt", }); } // Handle stream if ("stream" in options) { return this.createFileFromStream(options.stream, { type: options.type, name: options.name, size: options.size, }); } throw new AlephaError( "Invalid createFile options: no valid source provided", ); } /** * Removes a file or directory. * * @param path - The path to remove * @param options - Remove options * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // Remove a file * await fs.rm("/tmp/file.txt"); * * // Remove a directory recursively * await fs.rm("/tmp/mydir", { recursive: true }); * * // Remove with force (no error if doesn't exist) * await fs.rm("/tmp/maybe-exists.txt", { force: true }); * ``` */ async rm(path: string, options?: RmOptions): Promise<void> { await fsRm(path, options); } /** * Copies a file or directory. * * @param src - Source path * @param dest - Destination path * @param options - Copy options * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // Copy a file * await fs.cp("/src/file.txt", "/dest/file.txt"); * * // Copy a directory (recursive by default) * await fs.cp("/src/dir", "/dest/dir"); * * // Copy with force (overwrite existing) * await fs.cp("/src/file.txt", "/dest/file.txt", { force: true }); * ``` */ async cp(src: string, dest: string, options?: CpOptions): Promise<void> { const srcStat = await stat(src); if (srcStat.isDirectory()) { await fsCp(src, dest, { recursive: options?.recursive ?? true, force: options?.force ?? false, }); } else { await copyFile(src, dest); } } /** * Moves/renames a file or directory. * * @param src - Source path * @param dest - Destination path * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // Move/rename a file * await fs.mv("/old/path.txt", "/new/path.txt"); * * // Move a directory * await fs.mv("/old/dir", "/new/dir"); * ``` */ async mv(src: string, dest: string): Promise<void> { await rename(src, dest); } /** * Creates a directory. * * @param path - The directory path to create * @param options - Mkdir options * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // Create a directory * await fs.mkdir("/tmp/mydir"); * * // Create nested directories * await fs.mkdir("/tmp/path/to/dir", { recursive: true }); * * // Create with specific permissions * await fs.mkdir("/tmp/mydir", { mode: 0o755 }); * ``` */ async mkdir(path: string, options: MkdirOptions = {}): Promise<void> { const p = fsMkdir(path, { recursive: options.recursive ?? true, mode: options.mode, }); if (options.force === false) { await p; } else { await p.catch(() => {}); } } /** * Lists files in a directory. * * @param path - The directory path to list * @param options - List options * @returns Array of filenames * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // List files in a directory * const files = await fs.ls("/tmp"); * console.log(files); // ["file1.txt", "file2.txt", "subdir"] * * // List with hidden files * const allFiles = await fs.ls("/tmp", { hidden: true }); * * // List recursively * const allFilesRecursive = await fs.ls("/tmp", { recursive: true }); * ``` */ async ls(path: string, options?: LsOptions): Promise<string[]> { const entries = await readdir(path); // Filter out hidden files if not requested const filteredEntries = options?.hidden ? entries : entries.filter((e) => !e.startsWith(".")); // If recursive, get all nested files if (options?.recursive) { const allFiles: string[] = []; for (const entry of filteredEntries) { const fullPath = join(path, entry); const entryStat = await stat(fullPath); if (entryStat.isDirectory()) { // Add directory entry allFiles.push(entry); // Recursively get files from subdirectory const subFiles = await this.ls(fullPath, options); allFiles.push(...subFiles.map((f) => join(entry, f))); } else { allFiles.push(entry); } } return allFiles; } return filteredEntries; } /** * Checks if a file or directory exists. * * @param path - The path to check * @returns True if the path exists, false otherwise * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * if (await fs.exists("/tmp/file.txt")) { * console.log("File exists"); * } * ``` */ async exists(path: string): Promise<boolean> { try { await access(path); return true; } catch { return false; } } /** * Reads the content of a file. * * @param path - The file path to read * @returns The file content as a Buffer * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * const buffer = await fs.readFile("/tmp/file.txt"); * console.log(buffer.toString("utf-8")); * ``` */ async readFile(path: string): Promise<Buffer> { return await fsReadFile(path); } /** * Writes data to a file. * * @param path - The file path to write to * @param data - The data to write (Buffer or string) * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * * // Write string * await fs.writeFile("/tmp/file.txt", "Hello, world!"); * * // Write Buffer * await fs.writeFile("/tmp/file.bin", Buffer.from([0x01, 0x02, 0x03])); * ``` */ async writeFile( path: string, data: Uint8Array | Buffer | string | FileLike, ): Promise<void> { if (isFileLike(data)) { await fsWriteFile(path, Readable.from(data.stream())); return; } await fsWriteFile(path, data); } /** * Reads the content of a file as a string. * * @param path - The file path to read * @returns The file content as a string * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * const content = await fs.readTextFile("/tmp/file.txt"); * ``` */ async readTextFile(path: string): Promise<string> { const buffer = await this.readFile(path); return buffer.toString("utf-8"); } /** * Reads the content of a file as JSON. * * @param path - The file path to read * @returns The parsed JSON content * * @example * ```typescript * const fs = alepha.inject(NodeFileSystemProvider); * const config = await fs.readJsonFile<{ name: string }>("/tmp/config.json"); * ``` */ async readJsonFile<T = unknown>(path: string): Promise<T> { const text = await this.readTextFile(path); return this.json.parse(text) as T; } /** * Creates a FileLike object from a Web File. * * @protected */ protected createFileFromWebFile( source: File, options: { type?: string; name?: string; size?: number; } = {}, ): FileLike { const name = options.name ?? source.name; return { name, type: options.type ?? (source.type || this.detector.getContentType(name)), size: options.size ?? source.size ?? 0, lastModified: source.lastModified || Date.now(), stream: () => source.stream(), arrayBuffer: async (): Promise<ArrayBuffer> => { return await source.arrayBuffer(); }, text: async (): Promise<string> => { return await source.text(); }, }; } /** * Creates a FileLike object from a Buffer. * * @protected */ protected createFileFromBuffer( source: Buffer, options: { type?: string; name?: string; } = {}, ): FileLike { const name: string = options.name ?? "file"; return { name, type: options.type ?? this.detector.getContentType(options.name ?? name), size: source.byteLength, lastModified: Date.now(), stream: (): Readable => Readable.from(source), arrayBuffer: async (): Promise<ArrayBuffer> => { return this.bufferToArrayBuffer(source); }, text: async (): Promise<string> => { return source.toString("utf-8"); }, }; } /** * Creates a FileLike object from a stream. * * @protected */ protected createFileFromStream( source: StreamLike, options: { type?: string; name?: string; size?: number; } = {}, ): FileLike & { _buffer: null | Buffer } { let buffer: Buffer | null = null; return { name: options.name ?? "file", type: options.type ?? this.detector.getContentType(options.name ?? "file"), size: options.size ?? 0, lastModified: Date.now(), stream: () => source, _buffer: null as Buffer | null, arrayBuffer: async () => { buffer ??= await this.streamToBuffer(source); return this.bufferToArrayBuffer(buffer); }, text: async () => { buffer ??= await this.streamToBuffer(source); return buffer.toString("utf-8"); }, }; } /** * Creates a FileLike object from a URL. * * @protected */ protected createFileFromUrl( url: string, options: { type?: string; name?: string; } = {}, ): FileLike { const parsedUrl = new URL(url); const filename = options.name || parsedUrl.pathname.split("/").pop() || "file"; let buffer: Buffer | null = null; return { name: filename, type: options.type ?? this.detector.getContentType(filename), size: 0, // Unknown size until loaded lastModified: Date.now(), stream: () => this.createStreamFromUrl(url), arrayBuffer: async () => { buffer ??= await this.loadFromUrl(url); return this.bufferToArrayBuffer(buffer); }, text: async () => { buffer ??= await this.loadFromUrl(url); return buffer.toString("utf-8"); }, filepath: url, }; } /** * Gets a streaming response from a URL. * * @protected */ protected getStreamingResponse(url: string): Readable { const stream = new PassThrough(); fetch(url) .then((res) => Readable.fromWeb(res.body as unknown as NodeWebStream).pipe(stream), ) .catch((err) => stream.destroy(err)); return stream; } /** * Loads data from a URL. * * @protected */ protected async loadFromUrl(url: string): Promise<Buffer> { const parsedUrl = new URL(url); if (parsedUrl.protocol === "file:") { // Handle file:// URLs const filePath = fileURLToPath(url); return await fsReadFile(filePath); } else if ( parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:" ) { // Handle HTTP/HTTPS URLs const response = await fetch(url); if (!response.ok) { throw new AlephaError( `Failed to fetch ${url}: ${response.status} ${response.statusText}`, ); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } else { throw new AlephaError(`Unsupported protocol: ${parsedUrl.protocol}`); } } /** * Creates a stream from a URL. * * @protected */ protected createStreamFromUrl(url: string): Readable { const parsedUrl = new URL(url); if (parsedUrl.protocol === "file:") { // For file:// URLs, create a stream that reads the file return createReadStream(fileURLToPath(url)); } else if ( parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:" ) { // For HTTP/HTTPS URLs, create a stream that fetches the content return this.getStreamingResponse(url); } else { throw new AlephaError(`Unsupported protocol: ${parsedUrl.protocol}`); } } /** * Converts a stream-like object to a Buffer. * * @protected */ protected async streamToBuffer(streamLike: StreamLike): Promise<Buffer> { const stream = streamLike instanceof Readable ? streamLike : Readable.fromWeb(streamLike as NodeWebStream); return new Promise<Buffer>((resolve, reject) => { const buffer: any[] = []; stream.on("data", (chunk) => buffer.push(Buffer.from(chunk))); stream.on("end", () => resolve(Buffer.concat(buffer))); stream.on("error", (err) => reject(new AlephaError("Error converting stream", { cause: err })), ); }); } /** * Converts a Node.js Buffer to an ArrayBuffer. * * @protected */ protected bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { return buffer.buffer.slice( buffer.byteOffset, buffer.byteOffset + buffer.byteLength, ) as ArrayBuffer; } }