UNPKG

@socketsupply/socket

Version:

A Cross-Platform, Native Runtime for Desktop and Mobile Apps — Create apps using HTML, CSS, and JavaScript. Written from the ground up to be small and maintainable.

716 lines (595 loc) 19 kB
/* global ReadableStream, WritableStream, Blob */ import { DEFAULT_STREAM_HIGH_WATER_MARK } from './stream.js' import { isBufferLike, toBuffer } from '../util.js' import { NotAllowedError } from '../errors.js' import { readFileSync } from './index.js' import mime from '../mime.js' import path from '../path.js' import fs from './promises.js' export const kFileSystemHandleFullName = Symbol.for('socket.runtune.fs.web.FileSystemHandleFullName') export const kFileDescriptor = Symbol.for('socket.runtune.fs.web.FileDescriptor') export const kFileFullName = Symbol.for('socket.runtune.fs.web.FileFullName') // @ts-ignore export const File = globalThis.File ?? class File { get lastModifiedDate () { return new Date(0) } get lastModified () { return 0 } get name () { return null } get size () { return 0 } get type () { return '' } slice () {} async arrayBuffer () {} async bytes () {} async text () {} stream () {} } // @ts-ignore export const FileSystemHandle = globalThis.FileSystemHandle ?? class FileSystemHandle { get name () { return null } get kind () { return null } } // @ts-ignore export const FileSystemFileHandle = globalThis.FileSystemFileHandle ?? class FileSystemFileHandle extends FileSystemHandle { async getFile () {} async createWritable (options = null) {} async createSyncAccessHandle () {} } // @ts-ignore export const FileSystemDirectoryHandle = globalThis.FileSystemDirectoryHandle ?? class FileSystemDirectoryHandle extends FileSystemHandle { async * entries () {} async * values () {} async * keys () {} async resolve (possibleDescendant) {} async removeEntry (name, options = null) {} async getDirectoryHandle (name, options = null) {} async getFileHandle (name, options = null) {} } // @ts-ignore export const FileSystemWritableFileStream = globalThis.FileSystemWritableFileStream ?? class FileSystemWritableFileStream extends WritableStream { async seek (position) {} async truncate (size) {} async write (data) {} } /** * Helper for creating an implementation adapters for various platform APIs. * @ignore * @param {new () => object} Adapter * @return {object} */ function adapter (Adapter) { return Object.getOwnPropertyDescriptors( typeof Adapter === 'object' ? Adapter : Adapter.prototype ) } /** * Helper function for creating a non-construable native class with adapter * implementation * @ignore * @param {new () => object|object} Super * @param {new () => object} Adapter * @return {object} */ function create (Super, Adapter) { return Object.create( typeof Super === 'object' ? Super : Super.prototype, adapter(Adapter) ) } /** * Creates a new `File` instance from `filename`. * @param {string} filename * @param {{ fd: fs.FileHandle, highWaterMark?: number }=} [options] * @return {File} */ export async function createFile (filename, options = null) { if (!globalThis.File) { console.warn('socket:fs/web: Missing platform \'File\' implementation') } const decoder = new TextDecoder() const stats = options?.fd ? await options.fd.stat() : await fs.stat(filename) const types = await mime.lookup( URL.canParse(filename) ? filename : path.extname(filename).slice(1) ) const type = types[0]?.mime ?? '' const highWaterMark = Number.isFinite(options?.highWaterMark) ? options.highWaterMark : Math.min(stats.size, DEFAULT_STREAM_HIGH_WATER_MARK) let fd = options?.fd ?? null let blobBuffer = null let bytesBuffer = null const name = URL.canParse(filename) ? filename : path.basename(filename) if (!fd) { fd = await fs.open(filename) } return create(File, class File { get [kFileFullName] () { return filename } get [kFileDescriptor] () { return fd } get lastModifiedDate () { return new Date(stats.mtimeMs) } get lastModified () { return stats.mtimeMs } get name () { return name } get size () { return stats.size } get type () { return type } slice (start = 0, end = stats.size, contentType = type) { if (!blobBuffer) { blobBuffer = readFileSync(filename) } const blob = new Blob([blobBuffer.buffer], { type: contentType }) if (start < 0) { start = stats.size - start } if (end < 0) { end = stats.size - end } return blob.slice(start, end) } async bytes () { if (!bytesBuffer) { bytesBuffer = await this.arrayBuffer() blobBuffer = bytesBuffer } return new Uint8Array(bytesBuffer) } async arrayBuffer () { const stream = this.stream() let buffer = null for await (const chunk of stream) { if (!buffer) { buffer = chunk } else { const next = new Uint8Array(buffer.byteLength + chunk.byteLength) next.set(buffer, 0) next.set(chunk, buffer.byteLength) buffer = next } } return buffer ? buffer.buffer : new ArrayBuffer(0) } async text () { const arrayBuffer = await this.arrayBuffer() return decoder.decode(new Uint8Array(arrayBuffer)) } stream () { let buffer = null let offset = 0 const stream = new ReadableStream({ async start (controller) { const fd = await fs.open(filename) fd.once('close', () => controller.close()) if (highWaterMark === 0) { await fd.close() await controller.close() return } buffer = new Uint8Array(highWaterMark) const result = await fd.read(buffer, 0, highWaterMark, offset) offset += result.bytesRead if (result.bytesRead === 0) { await controller.close() } else { controller.enqueue(buffer.slice(0, result.bytesRead)) } }, async cancel () { await fd.close() fd = null }, async pull (controller) { // @ts-ignore if (controller.byobRequest) { // @ts-ignore const { byobRequest } = /** @type {ReadableStreamBYOBRequest} */(controller) const result = await fd.read( // @ts-ignore byobRequest.view.buffer, byobRequest.view.byteOffset, byobRequest.view.byteLength, offset ) offset += result.bytesRead byobRequest.respond(result.bytesRead) if (result.bytesRead === 0) { await controller.close() } } else { const result = await fd.read(buffer, 0, highWaterMark, offset) offset += result.bytesRead if (result.bytesRead === 0) { await controller.close() } else { controller.enqueue(buffer.slice(0, result.bytesRead)) } } } }) // provide async iterator so `for await (...)` works on the `stream` return Object.defineProperty(stream, Symbol.asyncIterator, { get () { const reader = stream.getReader() return async function * () { while (true) { const { done, value = null } = await reader.read() if (done) break yield value } } } }) } }) } /** * Creates a `FileSystemWritableFileStream` instance backed * by `socket:fs:` module from a given `FileSystemFileHandle` instance. * @param {string|File} file * @return {Promise<FileSystemFileHandle>} */ export async function createFileSystemWritableFileStream (handle, options) { if (!globalThis.FileSystemWritableFileStream) { console.warn('socket:fs/web: Missing platform \'FileSystemWritableFileStream\' implementation') } const file = await handle.getFile() let offset = 0 let fd = null if (options?.keepExistingData === true) { try { fd = await fs.open(file.name, 'r+') } catch {} } if (!fd) { fd = await fs.open(file.name, 'w+') } // @ts-ignore return create(FileSystemWritableFileStream, class FileSystemWritableFileStream { get [kFileDescriptor] () { return fd } async seek (position) { offset = position } async truncate (position) { await fd.truncate(position) } async write (options) { if (!fd) { throw new TypeError('Not opened') } const position = options?.position || null const data = options?.data || options || null const size = options?.size || null const type = options?.type || 'write' if (type === 'seek') { if (!Number.isFinite(position) || position < 0) { throw new TypeError('Expecting options.position to be an unsigned integer') } offset = position } if (type === 'truncate') { if (!Number.isFinite(size) || size < 0) { throw new TypeError('Expecting options.size to be an unsigned integer') } await this.truncate(size) } if (type === 'write') { if ( !isBufferLike(data) && !(data instanceof Blob) && !(data instanceof DataView) && !(data instanceof ArrayBuffer) && typeof data !== 'string' ) { throw new TypeError( 'Expecting data to be an ArrayBuffer, TypedArray, DataView, Blob, or string' ) } if (Number.isFinite(position)) { offset = position } const buffer = toBuffer(data) await fd.write(buffer, 0, buffer.byteLength, offset) } } async close () { await fd.close() fd = null } }) } /** * Creates a `FileSystemFileHandle` instance backed by `socket:fs:` module from * a given `File` instance or filename string. * @param {string|File} file * @param {object} [options] * @return {Promise<FileSystemFileHandle>} */ export async function createFileSystemFileHandle (file, options = null) { const { writable = true } = options || {} if (!globalThis.FileSystemFileHandle) { console.warn('socket:fs/web: Missing platform \'FileSystemFileHandle\' implementation') } return create(FileSystemFileHandle, class FileSystemFileHandle { get [kFileSystemHandleFullName] () { return file[kFileFullName] } get [kFileDescriptor] () { return file[kFileDescriptor] } get name () { return file.name } get kind () { return 'file' } async getFile () { if (typeof file === 'string') { file = await createFile(file) } return file } async isSameEntry (entry) { if (this === entry) { return true } if (this[kFileSystemHandleFullName] === entry[kFileSystemHandleFullName]) { return true } try { if ('FileSystemFileHandle' in globalThis) { const { isSameEntry } = globalThis.FileSystemFileHandle.prototype if (await isSameEntry.call(this, entry)) { return true } } } catch {} return false } async move (nameOrDestinationHandle, name = null) { if (writable === false || URL.canParse(file?.name ?? file)) { throw new NotAllowedError('FileSystemFileHandle is in \'readonly\' mode') } if (typeof file === 'string') { file = await createFile(file) } let destination = null if (typeof nameOrDestinationHandle === 'string') { name = nameOrDestinationHandle } else if (nameOrDestinationHandle instanceof FileSystemDirectoryHandle) { destination = nameOrDestinationHandle } const newName = path.resolve( destination?.[kFileSystemHandleFullName] || this[kFileSystemHandleFullName], '..', name ) await fs.rename( this[kFileSystemHandleFullName], newName ) file = await createFile(newName) } async createWritable (options = null) { if (writable === false || URL.canParse(file?.name ?? file)) { throw new NotAllowedError('FileSystemFileHandle is in \'readonly\' mode') } if (typeof file === 'string') { file = await createFile(file) } return await createFileSystemWritableFileStream(this, options) } }) } /** * Creates a `FileSystemDirectoryHandle` instance backed by `socket:fs:` module * from a given directory name string. * @param {string} dirname * @return {Promise<FileSystemFileHandle>} */ export async function createFileSystemDirectoryHandle (dirname, options = null) { const { writable = true } = options || {} if (!globalThis.FileSystemDirectoryHandle) { console.warn('socket:fs/web: Missing platform \'FileSystemDirectoryHandle\' implementation') } if (!dirname || typeof dirname !== 'string') { throw new TypeError('Expecting directory name to be a string') } dirname = path.resolve(dirname) try { const hasAccess = await fs.access(dirname) if (!hasAccess) { throw new NotAllowedError(`Unable to access directory: ${dirname}`) } } catch (err) { if (err instanceof NotAllowedError) { throw err } throw err } // `fd` is opened with `lazyOpen` at on demand let fd = null if (options?.open === true) { await lazyOpen() } return create(FileSystemDirectoryHandle, class FileSystemDirectoryHandle { get [kFileSystemHandleFullName] () { return dirname } get [kFileDescriptor] () { return fd } get name () { return path.basename(dirname) } get kind () { return 'directory' } async isSameEntry (entry) { if (this === entry || this[kFileSystemHandleFullName] === entry[kFileSystemHandleFullName]) { return true } try { if ('FileSystemDirectoryHandle' in globalThis) { const { isSameEntry } = globalThis.FileSystemDirectoryHandle.prototype if (await isSameEntry.call(this, entry)) { return true } } } catch {} return false } async move (nameOrDestinationHandle, name = null) { if (!writable) { throw new NotAllowedError('FileSystemDirectoryHandle is in \'readonly\' mode') } let destination = null if (typeof nameOrDestinationHandle === 'string') { name = nameOrDestinationHandle } else if (nameOrDestinationHandle instanceof FileSystemDirectoryHandle) { destination = nameOrDestinationHandle } const newName = path.resolve(destination?.[kFileSystemHandleFullName] || dirname, '..', name) await fs.rename(dirname, newName) dirname = newName } async * entries () { await lazyOpen() for await (const entry of fd) { if (entry.isDirectory()) { yield [ entry.name, await createFileSystemDirectoryHandle( path.join(dirname, entry.name), { writable } ) ] } else if (entry.isFile()) { yield [ entry.name, await createFileSystemFileHandle( path.join(dirname, entry.name), { writable } ) ] } } } async * keys () { for await (const entry of this.entries()) { yield entry[0] } } async * values () { for await (const entry of this.entries()) { yield entry[1] } } async resolve (possibleDescendant) { await lazyOpen() if (!(possibleDescendant instanceof FileSystemHandle)) { throw new TypeError( 'Expecting possibleDescendant to be an instance of FileSystemHandle' ) } const filename = possibleDescendant[kFileSystemHandleFullName] if (!filename) { return null } try { const hasAccess = await fs.access(filename) if (!hasAccess) { return null } } catch (err) { return null } const relative = path.relative(dirname, filename) if (relative.startsWith('.')) { return null } const components = relative.split(path.sep) const paths = [] while (components.length) { const component = components.shift() const last = paths[paths.length - 1] const value = last && component ? path.join(last, component) : component if (value) { paths.push(value) } } return paths.length > 0 ? paths : null } async getDirectoryHandle (name, options = null) { const filename = path.join(dirname, name) if (options?.create) { try { const stats = await fs.stats(filename) if (!stats.isDirectory()) { throw new NotAllowedError(`'${dirname}' is not a directory`) } } catch (err) { if (!/exist/i.test(err.message)) { throw err } await fs.mkdir(filename) } } return await createFileSystemDirectoryHandle(filename, { writable }) } async getFileHandle (name, options = null) { const filename = path.resolve(dirname, name) let fd = null if (options?.create === true) { try { const stats = await fs.stats(filename) if (!stats.isFile()) { throw new NotAllowedError(`'${dirname}' is not a file`) } } catch (err) { if (!/exist/i.test(err.message)) { throw err } fd = await fs.open(filename, 'w+') } } const file = await createFile(filename, { fd }) const handle = await createFileSystemFileHandle(file, { writable }) return handle } async removeEntry (name, options = null) { if (!writable) { throw new NotAllowedError('FileSystemDirectoryHandle is in \'readonly\' mode') } const filename = path.resolve(dirname, name) const stats = await fs.stats(filename) let handle = options?.handle || null if (!handle) { if (stats.isDirectory()) { handle = await createFileSystemDirectoryHandle(filename, { writable }) } else if (stats.isFile()) { handle = await createFileSystemFileHandle(filename, { writable }) } } if (options?.recursive === true) { for await (const entry of handle.values()) { if (entry.kind === 'file') { await fs.unlink(entry[kFileSystemHandleFullName]) } else { await handle.removeEntry(entry.name, { recursive: true, handle: entry }) } } } await fs.rmdir(dirname) } }) async function lazyOpen () { if (!fd) { fd = await fs.opendir(dirname) } } } export default { createFileSystemWritableFileStream, createFileSystemDirectoryHandle, createFileSystemFileHandle, createFile }