@neodx/vfs
Version:
Simple virtual file system - working dir context, lazy changes, different modes, integrations and moreover
465 lines (455 loc) • 13.1 kB
JavaScript
import {
d as displayPath,
g as getVfsActions,
i as isVfsDir,
a as isVfsFile,
e as existsVfsPath,
r as readVfsDir,
b as readVfsFile,
w as writeVfsFile,
c as renameVfs,
f as deleteVfsPath,
t as tryReadVfsFile,
h as createTaskRunner,
j as createInMemoryBackend,
p as pathStartsWith
} from './_internal/operations-BqwMWtvn.mjs';
export { k as createInMemoryFilesRecord } from './_internal/operations-BqwMWtvn.mjs';
import { readdir } from 'node:fs/promises';
import { readFile, ensureFile, writeFile, rm, exists, isDirectory, isFile } from '@neodx/fs';
import { createLogger, createAutoLogger } from '@neodx/log/node';
import {
uniqBy,
mapValues,
isNull,
fromKeys,
asyncReduce,
quickPluralize,
concurrently,
not,
isTypeOfBoolean
} from '@neodx/std';
import { resolve, relative, normalize, dirname } from 'pathe';
import { colors } from '@neodx/colors';
import { propEq } from '@neodx/std/object';
import { match } from 'ts-pattern';
import { eslint } from './plugins/eslint.mjs';
import { glob } from './plugins/glob.mjs';
import { json } from './plugins/json.mjs';
import { packageJson } from './plugins/package-json.mjs';
import { prettier } from './plugins/prettier.mjs';
import { scan } from './plugins/scan.mjs';
export { c as createVfsPlugin } from './_internal/create-vfs-plugin-BzqnUd8c.mjs';
function createNodeFsBackend() {
return {
async read(path) {
try {
return await readFile(path);
} catch {
return null;
}
},
async write(path, content) {
await ensureFile(path);
return await writeFile(path, content);
},
async delete(path) {
return await rm(path, {
force: true,
recursive: true
});
},
async exists(path) {
return await exists(path);
},
async readDir(path) {
try {
const dirents = await readdir(path, {
withFileTypes: true
});
return dirents;
} catch {
return [];
}
},
async isDir(path) {
return await isDirectory(path);
},
async isFile(path) {
return await isFile(path);
},
__: {
kind: 'node-fs'
}
};
}
const createVfsContext = ({
parent,
logLevel,
log = logLevel
? defaultLogger.fork({
level: logLevel
})
: defaultLogger,
...params
}) => {
const store = new Map();
const ctx = {
log,
...params,
get(path) {
const resolved = ctx.resolve(path);
return ctx.getAllChanges().find(meta => meta.path === resolved) ?? null;
},
getAllChanges() {
return getAndMergeChanges(ctx.__.getAll());
},
getAllDirectChanges() {
return getAndMergeChanges(ctx.__.getScoped());
},
getRelativeChanges(path) {
const resolved = trailingSlash(ctx.resolve(path));
return uniqBy(
ctx.__.getAll()
.flatMap(ctx => Array.from(ctx.__.getStore().values()))
.filter(meta => meta.path.startsWith(resolved)),
meta => meta.path
);
},
registerPath(path, content = null, updatedAfterDelete, overwrittenDir) {
const resolved = ctx.resolve(path);
updatedAfterDelete ??= Boolean(ctx.get(resolved)?.deleted);
ctx.__.register(resolved, {
deleted: false,
content,
updatedAfterDelete,
overwrittenDir
});
},
deletePath(path) {
ctx.__.register(ctx.resolve(path), {
content: null,
deleted: true
});
},
unregister(path) {
ctx.__.unregister(ctx.resolve(path));
},
resolve(...to) {
return resolve(ctx.path, ...to);
},
relative(path) {
return relative(ctx.path, resolve(ctx.path, normalize(path)));
},
backend: mapValues(params.backend, (fn, key) =>
key === '__' ? fn : async (path, ...args) => await fn(ctx.resolve(path), ...args)
),
__: {
kind,
parent,
plugins: [],
children: [],
getStore: () => store,
getAll: () => [...ctx.__.getAncestors(), ctx, ...ctx.__.getDescendants()],
getScoped: () => [ctx, ...ctx.__.getDescendants()],
getAncestors: () => getAncestors(ctx),
getDescendants: () => getDescendants(ctx),
register: (path, overrides) => {
const currentMeta = ctx.get(path);
const meta = {
...currentMeta,
...overrides,
path,
content: isNull(overrides.content) ? overrides.content : Buffer.from(overrides.content),
relativePath: ctx.relative(path)
};
ctx.__.getAll().forEach(ctx => ctx.__.getStore().set(path, meta));
},
unregister: path => {
ctx.__.getAll().forEach(ctx => ctx.__.getStore().delete(path));
}
}
};
if (parent) {
parent.__.children.push(ctx);
}
return ctx;
};
const kind = Symbol('VfsContext');
const getAncestors = ctx => (ctx.__.parent ? [ctx.__.parent, ...getAncestors(ctx.__.parent)] : []);
const getDescendants = ctx => ctx.__.children.flatMap(child => [child, ...getDescendants(child)]);
const getAndMergeChanges = contexts =>
uniqBy(
contexts.flatMap(ctx => Array.from(ctx.__.getStore().values())),
meta => meta.path
);
const trailingSlash = path => (path.endsWith('/') ? path : `${path}/`);
const defaultLogger = createLogger({
name: 'vfs',
level: 'info'
});
const getVfsBackendKind = backend => backend.__?.kind ?? 'unknown';
const createHookRegistry = () => {
const hooks = new Map();
return {
scope(name) {
const hook = hooks.get(name) ?? [];
hooks.set(name, hook);
return handler => hook.push(handler);
},
get(name) {
return hooks.get(name) ?? [];
},
async run(name, ...args) {
return asyncReduce(this.get(name), async (_, handler) => await handler(...args), undefined);
}
};
};
const toPublicScope = (vfs, ctx, hooks) => {
const privateApi = {
context: ctx,
...fromKeys(['beforeApply', 'beforeApplyFile', 'afterDelete'], hooks.scope)
};
return {
...vfs,
__: privateApi,
child(path) {
const { log, backend } = ctx;
const childCtx = createVfsContext({
backend,
parent: ctx,
path: ctx.resolve(path),
log
});
return pipe(createBaseVfs(childCtx), childCtx, createHookRegistry(), ...ctx.__.plugins);
},
pipe(...plugins) {
return pipe(vfs, ctx, hooks, ...plugins);
}
};
};
const pipe = (vfs, ctx, hooks, ...plugins) => {
const next = plugins.reduce(
(next, plugin) =>
plugin(next, {
context: ctx,
...fromKeys(['beforeApply', 'beforeApplyFile', 'afterDelete'], hooks.scope)
}),
vfs
);
ctx.__.plugins.push(...plugins);
ctx.__.vfs = next;
return toPublicScope(next, ctx, hooks);
};
function createBaseVfs(ctx) {
const hooks = createHookRegistry();
const backendKind = getVfsBackendKind(ctx.backend);
const { task } = createTaskRunner({
log: ctx.log
});
const applyDelete = task(
'delete',
async action => {
const reason = match(action)
.with(
{
overwrittenDir: true
},
() => 'directory overwrite as file'
)
.with(
{
type: 'delete'
},
() => 'direct deletion'
)
.otherwise(() => 'force deletion for ensure consistency');
ctx.log.info('%s %s (%s)', colors.red('delete'), displayPath(ctx, action.path), reason);
await ctx.backend.delete(action.path);
await hooks.run('afterDelete', action.path, getCurrentVfs());
},
{
mapError: (_, action) => `failed to delete "${action.relativePath}"`
}
);
const applyFile = task(
'apply file',
async action => {
ctx.log.info('%s %s', labels[action.type], displayPath(ctx, action.path));
await hooks.run('beforeApplyFile', action, getCurrentVfs());
if (action.content) await ctx.backend.write(action.path, action.content);
ctx.unregister(action.path);
},
{
mapError: (_, action) => `failed to ${action.type} "${action.relativePath}"`
}
);
const baseVfs = {
// @ts-expect-error internal
[contextSymbol]: ctx,
get log() {
return ctx.log;
},
get path() {
return ctx.path;
},
get dirname() {
return dirname(ctx.path);
},
get virtual() {
return backendKind === 'in-memory';
},
get readonly() {
return backendKind === 'readonly';
},
apply: task(
'apply',
async () => {
const startingChanges = await getVfsActions(ctx);
ctx.log.info(
'Applying %d %s...',
startingChanges.length,
quickPluralize(startingChanges.length, 'change', 'changes')
);
await hooks.run('beforeApply', startingChanges, getCurrentVfs());
const changes = await getVfsActions(ctx);
const deletions = changes.filter(
action => action.type === 'delete' || action.updatedAfterDelete || action.overwrittenDir
);
// First, we need to delete all files and directories that were deleted
await concurrently(deletions, applyDelete);
await concurrently(changes.filter(not(propEq('type', 'delete'))), applyFile);
ctx.getAllDirectChanges().forEach(it => ctx.unregister(it.path));
},
{
mapError: () => `failed to apply changes`,
mapSuccessMessage: () => `applied changes`
}
),
resolve: ctx.resolve,
relative: ctx.relative,
isDir: path => isVfsDir(ctx, path),
isFile: path => isVfsFile(ctx, path),
exists: path => existsVfsPath(ctx, path),
readDir: async (pathOrParams, params) => {
const [path, { withFileTypes } = {}] =
typeof pathOrParams === 'string' ? [pathOrParams, params] : [undefined, pathOrParams];
const entries = await readVfsDir(ctx, path);
return withFileTypes ? entries : entries.map(dirent => dirent.name);
},
read: (path, encoding) => readVfsFile(ctx, path, encoding),
write: (path, content) => writeVfsFile(ctx, path, content),
rename: (from, ...to) => renameVfs(ctx, from, ...to),
delete: path => deleteVfsPath(ctx, path),
tryRead: (path, encoding) => tryReadVfsFile(ctx, path, encoding)
};
const getCurrentVfs = () => ctx.__.vfs ?? baseVfs;
return toPublicScope(baseVfs, ctx, hooks);
}
const labels = {
delete: colors.red('delete'),
create: colors.green('create'),
update: colors.yellow('update')
};
const contextSymbol = Symbol('context');
function createReadonlyBackend(backend) {
const inMemory = createInMemoryBackend();
const deleted = path => {
const paths = Array.from(inMemory.__.getDeleted());
return (
paths.includes(path) ||
paths.some(expectedParentPath => pathStartsWith(path, expectedParentPath))
);
};
return {
read: path => (deleted(path) ? null : inMemory.read(path) ?? backend.read(path)),
exists: path => !deleted(path) && (inMemory.exists(path) || backend.exists(path)),
isFile: path => !deleted(path) && (inMemory.isFile(path) || backend.isFile(path)),
isDir: path => !deleted(path) && (inMemory.isDir(path) || backend.isDir(path)),
write: inMemory.write,
delete: inMemory.delete,
async readDir(path) {
if (deleted(path)) return [];
const actual = await backend.readDir(path);
return uniqBy(
actual.filter(entry => !deleted(resolve(path, entry.name))).concat(inMemory.readDir(path)),
entry => entry.name
);
},
__: {
kind: 'readonly'
}
};
}
function createVfs(
path,
{ eslint: eslintParams = true, prettier: prettierParams = true, ...params } = {}
) {
return createHeadlessVfs(path, params).pipe(
json(),
scan(),
glob(),
eslint(
isTypeOfBoolean(eslintParams)
? {
auto: eslintParams
}
: eslintParams
),
prettier(
isTypeOfBoolean(prettierParams)
? {
auto: prettierParams
}
: prettierParams
),
packageJson()
);
}
function createHeadlessVfs(
path,
{
log = 'error',
virtual,
readonly,
backend = createDefaultVfsBackend(path, {
virtual,
readonly
})
} = {}
) {
const context = createVfsContext({
path,
log: createAutoLogger(log, {
name: 'vfs'
}),
backend
});
return createBaseVfs(context);
}
function createDefaultVfsBackend(path, { virtual, readonly }) {
const originalBackend = virtual
? createInMemoryBackend(path, virtual === true ? {} : virtual)
: createNodeFsBackend();
return readonly ? createReadonlyBackend(originalBackend) : originalBackend;
}
function createAutoVfs(input, additionalParams) {
if (typeof input === 'string') return createVfs(input, additionalParams);
if ('path' in input)
return createVfs(input.path, {
...additionalParams,
...input
});
return input;
}
export {
createAutoVfs,
createBaseVfs,
createDefaultVfsBackend,
createHeadlessVfs,
createInMemoryBackend,
createNodeFsBackend,
createVfs,
createVfsContext
};
//# sourceMappingURL=index.mjs.map