import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd"; import { StorageAdapter, guessMimeType } from "bknd"; import { parse, s, isFile } from "bknd/utils"; export const localAdapterConfig = s.object( { path: s.string({ default: "./" }), }, { title: "Local", description: "Local file system storage", additionalProperties: false }, ); export type LocalAdapterConfig = s.Static; export class StorageLocalAdapter extends StorageAdapter { private config: LocalAdapterConfig; constructor(config: Partial = {}) { super(); this.config = parse(localAdapterConfig, config); } getSchema() { return localAdapterConfig; } getName(): string { return "local"; } async listObjects(prefix?: string): Promise { const files = await readdir(this.config.path); const fileStats = await Promise.all( files .filter((file) => !prefix || file.startsWith(prefix)) .map(async (file) => { const stats = await stat(`${this.config.path}/${file}`); return { key: file, last_modified: stats.mtime, size: stats.size, }; }), ); return fileStats; } private async computeEtag(body: FileBody): Promise { const content = isFile(body) ? body : new Response(body); const hashBuffer = await crypto.subtle.digest("SHA-256", await content.arrayBuffer()); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); // Wrap the hex string in quotes for ETag format return `"${hashHex}"`; } async putObject(key: string, body: FileBody): Promise { if (body === null) { throw new Error("Body is empty"); } const filePath = `${this.config.path}/${key}`; await writeFile(filePath, isFile(body) ? body.stream() : body); return await this.computeEtag(body); } async deleteObject(key: string): Promise { try { await unlink(`${this.config.path}/${key}`); } catch (e) {} } async objectExists(key: string): Promise { try { const stats = await stat(`${this.config.path}/${key}`); return stats.isFile(); } catch (error) { return false; } } private parseRangeHeader( rangeHeader: string, fileSize: number, ): { start: number; end: number } | null { // Parse "bytes=start-end" format const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); if (!match) return null; const [, startStr, endStr] = match; let start = startStr ? Number.parseInt(startStr, 10) : 0; let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1; // Handle suffix-byte-range-spec (e.g., "bytes=-500") if (!startStr && endStr) { start = Math.max(0, fileSize - Number.parseInt(endStr, 10)); end = fileSize - 1; } // Validate range if (start < 0 || end >= fileSize || start > end) { return null; } return { start, end }; } async getObject(key: string, headers: Headers): Promise { try { const filePath = `${this.config.path}/${key}`; const stats = await stat(filePath); const fileSize = stats.size; const mimeType = guessMimeType(key); const responseHeaders = new Headers({ "Accept-Ranges": "bytes", "Content-Type": mimeType || "application/octet-stream", }); const rangeHeader = headers.get("range"); if (rangeHeader) { const range = this.parseRangeHeader(rangeHeader, fileSize); if (!range) { // Invalid range - return 416 Range Not Satisfiable responseHeaders.set("Content-Range", `bytes */${fileSize}`); return new Response("", { status: 416, headers: responseHeaders, }); } const { start, end } = range; const content = await readFile(filePath, { encoding: null }); const chunk = content.slice(start, end + 1); responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`); responseHeaders.set("Content-Length", chunk.length.toString()); return new Response(chunk, { status: 206, // Partial Content headers: responseHeaders, }); } else { // Normal request - return entire file const content = await readFile(filePath); responseHeaders.set("Content-Length", content.length.toString()); return new Response(content, { status: 200, headers: responseHeaders, }); } } catch (error) { // Handle file reading errors return new Response("", { status: 404 }); } } getObjectUrl(key: string): string { throw new Error("Method not implemented."); } async getObjectMeta(key: string): Promise { const stats = await stat(`${this.config.path}/${key}`); return { type: guessMimeType(key) || "application/octet-stream", size: stats.size, }; } toJSON(secrets?: boolean) { return { type: this.getName(), config: this.config, }; } }