@neodx/vfs
Version:
Simple virtual file system - working dir context, lazy changes, different modes, integrations and moreover
455 lines (444 loc) • 13.5 kB
JavaScript
;
var operations = require('./_internal/operations-C-RkTDIe.cjs');
var promises = require('node:fs/promises');
var fs = require('@neodx/fs');
var node = require('@neodx/log/node');
var std = require('@neodx/std');
var pathe = require('pathe');
var colors = require('@neodx/colors');
var object = require('@neodx/std/object');
var tsPattern = require('ts-pattern');
var plugins_eslint = require('./plugins/eslint.cjs');
var plugins_glob = require('./plugins/glob.cjs');
var plugins_json = require('./plugins/json.cjs');
var plugins_packageJson = require('./plugins/package-json.cjs');
var plugins_prettier = require('./plugins/prettier.cjs');
var plugins_scan = require('./plugins/scan.cjs');
var createVfsPlugin = require('./_internal/create-vfs-plugin-1jK9qNm1.cjs');
function createNodeFsBackend() {
return {
async read(path) {
try {
return await fs.readFile(path);
} catch {
return null;
}
},
async write(path, content) {
await fs.ensureFile(path);
return await fs.writeFile(path, content);
},
async delete(path) {
return await fs.rm(path, {
force: true,
recursive: true
});
},
async exists(path) {
return await fs.exists(path);
},
async readDir(path) {
try {
const dirents = await promises.readdir(path, {
withFileTypes: true
});
return dirents;
} catch {
return [];
}
},
async isDir(path) {
return await fs.isDirectory(path);
},
async isFile(path) {
return await fs.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 std.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 pathe.resolve(ctx.path, ...to);
},
relative(path) {
return pathe.relative(ctx.path, pathe.resolve(ctx.path, pathe.normalize(path)));
},
backend: std.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: std.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 =>
std.uniqBy(
contexts.flatMap(ctx => Array.from(ctx.__.getStore().values())),
meta => meta.path
);
const trailingSlash = path => (path.endsWith('/') ? path : `${path}/`);
const defaultLogger = node.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 std.asyncReduce(
this.get(name),
async (_, handler) => await handler(...args),
undefined
);
}
};
};
const toPublicScope = (vfs, ctx, hooks) => {
const privateApi = {
context: ctx,
...std.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,
...std.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 } = operations.createTaskRunner({
log: ctx.log
});
const applyDelete = task(
'delete',
async action => {
const reason = tsPattern
.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.colors.red('delete'),
operations.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], operations.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 pathe.dirname(ctx.path);
},
get virtual() {
return backendKind === 'in-memory';
},
get readonly() {
return backendKind === 'readonly';
},
apply: task(
'apply',
async () => {
const startingChanges = await operations.getVfsActions(ctx);
ctx.log.info(
'Applying %d %s...',
startingChanges.length,
std.quickPluralize(startingChanges.length, 'change', 'changes')
);
await hooks.run('beforeApply', startingChanges, getCurrentVfs());
const changes = await operations.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 std.concurrently(deletions, applyDelete);
await std.concurrently(changes.filter(std.not(object.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 => operations.isVfsDir(ctx, path),
isFile: path => operations.isVfsFile(ctx, path),
exists: path => operations.existsVfsPath(ctx, path),
readDir: async (pathOrParams, params) => {
const [path, { withFileTypes } = {}] =
typeof pathOrParams === 'string' ? [pathOrParams, params] : [undefined, pathOrParams];
const entries = await operations.readVfsDir(ctx, path);
return withFileTypes ? entries : entries.map(dirent => dirent.name);
},
read: (path, encoding) => operations.readVfsFile(ctx, path, encoding),
write: (path, content) => operations.writeVfsFile(ctx, path, content),
rename: (from, ...to) => operations.renameVfs(ctx, from, ...to),
delete: path => operations.deleteVfsPath(ctx, path),
tryRead: (path, encoding) => operations.tryReadVfsFile(ctx, path, encoding)
};
const getCurrentVfs = () => ctx.__.vfs ?? baseVfs;
return toPublicScope(baseVfs, ctx, hooks);
}
const labels = {
delete: colors.colors.red('delete'),
create: colors.colors.green('create'),
update: colors.colors.yellow('update')
};
const contextSymbol = Symbol('context');
function createReadonlyBackend(backend) {
const inMemory = operations.createInMemoryBackend();
const deleted = path => {
const paths = Array.from(inMemory.__.getDeleted());
return (
paths.includes(path) ||
paths.some(expectedParentPath => operations.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 std.uniqBy(
actual
.filter(entry => !deleted(pathe.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(
plugins_json.json(),
plugins_scan.scan(),
plugins_glob.glob(),
plugins_eslint.eslint(
std.isTypeOfBoolean(eslintParams)
? {
auto: eslintParams
}
: eslintParams
),
plugins_prettier.prettier(
std.isTypeOfBoolean(prettierParams)
? {
auto: prettierParams
}
: prettierParams
),
plugins_packageJson.packageJson()
);
}
function createHeadlessVfs(
path,
{
log = 'error',
virtual,
readonly,
backend = createDefaultVfsBackend(path, {
virtual,
readonly
})
} = {}
) {
const context = createVfsContext({
path,
log: node.createAutoLogger(log, {
name: 'vfs'
}),
backend
});
return createBaseVfs(context);
}
function createDefaultVfsBackend(path, { virtual, readonly }) {
const originalBackend = virtual
? operations.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;
}
exports.createInMemoryBackend = operations.createInMemoryBackend;
exports.createInMemoryFilesRecord = operations.createInMemoryFilesRecord;
exports.createVfsPlugin = createVfsPlugin.createVfsPlugin;
exports.createAutoVfs = createAutoVfs;
exports.createBaseVfs = createBaseVfs;
exports.createDefaultVfsBackend = createDefaultVfsBackend;
exports.createHeadlessVfs = createHeadlessVfs;
exports.createNodeFsBackend = createNodeFsBackend;
exports.createVfs = createVfs;
exports.createVfsContext = createVfsContext;
//# sourceMappingURL=index.cjs.map