alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
686 lines (640 loc) • 17.9 kB
text/typescript
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;
}
}