@ayonli/jsext
Version:
A JavaScript extension package for building strong and modern applications.
686 lines (618 loc) • 21.1 kB
text/typescript
/**
* A slim version of the `fs` module for the browser.
*
* Normally, we should just use the `fs` module, however, if we don't want
* to include other parts that are not needed in the browser, we can use this
* module instead.
* @module
*/
import { abortable } from "../async.ts";
import { getMIME } from "../filetype.ts";
import { as } from "../object.ts";
import { basename, dirname, extname, join, split } from "../path.ts";
import { readAsArray, readAsText, resolveByteStream, toAsyncIterable } from "../reader.ts";
import { stripStart } from "../string.ts";
import { try_ } from "../result.ts";
import { fixDirEntry, fixFileType, makeTree, rawOp, wrapFsError } from "./util.ts";
import type { FileInfo, FileSystemOptions, DirEntry, DirTree } from "./types.ts";
import type {
CopyOptions,
GetDirOptions,
GetFileOptions,
MkdirOptions,
ReadDirOptions,
ReadFileOptions,
RemoveOptions,
StatOptions,
WriteFileOptions,
} from "../fs.ts";
import { AlreadyExistsError } from "../error.ts";
import { InvalidOperationError, NotDirectoryError } from "./errors.ts";
export type { FileSystemOptions, FileInfo, DirEntry, DirTree };
export const EOL: "\n" | "\r\n" = "\n";
/**
* Obtains the directory handle of the given path.
*
* NOTE: This function is only available in the browser.
*
* NOTE: If the `path` is not provided or is empty, the root directory handle
* will be returned.
*
* @example
* ```ts
* // with the default storage
* import { getDirHandle } from "@ayonli/jsext/fs";
*
* const dir = await getDirHandle("/path/to/dir");
* ```
*
* @example
* ```ts
* // with a user-selected directory as root (Chromium only)
* import { getDirHandle } from "@ayonli/jsext/fs";
*
* const root = await window.showDirectoryPicker();
* const dir = await getDirHandle("/path/to/dir", { root });
* ```
*
* @example
* ```ts
* // create the directory if not exist
* import { getDirHandle } from "@ayonli/jsext/fs";
*
* const dir = await getDirHandle("/path/to/dir", { create: true, recursive: true });
* ```
*
* @example
* ```ts
* // return the root directory handle
* import { getDirHandle } from "@ayonli/jsext/fs";
*
* const root = await getDirHandle();
* ```
*/
export async function getDirHandle(
path: string = "",
options: GetDirOptions = {}
): Promise<FileSystemDirectoryHandle> {
if (typeof location === "object" && typeof location.origin === "string") {
path = stripStart(path, location.origin);
}
const { create = false, recursive = false } = options;
const paths = split(stripStart(path, "/")).filter(p => p !== ".");
const root = options.root ?? await rawOp(navigator.storage.getDirectory(), "directory");
let dir = root as FileSystemDirectoryHandle;
for (let i = 0; i < paths.length; i++) {
const _path = paths[i]!;
dir = await rawOp(dir.getDirectoryHandle(_path, {
create: create && (recursive || (i === paths.length - 1)),
}), "directory");
}
return dir;
}
/**
* Obtains the file handle of the given path.
*
* NOTE: This function is only available in the browser.
*
* @example
* ```ts
* // with the default storage
* import { getFileHandle } from "@ayonli/jsext/fs";
*
* const file = await getFileHandle("/path/to/file.txt");
* ```
*
* @example
* ```ts
* // with a user-selected directory as root (Chromium only)
* import { getFileHandle } from "@ayonli/jsext/fs";
*
* const root = await window.showDirectoryPicker();
* const file = await getFileHandle("/path/to/file.txt", { root });
* ```
*
* @example
* ```ts
* // create the file if not exist
* import { getFileHandle } from "@ayonli/jsext/fs";
*
* const file = await getFileHandle("/path/to/file.txt", { create: true });
* ```
*/
export async function getFileHandle(
path: string,
options: GetFileOptions = {}
): Promise<FileSystemFileHandle> {
const dirPath = dirname(path);
const name = basename(path);
const dir = await getDirHandle(dirPath, { root: options.root });
return await rawOp(dir.getFileHandle(name, {
create: options.create ?? false,
}), "file");
}
export async function exists(path: string, options: FileSystemOptions = {}): Promise<boolean> {
try {
await stat(path, options);
return true;
} catch (err) {
if (err instanceof Exception) {
if (err.name === "NotFoundError") {
return false;
}
}
throw err;
}
}
export async function stat(
target: string | FileSystemFileHandle | FileSystemDirectoryHandle,
options: StatOptions = {}
): Promise<FileInfo> {
if (typeof target === "object") {
if (target.kind === "file") {
const info = await rawOp(target.getFile(), "file");
return {
name: target.name,
kind: "file",
size: info.size,
type: info.type ?? getMIME(extname(target.name)) ?? "",
mtime: new Date(info.lastModified),
atime: null,
birthtime: null,
mode: 0,
uid: 0,
gid: 0,
isBlockDevice: false,
isCharDevice: false,
isFIFO: false,
isSocket: false,
};
} else {
return {
name: target.name,
kind: "directory",
size: 0,
type: "",
mtime: null,
atime: null,
birthtime: null,
mode: 0,
uid: 0,
gid: 0,
isBlockDevice: false,
isCharDevice: false,
isFIFO: false,
isSocket: false,
};
}
} else {
const {
value: file,
error: err,
} = await try_(getFileHandle(target, options));
if (file) {
const info = await rawOp(file.getFile(), "file");
return {
name: info.name,
kind: "file",
size: info.size,
type: info.type ?? getMIME(extname(info.name)) ?? "",
mtime: new Date(info.lastModified),
atime: null,
birthtime: null,
mode: 0,
uid: 0,
gid: 0,
isBlockDevice: false,
isCharDevice: false,
isFIFO: false,
isSocket: false,
};
} else if (as(err, Exception)?.name === "IsDirectoryError") {
return {
name: basename(target),
kind: "directory",
size: 0,
type: "",
mtime: null,
atime: null,
birthtime: null,
mode: 0,
uid: 0,
gid: 0,
isBlockDevice: false,
isCharDevice: false,
isFIFO: false,
isSocket: false,
};
} else {
throw err;
}
}
}
export async function mkdir(path: string, options: MkdirOptions = {}): Promise<void> {
if (await exists(path, { root: options.root })) {
throw new AlreadyExistsError(`File or folder already exists, mkdir '${path}'`);
}
await getDirHandle(path, { ...options, create: true });
}
export async function ensureDir(
path: string,
options: Omit<MkdirOptions, "recursive"> = {}
): Promise<void> {
if (await exists(path, options)) {
return;
}
try {
await mkdir(path, { ...options, recursive: true });
} catch (err) {
if (as(err, Exception)?.name === "AlreadyExistsError") {
return;
} else {
throw err;
}
}
}
export async function* readDir(
target: string | FileSystemDirectoryHandle,
options: ReadDirOptions = {}
): AsyncIterableIterator<DirEntry> {
const handle = typeof target === "object"
? target
: await getDirHandle(target, options);
yield* readDirHandle(handle, options);
}
async function* readDirHandle(dir: FileSystemDirectoryHandle, options: {
base?: string,
recursive?: boolean;
} = {}): AsyncIterableIterator<DirEntry> {
const { base = "", recursive = false } = options;
const entries = dir.entries();
for await (const [_, entry] of entries) {
const _entry = fixDirEntry({
name: entry.name,
kind: entry.kind,
relativePath: join(base, entry.name),
handle: entry as FileSystemFileHandle | FileSystemDirectoryHandle,
});
yield _entry;
if (recursive && entry.kind === "directory") {
yield* readDirHandle(entry as FileSystemDirectoryHandle, {
base: _entry.relativePath,
recursive,
});
}
}
}
export async function readTree(
target: string | FileSystemDirectoryHandle,
options: FileSystemOptions = {}
): Promise<DirTree> {
const entries = (await readAsArray(readDir(target, { ...options, recursive: true })));
const tree = makeTree<DirEntry, DirTree>(target, entries, true);
if (!tree.handle && options.root) {
tree.handle = options.root as FileSystemDirectoryHandle;
}
return tree;
}
export async function readFile(
target: string | FileSystemFileHandle,
options: ReadFileOptions = {}
): Promise<Uint8Array> {
const handle = typeof target === "object"
? target
: await getFileHandle(target, { root: options.root });
const file = await rawOp(handle.getFile(), "file");
const arr = new Uint8Array(file.size);
let offset = 0;
let reader = toAsyncIterable(file.stream());
if (options.signal) {
reader = abortable(reader, options.signal);
}
for await (const chunk of reader) {
arr.set(chunk, offset);
offset += chunk.length;
}
return arr;
}
export async function readFileAsText(
target: string | FileSystemFileHandle,
options: ReadFileOptions & { encoding?: string; } = {}
): Promise<string> {
const { encoding, ...rest } = options;
const file = await readFile(target, rest);
return await readAsText(file, encoding);
}
export async function readFileAsFile(
target: string | FileSystemFileHandle,
options: ReadFileOptions = {}
): Promise<File> {
const handle = typeof target === "object"
? target
: await getFileHandle(target, { root: options.root });
const file = await rawOp(handle.getFile(), "file");
return fixFileType(file);
}
export async function writeFile(
target: string | FileSystemFileHandle,
data: string | ArrayBuffer | ArrayBufferView | ReadableStream<Uint8Array> | Blob,
options: WriteFileOptions = {}
): Promise<void> {
const handle = typeof target === "object"
? target
: await getFileHandle(target, { root: options.root, create: true });
const writer = await createFileHandleWritableStream(handle, options);
if (options.signal) {
const { signal } = options;
if (signal.aborted) {
await writer.abort(signal.reason);
throw wrapFsError(signal.reason, "file");
} else {
signal.addEventListener("abort", () => {
writer.abort(signal.reason);
});
}
}
try {
if (data instanceof Blob) {
await data.stream().pipeTo(writer);
} else if (data instanceof ReadableStream) {
await data.pipeTo(writer);
} else {
await writer.write(data);
await writer.close();
}
} catch (err) {
throw wrapFsError(err, "file");
}
}
export async function writeLines(
target: string | FileSystemFileHandle,
lines: string[],
options: WriteFileOptions = {}
): Promise<void> {
const current = await readFileAsText(target, options).catch(err => {
if (as(err, Exception)?.name !== "NotFoundError") {
throw err;
} else {
return "";
}
});
const lineEndings = current.match(/\r?\n/g);
let eol = EOL;
if (lineEndings) {
const crlf = lineEndings.filter(e => e === "\r\n").length;
const lf = lineEndings.length - crlf;
eol = crlf > lf ? "\r\n" : "\n";
}
let content = lines.join(eol);
if (!content.endsWith(eol)) {
if (eol === "\r\n" && content.endsWith("\r")) {
content += "\n";
} else {
content += eol;
}
}
if (options.append && !current.endsWith(eol) && !content.startsWith(eol)) {
if (eol === "\r\n" && current.endsWith("\r")) {
if (!content.startsWith("\n")) {
content = "\n" + content;
}
} else {
content = eol + content;
}
}
await writeFile(target, content, options);
}
export async function truncate(
target: string | FileSystemFileHandle,
size = 0,
options: FileSystemOptions = {}
): Promise<void> {
const handle = typeof target === "object"
? target
: await getFileHandle(target, { root: options.root });
try {
const writer = await handle.createWritable({ keepExistingData: true });
await writer.truncate(size);
await writer.close();
} catch (err) {
throw wrapFsError(err, "file");
}
}
export async function remove(path: string, options: RemoveOptions = {}): Promise<void> {
const parent = dirname(path);
const name = basename(path);
const dir = await getDirHandle(parent, { root: options.root });
await rawOp(dir.removeEntry(name, options), "directory");
}
export async function rename(
oldPath: string,
newPath: string,
options: FileSystemOptions = {}
): Promise<void> {
return await copyInBrowser(oldPath, newPath, {
root: options.root,
recursive: true,
move: true,
});
}
export async function copy(src: string, dest: string, options?: CopyOptions): Promise<void>;
export async function copy(
src: FileSystemFileHandle,
dest: FileSystemFileHandle | FileSystemDirectoryHandle
): Promise<void>;
export async function copy(
src: FileSystemDirectoryHandle,
dest: FileSystemDirectoryHandle,
options?: Pick<CopyOptions, "recursive">
): Promise<void>;
export async function copy(
src: string | FileSystemFileHandle | FileSystemDirectoryHandle,
dest: string | FileSystemFileHandle | FileSystemDirectoryHandle,
options: CopyOptions = {}
): Promise<void> {
return copyInBrowser(src, dest, options);
}
async function copyInBrowser(
src: string | FileSystemFileHandle | FileSystemDirectoryHandle,
dest: string | FileSystemFileHandle | FileSystemDirectoryHandle,
options: FileSystemOptions & {
recursive?: boolean;
move?: boolean;
} = {}
): Promise<void> {
if (typeof src === "object" && typeof dest !== "object") {
throw new TypeError("The destination must be a FileSystemHandle.");
} else if (typeof dest === "object" && typeof src !== "object") {
throw new TypeError("The source must be a FileSystemHandle.");
} else if (typeof src === "object" && typeof dest === "object") {
if (src.kind === "file") {
if (dest.kind === "file") {
return await copyFileHandleToFileHandle(src, dest);
} else {
return await copyFileHandleToDirHandle(src, dest);
}
} else if (dest.kind === "directory") {
if (!options.recursive) {
throw new InvalidOperationError(
"Cannot copy a directory without the 'recursive' option");
}
return await copyDirHandleToDirHandle(src, dest);
} else {
throw new NotDirectoryError("The destination location is not a directory");
}
}
const oldParent = dirname(src as string);
const oldName = basename(src as string);
let oldDir = await getDirHandle(oldParent, { root: options.root });
const {
value: oldFile,
error: oldErr,
} = await try_(rawOp(oldDir.getFileHandle(oldName), "file"));
if (oldFile) {
const newParent = dirname(dest as string);
const newName = basename(dest as string);
let newDir = await getDirHandle(newParent, { root: options.root });
const {
error: newErr,
value: newFile,
} = await try_(rawOp(newDir.getFileHandle(newName, {
create: true,
}), "file"));
if (newFile) {
await copyFileHandleToFileHandle(oldFile, newFile);
if (options.move) {
await rawOp(oldDir.removeEntry(oldName), "directory");
}
} else if (as(newErr, Exception)?.name === "IsDirectoryError" && !options.move) {
// The destination is a directory, copy the file into the new path
// with the old name.
newDir = await rawOp(newDir.getDirectoryHandle(newName), "directory");
await copyFileHandleToDirHandle(oldFile, newDir);
} else {
throw newErr;
}
} else if (as(oldErr, Exception)?.name === "IsDirectoryError") {
if (!options.recursive) {
throw new InvalidOperationError(
"Cannot copy a directory without the 'recursive' option");
}
const parent = oldDir;
oldDir = await rawOp(oldDir.getDirectoryHandle(oldName), "directory");
const newDir = await getDirHandle(dest as string, { root: options.root, create: true });
await copyDirHandleToDirHandle(oldDir, newDir);
if (options.move) {
await rawOp(parent.removeEntry(oldName, { recursive: true }), "directory");
}
} else {
throw oldErr;
}
}
async function copyFileHandleToFileHandle(
src: FileSystemFileHandle,
dest: FileSystemFileHandle
) {
try {
const srcFile = await src.getFile();
const destFile = await dest.createWritable();
await srcFile.stream().pipeTo(destFile);
} catch (err) {
throw wrapFsError(err, "file");
}
}
async function copyFileHandleToDirHandle(
src: FileSystemFileHandle,
dest: FileSystemDirectoryHandle
) {
try {
const srcFile = await src.getFile();
const newFile = await dest.getFileHandle(src.name, { create: true });
const destFile = await newFile.createWritable();
await srcFile.stream().pipeTo(destFile);
} catch (err) {
throw wrapFsError(err, "file");
}
}
async function copyDirHandleToDirHandle(
src: FileSystemDirectoryHandle,
dest: FileSystemDirectoryHandle
) {
const entries = src.entries();
for await (const [_, entry] of entries) {
if (entry.kind === "file") {
try {
const oldFile = await (entry as FileSystemFileHandle).getFile();
const newFile = await dest.getFileHandle(entry.name, {
create: true,
});
const reader = oldFile.stream();
const writer = await newFile.createWritable();
await reader.pipeTo(writer);
} catch (err) {
throw wrapFsError(err, "file");
}
} else {
const newSubDir = await rawOp(dest.getDirectoryHandle(entry.name, {
create: true,
}), "directory");
await copyDirHandleToDirHandle(entry as FileSystemDirectoryHandle, newSubDir);
}
}
}
export function createReadableStream(
target: string | FileSystemFileHandle,
options: FileSystemOptions = {}
): ReadableStream<Uint8Array> {
return resolveByteStream((async () => {
const handle = typeof target === "object"
? target
: await getFileHandle(target, { root: options.root });
const file = await rawOp(handle.getFile(), "file");
return file.stream();
})());
}
export function createWritableStream(
target: string | FileSystemFileHandle,
options: Omit<WriteFileOptions, "signal"> = {}
): WritableStream<Uint8Array> {
const { readable, writable } = new TransformStream();
const getHandle = typeof target === "object"
? Promise.resolve(target)
: getFileHandle(target, { root: options.root, create: true });
getHandle.then(handle => createFileHandleWritableStream(handle, options))
.then(stream => readable.pipeTo(stream));
return writable;
}
async function createFileHandleWritableStream(handle: FileSystemFileHandle, options: {
append?: boolean;
}): Promise<FileSystemWritableFileStream> {
const stream = await rawOp(handle.createWritable({
keepExistingData: options?.append ?? false,
}), "file");
if (options.append) {
const file = await rawOp(handle.getFile(), "file");
file.size && stream.seek(file.size);
}
return stream;
}