Cross-platform file system abstraction for web, Tauri, and Capacitor.
- File System Access API with handle persistence via IndexedDB
- Fallback mode using file picker + IndexedDB storage
- Tauri integration via @tauri-apps/plugin-dialog and @tauri-apps/plugin-fs
- Capacitor integration via @capacitor/filesystem
- Automatic platform detection with configurable overrides
- Type-safe error handling with discriminated result types
- Lazy directory loading - list entries without loading file contents
- Automatic storage pruning - keeps recent files within configured limit
npm install onefs
# or
bun add onefsimport { createOneFS } from 'onefs'
const fs = createOneFS({ appName: 'myapp' })
// Open a file
const result = await fs.openFile({ accept: ['.json', '.txt'] })
if (result.ok) {
const text = fs.readAsText(result.data)
console.log(text)
} else {
if (result.error.code === 'cancelled') {
console.log('User cancelled')
} else {
console.error(result.error.message)
}
}
// Save to the same file
const saveResult = await fs.saveFile(file, 'updated content')
// Save as new file
const newFile = await fs.saveFileAs('content', {
suggestedName: 'document.txt'
})OneFS abstracts platform differences, but some behaviors vary. Always check capabilities before assuming behavior.
File content is always returned as Uint8Array, never as a string. Use helper methods to convert:
const file = (await fs.openFile()).data
// Convert to string
const text = fs.readAsText(file)
// Parse as JSON
const json = fs.readAsJSON<MyType>(file)
// Get as Blob for images
const blob = fs.readAsBlob(file)The saveFile() method behaves differently depending on the platform:
| Platform | Behavior |
|---|---|
| web-fs-access | Saves in-place to original file location |
| tauri | Saves in-place to original file location |
| web-fallback | Triggers a download (cannot save in-place) |
| capacitor | Saves to app's Data directory (not original location) |
Check capabilities.canSaveInPlace to detect this:
if (fs.capabilities.canSaveInPlace) {
// Will save to original location
await fs.saveFile(file, newContent)
} else {
// Will trigger download or save to app directory
// Consider showing a different UI
await fs.saveFile(file, newContent)
}The file.path property has different meanings:
| Platform | file.path value |
|---|---|
| web-fs-access | undefined (no path access in browser) |
| web-fallback | undefined |
| tauri | Real filesystem path (e.g., /home/user/doc.txt) |
| capacitor | Synthetic identifier (e.g., onefs_123_doc.txt) |
Directory operations are not available on all platforms:
| Platform | openDirectory |
readDirectory |
|---|---|---|
| web-fs-access | Full support | Full support |
| web-fallback | Not supported | Not supported |
| tauri | Full support | Full support |
| capacitor | Documents only | Documents only |
if (fs.supportsDirectories) {
const dir = await fs.openDirectory()
// ...
}Directories are loaded lazily to avoid memory issues with large folders:
// Open directory picker
const dirResult = await fs.openDirectory()
if (!dirResult.ok) return
// List entries (metadata only - no content loaded)
const entriesResult = await fs.readDirectory(dirResult.data)
if (!entriesResult.ok) return
for (const entry of entriesResult.data) {
console.log(entry.name, entry.kind, entry.size)
if (entry.kind === 'file') {
// Load specific file content on demand
const fileResult = await fs.readFileFromDirectory(dirResult.data, entry)
if (fileResult.ok) {
const content = fs.readAsText(fileResult.data)
}
}
}Directory entries include metadata without content:
interface OneFSEntry {
name: string // "document.txt" or "subfolder"
kind: 'file' | 'directory'
size?: number // File size in bytes (files only)
lastModified?: number // Timestamp (files only)
path?: string // Full path (Tauri/Capacitor only)
handle?: FileSystemHandle // Native handle (web-fs-access only)
}All async operations return OneFSResult<T>:
type OneFSResult<T> =
| { ok: true; data: T }
| { ok: false; error: OneFSError }
interface OneFSError {
code: OneFSErrorCode
message: string
cause?: unknown // Original error if available
}
type OneFSErrorCode =
| 'cancelled' // User cancelled operation
| 'permission_denied' // No permission to access file/directory
| 'not_supported' // Operation not supported on this platform
| 'not_found' // File/handle not found
| 'io_error' // Generic I/O error
| 'unknown' // Unknown errorExample:
const result = await fs.openFile()
if (!result.ok) {
switch (result.error.code) {
case 'cancelled':
// User clicked cancel - not an error
break
case 'permission_denied':
showPermissionDialog()
break
case 'not_supported':
showFallbackUI()
break
default:
console.error('Failed:', result.error.message)
}
return
}
const file = result.dataconst fs = createOneFS({
appName: 'myapp', // Required - used for IndexedDB database name
maxRecentFiles: 10, // Max files to remember (default: 10)
persistByDefault: true, // Store files/handles in IndexedDB (default: true)
useNativeFSAccess: true, // Use File System Access API when available (default: true)
preferredAdapter: 'tauri', // Force specific adapter (optional)
})// Don't persist this file to recent list
const file = await fs.openFile({ persist: false })
// Save without adding to recent
await fs.saveFileAs(content, { persist: false })console.log(fs.platform)
// 'web-fs-access' | 'web-fallback' | 'tauri' | 'capacitor'
console.log(fs.capabilities)
// {
// openFile: true,
// saveFile: true,
// saveFileAs: true,
// openDirectory: true,
// readDirectory: true,
// handlePersistence: true,
// canSaveInPlace: true,
// }
console.log(fs.supportsDirectories) // boolean
console.log(fs.supportsHandlePersistence) // boolean| Capability | web-fs-access | web-fallback | tauri | capacitor |
|---|---|---|---|---|
| openFile | Yes | Yes | Yes | Yes |
| saveFile | Yes | Yes (download) | Yes | Yes (app dir) |
| saveFileAs | Yes | Yes (download) | Yes | Yes (app dir) |
| openDirectory | Yes | No | Yes | Limited |
| readDirectory | Yes | No | Yes | Limited |
| handlePersistence | Yes | No | No | No |
| canSaveInPlace | Yes | No | Yes | No |
interface OneFSFile {
id: string // Unique identifier
name: string // File name (e.g., "document.txt")
path?: string // Full path (Tauri/Capacitor only)
content: Uint8Array // File content as bytes
mimeType: string // MIME type (e.g., "text/plain")
size: number // File size in bytes
lastModified: number // Timestamp (ms since epoch)
handle?: FileSystemFileHandle // Native handle (web-fs-access only)
}fs.readAsText(file) // string (UTF-8)
fs.readAsJSON(file) // parsed JSON
fs.readAsDataURL(file) // data:mime;base64,...
fs.readAsBlob(file) // Blob
fs.readAsObjectURL(file) // blob:... (remember to revoke!)// Get recent files
const recent = await fs.getRecentFiles()
// Returns StoredHandle[] with { id, name, path?, type, storedAt }
// Restore a file
const file = await fs.restoreFile(recent[0])
// On web-fs-access: Re-reads from disk (may prompt for permission)
// On other platforms: Returns cached content from IndexedDB
// Restore a directory with specific permission mode
const dir = await fs.restoreDirectory(recent[0], 'readwrite')
// Remove from recent
await fs.removeFromRecent(id)
// Clear all
await fs.clearRecent()Check and request permissions on files and directories:
// Check current permission status
const status = await fs.queryPermission(directory, 'readwrite')
// Returns: 'granted' | 'denied' | 'prompt'
// Request permission (must be called during user gesture)
const result = await fs.requestPermission(directory, 'readwrite')
if (result.ok) {
// Permission granted
}
// On non-web-fs-access platforms, these return 'granted' and ok(true)Store directories by key, separate from the recent files list. Useful for app preferences like output directories:
// Open and store a directory by key
const dir = await fs.openDirectory({ mode: 'readwrite' })
if (dir.ok) {
await fs.setNamedDirectory('outputDir', dir.data)
}
// Retrieve later (automatically requests permission)
const stored = await fs.getNamedDirectory('outputDir', 'readwrite')
if (stored.ok) {
// Use stored.data.handle for file operations
}
// Remove
await fs.removeNamedDirectory('outputDir')// Main factory
import { createOneFS, OneFS } from 'onefs'
// Types
import type {
OneFSFile,
OneFSDirectory,
OneFSEntry,
OneFSResult,
OneFSError,
OneFSErrorCode,
OneFSCapabilities,
Platform,
StoredHandle,
} from 'onefs'
// Helpers
import { ok, err, PLATFORM_CAPABILITIES } from 'onefs'
// Individual adapters (for advanced use)
import {
FSAccessAdapter,
PickerIDBAdapter,
TauriAdapter,
CapacitorAdapter,
} from 'onefs'Add the required plugins to your Cargo.toml:
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"And initialize them in your Tauri app:
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}Install the filesystem plugin:
npm install @capacitor/filesystem
npx cap syncFor iOS Files app integration (users can drop files into your app's folder):
Add to your ios/App/App/Info.plist:
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>This exposes your app's Documents folder in the iOS Files app. Users can drag files there, and your app can scan them with openDirectory() and scanDirectory().
Optional: Install @capawesome/capacitor-file-picker for native file picker (otherwise falls back to HTML input).
Recursively scan directories for files with optional filtering:
const dir = await fs.openDirectory()
if (!dir.ok) return
// Scan for specific file types
const result = await fs.scanDirectory(dir.data, {
extensions: ['.mp3', '.flac', '.wav'],
skipStats: true, // Faster - don't fetch size/mtime
onProgress: (scanned, found) => {
console.log(`Scanned ${scanned} entries, found ${found} matches`)
},
signal: abortController.signal, // Optional cancellation
})
if (result.ok) {
for (const entry of result.data) {
console.log(entry.name, entry.path)
}
}Get efficient URLs for media playback without loading files into memory:
// From a directory entry (recommended for media apps)
const url = await fs.getEntryUrl(entry)
if (url) {
audioElement.src = url
}
// From a file object
const url = await fs.getFileUrl(file)On Tauri/Capacitor, this uses convertFileSrc() for efficient native streaming.
On web platforms, falls back to blob URLs.
The following features are planned but not yet implemented:
- Streaming support for large files (ReadableStream)
- File watching for external changes (FileSystemObserver)
MIT