Skip to content

Commit

Permalink
feat: fix missing createWritable in safari (#62)
Browse files Browse the repository at this point in the history
* feat: fix missing createWritable in safari

* make it easier for bundlers

* fix check
  • Loading branch information
jimmywarting authored Jul 19, 2023
1 parent e0c0f0b commit c458b58
Show file tree
Hide file tree
Showing 9 changed files with 297 additions and 18 deletions.
2 changes: 1 addition & 1 deletion example/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
<caption>Browser storage</caption>
<thead><tr>
<td>Test</td>
<td>Native</td>
<td>Native (Somewhat patched to fix buggy safari impl)</td>
<td>Sandbox</td>
<td>Memory</td>
<td>IndexedDB</td>
Expand Down
61 changes: 61 additions & 0 deletions src/FileSystemDirectoryHandle.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import FileSystemHandle from './FileSystemHandle.js'
import { errors } from './util.js'

const { GONE, MOD_ERR } = errors

const kAdapter = Symbol('adapter')

Expand Down Expand Up @@ -90,6 +93,7 @@ class FileSystemDirectoryHandle extends FileSystemHandle {

while (openSet.length) {
let { handle: current, path } = openSet.pop()

for await (const entry of current.values()) {
if (await entry.isSameEntry(possibleDescendant)) {
return [...path, entry.name]
Expand Down Expand Up @@ -132,5 +136,62 @@ Object.defineProperties(FileSystemDirectoryHandle.prototype, {
removeEntry: { enumerable: true }
})

if (globalThis.FileSystemDirectoryHandle) {
const proto = globalThis.FileSystemDirectoryHandle.prototype

proto.resolve = async function resolve (possibleDescendant) {
if (await possibleDescendant.isSameEntry(this)) {
return []
}

const openSet = [{ handle: this, path: [] }]

while (openSet.length) {
let { handle: current, path } = openSet.pop()

for await (const entry of current.values()) {
if (await entry.isSameEntry(possibleDescendant)) {
return [...path, entry.name]
}
if (entry.kind === 'directory') {
openSet.push({ handle: entry, path: [...path, entry.name] })
}
}
}

return null
}

// Safari allows us operate on deleted files,
// so we need to check if they still exist.
// Hope to remove this one day.
async function ensureDoActuallyStillExist (handle) {
const root = await navigator.storage.getDirectory()
const path = await root.resolve(handle)
if (path === null) { throw new DOMException(...GONE) }
}

const entries = proto.entries
proto.entries = async function * () {
await ensureDoActuallyStillExist(this)
yield * entries.call(this)
}
proto[Symbol.asyncIterator] = async function * () {
yield * this.entries()
}

const removeEntry = proto.removeEntry
proto.removeEntry = async function (name, options = {}) {
return removeEntry.call(this, name, options).catch(async err => {
const unknown = err instanceof DOMException && err.name === 'UnknownError'
if (unknown && !options.recursive) {
const empty = (await entries.call(this).next()).done
if (!empty) { throw new DOMException(...MOD_ERR) }
}
throw err
})
}
}

export default FileSystemDirectoryHandle
export { FileSystemDirectoryHandle }
181 changes: 181 additions & 0 deletions src/FileSystemFileHandle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import FileSystemHandle from './FileSystemHandle.js'
import FileSystemWritableFileStream from './FileSystemWritableFileStream.js'
import { errors } from './util.js'

const { INVALID, SYNTAX, GONE } = errors

const kAdapter = Symbol('adapter')

Expand Down Expand Up @@ -43,5 +46,183 @@ Object.defineProperties(FileSystemFileHandle.prototype, {
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)
function 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 blob = new Blob([code.toString() + `;${code.name}();`], {
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 parent.resolve(root)

// 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 }
15 changes: 14 additions & 1 deletion src/FileSystemHandle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
const kAdapter = Symbol('adapter')

/**
* @typedef {Object} FileSystemHandlePermissionDescriptor
* @property {('read'|'readwrite')} [mode='read']
*/
class FileSystemHandle {
/** @type {FileSystemHandle} */
[kAdapter]
Expand All @@ -16,7 +20,9 @@ class FileSystemHandle {
this[kAdapter] = adapter
}

async queryPermission ({mode = 'read'} = {}) {
/** @param {FileSystemHandlePermissionDescriptor} descriptor */
async queryPermission (descriptor = {}) {
const { mode = 'read' } = descriptor
const handle = this[kAdapter]

if (handle.queryPermission) {
Expand Down Expand Up @@ -79,5 +85,12 @@ Object.defineProperty(FileSystemHandle.prototype, Symbol.toStringTag, {
configurable: true
})

// Safari safari doesn't support writable streams yet.
if (globalThis.FileSystemHandle) {
globalThis.FileSystemHandle.prototype.queryPermission ??= function (descriptor) {
return 'granted'
}
}

export default FileSystemHandle
export { FileSystemHandle }
30 changes: 24 additions & 6 deletions src/FileSystemWritableFileStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import config from './config.js'
const { WritableStream } = config

class FileSystemWritableFileStream extends WritableStream {
constructor (...args) {
super(...args)

#writer
constructor (writer) {
super(writer)
this.#writer = writer
// Stupid Safari hack to extend native classes
// https://bugs.webkit.org/show_bug.cgi?id=226201
Object.setPrototypeOf(this, FileSystemWritableFileStream.prototype)
Expand All @@ -14,7 +15,7 @@ class FileSystemWritableFileStream extends WritableStream {
this._closed = false
}

close () {
async close () {
this._closed = true
const w = this.getWriter()
const p = w.close()
Expand All @@ -33,15 +34,23 @@ class FileSystemWritableFileStream extends WritableStream {
return this.write({ type: 'truncate', size })
}

// The write(data) method steps are:
write (data) {
if (this._closed) {
return Promise.reject(new TypeError('Cannot write to a CLOSED writable stream'))
}

// 1. Let writer be the result of getting a writer for this.
const writer = this.getWriter()
const p = writer.write(data)

// 2. Let result be the result of writing a chunk to writer given data.
const result = writer.write(data)

// 3. Release writer.
writer.releaseLock()
return p

// 4. Return result.
return result
}
}

Expand All @@ -59,5 +68,14 @@ Object.defineProperties(FileSystemWritableFileStream.prototype, {
write: { enumerable: true }
})

// Safari safari doesn't support writable streams yet.
if (
globalThis.FileSystemFileHandle &&
!globalThis.FileSystemFileHandle.prototype.createWritable &&
!globalThis.FileSystemWritableFileStream
) {
globalThis.FileSystemWritableFileStream = FileSystemWritableFileStream
}

export default FileSystemWritableFileStream
export { FileSystemWritableFileStream }
11 changes: 10 additions & 1 deletion src/adapters/indexeddb.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@

import { errors } from '../util.js'

const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX } = errors
const { INVALID, GONE, MISMATCH, MOD_ERR, SYNTAX, ABORT } = errors

/**
* @param {IDBTransaction} tx
* @param {(e) => {}} onerror
*/
function setupTxErrorHandler (tx, onerror) {
tx.onerror = () => onerror(tx.error)
tx.onabort = () => onerror(tx.error || new DOMException(...ABORT))
}

class Sink {
/**
Expand Down
Loading

0 comments on commit c458b58

Please sign in to comment.