one
Version:
One is a React framework for web, iOS, and Android apps.
540 lines (457 loc) • 15.6 kB
text/typescript
import { directories, entry, parent, parts, serial, transfer, write } from './opfs'
import { PathUtilities } from './paths'
import { FileSystemReadableStreamSource, FileSystemWritableSink } from './streams'
import { FileMode } from './types'
import type {
DirectoryCreateOptions,
DirectoryStat,
FileCreateOptions,
FileDigestAlgorithm,
FileStat,
FileWriteOptions,
PathInfo,
RelocationOptions,
} from './types'
import {
assertAlgorithm,
assertByteLength,
assertDestination,
assertEncoding,
assertMode,
assertName,
assertOffset,
assertReadable,
assertUri,
assertWritable,
assertWriteBytes,
assertWriteContent,
} from './validate'
export type * from './types'
// File, Directory, Paths and FileHandle on web, over the origin private file
// system. the async members work; the synchronous twins cannot run on the
// main thread's async storage and throw. flat One.FileSystem methods cover
// web create, delete and list.
function unavailable(member: string): Error {
return new Error(`${member} is not available on web`)
}
function server(): boolean {
return typeof window === 'undefined'
}
function toBytes(content: Uint8Array | ArrayBuffer): Uint8Array<ArrayBuffer> {
if (content instanceof ArrayBuffer) return new Uint8Array(content)
if (content.buffer instanceof ArrayBuffer) {
return new Uint8Array(content.buffer, content.byteOffset, content.byteLength)
}
const copy = new Uint8Array(content.byteLength)
copy.set(content)
return copy
}
function toBase64(bytes: Uint8Array): string {
let binary = ''
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
}
return btoa(binary)
}
async function fileEntry(uri: string): Promise<FileSystemFileHandle> {
const handle = await entry(parts(uri))
if (handle.kind !== 'file') throw new Error('FileSystem: expected a file')
return handle
}
async function readWholeFile(uri: string): Promise<Uint8Array<ArrayBuffer>> {
return new Uint8Array(await (await (await fileEntry(uri)).getFile()).arrayBuffer())
}
async function writePositioned(
uri: string,
position: number,
data: Uint8Array<ArrayBuffer>
): Promise<void> {
const handle = await fileEntry(uri)
const writable = await handle.createWritable({ keepExistingData: true })
try {
await writable.write({ type: 'write', position, data })
await writable.close()
} catch (error) {
await writable.abort().catch(() => {})
throw error
}
}
export class FileHandle {
private targetUri: string
private mode: FileMode
private offsetValue = 0
private closed = false
private truncatePending: boolean
private appendPending: boolean
// obtain through File.open().
constructor(uri: string, mode: FileMode = FileMode.ReadWrite) {
assertUri(uri, 'FileHandle')
assertMode(mode, 'FileHandle')
this.targetUri = uri
this.mode = mode
this.truncatePending = mode === FileMode.Truncate
this.appendPending = mode === FileMode.Append
}
get offset(): number | null {
if (this.closed) return null
return this.offsetValue
}
set offset(value: number) {
this.assertOpen('offset')
assertOffset(value, 'FileHandle.offset')
this.offsetValue = value
this.appendPending = false
}
get size(): number | null {
if (this.closed) return null
throw unavailable('FileHandle.size')
}
close(): void {
this.closed = true
}
readBytes(length: number): Promise<Uint8Array<ArrayBuffer>> {
this.assertOpen('readBytes')
assertReadable(this.mode, 'FileHandle.readBytes')
assertByteLength(length, 'FileHandle.readBytes')
if (server()) return Promise.resolve(new Uint8Array())
return serial(async () => {
await this.prepare()
const bytes = await readWholeFile(this.targetUri)
const slice = bytes.slice(this.offsetValue, this.offsetValue + length)
this.offsetValue += slice.length
return slice
})
}
readBytesSync(_length: number): Uint8Array<ArrayBuffer> {
throw unavailable('FileHandle.readBytesSync')
}
writeBytes(bytes: Uint8Array | ArrayBuffer): Promise<void> {
this.assertOpen('writeBytes')
assertWritable(this.mode, 'FileHandle.writeBytes')
assertWriteBytes(bytes, 'FileHandle.writeBytes')
if (server()) return Promise.resolve()
const data = toBytes(bytes)
return serial(async () => {
await this.prepare()
let size = 0
try {
size = (await (await fileEntry(this.targetUri)).getFile()).size
} catch (error) {
if ((error as DOMException).name !== 'NotFoundError') throw error
if (this.mode === FileMode.ReadOnly || this.mode === FileMode.ReadWrite) throw error
const names = parts(this.targetUri)
await (await parent(names)).getFileHandle(names.at(-1)!, { create: true })
}
const position = Math.min(this.offsetValue, size)
await writePositioned(this.targetUri, position, data)
this.offsetValue = position + data.length
})
}
writeBytesSync(_bytes: Uint8Array | ArrayBuffer): void {
throw unavailable('FileHandle.writeBytesSync')
}
private async prepare(): Promise<void> {
if (this.truncatePending) {
this.truncatePending = false
this.offsetValue = 0
const names = parts(this.targetUri)
await write(await (await parent(names)).getFileHandle(names.at(-1)!, { create: true }), '')
} else if (this.appendPending) {
this.appendPending = false
try {
this.offsetValue = (await (await fileEntry(this.targetUri)).getFile()).size
} catch (error) {
if ((error as DOMException).name !== 'NotFoundError') throw error
this.offsetValue = 0
}
}
}
private assertOpen(verb: string): void {
if (this.closed) throw new Error(`FileHandle.${verb}: the handle is closed`)
}
}
function resolveDestination(
source: File | Directory,
destination: File | Directory,
verb: string
): string {
assertDestination(destination, verb)
if (destination instanceof Directory) {
return PathUtilities.join(destination.uri, source.name)
}
if (destination instanceof File) return destination.uri
throw new Error(`${verb}: expected a File or Directory destination`)
}
async function relocate(
source: string,
to: string,
move: boolean,
overwrite: boolean
): Promise<void> {
if (overwrite && parts(to).length === 1) {
throw new Error('FileSystem: app root directories are protected')
}
if (overwrite) {
const names = parts(to)
await (await parent(names)).removeEntry(names.at(-1)!, { recursive: true }).catch((error) => {
if ((error as DOMException).name !== 'NotFoundError') throw error
})
}
await transfer(source, to, move)
}
export class File {
private currentUri: string
constructor(...uris: (string | File | Directory)[]) {
const joined = PathUtilities.join(...uris)
assertUri(joined, 'File')
this.currentUri = joined
}
get uri(): string {
return this.currentUri
}
get name(): string {
return PathUtilities.basename(this.currentUri)
}
get extension(): string {
return PathUtilities.extname(this.currentUri)
}
get parentDirectory(): Directory {
return new Directory(PathUtilities.dirname(this.currentUri))
}
get exists(): boolean {
throw unavailable('File.exists')
}
get size(): number {
throw unavailable('File.size')
}
get type(): string {
throw unavailable('File.type')
}
get creationTime(): number | null {
throw unavailable('File.creationTime')
}
get lastModified(): number | null {
throw unavailable('File.lastModified')
}
text(): Promise<string> {
if (server()) return Promise.resolve('')
const uri = this.currentUri
return serial(async () => (await (await fileEntry(uri)).getFile()).text())
}
textSync(): string {
throw unavailable('File.textSync')
}
bytes(): Promise<Uint8Array<ArrayBuffer>> {
if (server()) return Promise.resolve(new Uint8Array())
const uri = this.currentUri
return serial(() => readWholeFile(uri))
}
bytesSync(): Uint8Array<ArrayBuffer> {
throw unavailable('File.bytesSync')
}
base64(): Promise<string> {
if (server()) return Promise.resolve('')
return this.bytes().then(toBase64)
}
base64Sync(): string {
throw unavailable('File.base64Sync')
}
async arrayBuffer(): Promise<ArrayBuffer> {
return (await this.bytes()).buffer
}
async json(): Promise<any> {
return JSON.parse(await this.text())
}
info(): FileStat {
throw unavailable('File.info')
}
digest(algorithm: FileDigestAlgorithm): Promise<string> {
assertAlgorithm(algorithm, 'File.digest')
if (server()) return Promise.reject(new Error('File.digest is not available on the server'))
if (algorithm === 'MD5') {
return Promise.reject(new Error('File.digest: MD5 is not available on web'))
}
if (!globalThis.crypto?.subtle) return Promise.reject(unavailable('File.digest'))
return this.bytes().then(async (bytes) => {
const hash = await globalThis.crypto.subtle.digest(algorithm, bytes)
return Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
})
}
write(content: string | Uint8Array | ArrayBuffer, options?: FileWriteOptions): Promise<void> {
assertWriteContent(content, 'File.write')
if (server()) return Promise.resolve()
const append = options?.append ?? false
let data: string | Uint8Array
if (typeof content === 'string') {
const encoding = options?.encoding ?? 'utf8'
assertEncoding(encoding, 'File.write')
if (encoding === 'base64') {
if (
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)
) {
throw new Error('FileSystem: invalid base64 contents')
}
data = Uint8Array.from(atob(content), (char) => char.charCodeAt(0))
} else {
data = content
}
} else {
if (options?.encoding !== undefined) assertEncoding(options.encoding, 'File.write')
data = toBytes(content)
}
const uri = this.currentUri
return serial(async () => {
if (append) {
let existing = new Uint8Array()
try {
existing = await readWholeFile(uri)
} catch (error) {
if ((error as DOMException).name !== 'NotFoundError') throw error
}
const next = typeof data === 'string' ? new TextEncoder().encode(data) : data
const combined = new Uint8Array(existing.length + next.length)
combined.set(existing)
combined.set(next, existing.length)
data = combined
}
const names = parts(uri)
await write(await (await parent(names)).getFileHandle(names.at(-1)!, { create: true }), data)
})
}
writeSync(_content: string | Uint8Array | ArrayBuffer, _options?: FileWriteOptions): void {
throw unavailable('File.writeSync')
}
create(_options?: FileCreateOptions): void {
throw unavailable('File.create')
}
delete(): void {
throw unavailable('File.delete')
}
copy(destination: File | Directory, options?: RelocationOptions): Promise<void> {
const to = resolveDestination(this, destination, 'File.copy')
if (server()) return Promise.resolve()
const from = this.currentUri
return serial(() => relocate(from, to, false, options?.overwrite ?? false))
}
copySync(_destination: File | Directory, _options?: RelocationOptions): void {
throw unavailable('File.copySync')
}
move(destination: File | Directory, options?: RelocationOptions): Promise<void> {
const to = resolveDestination(this, destination, 'File.move')
if (to === this.currentUri || server()) return Promise.resolve()
const from = this.currentUri
return serial(() =>
relocate(from, to, true, options?.overwrite ?? false).then(() => {
this.currentUri = to
})
)
}
moveSync(_destination: File | Directory, _options?: RelocationOptions): void {
throw unavailable('File.moveSync')
}
rename(_newName: string): void {
throw unavailable('File.rename')
}
open(mode: FileMode = FileMode.ReadWrite): FileHandle {
assertMode(mode, 'File.open')
return new FileHandle(this.currentUri, mode)
}
readableStream(): ReadableStream<Uint8Array<ArrayBuffer>> {
return new ReadableStream(new FileSystemReadableStreamSource(this.open(FileMode.ReadOnly)))
}
writableStream(): WritableStream<Uint8Array<ArrayBufferLike>> {
return new WritableStream(new FileSystemWritableSink(this.open(FileMode.WriteOnly)))
}
stream(): ReadableStream<Uint8Array<ArrayBuffer>> {
return this.readableStream()
}
}
export class Directory {
private currentUri: string
constructor(...uris: (string | File | Directory)[]) {
const joined = PathUtilities.join(...uris)
assertUri(joined, 'Directory')
this.currentUri = joined
}
get uri(): string {
return this.currentUri
}
get name(): string {
return PathUtilities.basename(this.currentUri)
}
get parentDirectory(): Directory {
return new Directory(PathUtilities.join(this.currentUri, '..'))
}
get exists(): boolean {
throw unavailable('Directory.exists')
}
get size(): number | null {
throw unavailable('Directory.size')
}
list(): (File | Directory)[] {
throw unavailable('Directory.list')
}
info(): DirectoryStat {
throw unavailable('Directory.info')
}
create(_options?: DirectoryCreateOptions): void {
throw unavailable('Directory.create')
}
createFile(_name: string, _mimeType: string | null): File {
throw unavailable('Directory.createFile')
}
createDirectory(_name: string): Directory {
throw unavailable('Directory.createDirectory')
}
delete(): void {
throw unavailable('Directory.delete')
}
copy(destination: File | Directory, options?: RelocationOptions): Promise<void> {
const to = resolveDestination(this, destination, 'Directory.copy')
if (server()) return Promise.resolve()
const from = this.currentUri
return serial(() => relocate(from, to, false, options?.overwrite ?? false))
}
copySync(_destination: File | Directory, _options?: RelocationOptions): void {
throw unavailable('Directory.copySync')
}
move(destination: File | Directory, options?: RelocationOptions): Promise<void> {
const to = resolveDestination(this, destination, 'Directory.move')
if (to === this.currentUri || server()) return Promise.resolve()
const from = this.currentUri
return serial(() =>
relocate(from, to, true, options?.overwrite ?? false).then(() => {
this.currentUri = to
})
)
}
moveSync(_destination: File | Directory, _options?: RelocationOptions): void {
throw unavailable('Directory.moveSync')
}
rename(_newName: string): void {
throw unavailable('Directory.rename')
}
}
export class Paths extends PathUtilities {
static get cache(): Directory {
return new Directory(directories.cache)
}
static get document(): Directory {
return new Directory(directories.documents)
}
static get bundle(): Directory {
throw unavailable('Paths.bundle')
}
static get totalDiskSpace(): number {
throw unavailable('Paths.totalDiskSpace')
}
static get availableDiskSpace(): number {
throw unavailable('Paths.availableDiskSpace')
}
static info(..._uris: string[]): PathInfo {
throw unavailable('Paths.info')
}
}
export { FileMode }