native-file-system-adapter
Version:
230 lines (192 loc) • 6.68 kB
JavaScript
import FileSystemHandle from './FileSystemHandle.js'
import FileSystemWritableFileStream from './FileSystemWritableFileStream.js'
import { errors } from './util.js'
const { INVALID, SYNTAX, GONE } = errors
const kAdapter = Symbol('adapter')
class FileSystemFileHandle extends FileSystemHandle {
/** @type {FileSystemFileHandle} */
[kAdapter]
constructor (adapter) {
super(adapter)
this[kAdapter] = adapter
}
/**
* @param {Object} [options={}]
* @param {boolean} [options.keepExistingData]
* @returns {Promise<FileSystemWritableFileStream>}
*/
async createWritable (options = {}) {
return new FileSystemWritableFileStream(
await this[kAdapter].createWritable(options)
)
}
/**
* @returns {Promise<File>}
*/
async getFile () {
return this[kAdapter].getFile()
}
}
Object.defineProperty(FileSystemFileHandle.prototype, Symbol.toStringTag, {
value: 'FileSystemFileHandle',
writable: false,
enumerable: false,
configurable: true
})
Object.defineProperties(FileSystemFileHandle.prototype, {
createWritable: { enumerable: true },
getFile: { enumerable: true }
})
// Safari doesn't support async createWritable streams yet.
if (
globalThis.FileSystemFileHandle &&
!globalThis.FileSystemFileHandle.prototype.createWritable
) {
const wm = new WeakMap()
let workerUrl
// Worker code that should be inlined (can't use any external functions)
const code = () => {
let fileHandle, handle
onmessage = async evt => {
const port = evt.ports[0]
const cmd = evt.data
switch (cmd.type) {
case 'open':
const file = cmd.name
let dir = await navigator.storage.getDirectory()
for (const folder of cmd.path) {
dir = await dir.getDirectoryHandle(folder)
}
fileHandle = await dir.getFileHandle(file)
handle = await fileHandle.createSyncAccessHandle()
break
case 'write':
handle.write(cmd.data, { at: cmd.position })
handle.flush()
break
case 'truncate':
handle.truncate(cmd.size)
break
case 'abort':
case 'close':
handle.close()
break
}
port.postMessage(0)
}
}
globalThis.FileSystemFileHandle.prototype.createWritable = async function (options) {
// Safari only support writing data in a worker with sync access handle.
if (!workerUrl) {
const stringCode = `(${code.toString()})()`
const blob = new Blob([stringCode], {
type: 'text/javascript'
})
workerUrl = URL.createObjectURL(blob)
}
const worker = new Worker(workerUrl, { type: 'module' })
let position = 0
const textEncoder = new TextEncoder()
let size = await this.getFile().then(file => file.size)
const send = message => new Promise((resolve, reject) => {
const mc = new MessageChannel()
mc.port1.onmessage = evt => {
if (evt.data instanceof Error) reject(evt.data)
else resolve(evt.data)
mc.port1.close()
mc.port2.close()
mc.port1.onmessage = null
}
worker.postMessage(message, [mc.port2])
})
// Safari also don't support transferable file system handles.
// So we need to pass the path to the worker. This is a bit hacky and ugly.
const root = await navigator.storage.getDirectory()
const parent = await wm.get(this)
const path = await root.resolve(parent)
// Should likely never happen, but just in case...
if (path === null) throw new DOMException(...GONE)
let controller
await send({ type: 'open', path, name: this.name })
if (options?.keepExistingData === false) {
await send({ type: 'truncate', size: 0 })
size = 0
}
const ws = new FileSystemWritableFileStream({
start: ctrl => {
controller = ctrl
},
async write(chunk) {
const isPlainObject = chunk?.constructor === Object
if (isPlainObject) {
chunk = { ...chunk }
} else {
chunk = { type: 'write', data: chunk, position }
}
if (chunk.type === 'write') {
if (!('data' in chunk)) {
await send({ type: 'close' })
throw new DOMException(...SYNTAX('write requires a data argument'))
}
chunk.position ??= position
if (typeof chunk.data === 'string') {
chunk.data = textEncoder.encode(chunk.data)
}
else if (chunk.data instanceof ArrayBuffer) {
chunk.data = new Uint8Array(chunk.data)
}
else if (!(chunk.data instanceof Uint8Array) && ArrayBuffer.isView(chunk.data)) {
chunk.data = new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength)
}
else if (!(chunk.data instanceof Uint8Array)) {
const ab = await new Response(chunk.data).arrayBuffer()
chunk.data = new Uint8Array(ab)
}
if (Number.isInteger(chunk.position) && chunk.position >= 0) {
position = chunk.position
}
position += chunk.data.byteLength
size += chunk.data.byteLength
} else if (chunk.type === 'seek') {
if (Number.isInteger(chunk.position) && chunk.position >= 0) {
if (size < chunk.position) {
throw new DOMException(...INVALID)
}
console.log('seeking', chunk)
position = chunk.position
return // Don't need to enqueue seek...
} else {
await send({ type: 'close' })
throw new DOMException(...SYNTAX('seek requires a position argument'))
}
} else if (chunk.type === 'truncate') {
if (Number.isInteger(chunk.size) && chunk.size >= 0) {
size = chunk.size
if (position > size) { position = size }
} else {
await send({ type: 'close' })
throw new DOMException(...SYNTAX('truncate requires a size argument'))
}
}
await send(chunk)
},
async close () {
await send({ type: 'close' })
worker.terminate()
},
async abort (reason) {
await send({ type: 'abort', reason })
worker.terminate()
},
})
return ws
}
const orig = FileSystemDirectoryHandle.prototype.getFileHandle
FileSystemDirectoryHandle.prototype.getFileHandle = async function (...args) {
const handle = await orig.call(this, ...args)
wm.set(handle, this)
return handle
}
}
export default FileSystemFileHandle
export { FileSystemFileHandle }