feat(ext/sw): mem-fs — dep-free in-memory fs for isomorphic-git org pushes

This commit is contained in:
adlee-was-taken
2026-06-25 23:51:00 -04:00
parent 273fe5cf30
commit 0bc1f53b48
2 changed files with 273 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import { createMemFs } from '../mem-fs';
describe('createMemFs', () => {
it('writeFile/readFile binary roundtrip (Uint8Array)', async () => {
const fs = createMemFs().promises;
const data = new Uint8Array([1, 2, 3, 4]);
await fs.writeFile('/a.bin', data);
const result = await fs.readFile('/a.bin', undefined);
expect(result).toBeInstanceOf(Uint8Array);
expect(Array.from(result as Uint8Array)).toEqual([1, 2, 3, 4]);
});
it('writeFile/readFile utf8 roundtrip via {encoding:"utf8"}', async () => {
const fs = createMemFs().promises;
await fs.writeFile('/hello.txt', 'hello Relicario');
const result = await fs.readFile('/hello.txt', { encoding: 'utf8' });
expect(result).toBe('hello Relicario');
});
it('writeFile/readFile utf8 roundtrip via string encoding arg', async () => {
const fs = createMemFs().promises;
await fs.writeFile('/hello2.txt', 'hola Relicario');
const result = await fs.readFile('/hello2.txt', 'utf8');
expect(result).toBe('hola Relicario');
});
it('writeFile copies a subarray view correctly', async () => {
const fs = createMemFs().promises;
const bigBuf = new Uint8Array([0, 1, 10, 20, 30, 99, 99]);
const view = bigBuf.subarray(2, 5); // [10, 20, 30]
await fs.writeFile('/sub.bin', view);
// Mutate the original buffer — stored copy must be unaffected
bigBuf[2] = 0xff;
bigBuf[3] = 0xff;
bigBuf[4] = 0xff;
const result = await fs.readFile('/sub.bin', undefined);
expect(Array.from(result as Uint8Array)).toEqual([10, 20, 30]);
});
it('readdir returns only immediate children for explicit mkdir dirs', async () => {
const fs = createMemFs().promises;
await fs.mkdir('/mydir');
await fs.writeFile('/mydir/file1.txt', 'a');
await fs.writeFile('/mydir/file2.txt', 'b');
await fs.mkdir('/mydir/subdir');
const entries = await fs.readdir('/mydir');
expect(entries.sort()).toEqual(['file1.txt', 'file2.txt', 'subdir'].sort());
});
it('readdir returns only immediate children for implicit dirs', async () => {
const fs = createMemFs().promises;
// Write a deeply nested file without explicit mkdir
await fs.writeFile('/a/b/c.txt', 'test');
const aEntries = await fs.readdir('/a');
expect(aEntries).toEqual(['b']);
const bEntries = await fs.readdir('/a/b');
expect(bEntries).toEqual(['c.txt']);
});
it('stat: a written file isFile()', async () => {
const fs = createMemFs().promises;
await fs.writeFile('/statfile.txt', 'data');
const st = await fs.stat('/statfile.txt');
expect(st.isFile()).toBe(true);
expect(st.isDirectory()).toBe(false);
expect(st.isSymbolicLink()).toBe(false);
expect(typeof st.size).toBe('number');
expect(typeof st.ino).toBe('number');
expect(typeof st.mode).toBe('number');
});
it('stat: an implicit parent directory isDirectory()', async () => {
const fs = createMemFs().promises;
await fs.writeFile('/c/items/x.enc', new Uint8Array([9]));
const st = await fs.stat('/c/items');
expect(st.isDirectory()).toBe(true);
expect(st.isFile()).toBe(false);
});
it('stat: missing path throws ENOENT', async () => {
const fs = createMemFs().promises;
await expect(fs.stat('/does/not/exist')).rejects.toMatchObject({ code: 'ENOENT' });
});
it('unlink removes a file (subsequent readFile throws ENOENT)', async () => {
const fs = createMemFs().promises;
await fs.writeFile('/todelete.txt', 'bye');
await fs.unlink('/todelete.txt');
await expect(fs.readFile('/todelete.txt', undefined)).rejects.toMatchObject({ code: 'ENOENT' });
});
it('readlink and symlink are functions (regression guard for isomorphic-git bindFs)', () => {
const { promises } = createMemFs();
expect(typeof promises.readlink).toBe('function');
expect(typeof promises.symlink).toBe('function');
});
});

View File

