file-system-access
Version:
File System Access API implementation (ponyfill) with pluggable storage adapters via IndexedDB, Cache API, in-memory etc.
265 lines (235 loc) • 8.32 kB
text/typescript
import { Adapter, FileSystemFileHandleAdapter, FileSystemFolderHandleAdapter, WriteChunk } from '../interfaces.js'
import { errors, isChunkObject } from '../util.js'
const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX } = errors
const DIR = { headers: { 'content-type': 'dir' } }
const FILE = () => ({ headers: { 'content-type': 'file', 'last-modified': '' + Date.now() } })
class Sink implements UnderlyingSink<WriteChunk> {
private _cache: Cache
private path: string
private size: number
private position: number
private file: File
constructor (cache: Cache, path: string, file: File) {
this._cache = cache
this.path = path
this.size = file.size
this.position = 0
this.file = file
}
async write (chunk: WriteChunk) {
const [r] = await this._cache.keys(this.path)
if (!r) throw new DOMException(...GONE)
if (isChunkObject(chunk)) {
if (chunk.type === 'write') {
if (typeof chunk.position === 'number' && chunk.position >= 0) {
if (this.size < chunk.position) {
const blob = new Blob([this.file, new ArrayBuffer(chunk.position - this.size)])
this.file = new File([blob], this.file.name, this.file)
}
this.position = chunk.position
}
if (!('data' in chunk)) {
throw new DOMException(...SYNTAX('write requires a data argument'))
}
chunk = chunk.data
} else if (chunk.type === 'seek') {
if (Number.isInteger(chunk.position) && chunk.position >= 0) {
if (this.size < chunk.position) {
throw new DOMException(...INVALID)
}
this.position = chunk.position
return
} else {
throw new DOMException(...SYNTAX('seek requires a position argument'))
}
} else if (chunk.type === 'truncate') {
if (Number.isInteger(chunk.size) && chunk.size >= 0) {
let file = this.file
file = new File(
chunk.size < this.size ? [file.slice(0, chunk.size)] : [file, new Uint8Array(chunk.size - this.size)],
file.name,
file
)
this.size = file.size
if (this.position > file.size) {
this.position = file.size
}
this.file = file
return
} else {
throw new DOMException(...SYNTAX('truncate requires a size argument'))
}
}
}
chunk = new Blob([chunk])
let blob = this.file
// Calc the head and tail fragments
const head = blob.slice(0, this.position)
const tail = blob.slice(this.position + chunk.size)
// Calc the padding
let padding = this.position - head.size
if (padding < 0) {
padding = 0
}
blob = new File([
head,
new Uint8Array(padding),
chunk,
tail
], blob.name)
this.size = blob.size
this.position += chunk.size
this.file = blob
}
async close () {
const [r] = await this._cache.keys(this.path)
if (!r) throw new DOMException(...GONE)
return this._cache.put(this.path, new Response(this.file, FILE()))
}
}
export class FileHandle implements FileSystemFileHandleAdapter {
readonly kind = 'file'
private _cache: Cache
private path: string
writable = true
readable = true
constructor (path: string, cache: Cache) {
this._cache = cache
this.path = path
}
get name () {
return this.path.split('/').pop()!
}
async isSameEntry (other: FileHandle) {
return this.path === other.path
}
async getFile () {
const res = await this._cache.match(this.path)
if (!res) throw new DOMException(...GONE)
const blob = await res.blob()
const file = new File([blob], this.name, { lastModified: + res.headers.get('last-modified')! })
return file
}
async createWritable (opts: FileSystemCreateWritableOptions) {
let file = await this.getFile()
if (!opts.keepExistingData) {
file = new File([], file.name, file)
}
return new Sink(this._cache, this.path, file)
// let p, rs
// p = new Promise(resolve => rs = resolve)
// const { readable, writable } = new TransformStream(new Sink(p))
// this._cache.put(this.path, new Response(readable, FILE())).then(rs)
// return writable.getWriter()
}
}
export class FolderHandle implements FileSystemFolderHandleAdapter {
readonly kind = 'directory'
readonly name: string
private _dir: string
private _cache: Cache
writable = true
readable = true
constructor (dir: string, cache: Cache) {
this._dir = dir
this._cache = cache
this.name = dir.split('/').pop()!
}
async * entries () {
for (const [path, isFile] of Object.entries(await this._tree)) {
yield [
path.split('/').pop()!,
isFile ? new FileHandle(path, this._cache) : new FolderHandle(path, this._cache)
] as [string, FileHandle | FolderHandle]
}
}
async isSameEntry (other: FolderHandle) {
return this._dir === other._dir
}
async getDirectoryHandle (name: string, opts: FileSystemGetDirectoryOptions = {}) {
const path = this._dir.endsWith('/') ? this._dir + name : `${this._dir}/${name}`
const tree = await this._tree
if (tree.hasOwnProperty(path)) {
const isFile = tree[path]
if (isFile) throw new DOMException(...MISMATCH)
return new FolderHandle(path, this._cache)
} else {
if (opts.create) {
tree[path] = false
await this._cache.put(path, new Response('{}', DIR))
await this._save(tree)
return new FolderHandle(path, this._cache)
}
throw new DOMException(...GONE)
}
}
get _tree () {
return this._cache.match(this._dir).then<Record<string, boolean>>(r => r!.json()).catch(e => {
throw new DOMException(...GONE)
})
}
_save (tree: Record<string, boolean>) {
return this._cache.put(this._dir, new Response(JSON.stringify(tree), DIR))
}
async getFileHandle (name: string, opts: FileSystemGetFileOptions = {}) {
const path = this._dir.endsWith('/') ? this._dir + name : `${this._dir}/${name}`
const tree = await this._tree
if (tree.hasOwnProperty(path)) {
const isFile = tree[path]
if (!isFile) throw new DOMException(...MISMATCH)
return new FileHandle(path, this._cache)
} else {
if (opts.create) {
const tree = await this._tree
tree[path] = true
await this._cache.put(path, new Response('', FILE()))
await this._save(tree)
return new FileHandle(path, this._cache)
} else {
throw new DOMException(...GONE)
}
}
}
async removeEntry (name: string, opts: FileSystemRemoveOptions) {
const tree = await this._tree
const path = this._dir.endsWith('/') ? this._dir + name : `${this._dir}/${name}`
if (tree.hasOwnProperty(path)) {
if (opts.recursive) {
const toDelete = [...Object.entries(tree)]
while (toDelete.length) {
const [path, isFile] = toDelete.pop()!
if (isFile) {
await this._cache.delete(path)
} else {
const e = await this._cache.match(path).then<Record<string, boolean>>(r => r!.json())
toDelete.push(...Object.entries(e))
}
}
delete tree[path]
} else {
const isFile = tree[path]
delete tree[path]
if (isFile) {
await this._cache.delete(path)
} else {
const e = await this._cache.match(path).then(r => r!.json())
const keys = Object.keys(e)
if (keys.length) {
throw new DOMException(...MOD_ERR)
} else {
await this._cache.delete(path)
}
}
}
await this._save(tree)
} else {
throw new DOMException(...GONE)
}
}
}
const adapter: Adapter<void> = async () => {
const cache = await caches.open('sandboxed-fs')
if (!await cache.match('/')) await cache.put('/', new Response('{}', DIR))
return new FolderHandle(location.origin + '/', cache)
}
export default adapter