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
JavaScript
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;