lightning-pyodide
v0.1.0
Published
Mount an @isomorphic-git/lightning-fs filesystem into Pyodide's Emscripten filesystem
Maintainers
Readme
lightning-pyodide
Mount an @isomorphic-git/lightning-fs
filesystem into Pyodide, so Python can read and write files that live
in IndexedDB — and that isomorphic-git can operate on.
pyodide.runPython(`open('/data/notes.txt', 'w').write('hello')`);
await mount.push(); // now in IndexedDB, and visible to isomorphic-gitHow it works (read this first)
Emscripten's filesystem API is synchronous. LightningFS is promise-based. Python's
open() cannot await an IndexedDB read, so a direct pass-through adapter is impossible.
Instead, a mount holds an in-memory working copy and reconciles it with LightningFS on
demand. This is exactly the approach Pyodide itself takes for mountNativeFS, whose
NATIVEFS_ASYNC bridges to the equally-async File System Access API.
What that means in practice:
- Reads and writes inside Python are fast and synchronous.
- Changes are not persisted until you
push()(or enableautoSync). - Changes made to LightningFS from outside are not visible until you
pull(). - The mounted subtree is held in memory, so mount a directory, not a huge archive.
The alternative — a truly synchronous pass-through via Atomics.wait and a
SharedArrayBuffer — requires cross-origin isolation (COOP/COEP headers) and forces
Pyodide into a worker, since it must block the thread it runs on. That is deliberately out of
scope here.
Install
npm install lightning-pyodidepyodide and @isomorphic-git/lightning-fs are not dependencies — the adapter takes whatever
you already have and only requires a compatible shape.
Usage
ESM / TypeScript
import { loadPyodide } from 'pyodide';
import LightningFS from '@isomorphic-git/lightning-fs';
import { mountLightningFS } from 'lightning-pyodide';
const pyodide = await loadPyodide();
const fs = new LightningFS('my-app');
const mount = await mountLightningFS(pyodide, { fs, path: '/data' });
// Files already in LightningFS are visible immediately.
pyodide.runPython(`
import os
print(os.listdir('/data'))
with open('/data/report.txt', 'w') as f:
f.write('written by Python')
`);
// Persist back to IndexedDB.
await mount.push();Browser <script> tag
<script src="https://cdn.jsdelivr.net/npm/lightning-pyodide"></script>
<script>
const mount = await LightningPyodide.mountLightningFS(pyodide, {
fs,
path: '/data'
});
</script>Sharing a repository with isomorphic-git
The reason LightningFS exists is isomorphic-git, and both sides can use one filesystem:
import git from 'isomorphic-git';
const fs = new LightningFS('repos');
await git.clone({ fs, http, dir: '/project', url: '...' });
const mount = await mountLightningFS(pyodide, {
fs,
path: '/project',
root: '/project',
});
pyodide.runPython(`open('/project/generated.py', 'w').write('X = 1')`);
await mount.push();
await git.add({ fs, dir: '/project', filepath: 'generated.py' });
await git.commit({ fs, dir: '/project', message: 'Generated from Python' });
// Pick up whatever git just wrote (index, refs, objects).
await mount.pull();Automatic syncing
autoSync pushes on a timer, so you do not have to call push() by hand:
const mount = await mountLightningFS(pyodide, {
fs,
path: '/data',
autoSync: 2000, // milliseconds; `true` means 1000
onError: err => console.warn('sync failed', err),
});A tick with nothing to do costs one in-memory tree walk and touches neither IndexedDB nor the
mount. When autoSync is on, unmount() performs a final push automatically.
API
mountLightningFS(pyodide, options)
Returns a Promise<LightningMount>.
| Option | Type | Default | Description |
| ---------- | ------------------- | --------------- | --------------------------------------------------------------------------- |
| fs | LightningFS | (required) | The filesystem to mount. Any object with a compatible promises API works. |
| path | string | '/lightning' | Emscripten mountpoint. Created if missing; must be empty if it exists. |
| root | string | '/' | Directory inside LightningFS to expose. Created if missing. |
| populate | boolean | true | Copy LightningFS into the mount before returning. |
| autoSync | boolean \| number | false | Push on a timer. true uses 1000 ms; a number sets the interval. |
| onError | (error) => void | console.error | Called when an autoSync tick fails — those have no caller to reject. |
The first argument only needs an Emscripten FS, so { FS: Module.FS } works for a
non-Pyodide Emscripten build too.
LightningMount
| Member | Description |
| ------------------- | --------------------------------------------------------------- |
| path | The Emscripten mountpoint. |
| root | The LightningFS directory exposed at path. |
| push() | Copy Emscripten → LightningFS. |
| pull() | Copy LightningFS → Emscripten. |
| syncfs(populate?) | Emscripten-style alias: true pulls, false (default) pushes. |
| flush() | Resolve once any in-flight or queued sync has settled. |
| unmount(options?) | Detach the mount. Later sync calls reject. |
Every sync is serialised per mount, so concurrent push()/pull() calls queue rather than
interleave.
unmount({ push }) overrides whether a final push happens; by default it does when autoSync
is enabled and does not otherwise.
pyodide.FS.syncfs
The adapter registers itself as FS.filesystems.LIGHTNINGFS, so Emscripten's own API drives
it too — useful when other mounts need syncing at the same time:
await new Promise((resolve, reject) =>
pyodide.FS.syncfs(false, err => (err ? reject(err) : resolve())),
);registerFilesystem(FS) is exported if you would rather call FS.mount yourself.
What is synced
Regular files, directories and symlinks, along with their permission bits. Character devices, sockets and FIFOs have no LightningFS equivalent and stay local to the mount.
A file is considered stale when its modification time is newer or its size differs. Both
clocks have millisecond resolution, so size is what catches two writes landing in the same
millisecond — the same quick check rsync uses. Two same-size edits within one millisecond
are indistinguishable and would be missed.
Directory timestamps are ignored: Emscripten bumps a directory's mtime whenever a child is created, so they drift apart on their own and say nothing about staleness.
After a push, the local timestamp is advanced to whatever LightningFS recorded (it stamps its
own and has no utimes). That keeps both sides comparable, so a repeated push() is a true
no-op instead of recopying everything.
Symlink targets
LightningFS normalises every path it stores, rewriting a relative target such as target.txt
to ./target.txt. A symlink round-tripped through LightningFS therefore comes back in that
form. The adapter treats the two spellings as equal, so an unchanged link is not rewritten on
every sync.
Caveats
- Memory. The mounted subtree is held in memory for as long as it is mounted.
- No read-through. A file added to LightningFS after mounting is invisible until
pull(). - Last writer wins. Reconciliation is one-directional per call; it compares timestamps and
sizes and does not merge. Nothing stops a
pull()from overwriting unpushed local edits. - One process at a time. LightningFS coordinates tabs with a mutex, but a long-lived mount holding an in-memory copy is not a substitute for that; do not mount the same subtree from two tabs and expect them to converge.
Development
npm install
npm test # boots a real Pyodide runtime
npm run coverage
npm run lint
npm run buildTests run in Node against the real Pyodide package, with fake-indexeddb standing in for the
browser's IndexedDB.
License
Copyright (c) 2026 Jakub T. Jankiewicz
Released under the MIT License. See LICENSE for details.
