@jeffy-g/universal-fs
Version:
Universal file system utils for Node.js and Browser in TypeScript.
470 lines (469 loc) • 15.5 kB
JavaScript
/**
* @import {
* IInternalFs,
* TMimeType,
* TUFSFormat,
* TUFSOptions,
* } from "./types.d.ts";
*
* @import {
* IUniversalFsErrorParams,
* } from "./utils.d.ts"
*/
/** @type {(value?: unknown) => string} */
const toString = {}.toString;
/**
* Checks if the value is a Blob or File.
*
* + This is a type guard that narrows the type to `Blob | File`.
*
* @param {unknown} value - The value to check.
* @returns {value is (Blob | File)} `true` if the value is a Blob or File, otherwise `false`.
*/
const isBlob = (value) => {
const tag = toString.call(value);
return tag === "[object Blob]" || tag === "[object File]";
};
// format=blob の場合、subject を `convertedData` として割り当て、`buffer` は undefined になる (実質、buffer の利用は不要)
// それ以外の場合、convertedData=undefined, buffer=await blob.arrayBuffer()
export async function handleBlob(subject, format) {
let url;
let size;
let buffer;
let mimeType;
let actualFilename;
let convertedData;
// case Blob, File
const blob = subject;
const isFile = "name" in subject;
size = blob.size;
// if instanceof File use name property, otherwise will be empty string
actualFilename = isFile ? subject.name : "anonymous";
url = `local-${isFile ? "file" : "blob"}://${encodeURIComponent(actualFilename)}`;
mimeType = blob.type || guessMimeType(actualFilename);
// If format is "blob", use it as is
if (format === "blob") {
convertedData = blob;
} else {
buffer = await blob.arrayBuffer();
}
return {
url,
size,
buffer,
mimeType,
actualFilename,
convertedData,
};
}
async function handleURL(filename) {
let url;
let size;
let buffer;
let mimeType;
let actualFilename;
// Handle URL input
const response = await fetch(filename);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
buffer = await response.arrayBuffer();
url = response.url;
size = buffer.byteLength;
mimeType = response.headers.get("content-type") || guessMimeType(filename);
actualFilename = filename.split("/").pop() || filename;
return {
url,
size,
buffer,
mimeType,
actualFilename,
};
}
/**
* @param {IUniversalFsErrorParams["strategy"]} strategy
* @param {IInternalFs["readFile"]} readFileLocal
* @returns {IInternalFs["readFile"]}
*/
export function emitReadFileFunction(strategy, readFileLocal) {
async function readFile(filename, options = {}) {
try {
let url;
let size;
let buffer;
let mimeType;
let actualFilename;
let convertedData;
if (typeof filename === "string") {
// Handle URL input
try {
({ url, size, buffer, mimeType, actualFilename } =
await handleURL(filename));
} catch (e) {
if (readFileLocal) {
try {
return await readFileLocal(filename, options);
} catch (e) {
throw e;
}
}
throw e;
}
} else if (isBlob(filename)) {
({ url, size, buffer, mimeType, actualFilename, convertedData } =
await handleBlob(filename, options.format || "text"));
} else {
// TypeScript exhaustiveness check
throw new Error(MSG_UNSUPPORTED_INPUT);
}
if (buffer && !convertedData) {
convertedData = await convertFromBuffer(buffer, mimeType, options);
}
if (!options.useDetails) return convertedData;
return {
url,
size,
mimeType,
strategy,
timestamp: Date.now(),
data: convertedData,
filename: actualFilename,
};
} catch (e) {
throw new UniversalFsError(
formatFsErrorMessage(e, strategy, "read"),
createErrorParameters(e, strategy, "read", filename?.toString()),
);
}
}
return readFile;
}
// helper
const te = new TextDecoder();
/**
* Converts an ArrayBuffer to the specified format based on `options.format`.
*
* Supported formats:
* - `"text"` → UTF-8 decoded string
* - `"json"` → Parsed JSON object or array (with error handling)
* - `"blob"` → `Blob` with inferred MIME type
* - `"binary"` → `Uint8Array`
* - `"arrayBuffer"` → `ArrayBuffer`
*
* @template T - Target data type after conversion.
* @param {ArrayBuffer} buffer - Raw binary data to convert.
* @param {TMimeType} mimeType - MIME type for Blob creation.
* @param {TUFSOptions} options - Options specifying desired format.
* @returns {Promise<T>} Converted data in the requested format.
* @throws {Error} If the format is unknown or JSON parsing fails.
*/
async function convertFromBuffer(buffer, mimeType, options) {
const format = decideFormat(options);
switch (format) {
case "text": /* falls through */
case "json":
const text = te.decode(buffer);
if (format === "text") return text;
return convertToJSON(text);
case "blob":
return new Blob([buffer], { type: mimeType });
case "binary": /* falls through */
case "arrayBuffer":
if (format === "arrayBuffer") return buffer;
return new Uint8Array(buffer);
}
}
/**
* Validates the input format string against the supported formats.
*/
const VALID_FORMATS = ["text", "json", "blob", "binary", "arrayBuffer"];
/**
* ```
* `Unsupported input type`
* ```
*/
export const MSG_UNSUPPORTED_INPUT = "Unsupported input type";
/**
* Validates whether the given format string is a supported file format.
*
* If the format is valid, the function returns `true` and refines the type to `TUFSFormat`.
* If invalid, it throws a `TypeError` with a descriptive error message listing all supported formats.
*
* @param format - The file format to validate (e.g. "text", "json", etc).
* @returns `true` if the format is valid (and type-guarded as `TUFSFormat`)
* @throws {TypeError} If the format is not supported.
*
* @example
* ```ts
* if (isValidFormat(format)) {
* // format is now of type TUFSFormat
* readFile("data.txt", { format });
* }
* ```
*/
export const isValidFormat = (format) => {
if (!VALID_FORMATS.includes(format)) {
throw new TypeError(
`[ufs] Unsupported format: "${format}", Supported: ${VALID_FORMATS.join(", ")}`,
);
}
return true;
};
/**
* Decides the format based on the options provided.
*
* + If the `options.format` is not specified, it defaults to "text".
*
* @param {TUFSOptions} options - UniversalFs options object.
* @returns {TUFSFormat | never} The format string, guaranteed to be a valid `TUFSFormat`.
* @throws {Error} If the format is not valid.
*/
export function decideFormat(options) {
const format = options.format || "text";
if (isValidFormat(format)) {
return format;
}
return format; // This will never be reached due to the isValidFormat check
}
/**
* Custom error class for universal file system operations.
*
* Provides enhanced error information including execution context,
* operation type, and underlying cause for better debugging and error handling.
*
* @example
* // Basic usage
* throw new UniversalFsError("File not found");
*
* // With detailed context
* throw new UniversalFsError("Permission denied", {
* cause: originalError,
* strategy: "node",
* operation: "read",
* filename: "/path/to/file.txt"
* });
*
* // Using helper function
* const params = createErrorParameters(err, "browser", "write", "config.json");
* throw new UniversalFsError("Write failed", params);
*/
export class UniversalFsError extends Error {
/**
* Creates a new UniversalFsError instance.
*
* @param message - The error message describing what went wrong
* @param params - Optional structured parameters providing error context
*/
constructor(message, params) {
super(message);
this.name = "UniversalFsError";
if (params) {
Object.assign(this, params);
}
}
}
/**
* Helper function to create error parameters object with type safety.
*
* Provides a convenient way to construct `IUniversalFsErrorParams` objects
* with proper type checking and intellisense support.
*
* @param cause - The underlying cause of the error
* @param strategy - The execution strategy/environment where the error occurred
* @param operation - The type of file operation that failed
* @param filename - The filename or path associated with the failed operation
* @returns A properly typed error parameters object
*
* @example
* const params = createErrorParameters(originalError, "node", "read", "config.json");
* throw new UniversalFsError("Failed to read configuration", params);
*/
export const createErrorParameters = (cause, strategy, operation, filename) => {
return { cause, strategy, operation, filename };
};
/**
* Formats a standardized error message for file system operations.
*
* Creates consistent error messages across different environments and operations,
* extracting meaningful information from various error types.
*
* @param e - The error or exception to format (can be Error instance or any value)
* @param strategy - The execution strategy/environment where the error occurred
* @param operation - The type of file operation that failed
* @returns A formatted error message string
*
* @example
* // Format Node.js file read error
* const message = formatFsErrorMessage(fsError, "node", "read");
* // Result: "Failed to read file in Node.js: ENOENT: no such file or directory"
*
* // Format browser fetch error
* const message = formatFsErrorMessage(fetchError, "browser", "write");
* // Result: "Failed to write file in Browser: Network request failed"
*
* // Handle unknown error types
* const message = formatFsErrorMessage("Something went wrong", "node", "both");
* // Result: "Failed to both file in Node.js: Something went wrong"
*/
export const formatFsErrorMessage = (e, strategy, operation) => {
const env = strategy === "node" ? "Node.js" : "Browser";
return `Failed to ${operation} file in ${env}: ${e instanceof Error ? e.message : "Unknown error"}`;
};
/**
* MIME type mapping dictionary.
* Defines the MIME type inferred from the file extension (with ".").
*
* @todo extract extenstion with types from https://github.com/jshttp/mime-db
*/
const MIME_TYPES = {
// text
".txt": "text/plain",
".json": "application/json",
".csv": "text/csv",
".md": "text/markdown",
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".ts": "application/typescript",
".xml": "application/xml",
// image (binary)
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
// audio, vidoe (binary)
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".mp4": "video/mp4",
".webm": "video/webm",
".avi": "video/x-msvideo",
// - midi data
".mid": "audio/midi",
".midi": "audio/midi",
// document (binary)
".pdf": "application/pdf",
".doc": "application/msword",
".docx":
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
// archive (binary)
".zip": "application/zip",
".tar": "application/x-tar",
".gz": "application/gzip",
// Ableton .als format (application/x-ableton-als ?)
".als": "application/gzip",
};
const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]+$/;
/**
* Utility function to guess MIME type from file name.
*
* Gets the extension with `path.extname()`, and returns the MIME type from `MIME_TYPES` based on it.
* Returns `"application/octet-stream"` if the extension is not supported.
*
* @param filename - File name including extension
* @returns Guessed MIME type (`"application/octet-stream"` if not known)
*/
export function guessMimeType(filename) {
const m = FILE_EXTENSION_REGEX.exec(filename);
const ext = m ? m[0].toLowerCase() : "";
return MIME_TYPES[ext] || "application/octet-stream";
}
/**
* Converts a Buffer to a Blob object if supported, otherwise returns the original Buffer.
*
* @param {Buffer} rawData - The raw binary data to convert.
* @param {TMimeType} mimeType - The MIME type to assign to the Blob.
* @returns {Blob | Buffer} The resulting Blob if supported, or the original Buffer as a fallback.
*/
export function convertToBlob(rawData, mimeType) {
// The proper way to create a Blob in Node.js
// Blob is available in Node.js 15.7.0+
try {
// ts-expect-error (ts v5.9.0-dev *) Buffer is a subclass of `Uint8Array` and valid `BlobPart` in runtime
return new Blob([rawData], { type: mimeType });
} catch (e) {
// For older Node.js versions where Blob is not available, returns a Buffer instead.
console.warn(
"Blob is not available in this Node.js version, returning Buffer instead",
);
return rawData;
}
}
/**
* Safely parses a JSON string into a strongly-typed object.
*
* @template T - The expected type of the parsed JSON object.
* @param {string} jsonString - The JSON string to parse.
* @returns {T} The parsed object with type `T`.
* @throws {Error} If the input string is not valid JSON, an error is thrown with details.
*/
export function convertToJSON(jsonString) {
try {
return JSON.parse(jsonString);
} catch (error) {
const preview = jsonString.slice(0, 100).replace(/\s+/g, " ");
throw new Error(
`Invalid JSON format: ${error instanceof Error ? error.message : "Unknown error"}\n` +
`Preview: ${preview}${jsonString.length > 100 ? "..." : ""}`,
);
}
}
/**
* Sanitizes a filename by removing dangerous characters and limiting its length.
* Intended primarily for use in browser environments.
*
* Note: As of Chrome latest (2025-07-27), browsers (including Chrome) automatically sanitize
* filenames containing unsafe characters (such as "/") when downloading files.
* Additional sanitization provided by this function ensures cross-browser compatibility and
* more consistent behavior across environments.
*
* @param {string} filename - The input filename to sanitize.
* @returns {string} The sanitized filename, with unsafe characters replaced and length capped at 255.
*/
export function sanitizeFilename(filename) {
// Remove dangerous characters
return filename
.replace(/[<>:"/\\|?*]/g, "_")
.replace(/^\.+/, "")
.slice(0, 255);
}
// NOTE: Remove in future
// /**
// * Returns the native data type for a given format.(Node.js)
// *
// * @param format Format name
// * @returns Example Default value
// * @date 2025/7/26 17:54:31
// */
// export function convertDataForFormat<T extends TUFSFormat>(
// rawData: Buffer,
// mimeType: TMimeType,
// format: T
// ): IUFSFormatMap[T] {
// let result: unknown;
// switch (format) {
// case "text": /* falls through */
// case "json":
// result = rawData.toString("utf8");
// if (format === "json")
// result = convertToJSON(result as string);
// break;
// case "arrayBuffer":
// result = rawData.buffer.slice(rawData.byteOffset, rawData.byteOffset + rawData.byteLength);
// break;
// case "blob":
// result = convertToBlob(rawData, mimeType);
// break;
// case "binary":
// result = rawData;
// break;
// default:
// throw new Error(`Unsupported format: ${format}`);
// }
// return result as IUFSFormatMap[T];
// }