├── cli └── index.html ├── index.ts ├── package.json ├── .gitignore ├── README.md ├── tsconfig.json ├── example.ts ├── src ├── VirtualAsset.ts └── VirtualFS.ts └── bun.lock /cli/index.html: -------------------------------------------------------------------------------- 1 |

Hello, Nobody!

-------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './src/VirtualAsset' 2 | export * from './src/VirtualFS' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hoist", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5" 10 | }, 11 | "dependencies": { 12 | "floss": "github.com:phillip-england/floss" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies (bun install) 2 | node_modules 3 | 4 | # output 5 | out 6 | dist 7 | *.tgz 8 | 9 | # code coverage 10 | coverage 11 | *.lcov 12 | 13 | # logs 14 | logs 15 | _.log 16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 | 18 | # dotenv environment variable files 19 | .env 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | .env.local 24 | 25 | # caches 26 | .eslintcache 27 | .cache 28 | *.tsbuildinfo 29 | 30 | # IntelliJ based IDEs 31 | .idea 32 | 33 | # Finder (MacOS) folder config 34 | .DS_Store 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hoist 2 | A virtual file system for bun. 3 | 4 | ## Installation 5 | bun add github:phillip-england/hoist#v0.0.6 6 | 7 | ## Usage 8 | ```ts 9 | let newFs = await VirtualFS.create('./cli',{ 10 | './cli': VirtualAsset.rootDir(), 11 | './cli/index.html': VirtualAsset.file(`

Hello, World!

`) 12 | }) 13 | let newText = newFs.read('./cli/index.html') 14 | 15 | let loadedFs = await VirtualFS.load('./cli') 16 | let loadedText = loadedFs.read('./cli/index.html') 17 | 18 | loadedFs.write('./cli/index.html', `

Hello, Nobody!

`) 19 | await loadedFs.sync() 20 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { VirtualAsset } from "./src/VirtualAsset"; 3 | import { VirtualFS } from './src/VirtualFS'; 4 | 5 | let newFs = await VirtualFS.overwrite(path.join(process.cwd(), 'cli'),{ 6 | [path.join(process.cwd(), 'cli')]: VirtualAsset.rootDir(), 7 | [path.join(process.cwd(), 'cli', 'index.html')]: VirtualAsset.file(`

Hello, World!

`) 8 | }) 9 | let newText = newFs.read(path.join(process.cwd(), 'cli', 'index.html')) 10 | console.log(newText) 11 | 12 | let loadedFs = await VirtualFS.load(path.join(process.cwd(), 'cli')) 13 | let loadedText = loadedFs.read(path.join(process.cwd(), 'cli', 'index.html')) 14 | console.log(loadedText) 15 | 16 | loadedFs.write(path.join(process.cwd(), 'cli', 'index.html'), `

Hello, Nobody!

`) 17 | await loadedFs.sync() 18 | console.log(loadedFs.read(path.join(process.cwd(), 'cli', 'index.html'))) 19 | -------------------------------------------------------------------------------- /src/VirtualAsset.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { extname } from 'path'; 3 | 4 | export enum AssetType { 5 | Dir, 6 | File, 7 | RootDir, 8 | } 9 | export class VirtualAsset { 10 | assetType: AssetType; 11 | text: string; 12 | isRoot: boolean; 13 | path: string; 14 | filename: string; 15 | dirname: string; 16 | ext: string; 17 | constructor(diskType: AssetType, text: string) { 18 | this.assetType = diskType; 19 | this.text = text; 20 | this.isRoot = false; 21 | this.path = ''; 22 | this.filename = ''; 23 | this.dirname = ''; 24 | this.ext = ''; 25 | } 26 | static dir(): VirtualAsset { 27 | let record = new VirtualAsset(AssetType.Dir, '') 28 | return record; 29 | } 30 | static file(content: string): VirtualAsset { 31 | let record = new VirtualAsset(AssetType.File, content) 32 | return record; 33 | } 34 | isDir(): boolean { 35 | return this.assetType == AssetType.Dir || this.assetType == AssetType.RootDir; 36 | } 37 | static rootDir(): VirtualAsset { 38 | let asset = new VirtualAsset(AssetType.RootDir, '') 39 | return asset 40 | } 41 | setPath(p: string) { 42 | this.path = p; 43 | this.ext = extname(p) 44 | this.dirname = path.dirname(p); 45 | this.filename = path.basename(p); 46 | } 47 | async save() { 48 | if (this.assetType != AssetType.File) { 49 | return 50 | } 51 | await Bun.write(this.path, this.text); 52 | } 53 | } -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "hoist", 6 | "dependencies": { 7 | "floss": "github.com:phillip-england/floss", 8 | }, 9 | "devDependencies": { 10 | "@types/bun": "latest", 11 | }, 12 | "peerDependencies": { 13 | "typescript": "^5", 14 | }, 15 | }, 16 | }, 17 | "packages": { 18 | "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], 19 | 20 | "@types/node": ["@types/node@24.8.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-5x08bUtU8hfboMTrJ7mEO4CpepS9yBwAqcL52y86SWNmbPX8LVbNs3EP4cNrIZgdjk2NAlP2ahNihozpoZIxSg=="], 21 | 22 | "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], 23 | 24 | "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], 25 | 26 | "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 27 | 28 | "floss": ["floss@git+ssh://github.com:phillip-england/floss#ebe7ce046a55edd2684c48f305db672a366dec43", { "peerDependencies": { "typescript": "^5" } }, "ebe7ce046a55edd2684c48f305db672a366dec43"], 29 | 30 | "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 31 | 32 | "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/VirtualFS.ts: -------------------------------------------------------------------------------- 1 | import { AssetType, VirtualAsset } from "./VirtualAsset"; 2 | import { walkDir } from "floss"; 3 | import { rm, mkdir } from "fs/promises"; 4 | import path from 'path'; 5 | 6 | export class VirtualFS { 7 | rootPath: string; 8 | assets: Record; 9 | 10 | constructor(rootPath: string, VirtualAssets: Record) { 11 | this.rootPath = path.resolve(rootPath); 12 | // Normalize all asset keys 13 | this.assets = {}; 14 | for (const [key, value] of Object.entries(VirtualAssets)) { 15 | const normalizedKey = this.normalizePath(key); 16 | this.assets[normalizedKey] = value; 17 | } 18 | } 19 | 20 | private normalizePath(inputPath: string): string { 21 | const relative = path.relative(process.cwd(), path.resolve(inputPath)); 22 | const normalized = relative.startsWith('.') ? relative : `./${relative}`; 23 | return normalized.split(path.sep).join('/'); 24 | } 25 | 26 | static async overwrite(rootPath: string, VirtualAssets: Record): Promise { 27 | const resolvedPath = path.resolve(rootPath); 28 | await rm(resolvedPath, { recursive: true, force: true }); 29 | return await VirtualFS.create(resolvedPath, VirtualAssets); 30 | } 31 | 32 | static async create(rootPath: string, VirtualAssets: Record): Promise { 33 | const fs = new VirtualFS(rootPath, VirtualAssets); 34 | await mkdir(fs.rootPath, { recursive: true }); 35 | for (const [relativePath, asset] of Object.entries(fs.assets)) { 36 | if (asset.assetType === AssetType.RootDir) { 37 | continue; 38 | } 39 | const fullPath = path.join(process.cwd(), relativePath); 40 | if (asset.isDir()) { 41 | await mkdir(fullPath, { recursive: true }); 42 | } else { 43 | await mkdir(path.dirname(fullPath), { recursive: true }); 44 | await Bun.write(fullPath, asset.text); 45 | } 46 | asset.setPath(fullPath) 47 | } 48 | return fs; 49 | } 50 | 51 | static async load(rootPath: string): Promise { 52 | const resolvedRoot = path.resolve(rootPath); 53 | const assets: Record = {}; 54 | await walkDir(resolvedRoot, async (fullPath, isDir) => { 55 | const relativePath = convertToRelativePath(fullPath, process.cwd()); 56 | if (isDir) { 57 | let asset: VirtualAsset; 58 | if (path.resolve(fullPath) === path.resolve(resolvedRoot)) { 59 | asset = VirtualAsset.rootDir() 60 | } else { 61 | asset = VirtualAsset.dir() 62 | } 63 | asset.setPath(fullPath) 64 | assets[relativePath] = asset; 65 | return; 66 | } 67 | const file = Bun.file(fullPath); 68 | const text = await file.text(); 69 | let asset = VirtualAsset.file(text) 70 | asset.setPath(fullPath) 71 | assets[relativePath] = asset; 72 | }); 73 | 74 | return new VirtualFS(resolvedRoot, assets); 75 | } 76 | 77 | read(filePath: string): string { 78 | const normalizedPath = this.normalizePath(filePath); 79 | const asset = this.assets[normalizedPath]; 80 | if (!asset) { 81 | throw new Error(`No asset found for ${filePath}`); 82 | } 83 | if (asset.isDir()) { 84 | throw new Error(`Cannot read directory: ${filePath}`); 85 | } 86 | return asset.text; 87 | } 88 | 89 | write(filePath: string, content: string) { 90 | const normalizedPath = this.normalizePath(filePath); 91 | const asset = this.assets[normalizedPath]; 92 | if (!asset) { 93 | throw new Error(`No asset found for ${filePath}`); 94 | } 95 | if (asset.isDir()) { 96 | throw new Error(`Cannot write to directory: ${filePath}`); 97 | } 98 | asset.text = content; 99 | } 100 | 101 | async sync(): Promise { 102 | for (const [relativePath, asset] of Object.entries(this.assets)) { 103 | if (asset.assetType != AssetType.File) { 104 | continue 105 | } 106 | await asset.save() 107 | } 108 | } 109 | 110 | async iterAssets(callback: (asset: VirtualAsset, relativePath: string) => Promise | void): Promise { 111 | for (const [relativePath, asset] of Object.entries(this.assets)) { 112 | await callback(asset, relativePath); 113 | } 114 | } 115 | 116 | } 117 | 118 | function convertToRelativePath(absolutePath: string, basePath: string): string { 119 | const relative = path.relative(basePath, absolutePath); 120 | const withDot = relative.startsWith('.') ? relative : `./${relative}`; 121 | return withDot.split(path.sep).join('/'); 122 | } --------------------------------------------------------------------------------