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.
266 lines (247 loc) • 8.11 kB
text/typescript
import fs from "node:fs";
import path from "node:path";
import pLimit, { type LimitFunction } from "p-limit";
import { globby } from "globby";
/**
* Entry describing an item from Bunny Storage list API.
* The API is not strongly consistent across docs, so we support common shapes.
*/
export interface BunnyListEntry {
ObjectName?: string;
Name?: string;
Key?: string;
IsDirectory?: boolean;
isDirectory?: boolean;
Type?: number; // 1 = directory in some responses
}
/**
* Options for Bunny Storage operations.
*/
export interface UploadOptions {
/** Storage zone name to target. */
storageZoneName: string;
/** Access key for the storage zone. */
accessKey: string;
/** Optional region prefix, e.g. `la`, `ny`, `sg`, etc. */
region?: string;
/**
* When set to `simple`, remove the destination path before uploading.
* When set to `avoid-deletes`, recursively prune remote files/folders that
* are not present locally while preserving replacements.
*/
cleanDestination?: "simple" | "avoid-deletes";
/** Max concurrent uploads when sending files. Default: 10. */
maxConcurrentUploads?: number;
/**
* Optional custom p-limit instance. If not provided, a limiter is created
* from `maxConcurrentUploads`.
*/
limit?: LimitFunction;
}
/** Build a Bunny Storage API URL for a given path. */
const buildUrl = (targetPath: string, options: UploadOptions): string => {
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: string,
options: UploadOptions,
): Promise<void> => {
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: string | undefined,
options: UploadOptions,
): Promise<BunnyListEntry[]> => {
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 as BunnyListEntry[]) : [];
};
/** Determine whether a given list entry represents a directory. */
const isDirectoryEntry = (entry: BunnyListEntry): boolean =>
entry.IsDirectory === true || entry.isDirectory === true || entry.Type === 1;
/**
* Recursively delete a remote directory's contents, then the directory itself.
*/
const deleteRecursively = async (
remoteDir: string,
options: UploadOptions,
): Promise<void> => {
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: string,
currentRelDir: string,
localFilesSet: Set<string>,
localDirsSet: Set<string>,
options: UploadOptions,
): Promise<void> => {
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: string,
targetPath: string,
options: UploadOptions,
): Promise<void> => {
const url = buildUrl(targetPath, options);
const fileContent = fs.createReadStream(sourcePath);
const res = await fetch(url, {
method: "PUT",
body: fileContent as unknown as globalThis.BodyInit,
// Required by Node's fetch (undici) when sending a stream body
duplex: "half",
headers: {
AccessKey: options.accessKey,
"Content-Type": "application/octet-stream",
},
} as unknown as globalThis.RequestInit);
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: string,
targetDirectory: string,
options: UploadOptions,
): Promise<void> => {
const effective: UploadOptions = {
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<string>();
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;