UNPKG

@neodx/vfs

Version:

Simple virtual file system - working dir context, lazy changes, different modes, integrations and moreover

465 lines (455 loc) 13.1 kB
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