@zenfs/core
Version:
A filesystem, anywhere
663 lines (581 loc) • 19 kB
text/typescript
import type { FileReadResult } from 'node:fs/promises';
import { O_APPEND, O_CREAT, O_EXCL, O_RDONLY, O_RDWR, O_SYNC, O_TRUNC, O_WRONLY, S_IFMT, size_max } from './emulation/constants.js';
import { config } from './emulation/config.js';
import { Errno, ErrnoError } from './error.js';
import type { FileSystem } from './filesystem.js';
import './polyfills.js';
import { Stats, type FileType } from './stats.js';
/**
Typescript does not include a type declaration for resizable array buffers.
It has been standardized into ECMAScript though
@todo Remove this if TS adds them to lib declarations
*/
declare global {
interface ArrayBuffer {
readonly resizable: boolean;
readonly maxByteLength?: number;
resize(newLength: number): void;
}
interface SharedArrayBuffer {
readonly resizable: boolean;
readonly maxByteLength?: number;
resize(newLength: number): void;
}
interface ArrayBufferConstructor {
new (byteLength: number, options: { maxByteLength?: number }): ArrayBuffer;
}
}
const validFlags = ['r', 'r+', 'rs', 'rs+', 'w', 'wx', 'w+', 'wx+', 'a', 'ax', 'a+', 'ax+'];
export function parseFlag(flag: string | number): string {
if (typeof flag === 'number') {
return flagToString(flag);
}
if (!validFlags.includes(flag)) {
throw new Error('Invalid flag string: ' + flag);
}
return flag;
}
export function flagToString(flag: number): string {
switch (flag) {
case O_RDONLY:
return 'r';
case O_RDONLY | O_SYNC:
return 'rs';
case O_RDWR:
return 'r+';
case O_RDWR | O_SYNC:
return 'rs+';
case O_TRUNC | O_CREAT | O_WRONLY:
return 'w';
case O_TRUNC | O_CREAT | O_WRONLY | O_EXCL:
return 'wx';
case O_TRUNC | O_CREAT | O_RDWR:
return 'w+';
case O_TRUNC | O_CREAT | O_RDWR | O_EXCL:
return 'wx+';
case O_APPEND | O_CREAT | O_WRONLY:
return 'a';
case O_APPEND | O_CREAT | O_WRONLY | O_EXCL:
return 'ax';
case O_APPEND | O_CREAT | O_RDWR:
return 'a+';
case O_APPEND | O_CREAT | O_RDWR | O_EXCL:
return 'ax+';
default:
throw new Error('Invalid flag number: ' + flag);
}
}
export function flagToNumber(flag: string): number {
switch (flag) {
case 'r':
return O_RDONLY;
case 'rs':
return O_RDONLY | O_SYNC;
case 'r+':
return O_RDWR;
case 'rs+':
return O_RDWR | O_SYNC;
case 'w':
return O_TRUNC | O_CREAT | O_WRONLY;
case 'wx':
return O_TRUNC | O_CREAT | O_WRONLY | O_EXCL;
case 'w+':
return O_TRUNC | O_CREAT | O_RDWR;
case 'wx+':
return O_TRUNC | O_CREAT | O_RDWR | O_EXCL;
case 'a':
return O_APPEND | O_CREAT | O_WRONLY;
case 'ax':
return O_APPEND | O_CREAT | O_WRONLY | O_EXCL;
case 'a+':
return O_APPEND | O_CREAT | O_RDWR;
case 'ax+':
return O_APPEND | O_CREAT | O_RDWR | O_EXCL;
default:
throw new Error('Invalid flag string: ' + flag);
}
}
/**
* Parses a flag as a mode (W_OK, R_OK, and/or X_OK)
* @param flag the flag to parse
*/
export function flagToMode(flag: string): number {
let mode = 0;
mode <<= 1;
mode += +isReadable(flag);
mode <<= 1;
mode += +isWriteable(flag);
mode <<= 1;
return mode;
}
export function isReadable(flag: string): boolean {
return flag.indexOf('r') !== -1 || flag.indexOf('+') !== -1;
}
export function isWriteable(flag: string): boolean {
return flag.indexOf('w') !== -1 || flag.indexOf('a') !== -1 || flag.indexOf('+') !== -1;
}
export function isTruncating(flag: string): boolean {
return flag.indexOf('w') !== -1;
}
export function isAppendable(flag: string): boolean {
return flag.indexOf('a') !== -1;
}
export function isSynchronous(flag: string): boolean {
return flag.indexOf('s') !== -1;
}
export function isExclusive(flag: string): boolean {
return flag.indexOf('x') !== -1;
}
export abstract class File {
public constructor(
/**
* @internal
* The file system that created the file
*/
public fs: FileSystem,
public readonly path: string
) {}
/**
* Get the current file position.
*/
public abstract position: number;
public abstract stat(): Promise<Stats>;
public abstract statSync(): Stats;
public abstract close(): Promise<void>;
public abstract closeSync(): void;
public [Symbol.asyncDispose](): Promise<void> {
return this.close();
}
public [Symbol.dispose](): void {
return this.closeSync();
}
public abstract truncate(len: number): Promise<void>;
public abstract truncateSync(len: number): void;
public abstract sync(): Promise<void>;
public abstract syncSync(): void;
/**
* Write buffer to the file.
* @param buffer Uint8Array containing the data to write to the file.
* @param offset Offset in the buffer to start reading data from.
* @param length The amount of bytes to write to the file.
* @param position Offset from the beginning of the file where this data should be written.
* If position is null, the data will be written at the current position.
* @returns Promise resolving to the new length of the buffer
*/
public abstract write(buffer: Uint8Array, offset?: number, length?: number, position?: number): Promise<number>;
/**
* Write buffer to the file.
* @param buffer Uint8Array containing the data to write to the file.
* @param offset Offset in the buffer to start reading data from.
* @param length The amount of bytes to write to the file.
* @param position Offset from the beginning of the file where this data should be written.
* If position is null, the data will be written at the current position.
*/
public abstract writeSync(buffer: Uint8Array, offset?: number, length?: number, position?: number): number;
/**
* Read data from the file.
* @param buffer The buffer that the data will be written to.
* @param offset The offset within the buffer where writing will start.
* @param length An integer specifying the number of bytes to read.
* @param position An integer specifying where to begin reading from in the file.
* If position is null, data will be read from the current file position.
* @returns Promise resolving to the new length of the buffer
*/
public abstract read<TBuffer extends NodeJS.ArrayBufferView>(buffer: TBuffer, offset?: number, length?: number, position?: number): Promise<FileReadResult<TBuffer>>;
/**
* Read data from the file.
* @param buffer The buffer that the data will be written to.
* @param offset The offset within the buffer where writing will start.
* @param length An integer specifying the number of bytes to read.
* @param position An integer specifying where to begin reading from in the file.
* If position is null, data will be read from the current file position.
*/
public abstract readSync(buffer: ArrayBufferView, offset?: number, length?: number, position?: number): number;
/**
* Default implementation maps to `sync`.
*/
public datasync(): Promise<void> {
return this.sync();
}
/**
* Default implementation maps to `syncSync`.
*/
public datasyncSync(): void {
return this.syncSync();
}
public abstract chown(uid: number, gid: number): Promise<void>;
public abstract chownSync(uid: number, gid: number): void;
public abstract chmod(mode: number): Promise<void>;
public abstract chmodSync(mode: number): void;
/**
* Change the file timestamps of the file.
*/
public abstract utimes(atime: Date, mtime: Date): Promise<void>;
/**
* Change the file timestamps of the file.
*/
public abstract utimesSync(atime: Date, mtime: Date): void;
/**
* Set the file type
* @internal
*/
public abstract _setType(type: FileType): Promise<void>;
/**
* Set the file type
* @internal
*/
public abstract _setTypeSync(type: FileType): void;
}
/**
* An implementation of `File` that operates completely in-memory.
* `PreloadFile`s are backed by a `Uint8Array`.
*/
export class PreloadFile<FS extends FileSystem> extends File {
/**
* Current position
*/
protected _position: number = 0;
/**
* Whether the file has changes which have not been written to the FS
*/
protected dirty: boolean = false;
/**
* Whether the file is open or closed
*/
protected closed: boolean = false;
/**
* Creates a file with `path` and, optionally, the given contents.
* Note that, if contents is specified, it will be mutated by the file.
*/
public constructor(
/**
* The file system that created the file.
* @internal
*/
public fs: FS,
path: string,
public readonly flag: string,
public readonly stats: Stats,
/**
* A buffer containing the entire contents of the file.
*/
protected _buffer: Uint8Array = new Uint8Array(new ArrayBuffer(0, fs.metadata().noResizableBuffers ? {} : { maxByteLength: size_max }))
) {
super(fs, path);
/*
Note:
This invariant is *not* maintained once the file starts getting modified.
It only actually matters if file is readable, as writeable modes may truncate/append to file.
*/
if (this.stats.size == _buffer.byteLength) {
return;
}
if (isReadable(this.flag)) {
throw new Error(`Size mismatch: buffer length ${_buffer.byteLength}, stats size ${this.stats.size}`);
}
this.dirty = true;
}
/**
* Get the underlying buffer for this file. Mutating not recommended and will mess up dirty tracking.
*/
public get buffer(): Uint8Array {
return this._buffer;
}
/**
* Get the current file position.
*
* We emulate the following bug mentioned in the Node documentation:
*
* 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.
* @returns The current file position.
*/
public get position(): number {
if (isAppendable(this.flag)) {
return this.stats.size;
}
return this._position;
}
public set position(value: number) {
this._position = value;
}
public async sync(): Promise<void> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.sync');
}
if (!this.dirty) {
return;
}
await this.fs.sync(this.path, this._buffer, this.stats);
this.dirty = false;
}
public syncSync(): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.sync');
}
if (!this.dirty) {
return;
}
this.fs.syncSync(this.path, this._buffer, this.stats);
this.dirty = false;
}
public async close(): Promise<void> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.close');
}
await this.sync();
this.dispose();
}
public closeSync(): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.close');
}
this.syncSync();
this.dispose();
}
/**
* Cleans up. This will *not* sync the file data to the FS
*/
protected dispose(force?: boolean): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.dispose');
}
if (this.dirty && !force) {
throw ErrnoError.With('EBUSY', this.path, 'File.dispose');
}
// @ts-expect-error 2790
delete this._buffer;
// @ts-expect-error 2790
delete this.stats;
this.closed = true;
}
public stat(): Promise<Stats> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.stat');
}
return Promise.resolve(new Stats(this.stats));
}
public statSync(): Stats {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.stat');
}
return new Stats(this.stats);
}
protected _truncate(length: number): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.truncate');
}
this.dirty = true;
if (!isWriteable(this.flag)) {
throw new ErrnoError(Errno.EPERM, 'File not opened with a writeable mode.');
}
this.stats.mtimeMs = Date.now();
if (length > this._buffer.length) {
const data = new Uint8Array(length - this._buffer.length);
// Write will set stats.size and handle syncing.
this.writeSync(data, 0, data.length, this._buffer.length);
return;
}
this.stats.size = length;
// Truncate.
this._buffer = this._buffer.slice(0, length);
}
public async truncate(length: number): Promise<void> {
this._truncate(length);
await this.sync();
}
public truncateSync(length: number): void {
this._truncate(length);
this.syncSync();
}
protected _write(buffer: Uint8Array, offset: number = 0, length: number = this.stats.size, position: number = this.position): number {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.write');
}
this.dirty = true;
if (!isWriteable(this.flag)) {
throw new ErrnoError(Errno.EPERM, 'File not opened with a writeable mode.');
}
const end = position + length;
if (end > this.stats.size) {
this.stats.size = end;
if (end > this._buffer.byteLength) {
if (this._buffer.buffer.resizable && this._buffer.buffer.maxByteLength! <= end) {
this._buffer.buffer.resize(end);
} else {
// Extend the buffer!
const newBuffer = new Uint8Array(new ArrayBuffer(end, this.fs.metadata().noResizableBuffers ? {} : { maxByteLength: size_max }));
newBuffer.set(this._buffer);
this._buffer = newBuffer;
}
}
}
const slice = buffer.slice(offset, offset + length);
this._buffer.set(slice, position);
this.stats.mtimeMs = Date.now();
this.position = position + slice.byteLength;
return slice.byteLength;
}
/**
* Write buffer to the file.
* @param buffer Uint8Array containing the data to write to the file.
* @param offset Offset in the buffer to start reading data from.
* @param length The amount of bytes to write to the file.
* @param position Offset from the beginning of the file where this data should be written.
* If position is null, the data will be written at the current position.
*/
public async write(buffer: Uint8Array, offset?: number, length?: number, position?: number): Promise<number> {
const bytesWritten = this._write(buffer, offset, length, position);
await this.sync();
return bytesWritten;
}
/**
* Write buffer to the file.
* @param buffer Uint8Array containing the data to write to the file.
* @param offset Offset in the buffer to start reading data from.
* @param length The amount of bytes to write to the file.
* @param position Offset from the beginning of the file where this data should be written.
* If position is null, the data will be written at the current position.
* @returns bytes written
*/
public writeSync(buffer: Uint8Array, offset: number = 0, length: number = this.stats.size, position: number = this.position): number {
const bytesWritten = this._write(buffer, offset, length, position);
this.syncSync();
return bytesWritten;
}
protected _read(buffer: ArrayBufferView, offset: number = 0, length: number = this.stats.size, position?: number): number {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.read');
}
if (!isReadable(this.flag)) {
throw new ErrnoError(Errno.EPERM, 'File not opened with a readable mode.');
}
this.dirty = true;
position ??= this.position;
let end = position + length;
if (end > this.stats.size) {
end = position + Math.max(this.stats.size - position, 0);
}
this.stats.atimeMs = Date.now();
this._position = end;
const bytesRead = end - position;
if (bytesRead == 0) {
// No copy/read. Return immediatly for better performance
return bytesRead;
}
new Uint8Array(buffer.buffer, offset, length).set(this._buffer.slice(position, end));
return bytesRead;
}
/**
* Read data from the file.
* @param buffer The buffer that the data will be written to.
* @param offset The offset within the buffer where writing will start.
* @param length An integer specifying the number of bytes to read.
* @param position An integer specifying where to begin reading from in the file.
* If position is null, data will be read from the current file position.
*/
public async read<TBuffer extends ArrayBufferView>(buffer: TBuffer, offset?: number, length?: number, position?: number): Promise<{ bytesRead: number; buffer: TBuffer }> {
const bytesRead = this._read(buffer, offset, length, position);
if (config.syncOnRead) await this.sync();
return { bytesRead, buffer };
}
/**
* Read data from the file.
* @param buffer The buffer that the data will be written to.
* @param offset The offset within the buffer where writing will start.
* @param length An integer specifying the number of bytes to read.
* @param position An integer specifying where to begin reading from in the file.
* If position is null, data will be read from the current file position.
* @returns number of bytes written
*/
public readSync(buffer: ArrayBufferView, offset?: number, length?: number, position?: number): number {
const bytesRead = this._read(buffer, offset, length, position);
if (config.syncOnRead) this.syncSync();
return bytesRead;
}
public async chmod(mode: number): Promise<void> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.chmod');
}
this.dirty = true;
this.stats.chmod(mode);
await this.sync();
}
public chmodSync(mode: number): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.chmod');
}
this.dirty = true;
this.stats.chmod(mode);
this.syncSync();
}
public async chown(uid: number, gid: number): Promise<void> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.chown');
}
this.dirty = true;
this.stats.chown(uid, gid);
await this.sync();
}
public chownSync(uid: number, gid: number): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.chown');
}
this.dirty = true;
this.stats.chown(uid, gid);
this.syncSync();
}
public async utimes(atime: Date, mtime: Date): Promise<void> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.utimes');
}
this.dirty = true;
this.stats.atime = atime;
this.stats.mtime = mtime;
await this.sync();
}
public utimesSync(atime: Date, mtime: Date): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File.utimes');
}
this.dirty = true;
this.stats.atime = atime;
this.stats.mtime = mtime;
this.syncSync();
}
public async _setType(type: FileType): Promise<void> {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File._setType');
}
this.dirty = true;
this.stats.mode = (this.stats.mode & ~S_IFMT) | type;
await this.sync();
}
public _setTypeSync(type: FileType): void {
if (this.closed) {
throw ErrnoError.With('EBADF', this.path, 'File._setType');
}
this.dirty = true;
this.stats.mode = (this.stats.mode & ~S_IFMT) | type;
this.syncSync();
}
public async [Symbol.asyncDispose]() {
await this.close();
}
public [Symbol.dispose]() {
this.closeSync();
}
}
/**
* For the file systems which do not sync to anything.
*/
export class NoSyncFile<T extends FileSystem> extends PreloadFile<T> {
public sync(): Promise<void> {
return Promise.resolve();
}
public syncSync(): void {}
public close(): Promise<void> {
return Promise.resolve();
}
public closeSync(): void {}
}