├── 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 | }
--------------------------------------------------------------------------------