Skip to content

ckep1/onefs

Repository files navigation

onefs

Cross-platform file system abstraction for web, Tauri, and Capacitor.

Features

  • 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

Installation

npm install onefs
# or
bun add onefs

Quick Start

import { 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'
})

Important: Platform Differences

OneFS abstracts platform differences, but some behaviors vary. Always check capabilities before assuming behavior.

Content is Always Uint8Array

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)

Save Behavior Varies by Platform

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)
}

Path Property Varies

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 Support

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()
  // ...
}

Directory Operations

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)
    }
  }
}

OneFSEntry

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)
}

Error Handling

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 error

Example:

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.data

Configuration

const 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)
})

Per-Operation Options

// 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 })

Platform Detection

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

Platform Capabilities Matrix

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

OneFSFile

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)
}

Helper Methods

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!)

Recent Files

// 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()

Permission Management (web-fs-access only)

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)

Named Directory Storage (web-fs-access only)

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')

Exports

// 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'

Platform-Specific Setup

Tauri

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");
}

Capacitor

Install the filesystem plugin:

npm install @capacitor/filesystem
npx cap sync

For 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).

Scanning Directories

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)
  }
}

Streaming URLs (Tauri/Capacitor)

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.

Future Improvements

The following features are planned but not yet implemented:

  • Streaming support for large files (ReadableStream)
  • File watching for external changes (FileSystemObserver)

License

MIT

About

Cross-platform file system abstraction for web, Tauri, and Capacitor

Topics

Resources

License

Stars

Watchers

Forks

Contributors