@@ -0,0 +1,175 @@
/**
* mem-fs.ts — dep-free in-memory filesystem for isomorphic-git org pushes.
*
* Implements a minimal PromiseFsClient backed by Map/Set (pure Web APIs).
* Org repo data MUST NOT persist (security invariant — never lightning-fs /
* IndexedDB). This module is intentionally side-effect-free and dep-free.
*
* CRITICAL: `readlink` and `symlink` MUST exist even though isomorphic-git
* types them as optional. `bindFs` unconditionally calls `.bind()` on them;
* omitting either throws "Cannot read properties of undefined (reading
* 'bind')" at git.init / clone. (Verified against isomorphic-git 1.38.5.)
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Stat {
isFile: () => boolean;
isDirectory: () => boolean;
isSymbolicLink: () => boolean;
type: 'file' | 'dir';
mode: number;
size: number;
mtimeMs: number;
ctimeMs: number;
mtime: Date;
ctime: Date;
uid: number;
gid: number;
dev: number;
ino: number;
}
// ---------------------------------------------------------------------------
// Public factory
// ---------------------------------------------------------------------------
export function createMemFs(): { promises: PromiseFsClient } {
const F = new Map<string, Uint8Array>(); // normalized path -> bytes (files)
const D = new Set<string>(['/']); // normalized dir paths
const inos = new Map<string, number>();
let ino = 1;
const norm = (p: string): string => {
p = ('/' + p).replace(/\/+/g, '/');
return p.length > 1 ? p.replace(/\/$/, '') : '/';
};
const inoFor = (p: string): number => {
if (!inos.has(p)) inos.set(p, ++ino);
return inos.get(p)!;
};
const ENOENT = (p: string): Error & { code: string } => {
const e = new Error(`ENOENT: no such file or directory, ${p}`) as Error & { code: string };
e.code = 'ENOENT';
return e;
};
const mkStat = (p: string, isFile: boolean, size: number): Stat => ({
isFile: () => isFile,
isDirectory: () => !isFile,
isSymbolicLink: () => false,
type: isFile ? 'file' : 'dir',
mode: isFile ? 0o100644 : 0o40000,
size,
mtimeMs: 1,
ctimeMs: 1,
mtime: new Date(1),
ctime: new Date(1),
uid: 1,
gid: 1,
dev: 1,
ino: inoFor(p),
});
const promises: PromiseFsClient = {
async readFile(p: string, opts?: unknown): Promise<Uint8Array | string> {
p = norm(p);
if (!F.has(p)) throw ENOENT(p);
const enc = typeof opts === 'string' ? opts : (opts as { encoding?: string } | null | undefined)?.encoding;
return enc ? new TextDecoder().decode(F.get(p)) : F.get(p)!;
},
async writeFile(p: string, data: string | Uint8Array): Promise<void> {
let bytes: Uint8Array;
if (typeof data === 'string') {
bytes = new TextEncoder().encode(data);
} else if (data instanceof Uint8Array) {
bytes = new Uint8Array(data); // copy — never alias a subarray view's backing buffer
} else {
bytes = new Uint8Array(data as ArrayBufferLike);
}
F.set(norm(p), bytes);
},
async unlink(p: string): Promise<void> {
F.delete(norm(p));
},
async readdir(p: string): Promise<string[]> {
p = norm(p);
const pre = p === '/' ? '/' : p + '/';
const kids = new Set<string>();
for (const f of F.keys()) {
if (f.startsWith(pre) && f !== p) kids.add(f.slice(pre.length).split('/')[0]);
}
for (const d of D) {
if (d.startsWith(pre) && d !== p) {
const r = d.slice(pre.length).split('/')[0];
if (r) kids.add(r);
}
}
return [...kids];
},
async mkdir(p: string): Promise<void> {
D.add(norm(p));
},
async rmdir(p: string): Promise<void> {
D.delete(norm(p));
},
// Org repos contain no symlinks — MUST exist for isomorphic-git bindFs.
async readlink(p: string): Promise<string> {
throw ENOENT(norm(p));
},
// Org repos contain no symlinks — MUST exist for isomorphic-git bindFs.
async symlink(): Promise<void> {
/* no-op: org repos contain no symlinks */
},
async stat(p: string): Promise<Stat> {
p = norm(p);
if (F.has(p)) return mkStat(p, true, F.get(p)!.length);
// Implicit directories: a path with file or dir children even if never mkdir'd.
if (
p === '/' ||
D.has(p) ||
[...F.keys()].some((x) => x.startsWith(p + '/')) ||
[...D].some((x) => x.startsWith(p + '/'))
) {
return mkStat(p, false, 0);
}
throw ENOENT(p);
},
async lstat(p: string): Promise<Stat> {
return promises.stat!(p) as Promise<Stat>;
},
};
return { promises };
}
// ---------------------------------------------------------------------------
// Local type alias — mirrors the subset of PromiseFsClient used above.
// We declare it locally to avoid pulling in the isomorphic-git package here.
// ---------------------------------------------------------------------------
interface PromiseFsClient {
readFile(filepath: string, options?: unknown): Promise<Uint8Array | string>;
writeFile(filepath: string, data: string | Uint8Array, options?: unknown): Promise<void>;
unlink(filepath: string, options?: unknown): Promise<void>;
readdir(filepath: string, options?: unknown): Promise<string[]>;
mkdir(filepath: string, options?: unknown): Promise<void>;
rmdir(filepath: string, options?: unknown): Promise<void>;
stat(filepath: string, options?: unknown): Promise<Stat>;
lstat?(filepath: string, options?: unknown): Promise<Stat>;
readlink?(filepath: string, options?: unknown): Promise<string>;
symlink?(target: string, filepath: string, type?: string): Promise<void>;
}