Last active
April 17, 2025 07:06
-
-
Save nberlette/b8e624205367d8dcfb3bf2931eca007c to your computer and use it in GitHub Desktop.
Virtual File System API (mostly compatible with `node:fs`)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* # `@nick/braces` | |
* | |
* This module provides a function to perform Bash-like brace expansion. It | |
* supports comma-separated options (e.g. "a{b,c}d" expands to ["abd", "acd"]), | |
* numeric sequences (e.g. "file{1..3}.txt"), alpha sequences, and even nested | |
* brace expressions. | |
* | |
* The module follows these steps: | |
* - It escapes backslashes and brace-related characters. | |
* - It recursively finds balanced pairs of braces and expands their | |
* comma-separated or sequence contents. | |
* - Finally, it unescapes the resulting strings. | |
* | |
* **Note**: A special quirk from Bash 4.3 is handled: strings starting with | |
* "{}" are prefixed with escape tokens so that the first two characters remain | |
* intact. | |
* | |
* ## Acknowledgements | |
* | |
* This module was ported to TypeScript from the original JavaScript package, | |
* `braces-expansion`: https://github.com/juliangruber/braces-expansion (MIT) | |
* | |
* The original code was written by Julian Gruber and is licensed under MIT. | |
* | |
* @module braces | |
*/ | |
// #region balanced match | |
export interface BalancedResult { | |
start: number; | |
end: number; | |
pre: string; | |
body: string; | |
post: string; | |
} | |
/** | |
* Returns the first balanced substring delimited by `a` and `b`. | |
* | |
* @param a - A string or regular expression representing the opening token. | |
* @param b - A string or regular expression representing the closing token. | |
* @param str - The string to search. | |
* @returns A balanced result object or null if no balanced tokens are found. | |
*/ | |
export function balanced( | |
a: string | RegExp, | |
b: string | RegExp, | |
str: string, | |
): BalancedResult | null { | |
if (a instanceof RegExp) a = maybeMatch(a, str)!; | |
if (b instanceof RegExp) b = maybeMatch(b, str)!; | |
if (a === null || b === null) return null; | |
const rng = range(a, b, str); | |
if (!rng) return null; | |
const [startIdx, endIdx] = rng; | |
return { | |
start: startIdx, | |
end: endIdx, | |
pre: str.slice(0, startIdx), | |
body: str.slice(startIdx + a.length, endIdx), | |
post: str.slice(endIdx + b.length), | |
}; | |
} | |
/** | |
* Attempts to match a regular expression against a string. | |
* | |
* @param reg - The regular expression to match. | |
* @param str - The string to search. | |
* @returns The first match or null. | |
*/ | |
function maybeMatch(reg: RegExp, str: string): string | null { | |
return str.match(reg)?.[0] ?? null; | |
} | |
/** | |
* Finds the range of the first balanced pair of tokens. | |
* | |
* @param a - The opening token. | |
* @param b - The closing token. | |
* @param str - The string to search. | |
* @returns A tuple of the start and end indices, or null if not found. | |
*/ | |
export function range( | |
a: string, | |
b: string, | |
str: string, | |
): [start: number, end: number] | null { | |
let ai = str.indexOf(a), bi = str.indexOf(b, ai + 1); | |
let i = ai; | |
let result: [number, number] | undefined; | |
const begs: number[] = []; | |
let left = str.length, right = 0; | |
if (ai >= 0 && bi > 0) { | |
if (a === b) return [ai, bi]; | |
while (i >= 0 && !result) { | |
if (i === ai) { | |
begs.push(i); | |
ai = str.indexOf(a, i + 1); | |
} else if (begs.length === 1) { | |
result = [begs.pop()!, bi]; | |
} else { | |
const beg = begs.pop(); | |
if (beg != null && beg < left) { | |
left = beg; | |
right = bi; | |
} | |
bi = str.indexOf(b, i + 1); | |
} | |
i = ai >= 0 && (ai < bi || bi < 0) ? ai : bi; | |
} | |
if (begs.length) result = [left, right]; | |
} | |
return result ?? null; | |
} | |
// #endregion balanced match | |
// #region brace expansion | |
// #region helpers (internal) | |
const create_patterns = <L extends string, C extends string>( | |
label: L, | |
char: C, | |
nonce = Math.random().toString(), | |
) => { | |
const esc = `\0${label.toUpperCase() as Uppercase<L>}_${nonce}\0` as const; | |
const escRegExp = new RegExp(esc, "g"); | |
const escChar = char === "\\" ? "\\\\\\\\" : "\\\\" + char; | |
const charRegExp = new RegExp(escChar, "g"); | |
return [ | |
[charRegExp, esc], | |
[escRegExp, char], | |
] as const; | |
}; | |
const patterns = [ | |
create_patterns("open", "{"), | |
create_patterns("close", "}"), | |
create_patterns("comma", ","), | |
create_patterns("period", "."), | |
create_patterns("slash", "\\"), | |
] as const; | |
/** | |
* Converts a numeric string to a number; if not numeric, returns the character | |
* code of the first character. | |
* | |
* @param str - The string to convert. | |
* @returns A number. | |
*/ | |
function numeric(str: string): number { | |
return !isNaN(Number(str)) ? parseInt(str, 10) : str.charCodeAt(0); | |
} | |
/** | |
* Escapes backslashes and brace-related characters in a string. | |
* | |
* @param str - The string to escape. | |
* @returns The escaped string. | |
*/ | |
function escapeBraces(str: string): string { | |
for (const [, [search, replace]] of patterns) { | |
str = str.replace(search, replace); | |
} | |
return str; | |
} | |
/** | |
* Reverses the escape operation applied by escapeBraces. | |
* | |
* @param str - The string to unescape. | |
* @returns The unescaped string. | |
*/ | |
function unescapeBraces(str: string): string { | |
for (const [[search, replace]] of patterns) { | |
str = str.replace(search, replace); | |
} | |
return str; | |
} | |
/** | |
* Splits a comma-separated string while preserving nested braced sections. | |
* | |
* @param str - The string to parse. | |
* @returns An array of parts. | |
*/ | |
function parseCommaParts(str: string): string[] { | |
if (!str) return [""]; | |
const m = balanced("{", "}", str); | |
if (!m) return str.split(/\s*,\s*/); | |
const { pre, body, post } = m; | |
const parts = pre.split(/\s*,\s*/); | |
parts[parts.length - 1] += "{" + body + "}"; | |
const postParts = parseCommaParts(post); | |
if (post.length) { | |
parts[parts.length - 1] += postParts.shift(); | |
parts.push(...postParts); | |
} | |
return parts; | |
} | |
/** | |
* Wraps a string in braces. | |
* | |
* @param str - The string to embrace. | |
* @returns The embraced string. | |
*/ | |
function embrace(str: string): string { | |
return "{" + str + "}"; | |
} | |
/** | |
* Checks if a string element has padded numeric formatting (e.g., "01", | |
* "-02"). | |
* | |
* @param el - The string element to test. | |
* @returns True if padded, false otherwise. | |
*/ | |
function isPadded(el: string): boolean { | |
return /^-?0\d/.test(el); | |
} | |
/** | |
* A helper that tests if one number is less than or equal to another. | |
* | |
* @param i - The first number. | |
* @param y - The second number. | |
* @returns True if `i` is less than or equal to `y`. | |
*/ | |
function lte(i: number, y: number): boolean { | |
return i <= y; | |
} | |
/** | |
* A helper that tests if one number is greater than or equal to another. | |
* | |
* @param i - The first number. | |
* @param y - The second number. | |
* @returns True if `i` is greater than or equal to `y`. | |
*/ | |
function gte(i: number, y: number): boolean { | |
return i >= y; | |
} | |
// #endregion helpers (internal) | |
/** | |
* Recursively expands brace expressions in a string. | |
* | |
* @param str - The string to expand. | |
* @param isTop - Whether this is the top-level expansion. | |
* @returns An array of expanded strings. | |
*/ | |
export function expand(str: string, isTop = false): string[] { | |
const expansions: string[] = []; | |
const m = balanced("{", "}", str); | |
if (!m) return [str]; | |
const pre = m.pre; | |
const post = m.post.length ? expand(m.post, false) : [""]; | |
// If the pre ends with '$', treat it as a literal brace set. | |
if (/\$$/.test(m.pre)) { | |
for (let k = 0; k < post.length; k++) { | |
expansions.push(pre + "{" + m.body + "}" + post[k]); | |
} | |
} else { | |
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); | |
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); | |
const isSequence = isNumericSequence || isAlphaSequence; | |
const isOptions = m.body.indexOf(",") >= 0; | |
if (!isSequence && !isOptions) { | |
// Handle cases like "{a},b}" | |
if (m.post.match(/,.*\}/)) { | |
str = m.pre + "{" + m.body + patterns[1][0][1] + m.post; | |
return expand(str); | |
} | |
return [str]; | |
} | |
let n: string[]; | |
if (isSequence) { | |
n = m.body.split(/\.\./); | |
} else { | |
n = parseCommaParts(m.body); | |
if (n.length === 1) { | |
// Expand nested braces, e.g. x{{a,b}}y → [x{a}y, x{b}y] | |
n = expand(n[0], false).map(embrace); | |
if (n.length === 1) return post.map((p) => m.pre + n[0] + p); | |
} | |
} | |
let N: string[]; | |
if (isSequence) { | |
const x = numeric(n[0]), y = numeric(n[1]); | |
const width = Math.max(n[0].length, n[1].length); | |
let incr = n.length === 3 ? Math.abs(numeric(n[2])) : 1; | |
let test = lte; | |
const reverse = y < x; | |
if (reverse) { | |
incr *= -1; | |
test = gte; | |
} | |
const pad = n.some(isPadded); | |
N = []; | |
for (let i = x; test(i, y); i += incr) { | |
let c: string; | |
if (isAlphaSequence) { | |
c = String.fromCharCode(i); | |
if (c === "\\") c = ""; | |
} else { | |
c = String(i); | |
if (pad) { | |
const need = width - c.length; | |
if (need > 0) { | |
const z = "0".repeat(need); | |
c = i < 0 ? "-" + z + c.slice(1) : z + c; | |
} | |
} | |
} | |
N.push(c); | |
} | |
} else { | |
N = []; | |
for (let j = 0; j < n.length; j++) { | |
N.push(...expand(n[j], false)); | |
} | |
} | |
for (let j = 0; j < N.length; j++) { | |
for (let k = 0; k < post.length; k++) { | |
const expansion = pre + N[j] + post[k]; | |
if (!isTop || isSequence || expansion) expansions.push(expansion); | |
} | |
} | |
} | |
return expansions; | |
} | |
/** | |
* Expands brace expressions in a string using Bash-like rules. | |
* | |
* @param str - The input string, which may contain brace expressions. | |
* @returns An array of expanded strings. | |
* @example | |
* ```ts | |
* braces("a{b,c}d"); | |
* // ["abd", "acd"] | |
* ``` | |
*/ | |
export function braces(str: string): string[] { | |
if (!str) return []; | |
// Bash 4.3 quirk: if a string starts with "{}", escape the leading braces. | |
if (str.slice(0, 2) === "{}") str = "\\{\\}" + str.slice(2); | |
return expand(escapeBraces(str), true).map(unescapeBraces); | |
} | |
// #endregion brace expansion |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
The MIT License (MIT) | |
Copyright (c) 2025+ Nicholas Berlette (https://github.com/nberlette) | |
Permission is hereby granted, free of charge, to any person obtaining a copy of | |
this software and associated documentation files (the "Software"), to deal in | |
the Software without restriction, including without limitation the rights to | |
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of | |
the Software, and to permit persons to whom the Software is furnished to do so, | |
subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS | |
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR | |
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER | |
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN | |
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// deno-lint-ignore-file no-explicit-any ban-types | |
import { lru } from "jsr:@decorators/lru"; | |
import { braces, balanced } from "./braces.ts"; | |
// #region Virtual File System (VFS) | |
// #region types | |
/** | |
* Options for mkdir operations. | |
* | |
* @category Options | |
*/ | |
export interface MkdirOptions extends RecursiveOptions, EncodingOptions { | |
mode?: Mode; | |
} | |
export interface CopyFileOptions { | |
flag?: OpenMode; | |
} | |
export interface ChownOptions extends RecursiveOptions { | |
uid?: number; | |
gid?: number; | |
mode?: Mode; | |
} | |
/** | |
* Options for file encoding. | |
* | |
* @category Options | |
*/ | |
export interface EncodingOptions { | |
encoding?: BufferEncoding; | |
} | |
/** | |
* Options for reading a file. Can either be an EncodingOptions object or a | |
* string. | |
* | |
* @category Options | |
*/ | |
export type ReadFileOptions = | |
| EncodingOptions | |
| BufferEncoding | |
| BufferEncodingOption; | |
/** | |
* Options for writing a file. Can either be an EncodingOptions object or a | |
* string. | |
* | |
* @category Options | |
*/ | |
export type WriteFileOptions = EncodingOptions | BufferEncoding; | |
interface GlobOptionsBase { | |
/** | |
* Current working directory. | |
* @default process.cwd() | |
*/ | |
cwd?: string | undefined; | |
/** | |
* `true` if the glob should return paths as `Dirent`s, `false` otherwise. | |
* @default false | |
* @since v22.2.0 | |
*/ | |
withFileTypes?: boolean | undefined; | |
/** | |
* Function to filter out files/directories. Return true to exclude the item, false to include it. | |
*/ | |
// deno-lint-ignore no-explicit-any | |
exclude?: ((fileName: any) => boolean) | undefined; | |
} | |
export interface GlobOptionsWithFileTypes extends GlobOptionsBase { | |
exclude?: ((fileName: Dirent) => boolean) | undefined; | |
withFileTypes: true; | |
} | |
export interface GlobOptionsWithoutFileTypes extends GlobOptionsBase { | |
exclude?: ((fileName: string) => boolean) | undefined; | |
withFileTypes?: false | undefined; | |
} | |
export interface GlobOptions extends GlobOptionsBase { | |
exclude?: ((fileName: Dirent | string) => boolean) | undefined; | |
} | |
type ToEncodingOptions<T extends BufferEncoding> = | |
| T | |
| EncodingOptions & { encoding: T }; | |
// export interface ErrnoException extends Error { | |
// readonly errno?: number | undefined; | |
// readonly code?: string | undefined; | |
// readonly path?: string | undefined; | |
// readonly syscall?: string | undefined; | |
// } | |
export type BufferEncoding = | |
| "ascii" | |
| "utf8" | |
| "utf-8" | |
| "utf16le" | |
| "utf-16le" | |
| "ucs2" | |
| "ucs-2" | |
| "base64" | |
| "base64url" | |
| "latin1" | |
| "binary" | |
| "hex"; | |
interface PathLikeTypes { | |
string: string; | |
URL: URL; | |
} | |
interface PathLikeExtTypes extends PathLikeTypes { | |
Buffer: Buffer; | |
} | |
export type PathLike = PathLikeTypes[keyof PathLikeTypes]; | |
export type PathLikeExt = PathLikeExtTypes[keyof PathLikeExtTypes]; | |
export type PathOrFileHandle = PathLike | number; | |
export type TimeLike = string | number | Date; | |
export type NoParamCallback = (err: ErrnoException | null) => void; | |
export type BufferEncodingOption = | |
| "buffer" | |
| { | |
encoding: "buffer"; | |
}; | |
export interface ObjectEncodingOptions { | |
encoding?: BufferEncoding | null | undefined; | |
} | |
export type EncodingOption = | |
| ObjectEncodingOptions | |
| BufferEncoding | |
| undefined | |
| null; | |
export type OpenMode = | |
| `${"r" | `${"w" | "a"}${"x" | ""}`}${"+" | ""}` | |
| strings | |
| numbers; | |
type Perms = `${"r" | "-"}${"w" | "-"}${"x" | "-"}`; | |
type DirFlag = "d" | "-"; | |
export type Mode = numbers | `${DirFlag | ""}${Perms}${Perms}${Perms}`; | |
type strings = string & {}; | |
type numbers = number & {}; | |
interface RecursiveOptions { | |
recursive?: boolean; | |
} | |
export interface ReadDirOptionsWithFileTypes extends RecursiveOptions { | |
withFileTypes: true; | |
} | |
export interface ReadDirOptionsWithoutFileTypes extends RecursiveOptions { | |
withFileTypes?: false | undefined; | |
} | |
export type ReadDirOptions = | |
| ReadDirOptionsWithFileTypes | |
| ReadDirOptionsWithoutFileTypes; | |
// #endregion types | |
// #region symbols (internal) | |
const kFD: unique symbol = Symbol("fd"); | |
type kFD = typeof kFD; | |
const kNode: unique symbol = Symbol("node"); | |
type kNode = typeof kNode; | |
const kPath: unique symbol = Symbol("path"); | |
type kPath = typeof kPath; | |
const kFS: unique symbol = Symbol("fs"); | |
type kFS = typeof kFS; | |
const kName: unique symbol = Symbol("name"); | |
type kName = typeof kName; | |
const kType: unique symbol = Symbol("type"); | |
type kType = typeof kType; | |
const kGetInodePath: unique symbol = Symbol("get_inode_record"); | |
type kGetInodePath = typeof kGetInodePath; | |
const kGetInode: unique symbol = Symbol("get_inode"); | |
type kGetInode = typeof kGetInode; | |
const kInternal: unique symbol = Symbol("internal"); | |
type kInternal = typeof kInternal; | |
const kGetInternal: unique symbol = Symbol("get_internal"); | |
type kGetInternal = typeof kGetInternal; | |
const kCache: unique symbol = Symbol("cache"); | |
type kCache = typeof kCache; | |
// #endregion symbols (internal) | |
// #region type guards | |
interface TypedArrayConstructorMap<T extends ArrayBufferLike> { | |
readonly Uint8Array: typeof Uint8Array<T>; | |
readonly Uint8ClampedArray: typeof Uint8ClampedArray<T>; | |
readonly Uint16Array: typeof Uint16Array<T>; | |
readonly Uint32Array: typeof Uint32Array<T>; | |
readonly Int8Array: typeof Int8Array<T>; | |
readonly Int16Array: typeof Int16Array<T>; | |
readonly Int32Array: typeof Int32Array<T>; | |
readonly Float16Array: typeof Float16Array<T>; | |
readonly Float32Array: typeof Float32Array<T>; | |
readonly Float64Array: typeof Float64Array<T>; | |
readonly BigInt64Array: typeof BigInt64Array<T>; | |
readonly BigUint64Array: typeof BigUint64Array<T>; | |
} | |
interface TypedArrayMap<T extends ArrayBufferLike> { | |
readonly Uint8Array: Uint8Array<T>; | |
readonly Uint8ClampedArray: Uint8ClampedArray<T>; | |
readonly Uint16Array: Uint16Array<T>; | |
readonly Uint32Array: Uint32Array<T>; | |
readonly Int8Array: Int8Array<T>; | |
readonly Int16Array: Int16Array<T>; | |
readonly Int32Array: Int32Array<T>; | |
readonly Float16Array: Float16Array<T>; | |
readonly Float32Array: Float32Array<T>; | |
readonly Float64Array: Float64Array<T>; | |
readonly BigInt64Array: BigInt64Array<T>; | |
readonly BigUint64Array: BigUint64Array<T>; | |
} | |
type TypedArrayConstructor<T extends ArrayBufferLike = ArrayBufferLike> = | |
TypedArrayConstructorMap<T>[keyof TypedArrayConstructorMap<T>]; | |
type TypedArray<T extends ArrayBufferLike = ArrayBufferLike> = TypedArrayMap< | |
T | |
>[keyof TypedArrayMap<T>]; | |
const TypedArray: TypedArrayConstructor = Object.getPrototypeOf(Uint8Array); | |
const TypedArrayPrototype: TypedArray = TypedArray?.prototype; | |
// deno-lint-ignore ban-types | |
type unknowns = {} | null | undefined; | |
export type { TypedArray, TypedArrayConstructor }; | |
function isSharedArrayBuffer(a: unknown): a is SharedArrayBuffer { | |
if (typeof globalThis.SharedArrayBuffer === "function" && a != null) { | |
const SharedArrayBufferPrototypeGetByteLength = Object | |
.getOwnPropertyDescriptor( | |
globalThis.SharedArrayBuffer.prototype, | |
"byteLength", | |
)?.get; | |
try { | |
SharedArrayBufferPrototypeGetByteLength?.call(a); | |
return true; | |
} catch { /* ignore */ } | |
} | |
return false; | |
} | |
function isArrayBuffer(a: unknown): a is ArrayBuffer { | |
if (typeof globalThis.ArrayBuffer === "function" && a != null) { | |
const ArrayBufferPrototypeGetByteLength = Object.getOwnPropertyDescriptor( | |
globalThis.ArrayBuffer.prototype, | |
"byteLength", | |
)?.get; | |
try { | |
ArrayBufferPrototypeGetByteLength?.call(a); | |
return true; | |
} catch { /* ignore */ } | |
} | |
return false; | |
} | |
function isArrayBufferLike( | |
that: unknown, | |
): that is ArrayBufferLike { | |
return isArrayBuffer(that) || isSharedArrayBuffer(that); | |
} | |
function isTypedArray< | |
T extends ArrayBufferLike, | |
K extends keyof TypedArrayMap<T>, | |
>( | |
that: unknown, | |
type?: K | undefined, | |
): that is TypedArrayMap<T>[K] { | |
if (typeof TypedArray === "function" && that != null) { | |
const TypedArrayPrototypeGetToStringTag = Object.getOwnPropertyDescriptor( | |
TypedArrayPrototype, | |
Symbol.toStringTag, | |
)?.get; | |
const tag = TypedArrayPrototypeGetToStringTag?.call(that); | |
return typeof tag !== "undefined" && | |
(typeof type === "undefined" || type === tag); | |
} | |
return false; | |
} | |
function isDataView( | |
that: unknown, | |
): that is DataView { | |
if (typeof DataView === "function" && that != null) { | |
const DataViewPrototypeGetByteLength = Object.getOwnPropertyDescriptor( | |
DataView.prototype, | |
"byteLength", | |
)?.get; | |
try { | |
DataViewPrototypeGetByteLength?.call(that); | |
return true; | |
} catch { /* ignore */ } | |
} | |
return false; | |
} | |
function isArrayBufferView<T extends ArrayBufferLike>( | |
that: unknown, | |
): that is ArrayBufferView<T> { | |
return isTypedArray(that) || isDataView(that); | |
} | |
function isBufferSource( | |
that: unknown, | |
): that is BufferSource { | |
return isArrayBufferLike(that) || isArrayBufferView(that); | |
} | |
function isIterable<T>(that: Iterable<T> | unknowns): that is Iterable<T> { | |
if ( | |
typeof that === "function" || (typeof that === "object" && that != null) | |
) { | |
return Symbol.iterator in that && | |
typeof that[Symbol.iterator] === "function"; | |
} | |
return false; | |
} | |
function isArrayLike<T>(that: unknown): that is ArrayLike<T> { | |
if ( | |
typeof that !== "function" && typeof that !== "undefined" && that !== null | |
) { | |
return ("length" in (that = Object(that)) && | |
typeof that.length === "number" && that.length === that.length >>> 0); | |
} | |
return false; | |
} | |
// #endregion type guards | |
// #region Buffer | |
const kBuffer: unique symbol = Symbol.for("nodejs.buffer"); | |
export class Buffer extends Uint8Array<ArrayBufferLike> { | |
static override from<T extends ArrayBufferLike>( | |
arrayBuffer: T | Buffer | TypedArray<T> | ArrayBufferView<T>, | |
encoding?: EncodingOption, | |
): Buffer; | |
static override from( | |
buffer: string, | |
encoding?: EncodingOption, | |
): Buffer; | |
static override from( | |
buffer: Buffer | TypedArray | ArrayBufferView, | |
encoding?: EncodingOption, | |
): Buffer; | |
static override from( | |
arrayLike: Iterable<number> | ArrayLike<number>, | |
encoding?: EncodingOption, | |
): Buffer; | |
static override from<T extends ArrayBufferLike>( | |
source: | |
| string | |
| Buffer | |
| TypedArray<T> | |
| ArrayBufferView<T> | |
| T | |
| Iterable<number> | |
| ArrayLike<number>, | |
mapfn?: (v: unknown, k: number) => number, | |
): Buffer; | |
static override from<T extends ArrayBufferLike>( | |
source: | |
| string | |
| Buffer | |
| TypedArray<T> | |
| ArrayBufferView<T> | |
| T | |
| Iterable<number> | |
| ArrayLike<number>, | |
encoding?: EncodingOption | ((v: unknown, k: number) => number), | |
): Buffer; | |
static override from<T extends ArrayBufferLike>( | |
source: | |
| string | |
| Buffer | |
| TypedArray<T> | |
| ArrayBufferView<T> | |
| T | |
| Iterable<number> | |
| ArrayLike<number>, | |
encoding?: EncodingOption | ((v: unknown, k: number) => number), | |
): Buffer { | |
let _mapfn: ((v: unknown, k: number) => number) | undefined; | |
if (typeof encoding === "function") { | |
[_mapfn, encoding] = [encoding, undefined]; | |
} | |
const enc = typeof encoding === "string" | |
? encoding | |
: encoding?.encoding ?? "utf8"; | |
if (typeof source === "string") { | |
if (enc === "base64" || enc === "base64url") { | |
source = super.from(atob(source), (b) => b.charCodeAt(0)); | |
} else if (enc === "hex") { | |
source = super.from(source, (b) => parseInt(b, 16)); | |
} else if (enc === "binary") { | |
source = super.from(source, (b) => b.charCodeAt(0)); | |
} else { | |
source = new TextEncoder().encode(source); | |
} | |
} | |
if (isArrayBufferView(source)) { | |
return new Buffer(source.buffer, source.byteOffset, source.byteLength); | |
} else if (isArrayBufferLike(source)) { | |
return new Buffer(source); | |
} else if (isIterable(source)) { | |
const b = super.from(source); | |
return new Buffer(b.buffer, b.byteOffset, b.byteLength); | |
} else if (isArrayLike(source)) { | |
const length = source.length; | |
const buffer = new ArrayBuffer(length); | |
const view = new Uint8Array(buffer); | |
for (let i = 0; i < length; i++) { | |
view[i] = typeof source[i] === "number" | |
? source[i] | |
: String(source[i]).charCodeAt(0); | |
} | |
return new Buffer(buffer); | |
} else { | |
throw new TypeError("Invalid source type"); | |
} | |
} | |
static isBuffer(it: unknown): it is Buffer { | |
if (typeof it === "object" && it != null) { | |
return kBuffer in it && it[kBuffer] === kBuffer; | |
} | |
return false; | |
} | |
static isEncoding(it: unknown): it is BufferEncoding { | |
return ( | |
typeof it === "string" && | |
(it = it.trim().toLowerCase()).length > 0 && | |
(it === "ascii" || | |
it === "utf8" || | |
it === "utf-8" || | |
it === "utf16le" || | |
it === "utf-16le" || | |
it === "ucs2" || | |
it === "ucs-2" || | |
it === "base64" || | |
it === "latin1" || | |
it === "binary" || | |
it === "hex") | |
); | |
} | |
declare readonly buffer: ArrayBuffer; | |
writeUIntLE( | |
value: number, | |
offset: number, | |
byteLength: number, | |
noAssert?: boolean, | |
): void { | |
if (byteLength === 1) { | |
this[offset] = value & 0xff; | |
} else if (byteLength === 2) { | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
} else if (byteLength === 4) { | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
this[offset + 2] = (value >> 16) & 0xff; | |
this[offset + 3] = (value >> 24) & 0xff; | |
} else if (byteLength === 8) { | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
this[offset + 2] = (value >> 16) & 0xff; | |
this[offset + 3] = (value >> 24) & 0xff; | |
this[offset + 4] = (value >> 32) & 0xff; | |
this[offset + 5] = (value >> 40) & 0xff; | |
this[offset + 6] = (value >> 48) & 0xff; | |
this[offset + 7] = (value >> 56) & 0xff; | |
} else if (!noAssert) { | |
throw new RangeError("Invalid byte length"); | |
} | |
} | |
writeUIntBE( | |
value: number, | |
offset: number, | |
byteLength: number, | |
noAssert?: boolean, | |
): void { | |
if (byteLength === 1) { | |
this[offset] = value & 0xff; | |
} else if (byteLength === 2) { | |
this[offset] = value & 0xff; | |
this[offset - 1] = (value >> 8) & 0xff; | |
} else if (byteLength === 4) { | |
this[offset] = value & 0xff; | |
this[offset - 1] = (value >> 8) & 0xff; | |
this[offset - 2] = (value >> 16) & 0xff; | |
this[offset - 3] = (value >> 24) & 0xff; | |
} else if (byteLength === 8) { | |
this[offset] = value & 0xff; | |
this[offset - 1] = (value >> 8) & 0xff; | |
this[offset - 2] = (value >> 16) & 0xff; | |
this[offset - 3] = (value >> 24) & 0xff; | |
this[offset - 4] = (value >> 32) & 0xff; | |
this[offset - 5] = (value >> 40) & 0xff; | |
this[offset - 6] = (value >> 48) & 0xff; | |
this[offset - 7] = (value >> 56) & 0xff; | |
} else if (!noAssert) { | |
throw new RangeError("Invalid byte length"); | |
} | |
} | |
writeUInt8(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0 || value > 255)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
} | |
writeUInt16LE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0 || value > 65535)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
} | |
writeUInt16BE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0 || value > 65535)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = (value >> 8) & 0xff; | |
this[offset + 1] = value & 0xff; | |
} | |
writeUInt32LE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0 || value > 4294967295)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
this[offset + 2] = (value >> 16) & 0xff; | |
this[offset + 3] = (value >> 24) & 0xff; | |
} | |
writeUInt32BE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0 || value > 4294967295)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = (value >> 24) & 0xff; | |
this[offset + 1] = (value >> 16) & 0xff; | |
this[offset + 2] = (value >> 8) & 0xff; | |
this[offset + 3] = value & 0xff; | |
} | |
writeIntLE( | |
value: number, | |
offset: number, | |
byteLength: number, | |
noAssert?: boolean, | |
): void { | |
if (byteLength === 1) { | |
this[offset] = value & 0xff; | |
} else if (byteLength === 2) { | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
} else if (byteLength === 4) { | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
this[offset + 2] = (value >> 16) & 0xff; | |
this[offset + 3] = (value >> 24) & 0xff; | |
} else if (!noAssert) { | |
throw new RangeError("Invalid byte length"); | |
} | |
} | |
writeIntBE( | |
value: number, | |
offset: number, | |
byteLength: number, | |
noAssert?: boolean, | |
): void { | |
if (byteLength === 1) { | |
this[offset] = value & 0xff; | |
} else if (byteLength === 2) { | |
this[offset] = value & 0xff; | |
this[offset - 1] = (value >> 8) & 0xff; | |
} else if (byteLength === 4) { | |
this[offset] = value & 0xff; | |
this[offset - 1] = (value >> 8) & 0xff; | |
this[offset - 2] = (value >> 16) & 0xff; | |
this[offset - 3] = (value >> 24) & 0xff; | |
} else if (!noAssert) { | |
throw new RangeError("Invalid byte length"); | |
} | |
} | |
writeInt8(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -128 || value > 127)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
} | |
writeInt16LE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -32768 || value > 32767)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
} | |
writeInt16BE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -32768 || value > 32767)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = (value >> 8) & 0xff; | |
this[offset + 1] = value & 0xff; | |
} | |
writeInt32LE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -2147483648 || value > 2147483647)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
this[offset + 2] = (value >> 16) & 0xff; | |
this[offset + 3] = (value >> 24) & 0xff; | |
} | |
writeInt32BE(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -2147483648 || value > 2147483647)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = (value >> 24) & 0xff; | |
this[offset + 1] = (value >> 16) & 0xff; | |
this[offset + 2] = (value >> 8) & 0xff; | |
this[offset + 3] = value & 0xff; | |
} | |
writeInt16(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -32768 || value > 32767)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
} | |
writeInt32(value: number, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < -2147483648 || value > 2147483647)) { | |
throw new RangeError("Value out of range"); | |
} | |
this[offset] = value & 0xff; | |
this[offset + 1] = (value >> 8) & 0xff; | |
this[offset + 2] = (value >> 16) & 0xff; | |
this[offset + 3] = (value >> 24) & 0xff; | |
} | |
writeBigUInt64LE(value: bigint, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0n || value > 0xFFFFFFFFFFFFFFFFn)) { | |
throw new RangeError("Value out of range"); | |
} | |
this.writeUIntLE(Number(value), offset, 8, noAssert); | |
} | |
writeBigUInt64BE(value: bigint, offset: number, noAssert?: boolean): void { | |
if (!noAssert && (value < 0n || value > 0xFFFFFFFFFFFFFFFFn)) { | |
throw new RangeError("Value out of range"); | |
} | |
this.writeUIntBE(Number(value), offset, 8, noAssert); | |
} | |
writeBigInt64LE(value: bigint, offset: number, noAssert?: boolean): void { | |
if ( | |
!noAssert && (value < -0x8000000000000000n || value > 0x7FFFFFFFFFFFFFFFn) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
this.writeIntLE(Number(value), offset, 8, noAssert); | |
} | |
writeBigInt64BE(value: bigint, offset: number, noAssert?: boolean): void { | |
if ( | |
!noAssert && (value < -0x8000000000000000n || value > 0x7FFFFFFFFFFFFFFFn) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
this.writeIntBE(Number(value), offset, 8, noAssert); | |
} | |
readUIntLE(offset: number, byteLength: number, noAssert?: boolean): number { | |
let value = 0; | |
for (let i = 0; i < byteLength; i++) { | |
value |= this[offset + i] << (i * 8); | |
} | |
if (!noAssert && (value < 0 || value > 0xFFFFFFFF)) { | |
throw new RangeError("Value out of range"); | |
} | |
return value >>> 0; | |
} | |
readUIntBE(offset: number, byteLength: number, noAssert?: boolean): number { | |
let value = 0; | |
for (let i = 0; i < byteLength; i++) { | |
value |= this[offset + byteLength - 1 - i] << (i * 8); | |
} | |
if (!noAssert && (value < 0 || value > 0xFFFFFFFF)) { | |
throw new RangeError("Value out of range"); | |
} | |
return value >>> 0; | |
} | |
readUInt8(offset: number, noAssert?: boolean): number { | |
if (!noAssert && (this[offset] < 0 || this[offset] > 255)) { | |
throw new RangeError("Value out of range"); | |
} | |
return this[offset]; | |
} | |
readUInt16LE(offset: number, noAssert?: boolean): number { | |
if (!noAssert && (this[offset] < 0 || this[offset + 1] < 0)) { | |
throw new RangeError("Value out of range"); | |
} | |
return this[offset] | (this[offset + 1] << 8); | |
} | |
readUInt16BE(offset: number, noAssert?: boolean): number { | |
if (!noAssert && (this[offset] < 0 || this[offset + 1] < 0)) { | |
throw new RangeError("Value out of range"); | |
} | |
return (this[offset] << 8) | this[offset + 1]; | |
} | |
readUInt32LE(offset: number, noAssert?: boolean): number { | |
if ( | |
!noAssert && | |
(this[offset] < 0 || this[offset + 1] < 0 || this[offset + 2] < 0 || | |
this[offset + 3] < 0) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return (this[offset] | (this[offset + 1] << 8) | (this[offset + 2] << 16) | | |
(this[offset + 3] << 24)) >>> 0; | |
} | |
readUInt32BE(offset: number, noAssert?: boolean): number { | |
if ( | |
!noAssert && | |
(this[offset] < 0 || this[offset + 1] < 0 || this[offset + 2] < 0 || | |
this[offset + 3] < 0) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return ((this[offset] << 24) | (this[offset + 1] << 16) | | |
(this[offset + 2] << 8) | this[offset + 3]) >>> 0; | |
} | |
readIntLE(offset: number, byteLength: number, noAssert?: boolean): number { | |
let value = 0; | |
for (let i = 0; i < byteLength; i++) { | |
value |= this[offset + i] << (i * 8); | |
} | |
if (!noAssert && (value < -0x80000000 || value > 0x7FFFFFFF)) { | |
throw new RangeError("Value out of range"); | |
} | |
return value | 0; | |
} | |
readIntBE(offset: number, byteLength: number, noAssert?: boolean): number { | |
let value = 0; | |
for (let i = 0; i < byteLength; i++) { | |
value |= this[offset + byteLength - 1 - i] << (i * 8); | |
} | |
if (!noAssert && (value < -0x80000000 || value > 0x7FFFFFFF)) { | |
throw new RangeError("Value out of range"); | |
} | |
return value | 0; | |
} | |
readInt8(offset: number, noAssert?: boolean): number { | |
if (!noAssert && (this[offset] < -128 || this[offset] > 127)) { | |
throw new RangeError("Value out of range"); | |
} | |
return this[offset] | 0; | |
} | |
readInt16LE(offset: number, noAssert?: boolean): number { | |
if (!noAssert && (this[offset] < -32768 || this[offset + 1] < -32768)) { | |
throw new RangeError("Value out of range"); | |
} | |
return this[offset] | (this[offset + 1] << 8); | |
} | |
readInt16BE(offset: number, noAssert?: boolean): number { | |
if (!noAssert && (this[offset] < -32768 || this[offset + 1] < -32768)) { | |
throw new RangeError("Value out of range"); | |
} | |
return (this[offset] << 8) | this[offset + 1]; | |
} | |
readInt32LE(offset: number, noAssert?: boolean): number { | |
if ( | |
!noAssert && | |
(this[offset] < -2147483648 || this[offset + 1] < -2147483648 || | |
this[offset + 2] < -2147483648 || this[offset + 3] < -2147483648) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return (this[offset] | (this[offset + 1] << 8) | (this[offset + 2] << 16) | | |
(this[offset + 3] << 24)) | 0; | |
} | |
readInt32BE(offset: number, noAssert?: boolean): number { | |
if ( | |
!noAssert && | |
(this[offset] < -2147483648 || this[offset + 1] < -2147483648 || | |
this[offset + 2] < -2147483648 || this[offset + 3] < -2147483648) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return ((this[offset] << 24) | (this[offset + 1] << 16) | | |
(this[offset + 2] << 8) | this[offset + 3]) | 0; | |
} | |
readBigUInt64LE(offset: number, noAssert?: boolean): bigint { | |
if ( | |
!noAssert && | |
(this[offset] < 0 || this[offset + 1] < 0 || this[offset + 2] < 0 || | |
this[offset + 3] < 0 || this[offset + 4] < 0 || this[offset + 5] < 0 || | |
this[offset + 6] < 0 || this[offset + 7] < 0) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return BigInt(this.readUIntLE(offset, 8)); | |
} | |
readBigUInt64BE(offset: number, noAssert?: boolean): bigint { | |
if ( | |
!noAssert && | |
(this[offset] < 0 || this[offset + 1] < 0 || this[offset + 2] < 0 || | |
this[offset + 3] < 0 || this[offset + 4] < 0 || this[offset + 5] < 0 || | |
this[offset + 6] < 0 || this[offset + 7] < 0) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return BigInt(this.readUIntBE(offset, 8)); | |
} | |
readBigInt64LE(offset: number, noAssert?: boolean): bigint { | |
if ( | |
!noAssert && | |
(this[offset] < 0 || this[offset + 1] < 0 || this[offset + 2] < 0 || | |
this[offset + 3] < 0 || this[offset + 4] < 0 || this[offset + 5] < 0 || | |
this[offset + 6] < 0 || this[offset + 7] < 0) | |
) { | |
throw new RangeError("Value out of range"); | |
} | |
return BigInt(this.readIntLE(offset, 8)); | |
} | |
override subarray(begin?: number, end?: number): Buffer { | |
const sub = super.subarray(begin, end); | |
return new Buffer(sub.buffer, sub.byteOffset, sub.byteLength); | |
} | |
override slice(start?: number, end?: number): Buffer { | |
const slice = super.slice(start, end); | |
return new Buffer(slice.buffer, slice.byteOffset, slice.byteLength); | |
} | |
override toString( | |
encoding?: BufferEncoding | BufferEncodingOption, | |
start?: number, | |
end?: number, | |
): string { | |
if (typeof encoding !== "string") encoding = encoding?.encoding ?? "utf8"; | |
if (encoding === "base64") { | |
return btoa(this.toString("binary", start, end)); | |
} else if (encoding === "base64url") { | |
return this.toString("base64", start, end).replace(/=/g, "").replace( | |
/\+/g, | |
"-", | |
).replace(/\//g, "_"); | |
} else if (encoding === "hex") { | |
return Array.from(this.subarray(start, end)).map((b) => | |
b.toString(16).padStart(2, "0") | |
).join(""); | |
} else if (encoding === "binary") { | |
return Array.from(this.subarray(start, end)).map((b) => | |
String.fromCharCode(b) | |
).join(""); | |
} else if ( | |
encoding === "utf8" || encoding === "utf-8" || encoding === "buffer" | |
) { | |
return new TextDecoder("utf-8").decode(this.subarray(start, end)); | |
} else { | |
return new TextDecoder(encoding).decode(this.subarray(start, end)); | |
} | |
} | |
/** @ignore */ | |
protected get [kBuffer](): typeof kBuffer { | |
return kBuffer; | |
} | |
} | |
// #endregion Buffer | |
// #region Dirent | |
// #region constants (dirent) | |
const UV_DIRENT_MODE_MASK = 0o170000; | |
const UV_DIRENT_FILE = 0o100000; | |
const UV_DIRENT_DIR = 0o40000; | |
const UV_DIRENT_CHAR = 0o20000; | |
const UV_DIRENT_BLOCK = 0o60000; | |
const UV_DIRENT_FIFO = 0o10000; | |
const UV_DIRENT_LINK = 0o120000; | |
const UV_DIRENT_SOCK = 0o140000; | |
// #endregion constants (dirent) | |
/** | |
* Represents a directory entry. | |
*/ | |
export class Dirent { | |
#name: string; | |
#type: number; | |
#path: string; | |
#parentPath: string | undefined; | |
constructor(name: string, mode: number, path: string) { | |
this.#name = name; | |
this.#path = path; | |
this.#parentPath = VFS.dirname(path); | |
// check if we're dealing with a mode or if the type was already unmasked | |
if (mode & UV_DIRENT_MODE_MASK) { | |
this.#type = mode & UV_DIRENT_MODE_MASK; | |
} else { | |
this.#type = mode; | |
} | |
} | |
protected set [kName](name: string) { | |
this.#name = name; | |
} | |
protected set [kPath](path: string) { | |
this.#parentPath = VFS.dirname(this.#path = path); | |
} | |
protected get [kType](): number { | |
return this.#type; | |
} | |
protected set [kType](type: number) { | |
this.#type = type; | |
} | |
get name(): string { | |
return this.#name; | |
} | |
get path(): string { | |
return this.#path; | |
} | |
get parentPath(): string { | |
return this.#parentPath ??= VFS.dirname(this.#path); | |
} | |
isDirectory(): boolean { | |
return this[kType] === UV_DIRENT_DIR; | |
} | |
isFile(): boolean { | |
return this[kType] === UV_DIRENT_FILE; | |
} | |
isBlockDevice(): boolean { | |
return this[kType] === UV_DIRENT_BLOCK; | |
} | |
isCharacterDevice(): boolean { | |
return this[kType] === UV_DIRENT_CHAR; | |
} | |
isSymbolicLink(): boolean { | |
return this[kType] === UV_DIRENT_LINK; | |
} | |
isFIFO(): boolean { | |
return this[kType] === UV_DIRENT_FIFO; | |
} | |
isSocket(): boolean { | |
return this[kType] === UV_DIRENT_SOCK; | |
} | |
} | |
// #endregion Dirent | |
// #region Stats | |
export interface StatsLike<T extends number | bigint = number> { | |
readonly dev: T; | |
readonly ino: T; | |
readonly mode: T; | |
readonly nlink: T; | |
readonly uid: T; | |
readonly gid: T; | |
readonly rdev: T; | |
readonly size: T; | |
readonly blksize: T; | |
readonly blocks: T; | |
readonly atime: Date; | |
readonly mtime: Date; | |
readonly ctime: Date; | |
readonly birthtime: Date; | |
readonly atimeMs?: T; | |
readonly mtimeMs?: T; | |
readonly ctimeMs?: T; | |
readonly birthtimeMs?: T; | |
} | |
/** | |
* Represents file system statistics for a file or directory. | |
*/ | |
export class Stats extends Dirent implements StatsLike<number> { | |
static create( | |
fs: VFS, | |
dev: number, | |
ino: number, | |
mode: number, | |
nlink: number, | |
uid: number, | |
gid: number, | |
rdev: number, | |
size: number, | |
blksize: number, | |
blocks: number, | |
atime: Date, | |
mtime: Date, | |
ctime: Date, | |
birthtime: Date, | |
): Stats { | |
const stats = new Stats( | |
fs, | |
dev, | |
ino, | |
mode, | |
nlink, | |
uid, | |
gid, | |
rdev, | |
size, | |
blksize, | |
blocks, | |
atime, | |
mtime, | |
ctime, | |
birthtime, | |
); | |
const nodes = fs[kInternal].get_inodes(); | |
const node = nodes[ino]; | |
if (node) stats[kNode] = new WeakRef(node); | |
stats[kFS] = new WeakRef(fs); | |
return stats; | |
} | |
protected [kNode]: WeakRef<Node> | undefined; | |
protected [kFS]: WeakRef<VFS> | undefined; | |
atimeMs: number; | |
mtimeMs: number; | |
ctimeMs: number; | |
birthtimeMs: number; | |
private constructor( | |
fs: VFS, | |
readonly dev: number, | |
readonly ino: number, | |
readonly mode: number, | |
readonly nlink: number, | |
readonly uid: number, | |
readonly gid: number, | |
readonly rdev: number, | |
readonly size: number, | |
readonly blksize: number, | |
readonly blocks: number, | |
readonly atime: Date, | |
readonly mtime: Date, | |
readonly ctime: Date, | |
readonly birthtime: Date, | |
) { | |
const { name, path } = fs[kInternal].get_inode_path(ino); | |
super(name, mode, path); | |
this.atimeMs = +atime; | |
this.mtimeMs = +mtime; | |
this.ctimeMs = +ctime; | |
this.birthtimeMs = +birthtime; | |
this[kPath] = path; | |
this[kName] = name; | |
this[kFS] = new WeakRef(fs); | |
const node = fs[kInternal].get_node(path); | |
if (node) this[kNode] = new WeakRef(node); | |
} | |
} | |
// #endregion Stats | |
// #region StatsBigInt | |
/** | |
* Represents file system statistics for a file or directory. | |
*/ | |
export class StatsBigInt extends Dirent implements StatsLike<bigint> { | |
static create( | |
fs: VFS, | |
dev: number | bigint, | |
ino: number | bigint, | |
mode: number | bigint, | |
nlink: number | bigint, | |
uid: number | bigint, | |
gid: number | bigint, | |
rdev: number | bigint, | |
size: number | bigint, | |
blksize: number | bigint, | |
blocks: number | bigint, | |
atime: Date, | |
mtime: Date, | |
ctime: Date, | |
birthtime: Date, | |
): StatsBigInt { | |
const stats = new StatsBigInt( | |
fs, | |
BigInt(dev), | |
BigInt(ino), | |
BigInt(mode), | |
BigInt(nlink), | |
BigInt(uid), | |
BigInt(gid), | |
BigInt(rdev), | |
BigInt(size), | |
BigInt(blksize), | |
BigInt(blocks), | |
atime, | |
mtime, | |
ctime, | |
birthtime, | |
); | |
const nodes = fs[kInternal].get_inodes(); | |
const node = nodes[Number(ino)]; | |
if (node) stats[kNode] = new WeakRef(node); | |
stats[kFS] = new WeakRef(fs); | |
stats[kPath] = fs[kInternal].get_inode_path(Number(ino), nodes).path; | |
return stats; | |
} | |
protected [kNode]: WeakRef<Node> | undefined; | |
protected [kFS]: WeakRef<VFS> | undefined; | |
atimeMs: bigint; | |
mtimeMs: bigint; | |
ctimeMs: bigint; | |
birthtimeMs: bigint; | |
atimeNs: bigint; | |
mtimeNs: bigint; | |
ctimeNs: bigint; | |
birthtimeNs: bigint; | |
private constructor( | |
fs: VFS, | |
readonly dev: bigint, | |
readonly ino: bigint, | |
readonly mode: bigint, | |
readonly nlink: bigint, | |
readonly uid: bigint, | |
readonly gid: bigint, | |
readonly rdev: bigint, | |
readonly size: bigint, | |
readonly blksize: bigint, | |
readonly blocks: bigint, | |
readonly atime: Date, | |
readonly mtime: Date, | |
readonly ctime: Date, | |
readonly birthtime: Date, | |
) { | |
const nodes = fs[kInternal].get_inodes(); | |
const { name, path } = fs[kInternal].get_inode_path(Number(ino), nodes); | |
super(name, Number(mode), path); | |
this.atimeNs = (this.atimeMs = BigInt(+atime)) * 1_000_000n; | |
this.mtimeNs = (this.mtimeMs = BigInt(+mtime)) * 1_000_000n; | |
this.ctimeNs = (this.ctimeMs = BigInt(+ctime)) * 1_000_000n; | |
this.birthtimeNs = (this.birthtimeMs = BigInt(+birthtime)) * 1_000_000n; | |
this[kPath] = path; | |
this[kName] = name; | |
this[kFS] = new WeakRef(fs); | |
const node = fs[kInternal].get_node(path, nodes); | |
if (node) this[kNode] = new WeakRef(node); | |
} | |
} | |
// #endregion StatsBigInt | |
// #region Dir | |
/** | |
* Represents an open directory handle in a virtual file system (VFS). This | |
* class is directly modeled after the `Dir` class from the `node:fs` module. | |
*/ | |
export class Dir { | |
#dir_path: string | Buffer; | |
#iter_sync: Generator<Dirent> | null = null; | |
#iter_async: AsyncGenerator<Dirent> | null = null; | |
#node: WeakRef<Node> | null = null; | |
#fs: VFS | undefined; | |
#fd: number | null = null; | |
constructor(path: string) { | |
this.#dir_path = path; | |
} | |
/** @internal */ | |
protected get [kNode](): WeakRef<Node> | null { | |
return this.#node; | |
} | |
/** @internal */ | |
protected set [kNode](node: Node | WeakRef<Node> | null) { | |
if (node instanceof Node) { | |
this.#node = new WeakRef(node); | |
} else { | |
this.#node = node; | |
} | |
} | |
/** @internal */ | |
protected get [kFS](): VFS | undefined { | |
return this.#fs; | |
} | |
/** @internal */ | |
protected set [kFS](fs: VFS | undefined) { | |
this.#fs = fs; | |
} | |
/** @internal */ | |
protected get [kPath](): string | Buffer { | |
return this.#dir_path; | |
} | |
/** @internal */ | |
protected set [kPath](path: string | Buffer) { | |
this.#dir_path = path; | |
} | |
/** @internal */ | |
protected get [kFD](): number | null { | |
return this.#fd; | |
} | |
/** @internal */ | |
protected set [kFD](fd: number | null) { | |
this.#fd = fd; | |
} | |
/** | |
* The path to the directory this `Dir` instance is pointing to. | |
*/ | |
get path(): string { | |
let path = this.#dir_path; | |
if (isBufferSource(path)) path = Buffer.from(path).toString("utf8"); | |
if (typeof path !== "string") throw new TypeError("Invalid dir path type."); | |
return path; | |
} | |
/** | |
* Asynchronously reads the next directory entry from the directory this | |
* `Dir` instance is pointing to, returning Promise that resolved to a | |
* {@linkcode Dirent} object for the entry, or `null` if there are no more | |
* entries to read. | |
*/ | |
read(): Promise<Dirent | null>; | |
/** | |
* Asynchronously reads the next directory entry from the directory this | |
* `Dir` instance is pointing to, invoking the provided callback function | |
* with a {@linkcode Dirent} object for the entry, or `null` if there are no | |
* more entries to read. | |
* | |
* If an error occurs while reading the directory, the callback is invoked | |
* with the error as the first argument and `undefined` as the second. | |
*/ | |
read(callback: (err: Error | null, dirent?: Dirent) => void): void; | |
/** @internal */ | |
read( | |
callback?: (err: Error | null, dirent?: Dirent) => void, | |
): Promise<Dirent | null> { | |
return new Promise((resolve, reject) => { | |
if (!this.#iter_async) { | |
const fs = this.#fs, self = this as Dir; | |
this.#iter_async = (async function* () { | |
const path = self.path; | |
const vals = await fs?.readdir(path, { withFileTypes: true }) ?? []; | |
for await (const val of vals) yield val; | |
})(); | |
} | |
if (!this.#iter_async) { | |
const error = new Error("No async iterator"); | |
if (callback) return callback(error); | |
return reject(error); | |
} | |
this.#iter_async.next().then((res) => { | |
resolve(res.done ? null : res.value); | |
if (callback) callback(null, res.done ? undefined : res.value); | |
}, (err) => (callback ? callback : reject)(err)); | |
}); | |
} | |
/** | |
* Synchronous version of the `read` method. Reads the next directory | |
* entry from the directory this `Dir` instance is pointing to, returning | |
* a {@linkcode Dirent} object for the entry, or `null` if there are no | |
* more entries to read. | |
*/ | |
readSync(): Dirent | null { | |
if (!this.#iter_sync) { | |
const fs = this.#fs, self = this as Dir; | |
this.#iter_sync = (function* () { | |
const path = self.path; | |
return yield* fs?.readdirSync(path, { withFileTypes: true }) ?? []; | |
})(); | |
} | |
return this.#iter_sync.next().value; | |
} | |
/** | |
* Asynchronously closes this directory handle, releasing any resources held | |
* by the associated file descriptor. Returns a Promise that resolves once | |
* the directory is closed, or rejects with an error if it fails. | |
* | |
* This is called internally by the `[Symbol.asyncDispose]` method, as part | |
* of the internal semantics of the `await using` statement. See the [TC39 | |
* Proposal for Explicit Resource Management][ERM] for more details. | |
* | |
* [ERM]: https://github.com/tc39/proposal-explicit-resource-management | |
*/ | |
close(): Promise<void>; | |
/** | |
* Asynchronously closes this directory handle, releasing any resources held | |
* by the associated file descriptor. Accepts a callback function that is | |
* called once the directory is closed, or if an error occurs, using the | |
* classic Node.js callback signature (error first, result second). | |
*/ | |
close(callback: (err: Error | null) => void): void; | |
close(callback?: (err: Error | null) => void): Promise<void> | void { | |
const fs = this.#fs; | |
if (!fs) { | |
const error = new Error("Dir instance is already closed."); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} | |
if (this.#iter_sync) this.#iter_sync.return(null); | |
if (this.#iter_async) this.#iter_async.return(null); | |
this.#iter_sync = this.#iter_async = null; | |
return new Promise((resolve) => (callback ?? resolve)(null!)); | |
} | |
/** | |
* Synchronously closes this directory handle, releasing any resources held | |
* by the associated file descriptor. This method is called internally by the | |
* `[Symbol.dispose]` method when a `Dir` instance is opened with the `using` | |
* statement per the [TC39 Proposal for Explicit Resource Management][ERM]. | |
* | |
* Once this method is called, either directly or indirectly through another | |
* internal API, the directory is immediately transitioned into a permanent | |
* "disposed" state. Any subsequent attempts to access the directory resource | |
* from that handle will result in a `ReferenceError` being thrown. | |
* | |
* [ERM]: https://github.com/tc39/proposal-explicit-resource-management | |
*/ | |
closeSync(): void { | |
if (!this.#fs) throw new ReferenceError("Dir instance is already closed."); | |
if (this.#iter_sync) this.#iter_sync.return(null); | |
if (this.#iter_async) this.#iter_async.return(null); | |
this.#iter_sync = this.#iter_async = null; | |
this.#fs = undefined; | |
this.#node = null; | |
this.#fd = null; | |
this.#dir_path = ""; | |
} | |
/** | |
* Returns an asynchronous iterable iterator that traverses the directory | |
* this `Dir` instance is pointing to, yielding a {@linkcode Dirent} object | |
* for each of its child entries. | |
* | |
* This is called internally by the semantics of the native `for-await-of` | |
* loop, and is not intended to be called directly. If you wish to iterate | |
* over the directory entries manually, use the {@linkcode Dir.read} method | |
* instead. For the synchronous version, see {@linkcode Dir.readSync}. | |
*/ | |
async *[Symbol.asyncIterator](): AsyncIterableIterator<Dirent> { | |
try { | |
while (true) { | |
const dirent = await this.read(); | |
if (dirent === null) break; | |
yield dirent; | |
} | |
} finally { | |
await this.close(); | |
} | |
} | |
/** | |
* Returns a synchronous iterable iterator that traverses the directory this | |
* `Dir` instance is pointing to, yielding a {@linkcode Dirent} object for | |
* each of its child entries. | |
*/ | |
*[Symbol.iterator](): IterableIterator<Dirent> { | |
try { | |
while (true) { | |
const dirent = this.readSync(); | |
if (dirent === null) break; | |
yield dirent; | |
} | |
} finally { | |
this.closeSync(); | |
} | |
} | |
/** @internal */ | |
[Symbol.dispose](): void { | |
this.closeSync(); | |
} | |
/** @internal */ | |
async [Symbol.asyncDispose](): Promise<void> { | |
return await this.close(); | |
} | |
} | |
// #endregion Dir | |
// #region Node (inode) | |
export type InodeChildMap<T = number> = { [name: string]: T }; | |
export type NodeType = | |
| "file" | |
| "directory" | |
| "symlink" | |
| "fifo" | |
| "socket" | |
| "block" | |
| "char"; | |
export interface NodeLike { | |
type: NodeType; | |
inode: number; | |
mode: number; | |
atime: number; | |
mtime: number; | |
ctime: number; | |
size: number; | |
data?: string; | |
children?: InodeChildMap; | |
target?: string; | |
blksize?: number; | |
blocks?: number; | |
nlink?: number; | |
rdev?: number; | |
dev?: number; | |
uid?: number; | |
gid?: number; | |
} | |
/** | |
* Represents a single node in a virtual file system (VFS). Nodes can be files, | |
* directories, or symbolic links. Each `Node` instance contains crucial file | |
* metadata used throughout the entirety of VFS operations, including its type, | |
* inode number, mode, timestamps, size, data, and other important attributes. | |
* | |
* Note that nodes (also referred to as inodes) are not directly exposed to the | |
* user, nor are they explicitly associated with a specific file path or file | |
* descriptor. Instead, they are identified and indexed by a monotonically | |
* increasing unique inode number. | |
* | |
* All inode numbers are then mapped to their respective paths and descriptors | |
* in a separate internal data structure, to allow for efficient lookups and | |
* operations regardless of the depth a given file may be located at in the | |
* file system. | |
* | |
* @remarks | |
* This API is directly inspired by the Linux kernel's inode API, which is used | |
* to manage file system objects in a similar manner. For more information on | |
* how this type of file system works, see the Linux kernals's inode API docs, | |
* or refer to the POSIX standard for file system operations. | |
* | |
* @see https://www.kernel.org/doc/html/latest/filesystems/inode.html | |
* @see https://pubs.opengroup.org/onlinepubs/9699919799/functions/inode.html | |
*/ | |
export class Node implements NodeLike { | |
static readonly #node_cache = new WeakMap<NodeLike, Node>(); | |
static #inode = 1; | |
static get inode(): number { | |
return Node.#inode++; | |
} | |
static set inode(value: number) { | |
Node.#inode = value; | |
} | |
static from(data: NodeLike): Node { | |
let node = Node.#node_cache.get(data); | |
if (!node) { | |
Node.#node_cache.set( | |
data, | |
node = new Node( | |
data.type ||= "file", | |
data.inode ||= Node.inode, | |
data.mode ||= 0o644, | |
data.atime ||= Date.now(), | |
data.mtime ||= Date.now(), | |
data.ctime ||= Date.now(), | |
data.size ??= 0, | |
data.data ??= "", | |
data.children ||= undefined, | |
data.target ||= "", | |
data.blksize ||= 4096, | |
data.blocks ||= Math.ceil(data.size / 512), | |
data.nlink ||= 1, | |
data.rdev ||= 0, | |
data.dev ||= 0, | |
data.uid ||= 0, | |
data.gid ||= 0, | |
), | |
); | |
} | |
return node; | |
} | |
constructor( | |
readonly type: NodeType, | |
public inode: number, | |
public mode: number, | |
public atime: number, | |
public mtime: number, | |
public ctime: number, | |
public size: number, | |
public data: string = "", | |
public children: InodeChildMap = { __proto__: null! }, | |
public target: string = "", | |
public blksize: number, | |
public blocks: number = 0, | |
public nlink: number = 1, | |
public rdev: number = 0, | |
public dev: number = 0, | |
public uid: number = 0, | |
public gid: number = 0, | |
) { | |
if (type === "directory") this.mode |= VFS.constants.S_IFDIR; | |
if (type === "symlink") this.mode |= VFS.constants.S_IFLNK; | |
if (type === "file") this.mode |= VFS.constants.S_IFREG; | |
if (type === "fifo") this.mode |= VFS.constants.S_IFIFO; | |
if (type === "socket") this.mode |= VFS.constants.S_IFSOCK; | |
if (type === "block") this.mode |= VFS.constants.S_IFBLK; | |
if (type === "char") this.mode |= VFS.constants.S_IFCHR; | |
if (type === "file" && !this.data) this.data = ""; | |
if (type === "directory" && !this.children) { | |
this.children = { __proto__: null! }; | |
} | |
if (type === "symlink" && !this.target) this.target = ""; | |
} | |
toBuffer(start?: number, end?: number): Buffer { | |
const data = Buffer.from(this.data); | |
if (start == null && end == null) return data; | |
return data.subarray(+(start ?? 0), +(end ?? this.data.length)); | |
} | |
toDirent(fs: VFS): Dirent { | |
const { name, path } = fs[kGetInodePath](this.inode); | |
return new Dirent(name, this.mode, path); | |
} | |
toJSON(): NodeLike { | |
return { ...this }; | |
} | |
toStats(fs: VFS): Stats { | |
const stats = Stats.create( | |
fs, | |
this.dev, | |
this.inode, | |
this.mode, | |
this.nlink, | |
this.uid, | |
this.gid, | |
this.rdev, | |
this.size, | |
this.blksize, | |
this.blocks, | |
new Date(this.atime), | |
new Date(this.mtime), | |
new Date(this.ctime), | |
new Date(this.mtime), | |
); | |
return stats; | |
} | |
toString(): string { | |
return this.data; | |
} | |
} | |
// #endregion Node (inode) | |
// #region FileHandle | |
/** | |
* Represents a file descriptor in a virtual file system (VFS) as an object | |
* with properties and methods to interact with a file or directory and its | |
* associated metadata. | |
* | |
* @internal | |
*/ | |
export class FileHandle { | |
#fs: WeakRef<VFS> | null = null; | |
#fd = 0; | |
#path = "/dev/null"; | |
#node: Node | null = null; | |
#flags = 0; | |
#position = 0; | |
/** | |
* Creates a new `FileHandle` instance from the given parameters. | |
* | |
* This is a low-level internal API and should not be used directly unless you | |
* are an advanced user with a specific need to manipulate file descriptors. | |
*/ | |
constructor( | |
fs: VFS, | |
fd: number = -1, | |
path: string = "/dev/null", | |
flags: string | number = "r", | |
node: Node | null = null, | |
position: number | bigint | null = 0, | |
) { | |
this.#fs = new WeakRef(fs); | |
this.#fd = fd; | |
this.#path = path; | |
this.#flags = FileHandle.normalizeFlags(flags); | |
this.#node = node; | |
this.#position = Number(position ?? 0) >>> 0; | |
} | |
/** | |
* Normalizes the flags to a numeric representation. | |
* | |
* @internal | |
*/ | |
static normalizeFlags(flags: string | number): number { | |
if (typeof flags === "string") { | |
// e.g. "r+", "w", "a", etc. | |
let mode = 0; | |
for (let i = 0; i < flags.length; i++) { | |
const c = flags[i]; | |
// set read mode | |
if (c === "r") mode |= VFS.constants.O_RDONLY; | |
// set write mode | |
if (c === "w") mode |= VFS.constants.O_WRONLY; | |
// set append mode | |
if (c === "a") mode |= VFS.constants.O_APPEND; | |
// set create mode | |
if (c === "c") mode |= VFS.constants.O_CREAT; | |
// set truncate mode | |
if (c === "t") mode |= VFS.constants.O_TRUNC; | |
// set exclusive mode | |
if (c === "x") mode |= VFS.constants.O_EXCL; | |
// set read/write mode | |
if (c === "+") mode |= VFS.constants.O_RDWR; | |
} | |
return mode; | |
} | |
// e.g. 0o100 | 0o200 | |
if (typeof flags === "number") return flags & 0o7777; | |
return 0; | |
} | |
get [kFS](): VFS | null { | |
return this.#fs?.deref() || null; | |
} | |
set [kFS](fs: VFS | null) { | |
this.#fs = fs ? new WeakRef(fs) : null; | |
} | |
get [kNode](): Node | null { | |
return this.#node; | |
} | |
set [kNode](node: Node | null) { | |
this.#node = node; | |
} | |
/** The actual numeric system file descriptor. */ | |
get fd(): number { | |
return this.#fd; | |
} | |
/** The physical path this file descriptor is pointing to. */ | |
get path(): string { | |
return this.#path; | |
} | |
/** The flags used to open this file descriptor. */ | |
get flags(): number { | |
return this.#flags; | |
} | |
/** The position in the file buffer where the next read/write will occur. */ | |
get position(): number { | |
return this.#position; | |
} | |
set position(value: number) { | |
this.#position = value; | |
} | |
statSync(): Stats { | |
const fs = this.#fs, node = this.#node, fd = this.#fd; | |
if (!fs?.deref()) throw new ReferenceError("VFS is not available"); | |
if (fd == null || fd < 0 || !node) { | |
throw new TypeError("BadResource: invalid resource ID"); | |
} | |
return node.toStats(fs.deref()!); | |
} | |
stat(): Promise<Stats>; | |
stat(callback: (err: Error | null, stats?: Stats) => void): void; | |
stat( | |
callback?: (err: Error | null, stats?: Stats) => void, | |
): Promise<Stats> | void { | |
const fs = this.#fs?.deref(), node = this.#node, fd = this.#fd; | |
if (!fs) { | |
const error = new Error("VFS is not available"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (fd == null || fd < 0 || !node) { | |
const error = new TypeError("BadResource: invalid resource ID"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (callback) { | |
return callback(null, node.toStats(fs)); | |
} else { | |
return Promise.resolve(node.toStats(fs)); | |
} | |
} | |
/** | |
* Reads data from the file descriptor, returning a Promise that resolves | |
* to the number of bytes read once the operation is complete, or rejects | |
* with an error if the operation fails. | |
* | |
* @param buffer The buffer to read data into. | |
* @param offset The offset in the buffer to start writing to. | |
* @param length The number of bytes to read. | |
* @param position The position in the file to read from. | |
* @returns A Promise that resolves to the number of bytes read. | |
* @throws {Error} if the file descriptor is not open for reading. | |
*/ | |
read( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
): Promise<number>; | |
/** | |
* Reads data from the file descriptor. | |
* | |
* @param buffer The buffer to read data into. | |
* @param offset The offset in the buffer to start writing to. | |
* @param length The number of bytes to read. | |
* @param position The position in the file to read from. | |
* @param callback Callback to be called when the operation is complete. | |
*/ | |
read( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
callback: (err: Error | null, bytesRead?: number) => void, | |
): void; | |
/** @internal */ | |
read( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
callback?: (err: Error | null, bytesRead?: number) => void, | |
): Promise<number> | void { | |
const fs = this[kFS]; | |
if (!fs) { | |
const error = new Error("VFS is not available"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (this.#fd < 0) { | |
const error = new Error("Invalid file descriptor"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (this.#flags & VFS.constants.O_RDONLY) { | |
const error = new Error("File descriptor is not open for reading"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (callback) { | |
return fs.read(this.#fd, buffer, offset, length, position, callback); | |
} else { | |
return fs.read(this.#fd, buffer, offset, length, position); | |
} | |
} | |
/** | |
* Reads data from the file descriptor. | |
* | |
* @param buffer The buffer to read data into. | |
* @param offset The offset in the buffer to start writing to. | |
* @param length The number of bytes to read. | |
* @param position The position in the file to read from. | |
* @returns The number of bytes read. | |
* @throws {Error} if the file descriptor is not open for reading. | |
*/ | |
readSync( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
): number { | |
const fs = this[kFS]; | |
if (!fs) throw new Error("VFS is not available"); | |
if (this.#fd < 0) throw new Error("Invalid file descriptor"); | |
if (this.#flags & VFS.constants.O_RDONLY) { | |
throw new Error("File descriptor is not open for reading"); | |
} | |
return fs.readSync(this.#fd, buffer, offset, length, position); | |
} | |
/** | |
* Writes data to the file descriptor, returning a Promise that resolves to | |
* the number of bytes written once the operation is complete, or rejects | |
* with an error if the operation fails. | |
* | |
* @param buffer The data to write. | |
* @param offset The offset in the buffer to start writing from. | |
* @param length The number of bytes to write. | |
* @param position The position in the file to write to. | |
* @returns A Promise that resolves to the number of bytes written. | |
*/ | |
write( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
): Promise<number>; | |
/** | |
* Writes data to the file descriptor. | |
* | |
* @param buffer The data to write. | |
* @param offset The offset in the buffer to start writing from. | |
* @param length The number of bytes to write. | |
* @param position The position in the file to write to. | |
* @param callback Callback to be called when the operation is complete. | |
*/ | |
write( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
callback: (err: Error | null, bytesWritten?: number) => void, | |
): void; | |
/** @internal */ | |
write( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
callback?: (err: Error | null, bytesWritten?: number) => void, | |
): Promise<number> | void { | |
const fs = this[kFS]; | |
if (!fs) { | |
const error = new Error("VFS is not available"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (this.#fd < 0) { | |
const error = new Error("Invalid file descriptor"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (this.#flags & VFS.constants.O_WRONLY) { | |
const error = new Error("File descriptor is not open for writing"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else if (callback) { | |
return fs.write(this.#fd, buffer, offset, length, position, callback); | |
} else { | |
return fs.write(this.#fd, buffer, offset, length, position).then(( | |
{ bytesWritten }, | |
) => bytesWritten); | |
} | |
} | |
/** | |
* Writes data to the file descriptor. | |
* | |
* @param buffer The data to write. | |
* @param offset The offset in the buffer to start writing from. | |
* @param length The number of bytes to write. | |
* @param position The position in the file to write to. | |
* @returns The number of bytes written. | |
* @throws {Error} if the file descriptor is not open for writing. | |
*/ | |
writeSync( | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
): number { | |
const fs = this[kFS]; | |
if (!fs) throw new Error("VFS is not available"); | |
if (this.#fd < 0) throw new Error("Invalid file descriptor"); | |
if (this.#flags & VFS.constants.O_WRONLY) { | |
throw new Error("File descriptor is not open for writing"); | |
} | |
return fs.writeSync(this.#fd, buffer, offset, length, position); | |
} | |
/** Closes the file descriptor. */ | |
close(): Promise<void>; | |
/** | |
* Closes the file descriptor. | |
* @param [callback] Callback to be called when the operation is complete. | |
*/ | |
close(callback: (err: Error | null) => void): void; | |
/** @internal */ | |
close(callback?: (err: Error | null) => void): Promise<void> | void { | |
try { | |
const fs = this[kFS]; | |
if (!fs) { | |
const error = new Error("VFS is not available"); | |
if (callback) return callback(error); | |
return Promise.reject(error); | |
} else { | |
return this.closeSync(); | |
} | |
} catch (err) { | |
if (callback) return callback(err as Error), void 0; | |
return Promise.reject(err); | |
} finally { | |
this.#fs = null; | |
this.#fd = -1; | |
this.#path = ""; | |
this.#flags = 0; | |
this.#position = 0; | |
} | |
} | |
/** Closes the file descriptor. */ | |
closeSync(): void { | |
try { | |
const fs = this[kFS]; | |
if (!fs) throw new Error("VFS is not available"); | |
const self = this as FileHandle; | |
fs[kInternal].update_fd_table(function (table) { | |
table[self.#fd] = null!; | |
delete table[self.#fd]; | |
}); | |
} finally { | |
this.#fs = null; | |
this.#fd = -1; | |
this.#path = ""; | |
this.#flags = 0; | |
this.#position = 0; | |
} | |
} | |
/** @internal */ | |
[Symbol.dispose](): void { | |
try { | |
this.closeSync(); | |
} catch { /* ignore */ } | |
} | |
/** @internal */ | |
async [Symbol.asyncDispose](): Promise<void> { | |
return await this.close(); | |
} | |
} | |
// #endregion FileHandle | |
// #region VFS | |
interface IndexMapLike { | |
[inode: string | number]: NodeLike; | |
} | |
interface IndexMap { | |
[inode: string | number]: Node; | |
} | |
interface FdTable { | |
[fd: string | number]: FileHandle; | |
} | |
const None: unique symbol = Symbol.for("None"); | |
type None = typeof None; | |
interface MinimalNodeFs { | |
readFileSync(p: string, encoding?: EncodingOption): string | Buffer; | |
readdirSync(p: string, options?: ReadDirOptions): Dirent[] | string[]; | |
readlinkSync(p: string, options?: EncodingOption): string | Buffer; | |
} | |
interface ImportOptions { | |
fs?: MinimalNodeFs; | |
sep?: "/" | "\\"; | |
} | |
function getNodeFs(): typeof import("node:fs") { | |
if ( | |
"process" in globalThis && | |
typeof (globalThis as any).process.getBuiltinModule === "function" | |
) { | |
return (globalThis as any).process.getBuiltinModule("node:fs"); | |
} else if ( | |
"require" in globalThis && typeof (globalThis as any).require === "function" | |
) { | |
return (globalThis as any).require("node:fs"); | |
} else { | |
throw new ReferenceError( | |
"node:fs is unavailable in the current environment.", | |
); | |
} | |
} | |
/** | |
* Virtual File System (VFS) | |
* | |
* This class implements a Node‑compatible file system API using browser | |
* storage. It persists metadata and file contents in localStorage and | |
* temporary state (like the current working directory and file descriptors) in | |
* sessionStorage. | |
* | |
* In addition to the standard file system functions, it now supports robust | |
* globbing via the glob and globSync functions. These methods recursively | |
* traverse the VFS, compile the provided glob pattern into a RegExp | |
* (supporting *, **, ?, and character classes), and return only those absolute | |
* paths that match. | |
* | |
* @example | |
* ```ts | |
* const fs = new VFS(); | |
* | |
* fs.writeFileSync("/src/index.js", "console.log('Hello')"); | |
* | |
* // Recursively search for all JS files: | |
* const matches = fs.globSync("/src\/\*\*\/\*.js"); | |
* | |
* console.log(matches); | |
* ``` | |
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage | |
* @see https://nodejs.org/api/fs.html | |
*/ | |
export class VFS { | |
// #region public (static) | |
/** | |
* Normalizes a path by resolving "." and "..". | |
*/ | |
static normalizePath(p: string, sep: "/" | "\\" = VFS.sep): string { | |
const parts = p.split(/[\\/]+/).filter((v) => !!v); | |
const stack: string[] = []; | |
for (const part of parts) { | |
if (part === ".") continue; | |
if (part === "..") { | |
stack.pop(); | |
continue; | |
} | |
stack.push(part); | |
} | |
return sep + stack.join(sep); | |
} | |
/** | |
* Normalizes the open mode flags to a numeric representation. | |
*/ | |
static normalizeOpenMode(flags: string | number): number { | |
if (typeof flags === "string") { | |
let mode = 0; | |
for (let i = 0; i < flags.length; i++) { | |
const c = flags[i]; | |
if (c === "r") mode |= VFS.constants.O_RDONLY; | |
if (c === "w") mode |= VFS.constants.O_WRONLY; | |
if (c === "a") mode |= VFS.constants.O_APPEND; | |
if (c === "c") mode |= VFS.constants.O_CREAT; | |
if (c === "t") mode |= VFS.constants.O_TRUNC; | |
if (c === "x") mode |= VFS.constants.O_EXCL; | |
if (c === "+") mode |= VFS.constants.O_RDWR; | |
} | |
return mode; | |
} | |
if (typeof flags === "number") return flags & 0o7777; | |
return 0; | |
} | |
/** | |
* Normalizes the file mode (i.e. the 'mode' property of a `Stats` object) to | |
* a numeric representation. This converts string-based representations like | |
* `"0o10644"` to their numeric equivalents, and also handles numeric | |
* representations like `0o10644` or `0o644`. | |
*/ | |
static normalizeFileMode(mode: string | number): number { | |
if (typeof mode === "string") { | |
const parsed = parseInt(mode, 8); | |
if (!isNaN(parsed)) return parsed; | |
} | |
return 0; | |
} | |
/** | |
* Returns the directory name of a path. | |
*/ | |
static dirname(p: string, sep: "/" | "\\" = VFS.sep): string { | |
p = VFS.normalizePath(p, sep); | |
if (p === sep) return sep; | |
const parts = p.split(/\//).filter((v) => !!v); | |
parts.pop(); | |
return sep + parts.join(sep); | |
} | |
/** | |
* Returns the basename of a path. | |
*/ | |
static basename(p: string, sep: "/" | "\\" = VFS.sep): string { | |
p = VFS.normalizePath(p, sep); | |
const parts = p.split(sep).filter(Boolean); | |
return parts[parts.length - 1] || ""; | |
} | |
/** | |
* Gets the globally-configured path separator for the VFS. This is used as the | |
* default path separator for all new `VFS` instances, but can be overridden at | |
* the instance-level as needed. | |
*/ | |
static get sep(): "/" | "\\" { | |
return VFS.#PATH_SEP; | |
} | |
/** | |
* Sets the globally-configured path separator for the VFS. This is used as | |
* the default path separator for all new `VFS` instances, but can be | |
* overridden at the instance-level as needed. | |
* | |
* **Note**: values other than `"/"` or `"\\"` are ignored by this property. | |
*/ | |
static set sep(value: "/" | "\\") { | |
VFS.#PATH_SEP = value === "\\" ? value : "/"; | |
} | |
/** FileSystem constants. */ | |
static readonly constants = { | |
// lifted from node's fs.constants | |
UV_FS_SYMLINK_DIR: 1, | |
UV_FS_SYMLINK_JUNCTION: 2, | |
O_RDONLY: 0, | |
O_WRONLY: 1, | |
O_RDWR: 2, | |
UV_DIRENT_UNKNOWN: 0, | |
UV_DIRENT_FILE: 1, | |
UV_DIRENT_DIR: 2, | |
UV_DIRENT_LINK: 3, | |
UV_DIRENT_FIFO: 4, | |
UV_DIRENT_SOCKET: 5, | |
UV_DIRENT_CHAR: 6, | |
UV_DIRENT_BLOCK: 7, | |
EXTENSIONLESS_FORMAT_JAVASCRIPT: 0, | |
EXTENSIONLESS_FORMAT_WASM: 1, | |
S_IFMT: 61440, | |
S_IFREG: 32768, | |
S_IFDIR: 16384, | |
S_IFCHR: 8192, | |
S_IFBLK: 24576, | |
S_IFIFO: 4096, | |
S_IFLNK: 40960, | |
S_IFSOCK: 49152, | |
O_CREAT: 64, | |
O_EXCL: 128, | |
UV_FS_O_FILEMAP: 0, | |
O_NOCTTY: 256, | |
O_TRUNC: 512, | |
O_APPEND: 1024, | |
O_DIRECTORY: 65536, | |
O_NOATIME: 262144, | |
O_NOFOLLOW: 131072, | |
O_SYNC: 1052672, | |
O_DSYNC: 4096, | |
O_DIRECT: 16384, | |
O_NONBLOCK: 2048, | |
S_IRWXU: 448, | |
S_IRUSR: 256, | |
S_IWUSR: 128, | |
S_IXUSR: 64, | |
S_IRWXG: 56, | |
S_IRGRP: 32, | |
S_IWGRP: 16, | |
S_IXGRP: 8, | |
S_IRWXO: 7, | |
S_IROTH: 4, | |
S_IWOTH: 2, | |
S_IXOTH: 1, | |
F_OK: 0, | |
R_OK: 4, | |
W_OK: 2, | |
X_OK: 1, | |
UV_FS_COPYFILE_EXCL: 1, | |
COPYFILE_EXCL: 1, | |
UV_FS_COPYFILE_FICLONE: 2, | |
COPYFILE_FICLONE: 2, | |
UV_FS_COPYFILE_FICLONE_FORCE: 4, | |
COPYFILE_FICLONE_FORCE: 4, | |
} as const; | |
/** */ | |
static join(...parts: PathLike[]): string { | |
parts = parts.map((p) => p.toString()).filter((p) => | |
p?.trim().length > 0 && p.trim() !== "." | |
); | |
return VFS.normalizePath(parts.join(VFS.sep), VFS.sep); | |
} | |
/** | |
* Attempts to recursively import files from the physical (disk) file system | |
* into a virtual file system (`VFS`) instance. The provided source directory | |
* `src` is traversed recursively, and all of its files and directories are | |
* copied into the VFS, in the destination directory specified by `dest`. | |
* | |
* Any existing files in the destination directory will be overwritten in the | |
* event of a naming collision. Parent directories are created as needed. | |
*/ | |
static import( | |
vfs: VFS, | |
src: PathLike, | |
dest: PathLike, | |
options?: ImportOptions, | |
): void { | |
const fs = options?.fs ?? getNodeFs(); | |
vfs.mkdirSync(dest, { recursive: true }); | |
for (const entry of fs.readdirSync(src)) { | |
const srcPath = `${src}/${entry.name}`; | |
const destPath = `${dest}/${entry.name}`; | |
if (entry.isDirectory) { | |
VFS.import(vfs, srcPath, destPath, { ...options, fs }); | |
} else if (entry.isFile) { | |
vfs.writeFileSync(destPath, fs.readFileSync(srcPath, "utf8")); | |
} else if (entry.isSymlink) { | |
vfs.symlinkSync(fs.readLinkSync(srcPath), destPath); | |
} | |
} | |
} | |
// #endregion public (static) | |
// #region private (static) | |
/** | |
* Converts a glob pattern into a RegExp. Supports: | |
* - "*" matches any sequence of characters except directory separators. | |
* - "?" matches any single character except a directory separator. | |
* - "**" matches any sequence of characters, including directory | |
* separators. | |
* - Character classes, e.g. [a-z]. | |
*/ | |
static #compile_glob(pattern: string): RegExp { | |
pattern = pattern.replace(/\\|\/+/g, "/"); | |
if (pattern.includes("{") && pattern.includes("}")) { | |
const _prior = pattern; | |
const balmat = balanced("{", "}", pattern); | |
if (balmat) { | |
let { pre, body, post } = balmat; | |
pre &&= inner_compile(pre).replace(/^\^|\$$/g, ""); | |
post &&= inner_compile(post).replace(/^\^|\$$/g, ""); | |
const vals = braces(`{${body}}`); | |
body = vals.length > 1 ? `(?:${vals.join("|")})` : (vals[0] || body); | |
return new RegExp(`^${pre}${body}${post}$`); | |
} | |
if (balmat !== null) { | |
const expansions = braces(pattern); | |
if (expansions.length > 1) { | |
const regexParts = expansions.map((exp) => { | |
// Recursively compile each alternative. | |
const r = VFS.#compile_glob(exp).source; | |
// Remove anchors so they can be merged. | |
return r.replace(/^\^|\$$/g, ""); | |
}); | |
return new RegExp(`^(?:${regexParts.join("|")})$`); | |
} else { | |
pattern = expansions[0]; | |
} | |
} | |
} | |
function inner_compile(pattern: string): string { | |
let i = 0, re = "", negated = false; | |
while (i < pattern.length) { | |
const char = pattern[i]; | |
if (char === "!" && i === 0) { | |
negated = true; | |
i++; | |
} | |
if (char === "\\") { | |
if (i + 1 < pattern.length) { | |
re += "\\" + pattern[i + 1]; | |
i += 2; | |
} else { | |
re += "\\\\"; | |
i++; | |
} | |
} else if (char === "*") { | |
i++; | |
if (i < pattern.length && pattern[i] === "*") { | |
re += "(?:[^/]*(?:/|$)+)*"; | |
i++; // Handle "**" | |
} else { | |
re += "[^/]*"; | |
} | |
} else if (char === "?") { | |
i++; | |
re += "[^/]"; | |
} else if (char === "[") { | |
let j = i; | |
while (j < pattern.length && pattern[j] !== "]") j++; | |
if (j < pattern.length) { | |
const charClass = pattern.slice(i, j + 1); | |
re += charClass; | |
i = j + 1; | |
} else { | |
re += "\\["; | |
i++; | |
} | |
} else if ("^$+?.()|{}".includes(char)) { | |
re += `\\${char}`; | |
i++; | |
} else if (char === "/") { | |
re += `/+`; | |
i++; | |
} else { | |
re += char; | |
i++; | |
} | |
} | |
if (negated) re = `(?!${re})`; | |
return re; | |
} | |
let re = inner_compile(pattern); | |
re = re.replace(/^\^|\$$/g, ""); | |
if (re === "") re = ".*"; | |
if (!/\\\/[*+?]?$/.test(re)) re += "\\/*"; | |
return new RegExp(`^${re}$`); | |
} | |
/** | |
* Extracts the fixed (literal) prefix from a glob pattern. | |
* For example, given "/foo/bar/** /*.js" it returns "/foo/bar". | |
*/ | |
static #extract_fixed_prefix( | |
pattern: string, | |
sep: "/" | "\\" = VFS.sep, | |
): string { | |
pattern = pattern.replace(/[\\/]+/g, sep); | |
const parts = pattern.split(sep); | |
const prefix_parts: string[] = []; | |
for (const part of parts) { | |
if (/[*[?]/.test(part)) break; | |
prefix_parts.push(part); | |
} | |
// Handle absolute paths (first element will be empty string) | |
if (pattern.startsWith(sep)) { | |
return sep + prefix_parts.slice(1).join(sep); | |
} else { | |
return prefix_parts.join(sep); | |
} | |
} | |
// Static private keys and constants. | |
static readonly #INODES_KEY = "vfs:inodes"; | |
static readonly #NEXT_INODE_KEY = "vfs:next_inode"; | |
static readonly #CWD_KEY = "vfs:cwd"; | |
static readonly #FD_TABLE_KEY = "vfs:fd_table"; | |
static readonly #NEXT_FD_KEY = "vfs:next_fd"; | |
static readonly #ROOT_INODE = 1; | |
static readonly #COPYFILE_EXCL = 1; | |
static #PATH_SEP: "/" | "\\" = "/"; | |
// #endregion private (static) | |
/** | |
* Creates a new VFS. | |
* @param localStorage - The persistent storage (default: | |
* window.localStorage). | |
* @param sessionStorage - The temporary storage (default: | |
* window.sessionStorage). | |
*/ | |
constructor(localStorage?: Storage, sessionStorage?: Storage) { | |
this.#localStorage = localStorage || globalThis.localStorage; | |
this.#sessionStorage = sessionStorage || globalThis.sessionStorage; | |
this.#init(); | |
} | |
// #region private (instance) | |
#localStorage: Storage; | |
#sessionStorage: Storage; | |
#sep: "/" | "\\" = VFS.#PATH_SEP; | |
#cwd: string = VFS.#PATH_SEP; | |
#fd_table: FdTable | null = null; | |
#inodes: IndexMap | null = null; | |
#root: Node = null!; | |
#next_fd: number = 3; | |
/** File system constants. */ | |
readonly constants = VFS.constants; | |
#init() { | |
if (!this.#localStorage) { | |
throw new Error("No localStorage available"); | |
} | |
if (!this.#sessionStorage) { | |
throw new Error("No sessionStorage available"); | |
} | |
this.#root ??= Node.from({ | |
type: "directory", | |
inode: VFS.#ROOT_INODE, | |
mode: 0o755, | |
atime: Date.now(), | |
mtime: Date.now(), | |
ctime: Date.now(), | |
size: 4096, | |
children: {}, | |
blksize: 4096, | |
blocks: 0, | |
nlink: 1, | |
rdev: 0, | |
dev: 0, | |
uid: 0, | |
gid: 0, | |
}); | |
const inodes = { [VFS.#ROOT_INODE]: this.#root } as IndexMap; | |
try { | |
const data = this.#get_inodes(); | |
if (data) Object.assign(inodes, data); | |
} catch { | |
this.#set_inodes(inodes); | |
this.#set_next_inode(VFS.#ROOT_INODE + 1); | |
} | |
this.#set_fd_table(); | |
let next_fd = this.#get_next_fd(); | |
if (next_fd < 3) next_fd = 3; | |
this.#set_next_fd(next_fd); | |
} | |
#join(...paths: string[]): string { | |
if (paths.length === 0 || paths.length === 1 && paths[0] === this.#sep) { | |
return this.#sep; | |
} | |
const sep = this.#sep; | |
const parts = paths.join(sep).split(/[\\/]+/).filter(Boolean); | |
return sep + parts.join(sep); | |
} | |
#get_inodes(): IndexMap { | |
let inodes: IndexMap = Object.create(null); | |
const data = this.#localStorage.getItem(VFS.#INODES_KEY); | |
try { | |
inodes = { ...inodes, ...data ? JSON.parse(data) : {} }; | |
} catch { /* ignore */ } | |
inodes[VFS.#ROOT_INODE] ||= this.#root; | |
return this.#hydrate_inodes(inodes); | |
} | |
#set_inodes(inodes: IndexMapLike): void { | |
this.#localStorage.setItem(VFS.#INODES_KEY, JSON.stringify(inodes)); | |
} | |
#update_inodes(callback: (this: this, inodes: IndexMap) => void): IndexMap { | |
const inodes = this.#get_inodes(); | |
callback.call(this, inodes); | |
this.#set_inodes(inodes); | |
return inodes; | |
} | |
#hydrate_inodes(inodes: IndexMapLike): IndexMap { | |
// First pass: hydrate all nodes. | |
const hydrated: IndexMap = Object.create(null); | |
for (const key of Object.keys(inodes)) { | |
const datum = inodes[key]; | |
if (datum && typeof datum === "object") { | |
const node: Node = datum instanceof Node | |
? datum | |
: Node.from(datum as NodeLike); | |
hydrated[key] = node; | |
} | |
} | |
// Second pass: ensure each node has a proper children map and update | |
// each child pointer to refer to the hydrated nodes. | |
for (const node of Object.values(hydrated)) { | |
if (node.children && typeof node.children === "object") { | |
// Remove any inherited properties. | |
Object.setPrototypeOf(node.children, null); | |
for (const [childName, childInode] of Object.entries(node.children)) { | |
const childNode = hydrated[childInode]; | |
if (childNode) { | |
// Set the child's target as the key from the parent's children map. | |
childNode.target = childName; | |
// Update the child pointer to point to the child's inode. | |
node.children[childName] = childNode.inode; | |
} | |
} | |
} else { | |
node.children = Object.create(null); | |
} | |
} | |
return hydrated; | |
} | |
#get_next_inode(): number { | |
const n = this.#localStorage.getItem(VFS.#NEXT_INODE_KEY); | |
return n ? parseInt(n, 10) : 2; | |
} | |
#set_next_inode(n: number): void { | |
this.#localStorage.setItem(VFS.#NEXT_INODE_KEY, n.toString()); | |
} | |
#hydrate_fd_table(data: object): FdTable { | |
const table = { __proto__: null } as unknown as FdTable; | |
// populate the initial stdio file descriptors (0, 1, 2) | |
table[0] = new FileHandle(this, 0, "/dev/stdin", "r", null); | |
table[1] = new FileHandle(this, 1, "/dev/stdout", "w", null); | |
table[2] = new FileHandle(this, 2, "/dev/stderr", "w", null); | |
// populate the rest of the file descriptors | |
for (const [fd, fd_obj] of Object.entries(data)) { | |
if (fd_obj && typeof fd_obj === "object") { | |
const node = Node.from(fd_obj); | |
const vfs = this as VFS; | |
const path = node.target || node.inode.toString(); | |
const flags = fd_obj.flags || 0; | |
const fdesc = new FileHandle( | |
vfs, | |
+fd, | |
path, | |
flags, | |
node, | |
); | |
table[fd] = fdesc; | |
if (node.type === "directory") { | |
const dir = new Dir(path); | |
dir[kNode] = new WeakRef(node); | |
} | |
} | |
} | |
return table; | |
} | |
#get_fd_table(): FdTable { | |
if (!this.#fd_table) { | |
const o = this.#sessionStorage.getItem(VFS.#FD_TABLE_KEY); | |
this.#fd_table = this.#hydrate_fd_table( | |
o ? JSON.parse(o) : { __proto__: null }, | |
); | |
} | |
return this.#fd_table; | |
} | |
#set_fd_table(table: FdTable | null | None = None): void { | |
if (table === None) { | |
if (this.#fd_table) { | |
table = this.#fd_table; | |
} else { | |
table = this.#get_fd_table(); | |
} | |
} | |
if (table === null) { | |
this.#fd_table = null; | |
this.#sessionStorage.removeItem(VFS.#FD_TABLE_KEY); | |
} else { | |
this.#fd_table = table; | |
} | |
this.#sessionStorage.setItem(VFS.#FD_TABLE_KEY, JSON.stringify(table)); | |
} | |
#update_fd_table( | |
callback: (this: this, fd_table: FdTable) => void, | |
): void { | |
const fd_table = this.#get_fd_table(); | |
callback.call(this, fd_table); | |
this.#set_fd_table(fd_table); | |
} | |
#get_next_fd(): number { | |
const n = this.#sessionStorage.getItem(VFS.#NEXT_FD_KEY); | |
const next_fd = Math.max( | |
n ? parseInt(n, 10) : Math.max( | |
...Object.keys(this.#get_fd_table()).map((f) => +f).filter((n) => | |
!isNaN(n) | |
), | |
) + 1, | |
3, | |
); | |
return this.#next_fd = next_fd; | |
} | |
#set_next_fd(n: number): void { | |
this.#sessionStorage.setItem(VFS.#NEXT_FD_KEY, n.toString()); | |
} | |
#open_fd(n: number): FileHandle { | |
const fd_table = this.#get_fd_table(); | |
const fd = fd_table[n]; | |
if (fd) return fd.position = 0, fd; | |
const vfs = this as VFS; | |
const path = this.#get_cwd(); | |
const flags = 0; | |
const node = this.#get_node(path, this.#inodes ??= this.#get_inodes()); | |
if (!node) throw new Error(`ENOENT: No such file or directory '${path}'`); | |
const fd_obj = new FileHandle(vfs, n, path, flags, node); | |
fd_table[n] = fd_obj; | |
this.#set_fd_table(fd_table); | |
return fd_obj; | |
} | |
#get_node(path: PathLike, inodes?: IndexMap): Node | null { | |
inodes ||= this.#get_inodes(); | |
path = path.toString(); | |
if (path === this.#sep) return inodes[VFS.#ROOT_INODE]; | |
const parts = path.split(this.#sep).filter(Boolean); | |
let node = inodes[VFS.#ROOT_INODE]; | |
for (const part of parts) { | |
if (node.type !== "directory" || !node.children) return null; | |
const childInode = node.children[part]; | |
if (!childInode) return null; | |
node = inodes[childInode]; | |
if (!node) return null; | |
} | |
return node; | |
} | |
/** | |
* Returns the path of the inode. | |
* @param inode - The inode number. | |
* @param [inodes] The inodes map. If not provided, it will be loaded from | |
* the persistent storage layer for the current VFS instance. | |
* @returns The path of the inode. | |
*/ | |
#get_node_path(inode: number, inodes?: IndexMap): string { | |
inodes ||= this.#get_inodes(); | |
const node = inodes[inode]; | |
if (!node) return ""; | |
const parts: string[] = []; | |
let currentNode: Node | null = node; | |
while (currentNode) { | |
const parentInode = Object.entries(currentNode.children).find(([_, v]) => | |
v === inode | |
)?.[0]; | |
if (parentInode) { | |
parts.unshift(parentInode); | |
inode = currentNode.inode; | |
currentNode = inodes[inode]; | |
} else { | |
parts.unshift(currentNode.target || currentNode.inode.toString()); | |
inode = currentNode.inode; | |
currentNode = null; | |
} | |
} | |
return this.#join(...parts); | |
} | |
/** | |
* Returns the path and name of the inode. | |
* @param inode - The inode number. | |
* @param [inodes] The inodes map. If not provided, it will be loaded from | |
* the persistent storage layer for the current VFS instance. | |
* @returns The path and name of the inode. | |
*/ | |
#get_inode_path( | |
inode: number, | |
inodes?: IndexMap, | |
): { path: string; name: string } { | |
inodes ??= this.#get_inodes(); | |
const node = inodes[inode]; | |
if (!node) return { path: "", name: "" }; | |
const path = this.#get_node_path(inode, inodes); | |
const name = VFS.basename(path, this.#sep); | |
return { path, name }; | |
} | |
/** | |
* Recursively walks the directory tree starting from the given path. | |
* Returns an array of absolute paths for all files and directories found. | |
*/ | |
#walk_dir(currentPath: PathLike): Dirent[] { | |
const results: Dirent[] = []; | |
currentPath = this.#resolve(currentPath); | |
try { | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(currentPath, inodes); | |
if (!node) return results; | |
const { mode, inode: ino } = node; | |
const { path, name } = this.#get_inode_path(ino, inodes); | |
const dirent = new Dirent(name, mode, path); | |
if (dirent) { | |
results.push(dirent); | |
if (dirent.isDirectory()) { | |
const entries = this.readdirSync(currentPath, { | |
withFileTypes: true, | |
}); | |
for (const entry of entries) { | |
results.push(...this.#walk_dir(entry.path).flat()); | |
} | |
} | |
} | |
} catch { /* ignore */ } | |
return results; | |
} | |
/** | |
* Resolves a path relative to the current working directory. | |
*/ | |
#resolve(p: PathLike, sep: "/" | "\\" = this.#sep): string { | |
if (!p) return this.#get_cwd(sep); | |
p = p.toString(); | |
if (p.startsWith(sep)) return VFS.normalizePath(p, sep); | |
return VFS.normalizePath(this.#join(this.#get_cwd(sep), p), sep); | |
} | |
#get_cwd(sep: "/" | "\\" = this.#sep): string { | |
const cwd = this.#sessionStorage.getItem(VFS.#CWD_KEY); | |
return cwd || (this.#cwd ||= sep); | |
} | |
#set_cwd(cwd: PathLike): void { | |
this.#sessionStorage.setItem(VFS.#CWD_KEY, this.#cwd = cwd.toString()); | |
} | |
// #endregion private (instance) | |
// #region internal | |
@lru() | |
[kGetInternal]() { | |
const self = this as VFS; | |
return { | |
get inodes() { | |
return self.#inodes ??= self.#get_inodes(); | |
}, | |
get fd_table() { | |
return self.#fd_table ??= self.#get_fd_table(); | |
}, | |
get root() { | |
return self.#root; | |
}, | |
get sep() { | |
return self.#sep; | |
}, | |
get next_inode() { | |
return self.#get_next_inode(); | |
}, | |
set next_inode(n: number) { | |
self.#set_next_inode(n); | |
}, | |
get next_fd() { | |
return self.#get_next_fd(); | |
}, | |
set next_fd(n: number) { | |
self.#set_next_fd(n); | |
}, | |
get cwd() { | |
return self.#get_cwd(); | |
}, | |
set cwd(path: string) { | |
self.#set_cwd(path); | |
}, | |
init: self.#init.bind(self), | |
join: self.#join.bind(self), | |
open_fd: self.#open_fd.bind(self), | |
resolve: self.#resolve.bind(self), | |
get_node: self.#get_node.bind(self), | |
walk_dir: self.#walk_dir.bind(self), | |
get_inodes: self.#get_inodes.bind(self), | |
set_inodes: self.#set_inodes.bind(self), | |
get_next_fd: self.#get_next_fd.bind(self), | |
set_next_fd: self.#set_next_fd.bind(self), | |
get_fd_table: self.#get_fd_table.bind(self), | |
set_fd_table: self.#set_fd_table.bind(self), | |
update_inodes: self.#update_inodes.bind(self), | |
get_node_path: self.#get_node_path.bind(self), | |
get_inode_path: self.#get_inode_path.bind(self), | |
get_next_inode: self.#get_next_inode.bind(self), | |
set_next_inode: self.#set_next_inode.bind(self), | |
hydrate_inodes: self.#hydrate_inodes.bind(self), | |
update_fd_table: self.#update_fd_table.bind(self), | |
hydrate_fd_table: self.#hydrate_fd_table.bind(self), | |
} as const; | |
} | |
get [kInternal](): ReturnType<VFS[kGetInternal]> { | |
return this[kGetInternal](); | |
} | |
get [kGetInodePath](): ( | |
inode: number, | |
inodes?: IndexMap, | |
) => { path: string; name: string } { | |
return this.#get_inode_path.bind(this); | |
} | |
get [kGetInode](): (path: string, inodes?: IndexMap) => Node | null { | |
return this.#get_node.bind(this); | |
} | |
// #endregion internal | |
// #region public | |
existsSync(p: PathLike): boolean { | |
const path = this.#resolve(p); | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(path, inodes); | |
return !!node; | |
} | |
exists(p: PathLike): Promise<boolean>; | |
exists(p: PathLike, callback: (exists: boolean) => void): void; | |
exists( | |
p: PathLike, | |
callback?: (exists: boolean) => void, | |
): void | Promise<boolean> { | |
if (typeof callback === "function") { | |
setTimeout(() => callback(this.existsSync(p)), 0); | |
} else { | |
return new Promise((resolve) => resolve(this.existsSync(p))); | |
} | |
} | |
fstatSync(fd: number): Stats | null { | |
return this.#open_fd(fd).statSync(); | |
} | |
fstat(fd: number): Promise<Stats | null>; | |
fstat(fd: number, callback: (err: Error | null, stat?: Stats | null) => void): void; | |
fstat( | |
fd: number, | |
callback?: (err: Error | null, stat?: Stats | null) => void, | |
): Promise<Stats | null> | void { | |
if (typeof callback === "function") { | |
setTimeout(() => { | |
try { | |
const stat = this.fstatSync(fd); | |
callback(null, stat); | |
} catch (err) { | |
callback(err as Error); | |
} | |
}, 0); | |
} else { | |
return new Promise((resolve, reject) => { | |
try { | |
const stat = this.fstatSync(fd); | |
resolve(stat); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} | |
} | |
mkdirSync(p: PathLike, options?: MkdirOptions): void { | |
const path = this.#resolve(p); | |
this.#update_inodes((inodes) => { | |
const parts = path.split(this.#sep).filter(Boolean); | |
let node = inodes[VFS.#ROOT_INODE]; | |
for (let i = 0; i < parts.length; i++) { | |
const part = parts[i]; | |
node.children ||= {}; | |
if (!node.children[part]) { | |
if (i < parts.length - 1 && !options?.recursive) { | |
throw new Error("ENOENT: No such file or directory"); | |
} | |
const newInode = this.#get_next_inode(); | |
const now = Date.now(); | |
const newDir = Node.from({ | |
type: "directory", | |
inode: newInode, | |
mode: 0o40755, | |
atime: now, | |
mtime: now, | |
ctime: now, | |
size: 4096, | |
children: {}, | |
}); | |
node.children[part] = newInode; | |
inodes[newInode] = newDir; | |
this.#set_next_inode(newInode + 1); | |
node = newDir; | |
} else { | |
const childInode = node.children[part]; | |
const child = inodes[childInode]; | |
if (!child || child.type !== "directory") { | |
throw new Error("ENOTDIR: Not a directory"); | |
} | |
node = child; | |
} | |
} | |
}); | |
} | |
mkdir(p: PathLike, options?: MkdirOptions): Promise<void>; | |
mkdir(p: PathLike, callback: (err: Error | null) => void): void; | |
mkdir( | |
p: PathLike, | |
options: MkdirOptions, | |
callback: (err: Error | null) => void, | |
): void; | |
mkdir( | |
p: PathLike, | |
options?: MkdirOptions | ((err: Error | null) => void), | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof options === "function") { | |
[callback, options] = [options, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.mkdirSync(p, options); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.mkdirSync(p, options); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
readFileSync(p: PathLike, options?: BufferEncodingOption): Buffer; | |
readFileSync(p: PathLike, options: ReadFileOptions): string; | |
readFileSync(p: PathLike, options?: ReadFileOptions): string | Buffer; | |
readFileSync( | |
p: PathLike, | |
options?: ReadFileOptions | BufferEncodingOption, | |
): string | Buffer { | |
let encoding = typeof options === "string" ? options : options?.encoding; | |
const path = this.#resolve(p); | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type !== "file") { | |
throw new Error("EISDIR: Illegal operation on a directory"); | |
} | |
node.atime = Date.now(); | |
this.#set_inodes(inodes); | |
let isBuffer = false; | |
if (encoding === "buffer") [isBuffer, encoding] = [true, undefined]; | |
const buf = Buffer.from(node.data ?? "", encoding); | |
return isBuffer ? buf : buf.toString(encoding); | |
} | |
readFile( | |
p: PathLike, | |
callback: (err: Error | null, data?: string | Buffer) => void, | |
): void; | |
readFile( | |
p: PathLike, | |
options: ReadFileOptions, | |
callback: (err: Error | null, data?: string) => void, | |
): void; | |
readFile( | |
p: PathLike, | |
options: BufferEncodingOption, | |
callback: (err: Error | null, data?: Buffer) => void, | |
): void; | |
readFile( | |
p: PathLike, | |
options: ReadFileOptions, | |
callback: (err: Error | null, data?: string | Buffer) => void, | |
): void; | |
readFile(p: PathLike, options: ReadFileOptions): Promise<string>; | |
readFile(p: PathLike, options: BufferEncodingOption): Promise<Buffer>; | |
readFile(p: PathLike, options?: ReadFileOptions): Promise<string | Buffer>; | |
readFile( | |
p: PathLike, | |
callbackOrOptions: | |
| ReadFileOptions | |
| ((err: Error | null, data?: string | Buffer) => void), | |
callback?: (err: Error | null, data?: string | Buffer) => void, | |
): Promise<string | Buffer> | void; | |
readFile( | |
p: PathLike, | |
options?: | |
| ReadFileOptions | |
// deno-lint-ignore no-explicit-any | |
| ((err: Error | null, data?: any) => void), | |
// deno-lint-ignore no-explicit-any | |
callback?: (err: Error | null, data?: any) => void, | |
): Promise<string | Buffer> | void { | |
if (typeof options === "function") { | |
[callback, options] = [options, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
const data = this.readFileSync(p, options); | |
resolve(data); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
const data = this.readFileSync(p, options); | |
setTimeout(() => callback(null, data), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
writeFileSync( | |
p: PathLike, | |
data: string | Buffer, | |
options?: WriteFileOptions, | |
): void { | |
const _encoding = typeof options === "string" ? options : options?.encoding; | |
const path = this.#resolve(p); | |
this.#update_inodes((inodes) => { | |
const parentPath = VFS.dirname(path); | |
const name = VFS.basename(path); | |
const parent = this.#get_node(parentPath, inodes); | |
if (!parent || parent.type !== "directory" || !parent.children) { | |
throw new Error("ENOENT: Parent directory does not exist"); | |
} | |
let node: Node | undefined; | |
const inodeNumber = parent.children[name]; | |
if (inodeNumber) { | |
node = inodes[inodeNumber]; | |
if (!node || node.type !== "file") { | |
throw new Error("EISDIR: Cannot write to a non-file"); | |
} | |
} else { | |
const newInode = this.#get_next_inode(); | |
node = Node.from({ | |
type: "file", | |
inode: newInode, | |
mode: 0o10644, | |
atime: Date.now(), | |
mtime: Date.now(), | |
ctime: Date.now(), | |
size: 0, | |
data: "", | |
blksize: 4096, | |
blocks: 0, | |
nlink: 1, | |
rdev: 0, | |
dev: 0, | |
uid: 0, | |
gid: 0, | |
}); | |
parent.children[name] = newInode; | |
inodes[newInode] = node; | |
this.#set_next_inode(newInode + 1); | |
} | |
const content = typeof data === "string" | |
? data | |
: new TextDecoder().decode(data); | |
node.data = content; | |
node.size = content.length; | |
node.mtime = Date.now(); | |
node.atime = Date.now(); | |
}); | |
} | |
writeFile(p: PathLike, data: string | Buffer): Promise<void>; | |
writeFile( | |
p: PathLike, | |
data: string | Buffer, | |
options: WriteFileOptions, | |
): Promise<void>; | |
writeFile( | |
p: PathLike, | |
data: string | Buffer, | |
options: WriteFileOptions, | |
callback: (err: Error | null) => void, | |
): void; | |
writeFile( | |
p: PathLike, | |
data: string | Buffer, | |
callback: (err: Error | null) => void, | |
): void; | |
writeFile( | |
p: PathLike, | |
data: string | Buffer, | |
options?: | |
| WriteFileOptions | |
| ((err: Error | null) => void), | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof options === "function") { | |
[callback, options] = [options, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.writeFileSync(p, data, options); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.writeFileSync(p, data, options); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
writeSync( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number | bigint | null, | |
): number; | |
writeSync( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
): number; | |
writeSync( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
): number; | |
writeSync(fd: number, buffer: Buffer): number; | |
writeSync( | |
fd: number, | |
buffer: Buffer, | |
offset = 0, | |
length = buffer.length, | |
position: number | bigint | null = null, | |
): number { | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(this.#fd_table![fd].path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type !== "file") { | |
throw new Error("EISDIR: Illegal operation on a directory"); | |
} | |
node.atime = Date.now(); | |
this.#set_inodes(inodes); | |
const fd_table = this.#get_fd_table(); | |
const fd_node = fd_table[fd] ??= (this.#fd_table ??= fd_table)[fd] ??= this | |
.#open_fd(fd); | |
let update_pos = false; | |
if (position !== null && position !== -1) { | |
position = Number(position); | |
} else { | |
position = fd_node.position; | |
update_pos = true; | |
} | |
const bytesWritten = fd_node.writeSync(buffer, offset, length, position); | |
if (update_pos) fd_node.position += bytesWritten; | |
return bytesWritten; | |
} | |
/** | |
* Asynchronously writes `buffer` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param offset The part of the buffer to be written. If not supplied, | |
* defaults to `0`. | |
* @param length The number of bytes to write. If not supplied, defaults to | |
* `buffer.length - offset`. | |
* @param position The offset from the beginning of the file where this data | |
* should be written. If not supplied, defaults to the current position. | |
*/ | |
write<TBuffer extends ArrayBufferView>( | |
fd: number, | |
buffer?: TBuffer, | |
offset?: number, | |
length?: number, | |
position?: number | null, | |
): Promise<{ | |
bytesWritten: number; | |
buffer: TBuffer; | |
}>; | |
/** | |
* Asynchronously writes `string` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param string A string to write. | |
* @param position The offset from the beginning of the file where this data | |
* should be written. If not supplied, defaults to the current position. | |
* @param encoding The expected string encoding. | |
*/ | |
write( | |
fd: number, | |
string: string, | |
position?: number | null, | |
encoding?: BufferEncoding | null, | |
): Promise<{ | |
bytesWritten: number; | |
buffer: string; | |
}>; | |
/** | |
* Write `buffer` to the file specified by `fd`. | |
* | |
* `offset` determines the part of the buffer to be written, and `length` is | |
* an integer specifying the number of bytes to write. | |
* | |
* `position` refers to the offset from the beginning of the file where this | |
* data should be written. If `typeof position !== 'number'`, the data will | |
* be written at the current position. See | |
* [`pwrite(2)`](http://man7.org/linux/man-pages/man2/pwrite.2.html). | |
* | |
* The callback will be given three arguments `(err, bytesWritten, buffer)` | |
* where `bytesWritten` specifies how many _bytes_ were written from | |
* `buffer`. | |
* | |
* If this method is invoked as its `util.promisify()` ed version, it returns | |
* a promise for an `Object` with `bytesWritten` and `buffer` properties. | |
* | |
* It is unsafe to use `fs.write()` multiple times on the same file without | |
* waiting for the callback. For this scenario, {@link createWriteStream} is | |
* recommended. | |
* | |
* On Linux, positional writes don't work when the file is opened in append | |
* mode. The kernel ignores the position argument and always appends the data | |
* to the end of the file. | |
* @since v0.0.2 | |
* @param [offset=0] | |
* @param [length=buffer.byteLength - offset] | |
* @param [position='null'] | |
*/ | |
write<TBuffer extends ArrayBufferView>( | |
fd: number, | |
buffer: TBuffer, | |
offset: number | undefined | null, | |
length: number | undefined | null, | |
position: number | undefined | null, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
buffer: TBuffer, | |
) => void, | |
): void; | |
/** | |
* Asynchronously writes `buffer` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param offset The part of the buffer to be written. If not supplied, | |
* defaults to `0`. | |
* @param length The number of bytes to write. If not supplied, defaults to | |
* `buffer.length - offset`. | |
*/ | |
write<TBuffer extends ArrayBufferView>( | |
fd: number, | |
buffer: TBuffer, | |
offset: number | undefined | null, | |
length: number | undefined | null, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
buffer: TBuffer, | |
) => void, | |
): void; | |
/** | |
* Asynchronously writes `buffer` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param offset The part of the buffer to be written. If not supplied, | |
* defaults to `0`. | |
*/ | |
write<TBuffer extends ArrayBufferView>( | |
fd: number, | |
buffer: TBuffer, | |
offset: number | undefined | null, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
buffer: TBuffer, | |
) => void, | |
): void; | |
/** | |
* Asynchronously writes `buffer` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
*/ | |
write<TBuffer extends ArrayBufferView>( | |
fd: number, | |
buffer: TBuffer, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
buffer: TBuffer, | |
) => void, | |
): void; | |
/** | |
* Asynchronously writes `string` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param string A string to write. | |
* @param position The offset from the beginning of the file where this data | |
* should be written. If not supplied, defaults to the current position. | |
* @param encoding The expected string encoding. | |
*/ | |
write( | |
fd: number, | |
string: string, | |
position: number | undefined | null, | |
encoding: BufferEncoding | undefined | null, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
str: string, | |
) => void, | |
): void; | |
/** | |
* Asynchronously writes `string` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param string A string to write. | |
* @param position The offset from the beginning of the file where this data | |
* should be written. If not supplied, defaults to the current position. | |
*/ | |
write( | |
fd: number, | |
string: string, | |
position: number | undefined | null, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
str: string, | |
) => void, | |
): void; | |
/** | |
* Asynchronously writes `string` to the file referenced by the supplied file | |
* descriptor. | |
* @param fd A file descriptor. | |
* @param string A string to write. | |
*/ | |
write( | |
fd: number, | |
string: string, | |
callback: ( | |
err: ErrnoException | null, | |
written: number, | |
str: string, | |
) => void, | |
): void; | |
write(...args: any[]): void | PromiseLike<any> { | |
let fd: number; | |
let buffer: Buffer | string; | |
let offset: number | null = null; | |
let length: number | null = null; | |
let position: number | null = null; | |
let encoding: EncodingOption | null = null; | |
let callback: | |
| ((err: Error | null, bytesWritten?: number, buffer?: Buffer) => void) | |
| undefined; | |
// copy the original arguments | |
const _initialArgs = args.slice(); | |
// fd and buffer are always the first two arguments | |
[fd, buffer, ...args] = args; | |
if (typeof fd !== "number" || !isFinite(fd)) { | |
throw new Error("ERR_INVALID_FD: Invalid file descriptor"); | |
} | |
if (fd < 0 || fd % 1 !== 0 || fd >= this.#get_next_fd()) { | |
throw new RangeError( | |
`ERR_BAD_FD: Bad file descriptor ${fd} (out of range)`, | |
); | |
} | |
if (typeof buffer !== "string" && !Buffer.isBuffer(buffer)) { | |
throw new TypeError( | |
`ERR_INVALID_ARG_TYPE: The "buffer" argument must be one of type string or Buffer. Received type ${typeof buffer}`, | |
); | |
} | |
// handle the rest of the overload patterns | |
while (args.length > 0) { | |
const arg = args.shift(); | |
if (typeof arg === "number") { | |
if (offset === null) { | |
offset = arg; | |
} else if (length === null) { | |
length = arg; | |
} else if (position === null) { | |
position = arg; | |
} | |
} else if (typeof arg === "function") { | |
callback = arg; | |
} else if ( | |
typeof arg === "string" || typeof arg === "object" && "encoding" in arg | |
) { | |
encoding = arg; | |
} | |
} | |
if (typeof encoding === "object") { | |
encoding = encoding?.encoding ?? "utf8"; | |
} | |
if (typeof encoding === "string" && <string> encoding !== "buffer") { | |
buffer = Buffer.from(buffer.toString(), encoding); | |
} | |
if (typeof buffer === "string") { | |
buffer = Buffer.from(buffer, encoding); | |
} else if (!Buffer.isBuffer(buffer)) { | |
throw new TypeError( | |
`ERR_INVALID_ARG_TYPE: The "buffer" argument must be one of type string or Buffer. Received type ${typeof buffer}`, | |
); | |
} | |
// normalize the arguments | |
if (offset === null) offset = 0; | |
if (length === null) length = buffer.length - offset; | |
if (position === null) position = -1; | |
// validate the arguments | |
if (!Number.isInteger(offset) || offset < 0) { | |
throw new RangeError( | |
`ERR_OUT_OF_RANGE: The "offset" argument is out of range. It must be >= 0 and <= ${buffer.length}`, | |
); | |
} | |
if (!Number.isInteger(length) || length < 0) { | |
throw new RangeError( | |
`ERR_OUT_OF_RANGE: The "length" argument is out of range. It must be >= 0 and <= ${ | |
buffer.length - offset | |
}`, | |
); | |
} | |
if (position !== null && !Number.isInteger(position)) { | |
throw new RangeError( | |
`ERR_OUT_OF_RANGE: The "position" argument is out of range. It must be >= 0 and <= ${buffer.length}`, | |
); | |
} | |
// use the writeSync method for the actual writing | |
const bytesWritten = this.writeSync(fd, buffer, offset, length, position); | |
// handle the callback | |
if (callback) { | |
setTimeout(() => callback(null, bytesWritten, buffer), 0); | |
return; | |
} | |
// return a promise for the async version | |
return Promise.resolve({ bytesWritten, buffer }); | |
} | |
readSync(fd: number, buffer: Buffer): number; | |
readSync( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number | bigint | null, | |
): number; | |
readSync( | |
fd: number, | |
buffer: Buffer, | |
offset?: number, | |
length?: number, | |
position?: number | bigint | null, | |
): number; | |
readSync( | |
fd: number, | |
buffer: Buffer, | |
offset = 0, | |
length = buffer.length, | |
position: number | bigint | null = null, | |
): number { | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(this.#fd_table![fd].path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type !== "file") { | |
throw new Error("EISDIR: Illegal operation on a directory"); | |
} | |
node.atime = Date.now(); | |
this.#set_inodes(inodes); | |
const fd_table = this.#get_fd_table(); | |
const fd_node = fd_table[fd] ??= (this.#fd_table ??= fd_table)[fd] ??= this | |
.#open_fd(fd); | |
let update_pos = false; | |
if (position !== null && position !== -1) { | |
position = Number(position); | |
} else { | |
position = fd_node.position; | |
update_pos = true; | |
} | |
const data = node.toBuffer(position, length); | |
const bytesRead = data.length; | |
if (bytesRead > 0) buffer.set(data, offset); | |
if (update_pos) fd_node.position += bytesRead; | |
if (bytesRead < length) { | |
buffer.fill(0, offset + bytesRead, offset + length); | |
} | |
return bytesRead; | |
} | |
read( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
): Promise<number>; | |
read( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
callback: { | |
(err: Error | null): void; | |
(err: null, bytesRead: number, buffer: Buffer): void; | |
}, | |
): void; | |
read( | |
fd: number, | |
buffer: Buffer, | |
offset: number, | |
length: number, | |
position: number, | |
callback?: (err: Error | null, bytesRead?: number, buffer?: Buffer) => void, | |
): Promise<number> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
const bytesRead = this.readSync(fd, buffer, offset, length, position); | |
resolve(bytesRead); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
const bytesRead = this.readSync(fd, buffer, offset, length, position); | |
setTimeout(() => callback(null, bytesRead, buffer), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
cpSync( | |
src: PathLike, | |
dest: PathLike, | |
options?: CopyFileOptions, | |
): void { | |
const srcPath = this.#resolve(src); | |
const destPath = this.#resolve(dest); | |
const inodes = this.#get_inodes(); | |
const srcNode = this.#get_node(srcPath, inodes); | |
if (!srcNode) throw new Error("ENOENT: No such file or directory"); | |
if (srcNode.type !== "file") { | |
throw new Error("EISDIR: Illegal operation on a directory"); | |
} | |
const destParentPath = VFS.dirname(destPath); | |
const destParentNode = this.#get_node(destParentPath, inodes); | |
if (!destParentNode || destParentNode.type !== "directory") { | |
throw new Error("ENOENT: Parent directory does not exist"); | |
} | |
const destName = VFS.basename(destPath); | |
const destNode = this.#get_node(destPath, inodes); | |
if (destNode) { | |
if (options?.flag === VFS.constants.COPYFILE_EXCL) { | |
throw new Error("EEXIST: File already exists"); | |
} | |
if (options?.flag === VFS.constants.COPYFILE_FICLONE) { | |
throw new Error("EEXIST: File already exists"); | |
} | |
if (options?.flag === VFS.constants.COPYFILE_FICLONE_FORCE) { | |
throw new Error("EEXIST: File already exists"); | |
} | |
} | |
const newInode = this.#get_next_inode(); | |
const now = Date.now(); | |
const newFile = Node.from({ | |
type: "file", | |
inode: newInode, | |
mode: 0o10644, | |
atime: now, | |
mtime: now, | |
ctime: now, | |
size: srcNode.size, | |
data: srcNode.data, | |
}); | |
destParentNode.children[destName] = newInode; | |
inodes[newInode] = newFile; | |
this.#set_next_inode(newInode + 1); | |
this.#set_inodes(inodes); | |
destParentNode.mtime = now; | |
destParentNode.atime = now; | |
destParentNode.ctime = now; | |
this.#set_inodes(inodes); | |
srcNode.atime = now; | |
srcNode.mtime = now; | |
this.#set_inodes(inodes); | |
} | |
cp( | |
src: PathLike, | |
dest: PathLike, | |
options?: CopyFileOptions, | |
): Promise<void>; | |
cp( | |
src: PathLike, | |
dest: PathLike, | |
options: CopyFileOptions, | |
callback: (err: Error | null) => void, | |
): void; | |
cp( | |
src: PathLike, | |
dest: PathLike, | |
callback: (err: Error | null) => void, | |
): void; | |
cp( | |
src: PathLike, | |
dest: PathLike, | |
options?: CopyFileOptions | ((err: Error | null) => void), | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof options === "function") { | |
[callback, options] = [options, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.cpSync(src, dest, options); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.cpSync(src, dest, options); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
statSync(p: PathLike): Stats { | |
const path = this.#resolve(p); | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
return node.toStats(this); | |
} | |
stat(p: PathLike): Promise<Stats>; | |
stat(p: PathLike, callback: (err: Error | null, stats?: Stats) => void): void; | |
stat( | |
p: PathLike, | |
callback?: (err: Error | null, stats?: Stats) => void, | |
): Promise<Stats> | void; | |
stat( | |
p: PathLike, | |
callback?: (err: Error | null, stats?: Stats) => void, | |
): void | Promise<Stats> { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
const stats = this.statSync(p); | |
resolve(stats); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
const stats = this.statSync(p); | |
setTimeout(() => callback(null, stats), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
readdirSync(p: PathLike, options?: ReadDirOptionsWithoutFileTypes): string[]; | |
readdirSync(p: PathLike, options: ReadDirOptionsWithFileTypes): Dirent[]; | |
readdirSync( | |
p: PathLike, | |
options?: ReadDirOptions, | |
): string[] | Dirent[]; | |
readdirSync(p: PathLike, options?: ReadDirOptions): string[] | Dirent[] { | |
const recursive = !!(options && options.recursive); | |
const withFileTypes = !!(options && (options as any).withFileTypes); | |
const path = this.#resolve(p); | |
const inodes = this.#get_inodes(); | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type !== "directory" || !node.children) { | |
throw new Error("ENOTDIR: Not a directory"); | |
} | |
// Helper function for recursive traversal. | |
const walk = (dirPath: PathLike): (string | Dirent)[] => { | |
const currentNode = this.#get_node(dirPath, inodes); | |
if (!currentNode || currentNode.type !== "directory") return []; | |
let results: (string | Dirent)[] = []; | |
// Get immediate entries. | |
const entries = Object.entries(currentNode.children); | |
for (const [name, inode] of entries) { | |
const childNode = inodes[inode]; | |
// Get full path for the child. | |
const { path: childPath, name: childName } = this.#get_inode_path( | |
inode, | |
inodes, | |
); | |
let entry: string | Dirent; | |
if (withFileTypes) { | |
entry = new Dirent(childName, childNode.mode, childPath); | |
} else { | |
entry = name; | |
} | |
results.push(entry); | |
// If recursive and this entry is a directory, traverse it. | |
if (recursive && childNode.type === "directory" && childNode.children) { | |
results = results.concat(walk(childPath)); | |
} | |
} | |
return results; | |
}; | |
if (recursive) { | |
return walk(path) as string[] | Dirent[]; | |
} else { | |
const result = Object.entries(node.children).map(([name, inode]) => { | |
const childNode = inodes[inode]; | |
if (withFileTypes) { | |
const { path, name } = this.#get_inode_path(inode, inodes); | |
return new Dirent(name, childNode.mode, path); | |
} else { | |
return name; | |
} | |
}) as string[] | Dirent[]; | |
return result; | |
} | |
} | |
readdir( | |
p: PathLike, | |
options?: ReadDirOptionsWithoutFileTypes, | |
): Promise<string[]>; | |
readdir(p: PathLike, options: ReadDirOptionsWithFileTypes): Promise<Dirent[]>; | |
readdir( | |
p: PathLike, | |
callback: (err: Error | null, entries?: string[]) => void, | |
): void; | |
readdir( | |
p: PathLike, | |
options: ReadDirOptionsWithFileTypes, | |
callback: (err: Error | null, entries?: Dirent[]) => void, | |
): void; | |
readdir( | |
p: PathLike, | |
options: ReadDirOptionsWithoutFileTypes, | |
callback: (err: Error | null, entries?: string[]) => void, | |
): void; | |
readdir( | |
p: PathLike, | |
// deno-lint-ignore no-explicit-any | |
options?: ReadDirOptions | ((err: Error | null, entries?: any[]) => void), | |
// deno-lint-ignore no-explicit-any | |
callback?: (err: Error | null, entries?: any[]) => void, | |
): Promise<string[] | Dirent[]> | void { | |
if (typeof options === "function") { | |
[callback, options] = [options, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
const entries = this.readdirSync(p, options); | |
resolve(entries); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
const entries = this.readdirSync(p, options); | |
setTimeout(() => callback(null, entries), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
// --- realpath --- | |
realpathSync(p: PathLike): string { | |
let path = this.#resolve(p); | |
const inodes = this.#get_inodes(); | |
const seen = new Set<string>(); | |
while (true) { | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type === "symlink") { | |
if (seen.has(path)) { | |
throw new Error("ELOOP: Too many symbolic links encountered"); | |
} | |
seen.add(path); | |
path = this.#resolve(node.target!); | |
} else { | |
break; | |
} | |
} | |
return path; | |
} | |
realpath(p: PathLike): Promise<string>; | |
realpath( | |
p: PathLike, | |
callback: (err: Error | null, resolved?: string) => void, | |
): void; | |
realpath( | |
p: PathLike, | |
callback?: (err: Error | null, resolved?: string) => void, | |
): Promise<string> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
const resolved = this.realpathSync(p); | |
resolve(resolved); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
const resolved = this.realpathSync(p); | |
setTimeout(() => callback(null, resolved), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
// --- symlink --- | |
symlinkSync(target: PathLike, p: PathLike): void { | |
const resolvedPath = this.#resolve(p); | |
this.#update_inodes((inodes) => { | |
const parentPath = VFS.dirname(resolvedPath, this.#sep); | |
const name = VFS.basename(resolvedPath, this.#sep); | |
const parent = this.#get_node(parentPath, inodes); | |
if (!parent || parent.type !== "directory" || !parent.children) { | |
throw new Error("ENOENT: Parent directory does not exist"); | |
} | |
const newInode = this.#get_next_inode(); | |
const now = Date.now(); | |
const node = Node.from({ | |
type: "symlink", | |
inode: newInode, | |
mode: 0o777, | |
atime: now, | |
mtime: now, | |
ctime: now, | |
size: resolvedPath.length, | |
data: resolvedPath, | |
target: this.#resolve(target), | |
blksize: 4096, | |
blocks: 0, | |
}); | |
parent.children[name] = newInode; | |
inodes[newInode] = node; | |
this.#set_next_inode(newInode + 1); | |
}); | |
} | |
symlink(target: PathLike, p: PathLike): Promise<void>; | |
symlink( | |
target: PathLike, | |
p: PathLike, | |
callback: (err: Error | null) => void, | |
): void; | |
symlink( | |
target: PathLike, | |
p: PathLike, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.symlinkSync(target, p); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.symlinkSync(target, p); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
// --- link --- | |
linkSync(existingPath: PathLike, newPath: PathLike): void { | |
const resolvedExisting = this.#resolve(existingPath); | |
const resolvedNew = this.#resolve(newPath); | |
this.#update_inodes((inodes) => { | |
const node = this.#get_node(resolvedExisting, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type === "directory") { | |
throw new Error("EPERM: Operation not permitted on directories"); | |
} | |
const parentPath = VFS.dirname(resolvedNew, this.#sep); | |
const name = VFS.basename(resolvedNew, this.#sep); | |
const parent = this.#get_node(parentPath, inodes); | |
if (!parent || parent.type !== "directory" || !parent.children) { | |
throw new Error("ENOENT: Parent directory does not exist"); | |
} | |
parent.children[name] = node.inode; | |
}); | |
} | |
link(existingPath: PathLike, newPath: PathLike): Promise<void>; | |
link( | |
existingPath: PathLike, | |
newPath: PathLike, | |
callback: (err: Error | null) => void, | |
): void; | |
link( | |
existingPath: PathLike, | |
newPath: PathLike, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.linkSync(existingPath, newPath); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.linkSync(existingPath, newPath); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
// --- open --- | |
openSync(p: PathLike, flags: OpenMode, mode?: number): FileHandle { | |
flags = VFS.normalizeOpenMode(flags); | |
const path = this.#resolve(p); | |
let fd = -1; | |
this.#update_inodes((inodes) => { | |
let node = this.#get_node(path, inodes); | |
mode ??= 0o666; | |
if (hasFlag(flags, "O_RDONLY")) { | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
if (node.type !== "file") { | |
throw new Error("EISDIR: Cannot open a directory for reading"); | |
} | |
node.atime = Date.now(); | |
this.#set_inodes(inodes); | |
} | |
if ( | |
hasFlag(flags, "O_APPEND") || hasFlag(flags, "O_CREAT") || | |
hasFlag(flags, "O_RDWR") || hasFlag(flags, "O_WRONLY") | |
) { | |
if (!node) { | |
if (!hasFlag(flags, "O_CREAT")) { | |
throw new ReferenceError("ENOENT: No such file or directory"); | |
} | |
const parentPath = VFS.dirname(path, this.#sep); | |
const name = VFS.basename(path, this.#sep); | |
const parent = this.#get_node(parentPath, inodes); | |
if (!parent || parent.type !== "directory" || !parent.children) { | |
throw new Error("ENOENT: Parent directory does not exist"); | |
} | |
const inode = this.#get_next_inode(); | |
node = Node.from({ | |
inode: inode, | |
type: "file", | |
mode: mode || 0o666, | |
atime: Date.now(), | |
mtime: Date.now(), | |
ctime: Date.now(), | |
size: 0, | |
data: "", | |
}); | |
parent.children[name] = inode; | |
inodes[inode] = node!; | |
this.#set_next_inode(inode + 1); | |
} else if (node.type !== "file") { | |
throw new Error( | |
"EISDIR: Cannot open a non-file resource for writing", | |
); | |
} else { | |
node.data = ""; | |
node.size = 0; | |
node.mtime = Date.now(); | |
} | |
} | |
const fdTable = this.#get_fd_table(); | |
fd = this.#get_next_fd(); | |
fdTable[fd] = new FileHandle( | |
this, | |
fd, | |
path, | |
flags, | |
node, | |
mode, | |
); | |
this.#set_fd_table(fdTable); | |
this.#set_next_fd(fd + 1); | |
}); | |
if (fd < 0) throw new Error("EMFILE: Too many open files"); | |
return this.#get_fd_table()[fd]; | |
} | |
open(p: PathLike, flags: string, mode?: number): Promise<FileHandle>; | |
open( | |
p: PathLike, | |
flags: string, | |
mode: number | undefined, | |
callback: (err: Error | null, handle?: FileHandle) => void, | |
): void; | |
open( | |
p: PathLike, | |
flags: string, | |
mode?: number | undefined, | |
callback?: (err: Error | null, fd?: FileHandle) => void, | |
): Promise<FileHandle> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
resolve(this.openSync(p, flags, mode)); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
setTimeout(() => callback(null, this.openSync(p, flags, mode)), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
// --- close --- | |
closeSync(fd: number): void { | |
const fdTable = this.#get_fd_table(); | |
const desc = fdTable[fd]; | |
if (!desc) throw new Error("EBADF: Bad file descriptor"); | |
try { | |
desc.closeSync(); | |
} catch { | |
/* ignore */ | |
} finally { | |
fdTable[fd] = null!; | |
delete fdTable[fd]; | |
this.#set_fd_table(fdTable); | |
} | |
} | |
close(fd: number): Promise<void>; | |
close(fd: number, callback: (err: Error | null) => void): void; | |
close( | |
fd: number, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.closeSync(fd); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.closeSync(fd); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
// --- glob --- | |
globSync(pattern: string | string[]): string[]; | |
globSync( | |
pattern: string | string[], | |
options?: GlobOptionsWithoutFileTypes, | |
): string[]; | |
globSync( | |
pattern: string | string[], | |
options: GlobOptionsWithFileTypes, | |
): Dirent[]; | |
globSync( | |
pattern: string | string[], | |
options?: GlobOptions, | |
): string[] | Dirent[]; | |
globSync( | |
pattern: string | string[], | |
options?: GlobOptionsWithFileTypes | GlobOptionsWithoutFileTypes, | |
): (string | Dirent)[] { | |
if (typeof pattern === "string") { | |
const absPattern = this.#resolve(pattern); | |
const fixedPrefix = VFS.#extract_fixed_prefix(absPattern, this.#sep); | |
let startingPath = fixedPrefix; | |
try { | |
const stats = this.statSync(startingPath); | |
if (!stats.isDirectory()) { | |
startingPath = VFS.dirname(startingPath, this.#sep); | |
} | |
} catch { | |
return []; | |
} | |
const entries = this.#walk_dir(startingPath); | |
const regex = VFS.#compile_glob(absPattern); | |
return entries.filter((e) => regex.test(e.path)).map((e) => { | |
if (options?.withFileTypes) return e; | |
return e.path; | |
}) as string[] | Dirent[]; | |
} else if (Array.isArray(pattern)) { | |
return pattern.flatMap<string | Dirent>((p) => | |
this.globSync(p, options as GlobOptions) | |
); | |
} else { | |
throw new Error("Invalid pattern type: " + typeof pattern); | |
} | |
} | |
glob( | |
pattern: string | string[], | |
options?: GlobOptionsWithoutFileTypes, | |
): Promise<string[]>; | |
glob( | |
pattern: string | string[], | |
options: GlobOptionsWithFileTypes, | |
): Promise<Dirent[]>; | |
glob( | |
pattern: string | string[], | |
options: GlobOptionsWithFileTypes, | |
callback: (err: Error | null, entries?: Dirent[]) => void, | |
): void; | |
glob( | |
pattern: string | string[], | |
options: GlobOptions | GlobOptionsWithoutFileTypes, | |
callback: (err: Error | null, entries?: string[]) => void, | |
): void; | |
glob( | |
pattern: string | string[], | |
callback: (err: Error | null, entries?: string[]) => void, | |
): void; | |
glob( | |
pattern: string | string[], | |
// deno-lint-ignore no-explicit-any | |
options?: | |
| GlobOptions | |
| GlobOptionsWithoutFileTypes | |
| GlobOptionsWithFileTypes | |
| ((err: Error | null, entries?: any[]) => void), | |
// deno-lint-ignore no-explicit-any | |
callback?: (err: Error | null, entries?: any[]) => void, | |
): Promise<string[] | Dirent[]> | void { | |
if (typeof options === "function") { | |
[callback, options] = [options, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
const entries = this.globSync(pattern, options as GlobOptions); | |
resolve(entries); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
const entries = this.globSync(pattern, options as GlobOptions); | |
setTimeout(() => callback(null, entries), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
chmodSync( | |
p: PathLike, | |
mode: number, | |
options?: { recursive?: boolean }, | |
): void { | |
const path = this.#resolve(p); | |
this.#update_inodes((inodes) => { | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
node.mode = mode; | |
node.ctime = Date.now(); | |
if (options?.recursive && node.type === "directory") { | |
const entries = this.readdirSync(path, { withFileTypes: true }); | |
for (const entry of entries) { | |
const childPath = this.#join(path, entry.name); | |
if (entry.isDirectory()) { | |
this.chmodSync(childPath, mode, options); | |
} else { | |
const childNode = this.#get_node(childPath, inodes); | |
if (childNode) { | |
childNode.mode = mode; | |
childNode.ctime = Date.now(); | |
} | |
} | |
} | |
} | |
}); | |
} | |
chmod(p: PathLike, mode: number, callback: (err: Error | null) => void): void; | |
chmod(p: PathLike, mode: number): Promise<void>; | |
chmod( | |
p: PathLike, | |
mode: number, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.chmodSync(p, mode); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.chmodSync(p, mode); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
chownSync(p: PathLike, uid: number, gid: number): void { | |
const path = this.#resolve(p); | |
this.#update_inodes((inodes) => { | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
node.uid = uid; | |
node.gid = gid; | |
node.ctime = Date.now(); | |
}); | |
} | |
chown( | |
p: PathLike, | |
uid: number, | |
gid: number, | |
callback: (err: Error | null) => void, | |
): void; | |
chown(p: PathLike, uid: number, gid: number): Promise<void>; | |
chown( | |
p: PathLike, | |
uid: number, | |
gid: number, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.chownSync(p, uid, gid); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.chownSync(p, uid, gid); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
utimesSync(p: PathLike, atime: Date | number, mtime: Date | number): void { | |
const path = this.#resolve(p); | |
const atimeNum = typeof atime === "number" ? atime : atime.getTime(); | |
const mtimeNum = typeof mtime === "number" ? mtime : mtime.getTime(); | |
this.#update_inodes((inodes) => { | |
const node = this.#get_node(path, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
node.atime = atimeNum; | |
node.mtime = mtimeNum; | |
node.ctime = Date.now(); | |
}); | |
} | |
utimes( | |
p: PathLike, | |
atime: Date | number, | |
mtime: Date | number, | |
callback: (err: Error | null) => void, | |
): void; | |
utimes( | |
p: PathLike, | |
atime: Date | number, | |
mtime: Date | number, | |
): Promise<void>; | |
utimes( | |
p: PathLike, | |
atime: Date | number, | |
mtime: Date | number, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.utimesSync(p, atime, mtime); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.utimesSync(p, atime, mtime); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
copyFileSync(src: PathLike, dest: PathLike, flags?: number): void { | |
const srcPath = this.#resolve(src); | |
const destPath = this.#resolve(dest); | |
const inodes = this.#get_inodes(); | |
const srcNode = this.#get_node(srcPath, inodes); | |
if (!srcNode) throw new Error("ENOENT: Source file does not exist"); | |
if (srcNode.type !== "file") { | |
throw new Error("EINVAL: Source is not a file"); | |
} | |
const destNode = this.#get_node(destPath, inodes); | |
if (destNode && flags === VFS.#COPYFILE_EXCL) { | |
throw new Error("EEXIST: Destination file exists"); | |
} | |
const parentPath = VFS.dirname(destPath, this.#sep); | |
const destName = VFS.basename(destPath, this.#sep); | |
const parent = this.#get_node(parentPath, inodes); | |
if (!parent || parent.type !== "directory" || !parent.children) { | |
throw new Error("ENOENT: Destination parent directory does not exist"); | |
} | |
const newInode = this.#get_next_inode(); | |
const now = Date.now(); | |
const newNode = Node.from({ | |
inode: newInode, | |
type: "file", | |
mode: srcNode.mode, | |
nlink: 1, | |
dev: srcNode.dev ?? 0, | |
rdev: srcNode.rdev ?? 0, | |
uid: srcNode.uid ?? 0, | |
gid: srcNode.gid ?? 0, | |
blksize: srcNode.blksize ?? 4096, | |
blocks: srcNode.blocks ?? Math.ceil(srcNode.size / 512), | |
data: srcNode.data, | |
atime: srcNode.atime, | |
mtime: srcNode.mtime, | |
ctime: now, | |
size: srcNode.size, | |
}); | |
parent.children[destName] = newInode; | |
inodes[newInode] = newNode; | |
this.#set_next_inode(newInode + 1); | |
this.#set_inodes(inodes); | |
} | |
copyFile( | |
src: PathLike, | |
dest: PathLike, | |
flags?: number, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void; | |
copyFile( | |
src: PathLike, | |
dest: PathLike, | |
callback: (err: Error | null) => void, | |
): void; | |
copyFile( | |
src: PathLike, | |
dest: PathLike, | |
flags?: number | ((err: Error | null) => void), | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof flags === "function") { | |
[callback, flags] = [flags, undefined]; | |
} | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.copyFileSync(src, dest, flags); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.copyFileSync(src, dest, flags); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
renameSync(oldPath: PathLike, newPath: PathLike): void { | |
const resolvedOld = this.#resolve(oldPath); | |
const resolvedNew = this.#resolve(newPath); | |
this.#update_inodes((inodes) => { | |
const node = this.#get_node(resolvedOld, inodes); | |
if (!node) throw new Error("ENOENT: No such file or directory"); | |
const oldParentPath = VFS.dirname(resolvedOld, this.#sep); | |
const oldName = VFS.basename(resolvedOld, this.#sep); | |
const oldParent = this.#get_node(oldParentPath, inodes); | |
if (!oldParent || oldParent.type !== "directory" || !oldParent.children) { | |
throw new Error("ENOENT: Old parent directory does not exist"); | |
} | |
delete oldParent.children[oldName]; | |
const newParentPath = VFS.dirname(resolvedNew, this.#sep); | |
const newName = VFS.basename(resolvedNew, this.#sep); | |
const newParent = this.#get_node(newParentPath, inodes); | |
if (!newParent || newParent.type !== "directory" || !newParent.children) { | |
throw new Error("ENOENT: New parent directory does not exist"); | |
} | |
newParent.children[newName] = node.inode; | |
node.ctime = Date.now(); | |
}); | |
} | |
rename( | |
oldPath: PathLike, | |
newPath: PathLike, | |
callback: (err: Error | null) => void, | |
): void; | |
rename(oldPath: PathLike, newPath: PathLike): Promise<void>; | |
rename( | |
oldPath: PathLike, | |
newPath: PathLike, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
if (typeof callback !== "function") { | |
return new Promise((resolve, reject) => { | |
try { | |
this.renameSync(oldPath, newPath); | |
resolve(); | |
} catch (err) { | |
reject(err as Error); | |
} | |
}); | |
} else { | |
try { | |
this.renameSync(oldPath, newPath); | |
setTimeout(() => callback(null), 0); | |
} catch (err) { | |
setTimeout(() => callback(err as Error), 0); | |
} | |
} | |
} | |
/** | |
* moveSync is an alias for renameSync. | |
*/ | |
moveSync(oldPath: PathLike, newPath: PathLike): void { | |
this.renameSync(oldPath, newPath); | |
} | |
move( | |
oldPath: PathLike, | |
newPath: PathLike, | |
callback: (err: Error | null) => void, | |
): void; | |
move(oldPath: PathLike, newPath: PathLike): Promise<void>; | |
move( | |
oldPath: PathLike, | |
newPath: PathLike, | |
callback?: (err: Error | null) => void, | |
): Promise<void> | void { | |
return this.rename(oldPath, newPath, callback as never); | |
} | |
// #endregion public | |
} | |
// #region misc helpers | |
const MAX_FLAG = Object.entries(VFS.constants) | |
.reduce((a, [k, v]) => k[0] === "O" ? Math.max(a, v) : a, 0); | |
function hasFlag< | |
F extends typeof VFS.constants[K], | |
K extends keyof typeof VFS.constants = keyof typeof VFS.constants, | |
>(mode: number, f: K | F, exact?: boolean): mode is F { | |
mode = (+mode >>> 0) & MAX_FLAG; | |
const flag = typeof f === "number" ? f : VFS.constants[f]; | |
// respect the exact flag to allow for multi-modal flag combinations | |
// (e.g. checking for O_RDONLY should still return true when the mode | |
// is O_RDWR; but if exact is true, it should only return true for O_RDONLY) | |
return exact ? (mode & flag) === flag : (mode & flag) !== 0; | |
} | |
// function toFlag< | |
// F extends typeof VFS.constants[K], | |
// K extends keyof typeof VFS.constants = keyof typeof VFS.constants, | |
// >(mode: number, f: K | F, alt?: NoInfer<K> | NoInfer<F>): F { | |
// mode = (+mode >>> 0) & MAX_FLAG; | |
// const flag = typeof f === "number" ? f : VFS.constants[f]; | |
// const fallback = typeof alt === "number" | |
// ? alt | |
// : VFS.constants[alt ?? "O_RDONLY"]; | |
// return ((mode & flag) ? flag : fallback) as F; | |
// } | |
// #endregion misc helpers | |
// #endregion VFS | |
// #region Errors | |
export class ErrnoException extends Error { | |
constructor( | |
readonly errno: number, | |
readonly code: string, | |
readonly syscall: string, | |
readonly path?: string, | |
) { | |
super(`Error ${code}: ${syscall} ${path}`); | |
this.name = "ErrnoException"; | |
} | |
} | |
const ERRNO_CODES = {}; | |
// #endregion Errors | |
// #endregion Virtual File System (VFS) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment