The packer images the web template bundles, every optional bundle, and an installation manifest instead of a dumped composition, and the Worker boots that profile through app-boot like dsh web. Package-local lazy client chunks move into content-addressed archives that the VFS fetches on first asynchronous read; client-modules reads chunks asynchronously. The Worker replaces node-addon-require-builtin with Node v24.18.0 internal-module interfaces and reports Node 24, so plugin metadata, the Cordis Loader, and HMR reach one cascaded loader backed by the Worker loader. Resolution failures carry Node error codes, the empty load cache lets HMR apply profile changes, and every optional bundle switches on and off live. Platform stubs throw ERR_FEATURE_UNAVAILABLE_ON_PLATFORM.
1190 lines
48 KiB
TypeScript
1190 lines
48 KiB
TypeScript
/**
|
|
* In-memory filesystem behind the worker's `node:fs` proxy. Contents come from
|
|
* the build-time image (see {@link loadVfsImage}); this remains the synchronous
|
|
* authority when an asynchronous durable sink mirrors selected subtrees.
|
|
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory
|
|
*/
|
|
import { dirname, join, normalize, resolve, SEP } from '../module-system/posix-path.ts'
|
|
import { IMAGE_OVERLAY_DIRECTORIES } from '../image-layout.ts'
|
|
import { parseTar } from './tar.ts'
|
|
import type {
|
|
Vfs, VfsBigIntStats, VfsDeferredArchive, VfsDir, VfsDirent, VfsEncoding, VfsError, VfsFileHandle, VfsMutation,
|
|
VfsOpenFile, VfsMutationListener, VfsMutationSink, VfsReadOptions, VfsSeedOptions, VfsStatOptions, VfsStats,
|
|
VfsWriteOptions,
|
|
} from './types.ts'
|
|
|
|
const decoder = new TextDecoder()
|
|
const encoder = new TextEncoder()
|
|
|
|
interface FileNode {
|
|
bytes: Uint8Array
|
|
mtimeMs: number
|
|
/** Permission bits (`0o777` mask), set at creation and changed only by `chmod`. */
|
|
mode: number
|
|
/** Stable identity shared by hard links and retained by open descriptors. */
|
|
identity?: bigint
|
|
/** One path normally, a Set only for hard links, or undefined after the final unlink. */
|
|
paths: string | Set<string> | undefined
|
|
}
|
|
|
|
/** One lazy archive's registration and its single in-flight fetch. */
|
|
interface DeferredArchiveState {
|
|
readonly name: string
|
|
readonly root: string
|
|
readonly load: () => Promise<Uint8Array>
|
|
/** Absolute registered path to its byte length. */
|
|
readonly files: ReadonlyMap<string, number>
|
|
/** The fetch every waiting reader shares; cleared when it fails so a later read retries. */
|
|
fetching: Promise<void> | undefined
|
|
}
|
|
|
|
/** A registered file whose bytes have not arrived. */
|
|
interface DeferredFile {
|
|
readonly size: number
|
|
readonly mode: number
|
|
readonly mtimeMs: number
|
|
readonly archive: DeferredArchiveState
|
|
}
|
|
|
|
/** Creation default for files, Node's `0o666` under the classic `022` umask. */
|
|
const DEFAULT_FILE_MODE = 0o644
|
|
|
|
/** Creation default for directories, Node's `0o777` under the classic `022` umask. */
|
|
const DEFAULT_DIRECTORY_MODE = 0o755
|
|
|
|
function fail(code: string, syscall: string, path: string, detail?: string): never {
|
|
const error = new Error(`${code}: ${detail ?? syscall} failed, ${syscall} '${path}'`) as VfsError
|
|
error.code = code
|
|
error.path = path
|
|
error.syscall = syscall
|
|
throw error
|
|
}
|
|
|
|
function encodingOf(options: VfsReadOptions): VfsEncoding | undefined {
|
|
if (options === null || options === undefined) return undefined
|
|
if (typeof options === 'string') return options
|
|
return options.encoding ?? undefined
|
|
}
|
|
|
|
// Permission bits are entry state: creation takes the caller's mode (or the
|
|
// umask-free default), `chmod` changes it, and both stat shapes report the
|
|
// stored value — the round-trip consumers like dsh-credentials-local's
|
|
// owner-only check rely on. The bits are never enforced: a single-owner
|
|
// filesystem reads and writes as its owner regardless, like root.
|
|
function statsOf(size: number, mtimeMs: number, directory: boolean, ino: bigint, mode: number): VfsStats {
|
|
return {
|
|
size,
|
|
ino: Number(ino),
|
|
mtimeMs,
|
|
ctimeMs: mtimeMs,
|
|
atimeMs: mtimeMs,
|
|
birthtimeMs: mtimeMs,
|
|
mtime: new Date(mtimeMs),
|
|
mode: (directory ? 0o040000 : 0o100000) | (mode & 0o777),
|
|
isFile: () => !directory,
|
|
isDirectory: () => directory,
|
|
isSymbolicLink: () => false,
|
|
isFIFO: () => false,
|
|
isSocket: () => false,
|
|
isBlockDevice: () => false,
|
|
isCharacterDevice: () => false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The same entry as {@link statsOf}, in the BigInt shape.
|
|
*
|
|
* Timestamps carry millisecond resolution scaled to nanoseconds, which is what
|
|
* the underlying `mtimeMs` holds; the VFS keeps that value strictly increasing
|
|
* per entry so two writes inside one millisecond still differ.
|
|
* @param size - Byte length; zero for a directory.
|
|
* @param mtimeMs - Modification time the entry carries.
|
|
* @param directory - Whether the entry is a directory.
|
|
* @param ino - Identity of the entry at this path.
|
|
* @param mode - Stored permission bits of the entry.
|
|
* @returns Stats in the shape Node returns under `{ bigint: true }`.
|
|
*/
|
|
function bigIntStatsOf(
|
|
size: number,
|
|
mtimeMs: number,
|
|
directory: boolean,
|
|
ino: bigint,
|
|
mode: number,
|
|
nlink = 1,
|
|
): VfsBigIntStats {
|
|
const milliseconds = BigInt(Math.trunc(mtimeMs))
|
|
const nanoseconds = milliseconds * 1_000_000n
|
|
const time = new Date(mtimeMs)
|
|
return {
|
|
size: BigInt(size),
|
|
mode: BigInt((directory ? 0o040000 : 0o100000) | (mode & 0o777)),
|
|
dev: 1n,
|
|
ino,
|
|
nlink: BigInt(nlink),
|
|
mtimeMs: milliseconds,
|
|
mtimeNs: nanoseconds,
|
|
ctimeMs: milliseconds,
|
|
ctimeNs: nanoseconds,
|
|
atimeMs: milliseconds,
|
|
atimeNs: nanoseconds,
|
|
birthtimeMs: milliseconds,
|
|
birthtimeNs: nanoseconds,
|
|
mtime: time,
|
|
ctime: time,
|
|
atime: time,
|
|
birthtime: time,
|
|
isFile: () => !directory,
|
|
isDirectory: () => directory,
|
|
isSymbolicLink: () => false,
|
|
isFIFO: () => false,
|
|
isSocket: () => false,
|
|
isBlockDevice: () => false,
|
|
isCharacterDevice: () => false,
|
|
}
|
|
}
|
|
|
|
interface OpenMode {
|
|
readonly readable: boolean
|
|
readonly writable: boolean
|
|
readonly append: boolean
|
|
readonly create: boolean
|
|
readonly truncate: boolean
|
|
readonly exclusive: boolean
|
|
}
|
|
|
|
/** Parse the Node string flags supported by the compatibility filesystem. */
|
|
function openMode(flags: string): OpenMode {
|
|
const base = flags[0]
|
|
const suffix = flags.slice(1).split('')
|
|
const validSuffix = suffix.every(flag => flag === '+' || flag === 'x' || flag === 's')
|
|
const uniqueSuffix = new Set(suffix).size === suffix.length
|
|
if ((base !== 'r' && base !== 'w' && base !== 'a') || !validSuffix || !uniqueSuffix
|
|
|| base === 'r' && flags.includes('x')) {
|
|
const error = new TypeError(`The argument 'flags' is invalid. Received '${flags}'`) as TypeError & { code: string }
|
|
error.code = 'ERR_INVALID_ARG_VALUE'
|
|
throw error
|
|
}
|
|
return {
|
|
readable: base === 'r' || flags.includes('+'),
|
|
writable: base !== 'r' || flags.includes('+'),
|
|
append: base === 'a',
|
|
create: base === 'w' || base === 'a',
|
|
truncate: base === 'w',
|
|
exclusive: flags.includes('x'),
|
|
}
|
|
}
|
|
|
|
/** Resize bytes exactly, preserving the prefix and zero-filling growth. */
|
|
function resize(bytes: Uint8Array, length: number): Uint8Array {
|
|
if (!Number.isSafeInteger(length) || length < 0) {
|
|
const error = new RangeError(`The value of "len" is out of range. It must be >= 0. Received ${String(length)}`) as RangeError & { code: string }
|
|
error.code = 'ERR_OUT_OF_RANGE'
|
|
throw error
|
|
}
|
|
const resized = new Uint8Array(length)
|
|
resized.set(bytes.subarray(0, length))
|
|
return resized
|
|
}
|
|
|
|
/** Construction inputs for {@link MemoryVfs}. */
|
|
export interface MemoryVfsOptions {
|
|
/** Durable write-behind observer; absent leaves the filesystem ephemeral. */
|
|
readonly sink?: VfsMutationSink
|
|
}
|
|
|
|
/**
|
|
* Filesystem held in two maps: one for file bytes, one for directories.
|
|
* Every path is normalized to an absolute POSIX path without a trailing
|
|
* separator, so callers may pass either form.
|
|
*/
|
|
export class MemoryVfs implements Vfs {
|
|
private readonly files = new Map<string, FileNode>()
|
|
/** Files a lazy archive supplies on their first asynchronous read. */
|
|
private readonly deferred = new Map<string, DeferredFile>()
|
|
private readonly directories = new Set<string>([SEP])
|
|
/** Directory permission bits; absence means {@link DEFAULT_DIRECTORY_MODE}. */
|
|
private readonly directoryModes = new Map<string, number>()
|
|
/** Directory mtimes advance when their immediate entry set changes. */
|
|
private readonly directoryMtimes = new Map<string, number>()
|
|
private readonly mutationListeners = new Set<VfsMutationListener>()
|
|
private readonly sink: VfsMutationSink | undefined
|
|
private temporaries = 0
|
|
// Directories retain path identities. File identities live on FileNode so
|
|
// descriptors, renames, and hard links continue to address the same file.
|
|
private readonly identities = new Map<string, bigint>()
|
|
private lastIdentity = 0n
|
|
|
|
/**
|
|
* Build the synchronous filesystem authority.
|
|
* @param options - Optional durable write-behind sink.
|
|
*/
|
|
constructor(options: MemoryVfsOptions = {}) {
|
|
this.sink = options.sink
|
|
}
|
|
|
|
/**
|
|
* Settle the durable sink without changing in-memory success.
|
|
* @returns A promise that resolves when all recorded mutations are stored.
|
|
*/
|
|
async flush(): Promise<void> {
|
|
await this.sink?.flush()
|
|
}
|
|
|
|
/**
|
|
* Observe committed runtime mutations. Image seeding is deliberately silent.
|
|
* @param listener - Consumer called after each successful mutation.
|
|
* @returns A disposer that prevents future calls.
|
|
*/
|
|
subscribe(listener: VfsMutationListener): () => void {
|
|
this.mutationListeners.add(listener)
|
|
return () => { this.mutationListeners.delete(listener) }
|
|
}
|
|
|
|
/** Publish after state changes; one faulty observer cannot roll back a write. */
|
|
private publish(mutation: VfsMutation): void {
|
|
const observers: VfsMutationListener[] = [
|
|
...(this.sink === undefined ? [] : [(change: VfsMutation): void => { this.sink?.record(change) }]),
|
|
...this.mutationListeners,
|
|
]
|
|
for (const listener of observers) {
|
|
try {
|
|
listener(mutation)
|
|
} catch (error) {
|
|
console.error('webworker vfs: mutation observer failed', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Promise face mirroring `node:fs/promises` for the methods the roster uses. */
|
|
readonly promises = {
|
|
readFile: async (path: string, options?: VfsReadOptions): Promise<string | Uint8Array> => {
|
|
await this.whenReadable(path)
|
|
return this.readFileSync(path, options)
|
|
},
|
|
writeFile: async (path: string, data: string | Uint8Array, options?: VfsWriteOptions): Promise<void> => {
|
|
this.writeFileSync(path, data, options)
|
|
},
|
|
appendFile: async (path: string, data: string | Uint8Array): Promise<void> => { this.appendFileSync(path, data) },
|
|
mkdir: async (path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined> =>
|
|
this.mkdirSync(path, options),
|
|
readdir: async (path: string, options?: { withFileTypes?: boolean }): Promise<string[] & VfsDirent[]> =>
|
|
this.readdirSync(path, options),
|
|
stat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
|
|
lstat: async (path: string, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(path, options),
|
|
realpath: async (path: string): Promise<string> => this.realpathSync(path),
|
|
rename: async (from: string, to: string): Promise<void> => { this.renameSync(from, to) },
|
|
unlink: async (path: string): Promise<void> => { this.unlinkSync(path) },
|
|
rm: async (path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void> => { this.rmSync(path, options) },
|
|
mkdtemp: async (prefix: string): Promise<string> => this.mkdtempSync(prefix),
|
|
link: async (existing: string, next: string): Promise<void> => { this.linkSync(existing, next) },
|
|
truncate: async (path: string, length?: number): Promise<void> => { this.truncateSync(path, length) },
|
|
chmod: async (path: string, mode: number): Promise<void> => { this.chmodSync(path, mode) },
|
|
opendir: async (path: string): Promise<VfsDir> => this.opendir(path),
|
|
open: async (path: string, flags?: string, mode?: number): Promise<VfsFileHandle> => {
|
|
await this.whenReadable(path)
|
|
return this.open(path, flags, mode)
|
|
},
|
|
/** Resolves for any existing path: the VFS grants read and write to everything it holds. */
|
|
access: async (path: string): Promise<void> => {
|
|
if (!this.existsSync(path)) fail('ENOENT', 'access', normalize(resolve(path)))
|
|
},
|
|
}
|
|
|
|
/** @returns Absolute path with no trailing separator. */
|
|
private key(path: string): string {
|
|
const absolute = normalize(resolve(path))
|
|
return absolute.length > 1 && absolute.endsWith(SEP) ? absolute.slice(0, -1) : absolute
|
|
}
|
|
|
|
/**
|
|
* Register files whose bytes arrive with one archive on the first
|
|
* asynchronous read of any of them. Their parent directories are created now.
|
|
* @param archive - Archive name, mount root, registered files, and fetch.
|
|
* @throws When a registered path already holds a file, directory, or another registration.
|
|
*/
|
|
deferArchive(archive: VfsDeferredArchive): void {
|
|
const root = this.key(archive.root)
|
|
const files = new Map<string, number>()
|
|
for (const file of archive.files) {
|
|
const target = this.key(join(root, file.path))
|
|
if (this.files.has(target) || this.directories.has(target) || this.deferred.has(target) || files.has(target)) {
|
|
throw new Error(`webworker vfs: ${target} is already registered, so lazy archive ${archive.name} cannot supply it`)
|
|
}
|
|
files.set(target, file.size)
|
|
}
|
|
const state: DeferredArchiveState = { name: archive.name, root, load: () => archive.load(), files, fetching: undefined }
|
|
const mtimeMs = Date.now()
|
|
for (const [target, size] of files) {
|
|
this.seedDirectory(dirname(target))
|
|
this.deferred.set(target, { size, mode: DEFAULT_FILE_MODE, mtimeMs, archive: state })
|
|
this.touchDirectory(dirname(target))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the lazy archive that supplies a registered path. Concurrent readers
|
|
* of one archive share one fetch; a failed fetch leaves its files registered,
|
|
* so the next read fetches again.
|
|
* @param path - Path about to be read asynchronously.
|
|
* @returns A promise that settles once the archive arrived, or undefined when the path needs no fetch.
|
|
*/
|
|
whenReadable(path: string): Promise<void> | undefined {
|
|
const file = this.deferred.get(this.key(path))
|
|
if (file === undefined) return undefined
|
|
file.archive.fetching ??= this.fetchArchive(file.archive)
|
|
return file.archive.fetching
|
|
}
|
|
|
|
/** Fetch, validate against the registration, and mount one archive's files. */
|
|
private async fetchArchive(state: DeferredArchiveState): Promise<void> {
|
|
try {
|
|
let archive: Uint8Array
|
|
try {
|
|
archive = await state.load()
|
|
} catch (error) {
|
|
throw new Error(`webworker vfs: lazy archive ${state.name} could not be fetched`, { cause: error })
|
|
}
|
|
const entries = new Map<string, Uint8Array>()
|
|
for (const entry of parseTar(archive)) {
|
|
if (entry.directory) continue
|
|
const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
|
|
const target = this.key(join(state.root, relativeName))
|
|
const size = state.files.get(target)
|
|
if (size === undefined) {
|
|
throw new Error(`webworker vfs: lazy archive ${state.name} carries unregistered entry "${relativeName}"`)
|
|
}
|
|
if (entry.bytes.length !== size) {
|
|
throw new Error(`webworker vfs: lazy archive ${state.name} holds ${String(entry.bytes.length)} bytes for ${target}, registered as ${String(size)}`)
|
|
}
|
|
entries.set(target, entry.bytes)
|
|
}
|
|
for (const target of state.files.keys()) {
|
|
if (!entries.has(target)) throw new Error(`webworker vfs: lazy archive ${state.name} lacks registered file ${target}`)
|
|
}
|
|
for (const [target, bytes] of entries) {
|
|
const file = this.deferred.get(target)
|
|
// A file removed or replaced after registration keeps its current state.
|
|
if (file?.archive !== state) continue
|
|
this.deferred.delete(target)
|
|
// The path identity observed while registered becomes the file identity.
|
|
const identity = this.identities.get(target)
|
|
this.identities.delete(target)
|
|
this.setFile(target, {
|
|
bytes, mtimeMs: file.mtimeMs, mode: file.mode, paths: undefined, ...identity === undefined ? {} : { identity },
|
|
})
|
|
}
|
|
} catch (error) {
|
|
state.fetching = undefined
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** Refuse a synchronous operation on a registered file whose bytes have not arrived. */
|
|
private requireArrived(syscall: string, target: string): void {
|
|
const file = this.deferred.get(target)
|
|
if (file === undefined) return
|
|
const error = new Error(
|
|
`ERR_DSH_VFS_DEFERRED: ${syscall} cannot wait for '${target}', which lazy archive ${file.archive.name} `
|
|
+ 'supplies on its first asynchronous read',
|
|
) as VfsError
|
|
error.code = 'ERR_DSH_VFS_DEFERRED'
|
|
error.path = target
|
|
error.syscall = syscall
|
|
throw error
|
|
}
|
|
|
|
/**
|
|
* Read a file.
|
|
* @param path - File path.
|
|
* @param options - `'utf8'` or `{encoding}` for text; omitted for bytes.
|
|
* @returns Text or a copy-free view of the stored bytes.
|
|
*/
|
|
readFileSync(path: string, options?: VfsReadOptions): string | Uint8Array {
|
|
const target = this.key(path)
|
|
this.requireArrived('open', target)
|
|
const node = this.files.get(target)
|
|
if (node === undefined) {
|
|
if (this.directories.has(target)) fail('EISDIR', 'read', target)
|
|
fail('ENOENT', 'open', target)
|
|
}
|
|
return encodingOf(options) === undefined ? node.bytes : decoder.decode(node.bytes)
|
|
}
|
|
|
|
/**
|
|
* Report whether a path exists.
|
|
* @param path - Path to test.
|
|
* @returns True for files and directories.
|
|
*/
|
|
existsSync(path: string): boolean {
|
|
const target = this.key(path)
|
|
return this.files.has(target) || this.directories.has(target) || this.deferred.has(target)
|
|
}
|
|
|
|
/**
|
|
* Stat a path.
|
|
* @param path - Path to stat.
|
|
* @param options - `bigint` selects the BigInt stats Node returns for it.
|
|
* @returns Stats for the file or directory.
|
|
*/
|
|
statSync(path: string, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
|
|
const target = this.key(path)
|
|
const node = this.files.get(target)
|
|
const deferred = this.deferred.get(target)
|
|
const [size, mtimeMs, directory, mode] = node !== undefined
|
|
? [node.bytes.length, node.mtimeMs, false, node.mode] as const
|
|
: deferred !== undefined
|
|
? [deferred.size, deferred.mtimeMs, false, deferred.mode] as const
|
|
: this.directories.has(target)
|
|
? [0, this.directoryMtimes.get(target) ?? 0, true, this.directoryModes.get(target) ?? DEFAULT_DIRECTORY_MODE] as const
|
|
: fail('ENOENT', 'stat', target)
|
|
const identity = node === undefined ? this.identityOf(target) : this.identityOfFile(node)
|
|
return options?.bigint === true
|
|
? bigIntStatsOf(size, mtimeMs, directory, identity, mode, node === undefined ? 1 : this.fileLinkCount(node))
|
|
: statsOf(size, mtimeMs, directory, identity, mode)
|
|
}
|
|
|
|
/** @returns Stats in the plain shape, for internal callers that read `size`/`mtimeMs`. */
|
|
private plainStats(path: string): VfsStats {
|
|
return this.statSync(path) as VfsStats
|
|
}
|
|
|
|
/** @returns The stable identity of an existing path, assigning one on first observation. */
|
|
private identityOf(target: string): bigint {
|
|
const existing = this.identities.get(target)
|
|
if (existing !== undefined) return existing
|
|
this.lastIdentity += 1n
|
|
this.identities.set(target, this.lastIdentity)
|
|
return this.lastIdentity
|
|
}
|
|
|
|
/** @returns The inode-like identity retained by a file node across names. */
|
|
private identityOfFile(node: FileNode): bigint {
|
|
if (node.identity !== undefined) return node.identity
|
|
this.lastIdentity += 1n
|
|
node.identity = this.lastIdentity
|
|
return node.identity
|
|
}
|
|
|
|
/** @returns The number of names currently linked to one file node. */
|
|
private fileLinkCount(node: FileNode): number {
|
|
return typeof node.paths === 'string' ? 1 : node.paths?.size ?? 0
|
|
}
|
|
|
|
/** Add one map name, promoting the rare hard-link case to a Set. */
|
|
private addFilePath(node: FileNode, path: string): void {
|
|
if (node.paths === undefined) {
|
|
node.paths = path
|
|
} else if (typeof node.paths === 'string') {
|
|
node.paths = new Set([node.paths, path])
|
|
} else {
|
|
node.paths.add(path)
|
|
}
|
|
}
|
|
|
|
/** Remove one map name, collapsing a remaining single link back to a string. */
|
|
private removeFilePath(node: FileNode, path: string): void {
|
|
if (typeof node.paths === 'string') {
|
|
node.paths = undefined
|
|
return
|
|
}
|
|
if (node.paths === undefined) return
|
|
node.paths.delete(path)
|
|
if (node.paths.size === 1) {
|
|
const [remaining] = node.paths
|
|
node.paths = remaining
|
|
}
|
|
}
|
|
|
|
/** Set one file-map entry while maintaining both nodes' reverse path indexes. */
|
|
private setFile(path: string, node: FileNode): void {
|
|
const previous = this.files.get(path)
|
|
if (previous === node) return
|
|
if (previous !== undefined) this.removeFilePath(previous, path)
|
|
this.files.set(path, node)
|
|
this.addFilePath(node, path)
|
|
}
|
|
|
|
/** Delete one file-map entry while retaining an unlinked node held by a descriptor. */
|
|
private deleteFile(path: string): FileNode | undefined {
|
|
const node = this.files.get(path)
|
|
if (node === undefined) return undefined
|
|
this.files.delete(path)
|
|
this.removeFilePath(node, path)
|
|
return node
|
|
}
|
|
|
|
/** Publish one linked name after a content or metadata write. */
|
|
private publishFilePath(node: FileNode, path: string, appendedFrom?: number): void {
|
|
this.publish({
|
|
kind: 'write', path, bytes: node.bytes, mode: node.mode, entryChanged: false,
|
|
...appendedFrom === undefined ? {} : { appendedFrom },
|
|
})
|
|
}
|
|
|
|
/** Publish a content or metadata write for every hard link to one node. */
|
|
private publishFile(node: FileNode, appendedFrom?: number): void {
|
|
if (typeof node.paths === 'string') {
|
|
this.publishFilePath(node, node.paths, appendedFrom)
|
|
return
|
|
}
|
|
if (node.paths === undefined) return
|
|
for (const path of node.paths) this.publishFilePath(node, path, appendedFrom)
|
|
}
|
|
|
|
/** Replace bytes on one file identity and notify all linked paths. */
|
|
private replaceFile(node: FileNode, bytes: Uint8Array, appendedFrom?: number): void {
|
|
node.bytes = bytes
|
|
node.mtimeMs = this.touchNode(node)
|
|
this.publishFile(node, appendedFrom)
|
|
}
|
|
|
|
/** Write at one offset, zero-filling any gap. */
|
|
private writeFileNode(node: FileNode, position: number, data: Uint8Array): number {
|
|
const offset = Math.max(0, position)
|
|
const previousLength = node.bytes.length
|
|
const bytes = new Uint8Array(Math.max(previousLength, offset + data.length))
|
|
bytes.set(node.bytes)
|
|
bytes.set(data, offset)
|
|
this.replaceFile(node, bytes, offset === previousLength ? previousLength : undefined)
|
|
return data.length
|
|
}
|
|
|
|
/** Resize one file identity and notify all linked paths. */
|
|
private truncateFile(node: FileNode, length: number): void {
|
|
this.replaceFile(node, resize(node.bytes, length))
|
|
}
|
|
|
|
/** Change one file identity's permission bits and notify every linked path. */
|
|
private chmodFile(node: FileNode, mode: number): void {
|
|
node.mode = mode & 0o777
|
|
if (typeof node.paths === 'string') {
|
|
this.publish({ kind: 'chmod', path: node.paths, mode: node.mode })
|
|
} else if (node.paths !== undefined) {
|
|
for (const path of node.paths) this.publish({ kind: 'chmod', path, mode: node.mode })
|
|
}
|
|
}
|
|
|
|
/** @returns Plain stats for an open file, including after its last name is removed. */
|
|
private fileStats(node: FileNode): VfsStats {
|
|
return statsOf(node.bytes.length, node.mtimeMs, false, this.identityOfFile(node), node.mode)
|
|
}
|
|
|
|
/** @returns BigInt stats for an open file, including its device and inode identity. */
|
|
private fileBigIntStats(node: FileNode): VfsBigIntStats {
|
|
return bigIntStatsOf(
|
|
node.bytes.length,
|
|
node.mtimeMs,
|
|
false,
|
|
this.identityOfFile(node),
|
|
node.mode,
|
|
this.fileLinkCount(node),
|
|
)
|
|
}
|
|
|
|
/** Forget removed directory identities, so recreated paths report new ones. */
|
|
private forgetIdentity(target: string): void {
|
|
this.identities.delete(target)
|
|
const prefix = `${target}${SEP}`
|
|
for (const known of [...this.identities.keys()]) {
|
|
if (known.startsWith(prefix)) this.identities.delete(known)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Modification time for a write, strictly after the entry's previous one.
|
|
*
|
|
* The clock has millisecond resolution and these writes are in memory, so two
|
|
* revisions of one file routinely land in the same millisecond. The filesystem
|
|
* service's stale-write guard compares timestamps, so an equal one would let a
|
|
* stale overwrite through.
|
|
* @param target - Normalized path being written.
|
|
* @returns Now, or one millisecond past the entry's current time.
|
|
*/
|
|
private touch(target: string): number {
|
|
return this.touchNode(this.files.get(target))
|
|
}
|
|
|
|
/** @returns A modification time strictly newer than one file node's current value. */
|
|
private touchNode(node?: FileNode): number {
|
|
const previous = node?.mtimeMs
|
|
const now = Date.now()
|
|
return previous === undefined ? now : Math.max(now, previous + 1)
|
|
}
|
|
|
|
/** Advance a directory's mtime after its immediate children change. */
|
|
private touchDirectory(target: string): void {
|
|
const previous = this.directoryMtimes.get(target)
|
|
const now = Date.now()
|
|
this.directoryMtimes.set(target, previous === undefined ? now : Math.max(now, previous + 1))
|
|
}
|
|
|
|
/**
|
|
* List a directory.
|
|
* @param path - Directory path.
|
|
* @param options - `withFileTypes` returns {@link VfsDirent} objects instead of names.
|
|
* @returns Immediate entry names, or directory entries.
|
|
*/
|
|
readdirSync(path: string, options?: { withFileTypes?: boolean }): string[] & VfsDirent[] {
|
|
const target = this.key(path)
|
|
if (!this.directories.has(target)) {
|
|
if (this.files.has(target) || this.deferred.has(target)) fail('ENOTDIR', 'scandir', target)
|
|
fail('ENOENT', 'scandir', target)
|
|
}
|
|
const prefix = target === SEP ? SEP : `${target}${SEP}`
|
|
const names = new Set<string>()
|
|
for (const candidate of [...this.files.keys(), ...this.deferred.keys(), ...this.directories]) {
|
|
if (!candidate.startsWith(prefix) || candidate === target) continue
|
|
const rest = candidate.slice(prefix.length)
|
|
if (rest === '') continue
|
|
const [head = rest] = rest.split(SEP)
|
|
names.add(head)
|
|
}
|
|
const sorted = [...names].sort()
|
|
if (options?.withFileTypes !== true) return sorted as string[] & VfsDirent[]
|
|
return sorted.map(name => this.direntOf(target, name)) as string[] & VfsDirent[]
|
|
}
|
|
|
|
/** @returns Directory entry for one child of `directory`. */
|
|
private direntOf(directory: string, name: string): VfsDirent {
|
|
const stats = this.plainStats(join(directory, name))
|
|
return {
|
|
name,
|
|
parentPath: directory,
|
|
isFile: () => stats.isFile(),
|
|
isDirectory: () => stats.isDirectory(),
|
|
isSymbolicLink: () => false,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a path; the VFS has no symlinks, so this only normalizes.
|
|
* @param path - Path to resolve.
|
|
* @returns Absolute path.
|
|
*/
|
|
realpathSync(path: string): string {
|
|
const target = this.key(path)
|
|
if (!this.existsSync(target)) fail('ENOENT', 'realpath', target)
|
|
return target
|
|
}
|
|
|
|
/**
|
|
* Create a directory.
|
|
* @param path - Directory path.
|
|
* @param options - `recursive` creates missing parents.
|
|
* @returns First created path when recursive, otherwise undefined.
|
|
*/
|
|
mkdirSync(path: string, options?: { recursive?: boolean; mode?: number }): string | undefined {
|
|
const target = this.key(path)
|
|
if (this.files.has(target) || this.deferred.has(target)) fail('EEXIST', 'mkdir', target)
|
|
if (this.directories.has(target)) {
|
|
if (options?.recursive === true) return undefined
|
|
fail('EEXIST', 'mkdir', target)
|
|
}
|
|
const parent = dirname(target)
|
|
if (!this.directories.has(parent)) {
|
|
if (options?.recursive !== true) fail('ENOENT', 'mkdir', target)
|
|
this.mkdirSync(parent, options)
|
|
}
|
|
this.directories.add(target)
|
|
this.touchDirectory(target)
|
|
this.touchDirectory(parent)
|
|
const mode = (options?.mode ?? DEFAULT_DIRECTORY_MODE) & 0o777
|
|
if (mode !== DEFAULT_DIRECTORY_MODE) this.directoryModes.set(target, mode)
|
|
this.publish({ kind: 'mkdir', path: target, mode })
|
|
return target
|
|
}
|
|
|
|
/**
|
|
* Write a file, replacing existing contents.
|
|
* @param path - File path; its parent directory must exist.
|
|
* @param data - Text or bytes.
|
|
* @param options - `flag` `wx` refuses an existing file, `a` appends.
|
|
*/
|
|
writeFileSync(path: string, data: string | Uint8Array, options?: VfsWriteOptions): void {
|
|
const target = this.key(path)
|
|
this.requireArrived('open', target)
|
|
if (this.directories.has(target)) fail('EISDIR', 'open', target)
|
|
if (!this.directories.has(dirname(target))) fail('ENOENT', 'open', target)
|
|
const flag = options?.flag ?? 'w'
|
|
if (flag.startsWith('wx') && this.files.has(target)) fail('EEXIST', 'open', target)
|
|
if (flag.startsWith('a')) { this.appendFileSync(target, data); return }
|
|
// POSIX open(O_CREAT): the mode applies at creation only; a rewrite keeps
|
|
// the entry's bits.
|
|
const previous = this.files.get(target)
|
|
const mode = previous?.mode ?? (options?.mode !== undefined ? options.mode & 0o777 : DEFAULT_FILE_MODE)
|
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data
|
|
if (previous !== undefined) {
|
|
this.replaceFile(previous, bytes)
|
|
return
|
|
}
|
|
const node: FileNode = { bytes, mtimeMs: this.touch(target), mode, paths: undefined }
|
|
this.setFile(target, node)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'write', path: target, bytes, mode, entryChanged: true })
|
|
}
|
|
|
|
/**
|
|
* Open a directory; consumers enumerate entries or just prove it is one.
|
|
* @param path - Directory path.
|
|
* @returns Directory handle.
|
|
*/
|
|
opendir(path: string): VfsDir {
|
|
const target = this.key(path)
|
|
const names = this.readdirSync(target)
|
|
let cursor = 0
|
|
const direntOf = (name: string): VfsDirent => this.direntOf(target, name)
|
|
return {
|
|
path: target,
|
|
close: async (): Promise<void> => {},
|
|
read: async (): Promise<{ name: string } | null> => {
|
|
const name = names[cursor]
|
|
cursor += 1
|
|
return name === undefined ? null : direntOf(name)
|
|
},
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const name of names) yield direntOf(name)
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a file handle.
|
|
* @param path - File path.
|
|
* @param flags - Node open flags; `r` requires the file, `wx` refuses an existing one.
|
|
* @param mode - Permission bits applied when the open creates the file.
|
|
* @returns File handle.
|
|
*/
|
|
open(path: string, flags = 'r', mode?: number): VfsFileHandle {
|
|
const target = this.key(path)
|
|
// Durable writers fsync the parent directory by opening it read-only.
|
|
if (this.directories.has(target)) {
|
|
if (!flags.startsWith('r')) fail('EISDIR', 'open', target)
|
|
return {
|
|
write: async (): Promise<{ bytesWritten: number }> => fail('EISDIR', 'write', target),
|
|
writeFile: async (): Promise<void> => fail('EISDIR', 'write', target),
|
|
readFile: async (): Promise<string | Uint8Array> => fail('EISDIR', 'read', target),
|
|
truncate: async (): Promise<void> => fail('EISDIR', 'ftruncate', target),
|
|
...this.handleTail(target),
|
|
}
|
|
}
|
|
const file = this.openFileSync(target, flags, mode)
|
|
let position = 0
|
|
let closed = false
|
|
const current = (syscall: string): VfsOpenFile => {
|
|
if (closed) fail('EBADF', syscall, target)
|
|
return file
|
|
}
|
|
return {
|
|
write: async (data: string | Uint8Array): Promise<{ bytesWritten: number }> => {
|
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data
|
|
const descriptor = current('write')
|
|
const offset = descriptor.append ? descriptor.stat().size : position
|
|
const bytesWritten = descriptor.write(offset, bytes)
|
|
position = offset + bytesWritten
|
|
return { bytesWritten }
|
|
},
|
|
writeFile: async (data: string | Uint8Array): Promise<void> => {
|
|
const bytes = typeof data === 'string' ? encoder.encode(data) : data
|
|
const descriptor = current('write')
|
|
const offset = descriptor.append ? descriptor.stat().size : position
|
|
position = offset + descriptor.write(offset, bytes)
|
|
},
|
|
readFile: async (options?: VfsReadOptions): Promise<string | Uint8Array> => {
|
|
const descriptor = current('read')
|
|
const bytes = descriptor.read(position, Math.max(0, descriptor.stat().size - position))
|
|
position += bytes.length
|
|
return encodingOf(options) === undefined ? bytes : decoder.decode(bytes)
|
|
},
|
|
truncate: async (length = 0): Promise<void> => {
|
|
current('ftruncate').truncate(length)
|
|
},
|
|
chmod: async (mode: number): Promise<void> => { current('fchmod').chmod(mode) },
|
|
stat: async (options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => options?.bigint === true
|
|
? current('fstat').statBigInt()
|
|
: current('fstat').stat(),
|
|
sync: async (): Promise<void> => { current('fsync'); await this.flush() },
|
|
datasync: async (): Promise<void> => { current('fdatasync'); await this.flush() },
|
|
close: async (): Promise<void> => { closed = true },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open one synchronous descriptor over a stable file identity.
|
|
* @param path - File path.
|
|
* @param flags - Node open flags.
|
|
* @param mode - Permission bits applied only when a file is created.
|
|
* @returns An open file that survives path rename, replacement, and unlink.
|
|
*/
|
|
openFileSync(path: string, flags = 'r', mode?: number): VfsOpenFile {
|
|
const target = this.key(path)
|
|
this.requireArrived('open', target)
|
|
const access = openMode(flags)
|
|
const existing = this.files.get(target)
|
|
if (this.directories.has(target)) fail('EISDIR', 'open', target)
|
|
if (access.exclusive && existing !== undefined) fail('EEXIST', 'open', target)
|
|
if (!access.create && existing === undefined) fail('ENOENT', 'open', target)
|
|
if (access.create && existing === undefined) {
|
|
this.writeFileSync(target, new Uint8Array(), mode === undefined ? undefined : { mode })
|
|
} else if (access.truncate && existing !== undefined) {
|
|
this.truncateFile(existing, 0)
|
|
}
|
|
const node = this.files.get(target)
|
|
if (node === undefined) fail('ENOENT', 'open', target)
|
|
return {
|
|
readable: access.readable,
|
|
writable: access.writable,
|
|
append: access.append,
|
|
read: (position, length) => {
|
|
if (!access.readable) fail('EBADF', 'read', target)
|
|
return node.bytes.subarray(position, position + length)
|
|
},
|
|
write: (position, data) => {
|
|
if (!access.writable) fail('EBADF', 'write', target)
|
|
return this.writeFileNode(node, access.append ? node.bytes.length : position, data)
|
|
},
|
|
truncate: (length) => {
|
|
if (!access.writable) fail('EINVAL', 'ftruncate', target)
|
|
this.truncateFile(node, length)
|
|
},
|
|
chmod: (mode) => { this.chmodFile(node, mode) },
|
|
stat: () => this.fileStats(node),
|
|
statBigInt: () => this.fileBigIntStats(node),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Directory-handle members for metadata, durability, and release.
|
|
* `sync`/`datasync` settle an attached durable sink; an ephemeral filesystem
|
|
* resolves immediately and `close` releases nothing.
|
|
* @param target - Normalized path the handle was opened on.
|
|
* @returns Metadata plus the no-op durability and release calls.
|
|
*/
|
|
private handleTail(target: string): Pick<VfsFileHandle, 'chmod' | 'stat' | 'sync' | 'datasync' | 'close'> {
|
|
return {
|
|
chmod: async (mode: number): Promise<void> => { this.chmodSync(target, mode) },
|
|
stat: async (options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => this.statSync(target, options),
|
|
sync: async (): Promise<void> => { await this.flush() },
|
|
datasync: async (): Promise<void> => { await this.flush() },
|
|
close: async (): Promise<void> => {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append to a file, creating it when absent.
|
|
* @param path - File path.
|
|
* @param data - Text or bytes.
|
|
*/
|
|
appendFileSync(path: string, data: string | Uint8Array): void {
|
|
const target = this.key(path)
|
|
this.requireArrived('open', target)
|
|
const existing = this.files.get(target)
|
|
const addition = typeof data === 'string' ? encoder.encode(data) : data
|
|
if (existing === undefined) { this.writeFileSync(target, addition); return }
|
|
this.writeFileNode(existing, existing.bytes.length, addition)
|
|
}
|
|
|
|
/**
|
|
* Move a file or directory subtree.
|
|
* @param from - Source path.
|
|
* @param to - Destination path.
|
|
*/
|
|
renameSync(from: string, to: string): void {
|
|
const source = this.key(from)
|
|
const destination = this.key(to)
|
|
this.requireArrived('rename', source)
|
|
this.requireArrived('rename', destination)
|
|
if (source === destination) return
|
|
const node = this.files.get(source)
|
|
if (node !== undefined) {
|
|
if (this.directories.has(destination)) fail('EISDIR', 'rename', destination)
|
|
if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
|
|
if (this.files.get(destination) === node) return
|
|
this.deleteFile(source)
|
|
this.setFile(destination, node)
|
|
this.forgetIdentity(source)
|
|
this.forgetIdentity(destination)
|
|
this.touchDirectory(dirname(source))
|
|
this.touchDirectory(dirname(destination))
|
|
this.publish({ kind: 'remove', path: source })
|
|
this.publish({ kind: 'write', path: destination, bytes: node.bytes, mode: node.mode, entryChanged: true })
|
|
return
|
|
}
|
|
if (!this.directories.has(source)) fail('ENOENT', 'rename', source)
|
|
if (this.files.has(destination)) fail('ENOTDIR', 'rename', destination)
|
|
if (!this.directories.has(dirname(destination))) fail('ENOENT', 'rename', destination)
|
|
const prefix = `${source}${SEP}`
|
|
// Refuse before the destination changes: a refused move leaves both trees as they were.
|
|
for (const registered of this.deferred.keys()) {
|
|
if (registered.startsWith(prefix)) this.requireArrived('rename', registered)
|
|
}
|
|
if (this.directories.has(destination)) {
|
|
if (this.readdirSync(destination).length > 0) fail('ENOTEMPTY', 'rename', destination)
|
|
this.directories.delete(destination)
|
|
this.directoryModes.delete(destination)
|
|
this.directoryMtimes.delete(destination)
|
|
}
|
|
const movedFiles: Array<{ path: string; bytes: Uint8Array; mode: number }> = []
|
|
for (const [candidate, value] of [...this.files]) {
|
|
if (!candidate.startsWith(prefix)) continue
|
|
this.deleteFile(candidate)
|
|
const target = join(destination, candidate.slice(prefix.length))
|
|
this.setFile(target, value)
|
|
movedFiles.push({ path: target, bytes: value.bytes, mode: value.mode })
|
|
}
|
|
const movedDirectories: Array<{ path: string; mode: number }> = []
|
|
for (const candidate of [...this.directories]) {
|
|
if (!candidate.startsWith(prefix) && candidate !== source) continue
|
|
const moved = candidate === source ? destination : join(destination, candidate.slice(prefix.length))
|
|
this.directories.delete(candidate)
|
|
this.directories.add(moved)
|
|
const bits = this.directoryModes.get(candidate)
|
|
this.directoryModes.delete(candidate)
|
|
if (bits !== undefined) this.directoryModes.set(moved, bits)
|
|
movedDirectories.push({ path: moved, mode: bits ?? DEFAULT_DIRECTORY_MODE })
|
|
const mtime = this.directoryMtimes.get(candidate)
|
|
this.directoryMtimes.delete(candidate)
|
|
if (mtime !== undefined) this.directoryMtimes.set(moved, mtime)
|
|
}
|
|
this.forgetIdentity(source)
|
|
this.forgetIdentity(destination)
|
|
this.touchDirectory(dirname(source))
|
|
this.touchDirectory(dirname(destination))
|
|
this.publish({ kind: 'remove', path: source })
|
|
for (const directory of movedDirectories) {
|
|
this.publish({ kind: 'mkdir', path: directory.path, mode: directory.mode })
|
|
}
|
|
for (const entry of movedFiles) {
|
|
this.publish({
|
|
kind: 'write', path: entry.path, bytes: entry.bytes, mode: entry.mode, entryChanged: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Give existing bytes a second name.
|
|
*
|
|
* Both names retain one file identity, so writes and metadata changes through
|
|
* either name remain visible through the other until that name is removed.
|
|
* @param existing - Source file path.
|
|
* @param next - Additional path; its parent must exist and it must be free.
|
|
*/
|
|
linkSync(existing: string, next: string): void {
|
|
const source = this.key(existing)
|
|
const target = this.key(next)
|
|
this.requireArrived('link', source)
|
|
const node = this.files.get(source)
|
|
if (node === undefined) fail('ENOENT', 'link', source)
|
|
if (this.existsSync(target)) fail('EEXIST', 'link', target)
|
|
if (!this.directories.has(dirname(target))) fail('ENOENT', 'link', target)
|
|
this.setFile(target, node)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'write', path: target, bytes: node.bytes, mode: node.mode, entryChanged: true })
|
|
}
|
|
|
|
/**
|
|
* Shorten a file.
|
|
* @param path - File path.
|
|
* @param length - Byte length to keep; defaults to zero.
|
|
*/
|
|
truncateSync(path: string, length = 0): void {
|
|
const target = this.key(path)
|
|
this.requireArrived('truncate', target)
|
|
const node = this.files.get(target)
|
|
if (node === undefined) fail('ENOENT', 'truncate', target)
|
|
this.truncateFile(node, length)
|
|
}
|
|
|
|
/**
|
|
* Change an entry's permission bits; stat reads back exactly what was set.
|
|
* @param path - File or directory path.
|
|
* @param mode - New permission bits (`0o777` mask).
|
|
*/
|
|
chmodSync(path: string, mode: number): void {
|
|
const target = this.key(path)
|
|
this.requireArrived('chmod', target)
|
|
const node = this.files.get(target)
|
|
if (node !== undefined) {
|
|
this.chmodFile(node, mode)
|
|
return
|
|
}
|
|
if (this.directories.has(target)) {
|
|
const bits = mode & 0o777
|
|
this.directoryModes.set(target, bits)
|
|
this.publish({ kind: 'chmod', path: target, mode: bits })
|
|
return
|
|
}
|
|
fail('ENOENT', 'chmod', target)
|
|
}
|
|
|
|
/**
|
|
* Remove a file.
|
|
* @param path - File path.
|
|
*/
|
|
unlinkSync(path: string): void {
|
|
const target = this.key(path)
|
|
if (this.deleteFile(target) === undefined && !this.deferred.delete(target)) fail('ENOENT', 'unlink', target)
|
|
this.forgetIdentity(target)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'remove', path: target })
|
|
}
|
|
|
|
/**
|
|
* Remove a file or directory.
|
|
* @param path - Path to remove.
|
|
* @param options - `recursive` removes subtrees, `force` ignores absence.
|
|
*/
|
|
rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void {
|
|
const target = this.key(path)
|
|
if (this.deleteFile(target) !== undefined || this.deferred.delete(target)) {
|
|
this.forgetIdentity(target)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'remove', path: target })
|
|
return
|
|
}
|
|
if (this.directories.has(target)) {
|
|
if (options?.recursive !== true) fail('ERR_FS_EISDIR', 'rm', target)
|
|
const prefix = `${target}${SEP}`
|
|
for (const candidate of [...this.files.keys()]) if (candidate.startsWith(prefix)) this.deleteFile(candidate)
|
|
for (const candidate of [...this.deferred.keys()]) if (candidate.startsWith(prefix)) this.deferred.delete(candidate)
|
|
for (const candidate of [...this.directories]) {
|
|
if (!candidate.startsWith(prefix)) continue
|
|
this.directories.delete(candidate)
|
|
this.directoryModes.delete(candidate)
|
|
this.directoryMtimes.delete(candidate)
|
|
}
|
|
this.directories.delete(target)
|
|
this.directoryModes.delete(target)
|
|
this.directoryMtimes.delete(target)
|
|
this.forgetIdentity(target)
|
|
this.touchDirectory(dirname(target))
|
|
this.publish({ kind: 'remove', path: target })
|
|
return
|
|
}
|
|
if (options?.force !== true) fail('ENOENT', 'rm', target)
|
|
}
|
|
|
|
/**
|
|
* Create a uniquely named directory beside `prefix`, as `fs.mkdtempSync` does.
|
|
* @param prefix - Path prefix; the suffix is appended without a separator.
|
|
* @returns The created directory path.
|
|
*/
|
|
mkdtempSync(prefix: string): string {
|
|
this.temporaries += 1
|
|
const target = `${prefix}${Date.now().toString(36)}${this.temporaries.toString(36)}`
|
|
this.mkdirSync(target, { recursive: true })
|
|
return this.key(target)
|
|
}
|
|
|
|
/**
|
|
* Seed a file and its parent directories, for image loading and tests.
|
|
* @param path - File path.
|
|
* @param data - Text or bytes.
|
|
* @param options - Permission bits and modification time supplied by the image or durable store.
|
|
*/
|
|
seed(path: string, data: string | Uint8Array, options: VfsSeedOptions = {}): void {
|
|
const target = this.key(path)
|
|
this.seedDirectory(dirname(target))
|
|
// Seeded bytes supersede a lazy registration for the same path.
|
|
this.deferred.delete(target)
|
|
this.setFile(target, {
|
|
bytes: typeof data === 'string' ? encoder.encode(data) : data,
|
|
mtimeMs: options.mtimeMs ?? this.touch(target),
|
|
mode: (options.mode ?? DEFAULT_FILE_MODE) & 0o777,
|
|
paths: undefined,
|
|
})
|
|
this.touchDirectory(dirname(target))
|
|
}
|
|
|
|
/**
|
|
* Create a directory and its parents.
|
|
* @param path - Directory path.
|
|
* @param options - Permission bits and modification time supplied by the image or durable store.
|
|
*/
|
|
seedDirectory(path: string, options: VfsSeedOptions = {}): void {
|
|
const target = this.key(path)
|
|
if (!this.directories.has(target)) {
|
|
const parent = dirname(target)
|
|
if (parent !== target) this.seedDirectory(parent)
|
|
if (this.files.has(target) || this.deferred.has(target)) fail('EEXIST', 'mkdir', target)
|
|
this.directories.add(target)
|
|
this.directoryMtimes.set(target, options.mtimeMs ?? Date.now())
|
|
this.touchDirectory(parent)
|
|
}
|
|
if (options.mode !== undefined) this.directoryModes.set(target, options.mode & 0o777)
|
|
if (options.mtimeMs !== undefined) this.directoryMtimes.set(target, options.mtimeMs)
|
|
}
|
|
|
|
/**
|
|
* Report what this filesystem holds, for the host's boot diagnostics.
|
|
* @returns File count, directory count, and total byte size.
|
|
*/
|
|
usage(): { files: number; directories: number; bytes: number } {
|
|
let bytes = 0
|
|
for (const node of this.files.values()) bytes += node.bytes.length
|
|
return { files: this.files.size, directories: this.directories.size, bytes }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mount a tar image produced by the build-time collector.
|
|
*
|
|
* Entry names are relative to `root` (`node_modules/...`, `config/vfs-manifest.json`);
|
|
* an absolute entry name is a collector defect and fails loud. File contents
|
|
* stay views into `image` — nothing is copied at mount time.
|
|
* @param image - The ustar archive, as `inflateImage` produces it from the fetched image.
|
|
* @param root - Virtual root the entries mount under.
|
|
* @param vfs - Filesystem to fill; a fresh one by default.
|
|
* @returns The filled filesystem.
|
|
*/
|
|
export function loadVfsImage(image: Uint8Array, root = '/dsh', vfs = new MemoryVfs()): MemoryVfs {
|
|
vfs.seedDirectory(root)
|
|
for (const entry of parseTar(image)) {
|
|
const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
|
|
if (relativeName.startsWith(SEP)) {
|
|
throw new Error(`webworker vfs: image entry must be relative to ${root}, received "${entry.name}"`)
|
|
}
|
|
const target = join(root, relativeName)
|
|
if (entry.directory) {
|
|
vfs.seedDirectory(target, { mode: entry.mode })
|
|
continue
|
|
}
|
|
vfs.seed(target, entry.bytes, { mode: entry.mode })
|
|
}
|
|
return vfs
|
|
}
|
|
|
|
/**
|
|
* Apply one ordered data overlay to an already mounted base image.
|
|
*
|
|
* Overlay entries may replace files only under the layout's data directories;
|
|
* module code, configuration, and the lowering manifest cannot be shadowed.
|
|
* Paths containing traversal segments are refused before normalization. Later
|
|
* overlays win for files, while file/directory type conflicts fail loud.
|
|
* @param image - Uncompressed ustar overlay archive.
|
|
* @param root - Virtual root shared with the base image.
|
|
* @param vfs - Mounted filesystem to update.
|
|
* @returns The same filesystem after applying the overlay.
|
|
*/
|
|
export function loadVfsOverlay(image: Uint8Array, root: string, vfs: MemoryVfs): MemoryVfs {
|
|
for (const entry of parseTar(image)) {
|
|
const relativeName = entry.name.startsWith('./') ? entry.name.slice(2) : entry.name
|
|
const path = relativeName.endsWith('/') ? relativeName.slice(0, -1) : relativeName
|
|
const segments = path.split('/')
|
|
if (path === '' || relativeName.startsWith(SEP)
|
|
|| segments.some(segment => segment === '' || segment === '.' || segment === '..')
|
|
|| !IMAGE_OVERLAY_DIRECTORIES.includes(segments[0] ?? '')) {
|
|
throw new Error(`webworker vfs: overlay entry must stay under ${IMAGE_OVERLAY_DIRECTORIES.join('/ or ')}, received "${entry.name}"`)
|
|
}
|
|
const target = join(root, path)
|
|
if (entry.directory) {
|
|
vfs.seedDirectory(target, { mode: entry.mode })
|
|
continue
|
|
}
|
|
if (vfs.existsSync(target) && vfs.statSync(target).isDirectory()) {
|
|
throw new Error(`webworker vfs: overlay file cannot replace directory "${target}"`)
|
|
}
|
|
vfs.seed(target, entry.bytes, { mode: entry.mode })
|
|
}
|
|
return vfs
|
|
}
|