@jeffy-g/universal-fs
Version:
Universal file system utils for Node.js and Browser in TypeScript.
222 lines (221 loc) • 8.01 kB
JavaScript
/*!
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Copyright (C) 2025 jeffy-g <hirotom1107@gmail.com>
// Released under the MIT license
// https://opensource.org/licenses/mit-license.php
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
*/
/**
* @file universal-fs/src/node-fs.ts
*/
import * as fs from "node:fs/promises";
import { constants } from "node:fs";
import * as path from "node:path";
import {
UniversalFsError,
guessMimeType,
formatFsErrorMessage,
createErrorParameters,
convertToBlob,
convertToJSON,
decideFormat,
MSG_UNSUPPORTED_INPUT,
emitReadFileFunction,
} from "./utils.js";
/**
* @import {
* TUFSOptions,
* IInternalFs
* } from "./types.d.ts"
*/
const writeErrParams = createErrorParameters(void 0, "node", "write");
async function readFileLocal(filename, options = {}) {
if (typeof filename !== "string") {
// TODO: 2025/7/31 7:11:15 - Or, Blob and File will be supported (in the future)
throw new Error(MSG_UNSUPPORTED_INPUT);
}
const fullPath = path.resolve(filename);
const stats = await fs.stat(fullPath);
if (!stats.isFile()) {
throw new UniversalFsError(`Path is not a file: ${fullPath}`);
}
const buffer = await fs.readFile(fullPath);
const convertedData = convertFromBuffer(buffer, options, fullPath);
if (!options.useDetails) return convertedData;
return {
filename,
path: fullPath,
size: stats.size,
strategy: "node",
timestamp: Date.now(),
data: convertedData,
mimeType: guessMimeType(filename),
};
}
/**
* Reads a file via HTTP(S) or from a Blob/File in a browser environment.
*
* - Supports input as `string` (URL) or `Blob`/`File`.
* - Automatically parses based on `options.format`
* - Supported formats: `"text"`, `"json"`, `"arrayBuffer"`, `"blob"`, `"binary"`.
*
* **Note:**
* When reading from a `Blob` or `File`, the returned `url` property will be a pseudo-URL (`"blob://local"`)
* instead of an actual object URL to avoid memory leaks. Consumers should not rely on this for displaying
* the content directly. If an actual object URL is required, consider using `URL.createObjectURL()` manually.
*
* @param filename - File path, URL string, or `Blob`/`File` instance.
* @param [options] - UniversalFs options.
* @returns Result object containing the data.
* @type {IInternalFs["readFile"]}
*/
// local file read:
// node env の場合、file access permision に問題なければ無制限
// browser env では不可
export const readFile = emitReadFileFunction("node", readFileLocal);
/**
* Write a file in Node.js environment.
* @param filename - File path.
* @param data - Data to write.
* @param [options] - UniversalFs options.
* @returns Result object.
* @type {IInternalFs["writeFile"]}
*/
export async function writeFile(filename, data, options = {}) {
try {
const fullPath = path.resolve(filename);
// If the directory does not exist, create it
const dir = path.dirname(fullPath);
if (!(await exists(dir))) {
await fs.mkdir(dir, { recursive: true });
}
const normalized = await normalizeWriteData(data, options);
// Since normalizeWriteData always returns a Uint8Array, there is no point in passing encoding to fs.writeFile.
await fs.writeFile(fullPath, normalized /* , options.encoding || "utf8" */);
if (options.useDetails) {
const stats = await fs.stat(fullPath);
return {
filename,
path: fullPath,
size: stats.size,
strategy: "node",
timestamp: Date.now(),
mimeType: guessMimeType(filename),
};
}
return void 0;
} catch (e) {
throw new UniversalFsError(formatFsErrorMessage(e, "node", "write"), {
...writeErrParams,
cause: e,
});
}
}
/**
* Checks whether a file or directory exists at the given path.
*
* This is a modern and Promise-based alternative to the deprecated `fs.exists()` API,
* using `fs.access()` with `constants.F_OK`.
*
* - Does **not** throw if the path does not exist.
* - Use in both sync/async-safe code paths.
*
* @param {string} path - The filesystem path to check.
* @returns `true` if the path exists, otherwise `false`.
*/
async function exists(path) {
try {
await fs.access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Converts raw Buffer data into the desired type based on the `format` option.
*
* - `"text"` → returns string (default, uses encoding or UTF-8)
* - `"json"` → parses as JSON and returns an object or array
* - `"binary"` → returns Node.js Buffer (Uint8Array)
* - `"arrayBuffer"` → returns ArrayBuffer (safe slice of underlying memory)
* - `"blob"` → returns Blob (Node.js >=15, fallback to Buffer if not available)
*
* @template T - Expected return type inferred from the format.
* @param {Buffer} rawData - Raw file data as a Node.js Buffer.
* @param {TUFSOptions} options - Universal FS options, must include a `format` field.
* @param {string} filepath - Original file path (used for guessing format if not provided).
* @returns {T} Processed data in the requested format.
* @throws {Error} If the format is unknown or JSON parsing fails.
*/
function convertFromBuffer(rawData, options, filepath) {
const format = decideFormat(options);
switch (format) {
case "text": /* falls through */
case "json":
const text = rawData.toString(options.encoding || "utf8");
if (format === "text") return text;
// T = Record<string, unknown> | unknown[] | object
return convertToJSON(text);
case "binary":
// T = Uint8Array
return rawData;
case "arrayBuffer":
// T = ArrayBuffer
return rawData.buffer.slice(
rawData.byteOffset,
rawData.byteOffset + rawData.byteLength,
);
case "blob":
// T = Blob | Buffer(fallback)
return convertToBlob(rawData, guessMimeType(filepath));
}
}
/**
* Normalize data for Node.js write operation.
*
* - Returns `Uint8Array` for all binary-like inputs.
* - Respects `encoding` for text data and Blob text mode.
*
* @param {BlobPart} data - Data to normalize for writing.
* @param {TUFSOptions} [options] - Universal FS options (affects encoding).
* @returns {Promise<Uint8Array>} Normalized data as `Uint8Array`.
* @throws {UniversalFsError} when data type is unsupported.
* @todo Future: Add support for multi-byte encodings (Shift_JIS, EUC-JP, etc.) via iconv-lite or Web TextDecoder API.
*/
async function normalizeWriteData(data, options) {
// 1. Uint8Array or Buffer (Node.js Buffer is Uint8Array subclass)
if (data instanceof Uint8Array) {
return data;
}
const encoding = options?.encoding || "utf8";
// 2. String → Encode using specified encoding
if (typeof data === "string") {
return Buffer.from(data, encoding);
}
// 3. ArrayBuffer → Wrap in Uint8Array
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
// 4. Blob → Handle text or binary based on encoding
if (data instanceof Blob) {
if (encoding !== "binary") {
// `encoding` is probably the text type. Interpret Blob as text and encode
// NOTE: Blob.text() always decodes as UTF-8. If original encoding was different,
// data will be corrupted. encoding param applies only to re-encode the UTF-8 text.
// **Blob.text() always returns a UTF-8 decoded string (as per the spec)**
const text = await data.text();
return Buffer.from(text, encoding);
}
// encoding is binary or not
const ab = await data.arrayBuffer();
return new Uint8Array(ab);
}
// 5. TypedArray (other than Uint8Array) or DataView
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
throw new UniversalFsError(
`Unsupported data type: ${Object.prototype.toString.call(data)}. Expected string, Uint8Array, ArrayBuffer, Buffer, or Blob.`,
writeErrParams,
);
}