UNPKG

upload-to-bunny

Version:

Upload-to-Bunny is a Node.JS library designed to simplify and speed up the process of uploading directories to BunnyCDN storage.

175 lines (174 loc) 6.98 kB
import fs from "node:fs"; import path from "node:path"; import pLimit from "p-limit"; import { globby } from "globby"; /** Build a Bunny Storage API URL for a given path. */ const buildUrl = (targetPath, options) => { const regionPrefix = options.region ? `${options.region}.` : ""; const trimmed = targetPath.replace(/^\/+/, ""); return `https://${regionPrefix}storage.bunnycdn.com/${options.storageZoneName}/${trimmed}`; }; /** * Delete a file or empty directory on Bunny Storage. * * @param targetPath - Remote path relative to the storage root. * @param options - Authentication and behavior options. */ export const deleteFile = async (targetPath, options) => { const url = buildUrl(targetPath, options); const res = await fetch(url, { method: "DELETE", headers: { AccessKey: options.accessKey, }, }); if (!res.ok && res.status !== 404) { throw new Error(`DELETE ${url} failed: ${res.status} ${res.statusText}`); } }; /** * List a remote directory. Returns an array of entries with at least * `ObjectName` and a flag that indicates whether the entry is a directory. */ const listDirectory = async (targetDirectory, options) => { const base = targetDirectory ? `${targetDirectory.replace(/\/+$/, "")}/` : ""; const url = buildUrl(base, options); const res = await fetch(url, { headers: { AccessKey: options.accessKey, }, }); if (res.status === 404) return []; if (!res.ok) throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`); const data = await res.json(); return Array.isArray(data) ? data : []; }; /** Determine whether a given list entry represents a directory. */ const isDirectoryEntry = (entry) => entry.IsDirectory === true || entry.isDirectory === true || entry.Type === 1; /** * Recursively delete a remote directory's contents, then the directory itself. */ const deleteRecursively = async (remoteDir, options) => { const entries = await listDirectory(remoteDir, options); for (const entry of entries) { const name = entry.ObjectName || entry.Name || entry.Key || ""; const childRemotePath = remoteDir ? `${remoteDir.replace(/\/+$/, "")}/${name}` : name; if (isDirectoryEntry(entry)) { await deleteRecursively(childRemotePath, options); await deleteFile(childRemotePath, options).catch(() => { }); } else { await deleteFile(childRemotePath, options).catch(() => { }); } } if (remoteDir) { await deleteFile(remoteDir, options).catch(() => { }); } }; /** * Recursively delete remote files/folders not present locally. * * @param rootTargetDir - Destination directory at the remote root. * @param currentRelDir - Current relative subdirectory from the root target. * @param localFilesSet - Set of relative file paths that should exist remotely. * @param localDirsSet - Set of relative directory paths that should exist remotely. */ const pruneRemote = async (rootTargetDir, currentRelDir, localFilesSet, localDirsSet, options) => { const remoteDir = currentRelDir ? `${rootTargetDir ? `${rootTargetDir.replace(/\/+$/, "")}/` : ""}${currentRelDir}` : rootTargetDir; const entries = await listDirectory(remoteDir, options); for (const entry of entries) { const name = entry.ObjectName || entry.Name || entry.Key || ""; const childRel = currentRelDir ? `${currentRelDir}/${name}` : name; const childRemotePath = remoteDir ? `${remoteDir.replace(/\/+$/, "")}/${name}` : name; if (isDirectoryEntry(entry)) { if (!localDirsSet.has(childRel)) { await deleteRecursively(childRemotePath, options); } else { await pruneRemote(rootTargetDir, childRel, localFilesSet, localDirsSet, options); } } else if (!localFilesSet.has(childRel)) { await deleteFile(childRemotePath, options).catch(() => { }); } } }; /** * Upload a single file to Bunny Storage. * * @param sourcePath - Local filesystem path to read from. * @param targetPath - Remote path relative to the storage root. * @param options - Authentication and behavior options. */ export const uploadFile = async (sourcePath, targetPath, options) => { const url = buildUrl(targetPath, options); const fileContent = fs.createReadStream(sourcePath); const res = await fetch(url, { method: "PUT", body: fileContent, // Required by Node's fetch (undici) when sending a stream body duplex: "half", headers: { AccessKey: options.accessKey, "Content-Type": "application/octet-stream", }, }); if (!res.ok) { throw new Error(`PUT ${url} failed: ${res.status} ${res.statusText}`); } }; /** * Upload a directory to Bunny Storage. * * - When `cleanDestination` is `simple`, the destination path is deleted first. * - When `cleanDestination` is `avoid-deletes`, the remote is pruned to match the local content * without deleting files that are being replaced. * * @param sourceDirectory - Local directory to upload from. * @param targetDirectory - Remote directory at the storage root to upload into. * @param options - Optional upload settings and authentication. */ export const uploadDirectory = async (sourceDirectory, targetDirectory, options) => { const effective = { maxConcurrentUploads: 10, ...options, }; // Establish a local limiter to avoid non-null assertions downstream const limit = effective.limit || pLimit(effective.maxConcurrentUploads ?? 10); const filePaths = await globby(`${sourceDirectory}/**/*`, { onlyFiles: true, absolute: true, }); const localFiles = filePaths.map((p) => path.relative(sourceDirectory, p).split(path.sep).join("/")); const localFilesSet = new Set(localFiles); const localDirsSet = new Set(); for (const relFile of localFiles) { let dir = path.posix.dirname(relFile); while (dir && dir !== "." && !localDirsSet.has(dir)) { localDirsSet.add(dir); const next = path.posix.dirname(dir); if (next === dir) break; dir = next; } } if (effective.cleanDestination === "simple") { await deleteFile(targetDirectory, effective).catch(() => { }); } else if (effective.cleanDestination === "avoid-deletes") { await pruneRemote(targetDirectory, "", localFilesSet, localDirsSet, effective); } await Promise.all(filePaths.map(async (sourcePath) => { const targetPath = path.join(targetDirectory, path.relative(sourceDirectory, sourcePath)); return limit(() => uploadFile(sourcePath, targetPath, effective)); })); }; export default uploadDirectory;