UNPKG

wasm-git

Version:

Wasm-git ======== ([Wasm should be pronounced like `awesome` starting with a `W`](https://youtu.be/C8j_ieOm4vE?t=1644) — as stated in the [WebAssembly Music](https://github.com/petersalomonsen/javascriptmusic) talk at WebAssembly Summit 2020).

291 lines (267 loc) 10.8 kB
/** * lg2_opfs_auto.js — runtime loader that picks the most optimal OPFS-backed * wasm-git build the current browser supports, and exposes a single, uniform * git API on top of all three OPFS variants. * * Selection order (best first): * 1. pthreads / WasmFS (lg2_opfs.js) — fastest, needs cross-origin * isolation (SharedArrayBuffer + COOP/COEP). * 2. JSPI (lg2_opfs_jspi.js) — WebAssembly stack switching; * no SharedArrayBuffer required. * 3. ASYNCIFY (lg2_opfs_async.js) — universal fallback; works * everywhere wasm + OPFS do, no SharedArrayBuffer required. * * If OPFS itself is unavailable (no `navigator.storage.getDirectory`, insecure * context) `selectOpfsVariant()` returns `null`; callers should fall back to * the non-OPFS IDBFS build (`lg2.js`). * * The detection helpers below are pure and take an `env` object (defaulting to * `globalThis`) so they can be unit-tested / forced in tests. * * Usage (inside a Web Worker — OPFS sync access handles require a Worker): * * import { loadOpfsGit } from './lg2_opfs_auto.js'; * const git = await loadOpfsGit({ user: 'Me', email: 'me@example.com' }); * console.log(git.variant); // 'pthreads' | 'jspi' | 'asyncify' * await git.clone(url, 'repo.git'); * await git.writeFile('repo.git', 'a.txt', 'hello'); * await git.addCommitPush('repo.git', 'a.txt', 'add a.txt'); * git.readFile('repo.git', 'a.txt'); // 'hello' */ export const VARIANT_FILES = { pthreads: 'lg2_opfs.js', jspi: 'lg2_opfs_jspi.js', asyncify: 'lg2_opfs_async.js', }; /** * Feature-detect the relevant capabilities of an environment. * @param {object} [env=globalThis] * @returns {{opfsAvailable:boolean, crossOriginIsolated:boolean, jspiAvailable:boolean}} */ export function detectOpfsEnvironment(env = globalThis) { const nav = env.navigator; const opfsAvailable = !!( nav && nav.storage && typeof nav.storage.getDirectory === 'function' ); const crossOriginIsolated = env.crossOriginIsolated === true && typeof env.SharedArrayBuffer !== 'undefined'; const wasm = env.WebAssembly; // JSPI shipped as WebAssembly.Suspending / WebAssembly.promising. Older // experimental builds exposed WebAssembly.Function with a suspending option; // we only rely on the shipped API here. const jspiAvailable = !!( wasm && (typeof wasm.Suspending === 'function' || typeof wasm.promising === 'function') ); return { opfsAvailable, crossOriginIsolated, jspiAvailable }; } /** * Choose the best OPFS build for an environment. * @param {object} [env=globalThis] * @returns {('pthreads'|'jspi'|'asyncify'|null)} null means OPFS is unavailable. */ export function selectOpfsVariant(env = globalThis) { const { opfsAvailable, crossOriginIsolated, jspiAvailable } = detectOpfsEnvironment(env); if (!opfsAvailable) return null; if (crossOriginIsolated) return 'pthreads'; if (jspiAvailable) return 'jspi'; return 'asyncify'; } /** * Load and initialise the selected OPFS build. * * @param {object} [options] * @param {object} [options.env=globalThis] environment to detect against * @param {string} [options.variant] force a variant (skip detection) * @param {string|URL} [options.baseUrl=import.meta.url] base for resolving the variant file * @param {object} [options.variantFiles=VARIANT_FILES] override variant→filename map * @param {object} [options.moduleOverrides] print/printErr overrides for the module * @param {string} [options.user] git user.name to write to ~/.gitconfig * @param {string} [options.email] git user.email to write to ~/.gitconfig * @returns {Promise<OpfsGit>} */ export async function loadOpfsGit(options = {}) { const env = options.env || globalThis; const variant = options.variant || selectOpfsVariant(env); if (!variant) { throw new Error( 'OPFS is not available in this context — fall back to the IDBFS build (lg2.js).' ); } const files = options.variantFiles || VARIANT_FILES; const file = files[variant]; if (!file) throw new Error(`Unknown OPFS variant: ${variant}`); if (options.moduleOverrides) { globalThis.wasmGitModuleOverrides = options.moduleOverrides; } const baseUrl = options.baseUrl || import.meta.url; const mod = await import(new URL(file, baseUrl)); const module = await mod.default(options.moduleArg || {}); const git = new OpfsGit(module, variant, env); await git.init({ user: options.user, email: options.email }); return git; } /** * Uniform git facade over the three OPFS variants. Repos live under `/opfs` * (the OPFS root). All methods accept a bare repo directory name (e.g. * `repo.git`) which is resolved under `/opfs`. */ export class OpfsGit { constructor(module, variant, env = globalThis) { this.module = module; this.FS = module.FS; this.variant = variant; this.crossOriginIsolated = env.crossOriginIsolated === true; this.opfsRoot = '/opfs'; } repoDir(repoName) { return `${this.opfsRoot}/${repoName}`; } async init({ user = 'wasm-git', email = 'wasm-git@example.com' } = {}) { const FS = this.FS; try { FS.mkdir('/home'); } catch (e) {} try { FS.mkdir('/home/web_user'); } catch (e) {} FS.writeFile('/home/web_user/.gitconfig', `[user]\nname = ${user}\nemail = ${email}`); if (this.variant === 'pthreads') { // WasmFS: explicitly mount an OPFS backend at /opfs. const backend = this.module._lg2_create_opfs_backend(); if (!backend) throw new Error('Failed to create OPFS backend'); const rc = this.module.ccall( 'lg2_create_directory', 'number', ['string', 'number', 'number'], [this.opfsRoot, 0o777, backend] ); if (rc !== 0) throw new Error('Failed to create OPFS directory, error: ' + rc); } // For async/jspi the /opfs MEMFS mount is created by the library at preRun. FS.chdir(this.opfsRoot); return this; } // ---- WasmFS getcwd() workaround (pthreads only) ------------------------- // WasmFS getcwd() drops the mount-point name for OPFS-backed dirs, returning // '//repo' instead of '/opfs/repo', which breaks libgit2 repo discovery. A // root symlink makes the broken path still resolve correctly. _createMountSymlink(repoName) { try { this.FS.unlink('/' + repoName); } catch (e) {} this.FS.symlink(this.repoDir(repoName), '/' + repoName); } _removeMountSymlink(repoName) { try { this.FS.unlink('/' + repoName); } catch (e) {} } _rmdirRecursive(path) { const FS = this.FS; for (const entry of FS.readdir(path).filter((e) => e !== '.' && e !== '..')) { const full = path + '/' + entry; try { FS.readdir(full); this._rmdirRecursive(full); } catch (e) { FS.unlink(full); } } FS.rmdir(path); } /** callMain that works for both sync (pthreads) and async (asyncify/jspi). */ async run(args) { return await this.module.callMain(args); } /** * Recreate git's REQUIRED empty directories. Anything that copies a repo * file-by-file (IDBFS→OPFS migrations, zip round-trips, object-store synced * copies) silently loses empty directories — and libgit2 cannot create * `.git/objects/pack` itself: a later fetch downloads the packfile but never * indexes it, failing with "target OID for the reference doesn't exist on * the repository" and no hint of the real cause (diagnosed from a real user * repository that had only ever written loose objects). */ _ensureGitSkeleton(dir) { const FS = this.FS; try { FS.readdir(`${dir}/.git/objects`); } catch (e) { return; } for (const sub of ['objects/pack', 'objects/info', 'refs', 'refs/heads', 'refs/tags']) { try { FS.mkdir(`${dir}/.git/${sub}`); } catch (e) { /* exists */ } } } /** * Make an already-persisted repo available locally. * @returns {Promise<boolean>} true if the repo exists in OPFS. */ async syncRepo(repoName) { const FS = this.FS; const dir = this.repoDir(repoName); if (this.variant === 'pthreads') { try { const contents = FS.readdir(dir); if (contents.find((f) => f === '.git')) { this._createMountSymlink(repoName); this._ensureGitSkeleton(dir); FS.chdir(dir); return true; } } catch (e) { /* not present */ } return false; } // async / jspi: load the OPFS sub-tree into the MEMFS cache. const loaded = await this.module.opfsLoadTree(dir); if (!loaded) return false; try { if (FS.readdir(dir).find((f) => f === '.git')) { this._ensureGitSkeleton(dir); FS.chdir(dir); return true; } } catch (e) {} return false; } /** Clone a repository into `/opfs/<repoName>`, replacing any existing copy. */ async clone(url, repoName) { const FS = this.FS; const dir = this.repoDir(repoName); await this.removeRepo(repoName); await this.run(['clone', url, dir]); if (this.variant === 'pthreads') this._createMountSymlink(repoName); FS.chdir(dir); return FS.readdir('.'); } /** Write a working-tree file, persisting it to OPFS. */ async writeFile(repoName, filename, contents) { const path = `${this.repoDir(repoName)}/${filename}`; if (this.variant === 'pthreads') { this.FS.writeFile(path, contents); } else { await this.module.opfsWriteFile(path, contents); } } /** Read a working-tree file from the local cache. */ readFile(repoName, filename, encoding = 'utf8') { return this.FS.readFile(`${this.repoDir(repoName)}/${filename}`, { encoding }); } readdir(repoName) { return this.FS.readdir(this.repoDir(repoName)); } /** Stage, commit and push a file. */ async addCommitPush(repoName, filename, message = `update ${filename}`) { const FS = this.FS; const dir = this.repoDir(repoName); FS.chdir(dir); await this.run(['add', '--verbose', filename]); FS.chdir(dir); await this.run(['commit', '-m', message]); FS.chdir(dir); await this.run(['push']); FS.chdir(dir); return FS.readdir('.'); } /** Delete a repo from both the local cache and OPFS. */ async removeRepo(repoName) { const dir = this.repoDir(repoName); if (this.variant === 'pthreads') { try { this._rmdirRecursive(dir); } catch (e) {} try { const root = await navigator.storage.getDirectory(); await root.removeEntry(repoName, { recursive: true }); } catch (e) {} this._removeMountSymlink(repoName); } else { await this.module.opfsRemoveTree(dir); } } } export default loadOpfsGit;