diff --git a/package-lock.json b/package-lock.json index 36c37bc..556f467 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@eslint/js": "^9.12.0", "eslint": "^9.12.0", "fake-indexeddb": "^6.0.0", - "file-system-access": "^1.0.4", "globals": "^15.10.0", "prettier": "^3.2.5", "tsx": "^4.19.2", @@ -771,9 +770,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.1.tgz", - "integrity": "sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==", + "version": "22.10.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", + "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "license": "MIT", "peer": true, "dependencies": { @@ -781,9 +780,9 @@ } }, "node_modules/@types/readable-stream": { - "version": "4.0.16", - "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.16.tgz", - "integrity": "sha512-Fvp+8OcU8PyV90KTk5tR/rI8OjD3MP5NUow5rjOsZo+9zxf4p4soJtK9j4V6yeG30TH6rZxqRaP4JLa8lNNTNQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz", + "integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==", "license": "MIT", "peer": true, "dependencies": { @@ -798,13 +797,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/wicg-file-system-access": { - "version": "2020.9.8", - "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", - "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.8.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz", @@ -1004,9 +996,9 @@ "peer": true }, "node_modules/@zenfs/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@zenfs/core/-/core-1.7.1.tgz", - "integrity": "sha512-hUqS+Sfd07q/RN9001TQNWtORtdlUPVIXEzYX7gEqBg1taauZP9AF8KwlMgGR4GTj379xZYbReZONkK51W/ccw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@zenfs/core/-/core-1.7.2.tgz", + "integrity": "sha512-RHtL0NNz1NIFtrV0+1tagxyZ+vhZP4rBot/O0Ov5v5yZqXIyY+Wt3eQfXrhh+egFrXHsxIbiFblGdI2SCPsmig==", "license": "MIT", "peer": true, "dependencies": { @@ -1630,30 +1622,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1667,24 +1635,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-system-access": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/file-system-access/-/file-system-access-1.0.4.tgz", - "integrity": "sha512-JDlhH+gJfZu/oExmtN4/6VX+q1etlrbJbR5uzoBa4BzfTRQbEXGFuGIBRk3ZcPocko3WdEclZSu+d/SByjG6Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/wicg-file-system-access": "^2020.9.2", - "fetch-blob": "^3.0.0", - "node-domexception": "^1.0.0" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "web-streams-polyfill": "^3.1.0" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2069,26 +2019,6 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -2256,9 +2186,9 @@ "license": "MIT" }, "node_modules/readable-stream": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", - "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "license": "MIT", "peer": true, "dependencies": { @@ -2574,9 +2504,9 @@ } }, "node_modules/utilium": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/utilium/-/utilium-1.1.1.tgz", - "integrity": "sha512-uNwrjhLA+KN5/EuDJaofvRerLffPSQ1aj6uot21tCwiU2oHKWHU2F3xW+wETgjJ3JFqDIloY6zSutQRmTbjAVw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/utilium/-/utilium-1.1.3.tgz", + "integrity": "sha512-Gip5dgsVHMy+7lf6m1l/2HJkUropZ4pIBRKxBqxw0Dtz8czcuhNyXFXywqdVnNekDaTzV7/CHYsnLPrPrvzMUA==", "license": "MIT", "peer": true, "dependencies": { @@ -2590,16 +2520,6 @@ "@xterm/xterm": "^5.5.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 28036b4..866216d 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "@eslint/js": "^9.12.0", "eslint": "^9.12.0", "fake-indexeddb": "^6.0.0", - "file-system-access": "^1.0.4", "globals": "^15.10.0", "prettier": "^3.2.5", "tsx": "^4.19.2", diff --git a/tests/setup-access.ts b/tests/setup-access.ts index 799e6a7..943a976 100644 --- a/tests/setup-access.ts +++ b/tests/setup-access.ts @@ -1,14 +1,11 @@ -import * as fsAccess from 'file-system-access'; -import adapter from 'file-system-access/lib/adapters/memory.js'; -Object.assign(globalThis, fsAccess); - +import { handle } from './web-access.js'; import { configureSingle } from '@zenfs/core'; import { WebAccess } from '../src/access.js'; import { copy, data } from '@zenfs/core/tests/setup.js'; await configureSingle({ backend: WebAccess, - handle: await fsAccess.getOriginPrivateDirectory(adapter), + handle, }); copy(data); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 25b45db..a981750 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,15 +1,12 @@ { + "extends": "../tsconfig.json", "type": "module", "compilerOptions": { - "module": "NodeNext", "target": "ES2022", "noEmit": true, "lib": ["ESNext", "ESNext.Disposable", "DOM"], - "moduleResolution": "NodeNext", "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true + "allowSyntheticDefaultImports": true }, - "extends": "../tsconfig.json", "include": ["**/*.ts", "*.ts"] } diff --git a/tests/web-access.ts b/tests/web-access.ts new file mode 100644 index 0000000..c430dee --- /dev/null +++ b/tests/web-access.ts @@ -0,0 +1,307 @@ +/* +Ponyfill of the File System Access web API. +This is a re-write of `file-system-access` by Alexandru Ciucă (@use-strict) +*/ + +const errorMessages = { + NotFoundError: 'A requested file or directory could not be found at the time an operation was processed.', + SyntaxError: 'Failed to write to underlying sink: Invalid params passed: ', +}; + +type ErrorName = keyof typeof errorMessages; + +function error(name: ErrorName): DOMException { + return new DOMException(errorMessages[name], name); +} + +function isCommand(chunk: unknown, type: T): chunk is WriteParams & { type: T } { + return typeof chunk === 'object' && chunk != null && 'type' in chunk && chunk.type == type; +} + +class Sink implements UnderlyingSink { + protected file: File; + protected position: number = 0; + + constructor( + private handle: FileHandle, + { keepExistingData }: FileSystemCreateWritableOptions + ) { + this.file = keepExistingData ? handle.file : new File([], handle.file.name, handle.file); + } + + async write(chunk: FileSystemWriteChunkType) { + if (!this.handle.file) throw error('NotFoundError'); + + if (isCommand(chunk, 'seek')) { + if (!Number.isInteger(chunk.position) || chunk.position! < 0) throw new DOMException(errorMessages.SyntaxError + 'seek requires a position argument', 'SyntaxError'); + if (this.file.size < chunk.position!) { + throw new DOMException('seeking position failed.', 'InvalidStateError'); + } + this.position = chunk.position!; + return; + } + + if (isCommand(chunk, 'truncate')) { + if (!Number.isInteger(chunk.size) || chunk.size! < 0) throw new DOMException(errorMessages.SyntaxError + 'truncate requires a size argument', 'SyntaxError'); + const parts = [chunk.size! < this.file.size ? this.file.slice(0, chunk.size!) : this.file, new Uint8Array(chunk.size! - this.file.size)]; + this.file = new File(parts, this.file.name, this.file); + if (this.position > this.file.size) this.position = this.file.size; + return; + } + + if (isCommand(chunk, 'write')) { + if (typeof chunk.position === 'number' && chunk.position >= 0) { + this.position = chunk.position; + if (this.file.size < chunk.position) { + this.file = new File([this.file, new ArrayBuffer(chunk.position - this.file.size)], this.file.name, this.file); + } + } + if (!('data' in chunk)) { + throw new DOMException(errorMessages.SyntaxError + 'write requires a data argument', 'SyntaxError'); + } + chunk = chunk.data!; + } + + chunk = new Blob([chunk as Exclude]); + + // Calc the head and tail fragments + const head = this.file.slice(0, this.position); + const tail = this.file.slice(this.position + chunk.size); + + // Calc the padding + let padding = this.position - head.size; + if (padding < 0) padding = 0; + this.file = new File([head, new Uint8Array(padding), chunk, tail], this.file.name); + + this.position += chunk.size; + } + + async close() { + if (!this.handle.file) throw error('NotFoundError'); + this.handle.file = this.file; + this.file = this.position = null!; + } +} + +class WritableFileStream extends WritableStream implements FileSystemWritableFileStream { + constructor( + protected readonly handle: FileHandle, + protected readonly options: FileSystemCreateWritableOptions + ) { + super(new Sink(handle, options)); + } + + public seek(position: number) { + return this.write({ type: 'seek', position }); + } + + public truncate(size: number) { + return this.write({ type: 'truncate', size }); + } + + public async write(data: FileSystemWriteChunkType) { + const writer = this.getWriter(); + await writer.write(data); + writer.releaseLock(); + } +} + +abstract class Handle implements globalThis.FileSystemHandle { + public [Symbol.toStringTag]() { + return 'FileSystemHandle'; + } + + public abstract readonly kind: FileSystemHandleKind; + + public constructor(public readonly name: string) {} + + public async queryPermission(): Promise { + return 'granted'; + } + + public async requestPermission(): Promise { + return 'granted'; + } + + public async isSameEntry(other: globalThis.FileSystemHandle) { + if (this === other) return true; + if (this.kind !== other.kind) return false; + if (!other) return false; + return false; // PLACEHOLDER + // Return if locators match + } + + public abstract remove(options?: FileSystemRemoveOptions): Promise; +} + +type HandleWithKind = T extends 'directory' ? DirectoryHandle : FileHandle; + +function is(handle: Handle, kind: T): handle is HandleWithKind { + return handle.kind == kind; +} + +interface FileSystemReadWriteOptions { + at: number; +} + +class SyncAccessHandle { + protected state: 'open' | 'closed' = 'open'; + + constructor(protected readonly file: FileHandle) {} + + read(buffer: AllowSharedBufferSource, options?: FileSystemReadWriteOptions): number { + return 0; + } + write(buffer: AllowSharedBufferSource, options?: FileSystemReadWriteOptions): number { + return 0; + } + truncate(newSize: number): void {} + getSize(): number { + return 0; + } + flush(): void {} + close(): void {} +} + +class FileHandle extends Handle implements FileSystemFileHandle { + public [Symbol.toStringTag]() { + return 'FileSystemFileHandle'; + } + + public readonly kind = 'file'; + + constructor( + name: string, + public file: File + ) { + super(name); + } + + public async getFile(): Promise { + if (!this.file) throw error('NotFoundError'); + return this.file; + } + + public async createWritable(options: FileSystemCreateWritableOptions = {}) { + if (!this.file) throw error('NotFoundError'); + return new WritableFileStream(this, options); + } + + public async createSyncAccessHandle(): Promise { + return new SyncAccessHandle(this); + } + + public async remove(): Promise {} +} + +type GetOptions = T extends 'directory' ? FileSystemGetDirectoryOptions : FileSystemGetFileOptions; + +class DirectoryHandle extends Handle implements FileSystemDirectoryHandle { + public [Symbol.toStringTag]() { + return 'FileSystemDirectoryHandle'; + } + + _parent?: DirectoryHandle; + + public readonly kind = 'directory'; + + /** + * + * @internal + */ + _data = new Map(); + + protected _get(kind: T, name: string, options: GetOptions): HandleWithKind { + if (name === '') throw new TypeError('Name can not be an empty string.'); + if (name === '.' || name === '..' || name.includes('/')) throw new TypeError('Name contains invalid characters.'); + + const entry = this._data.get(name); + + if (entry && !is(entry, kind)) throw new DOMException('The path supplied exists, but was not an entry of requested type.', 'TypeMismatchError'); + + if (entry) return entry; + + if (!options.create) throw error('NotFoundError'); + + const handle = (kind == 'directory' ? new DirectoryHandle(name) : new FileHandle(name, new File([], name))) as HandleWithKind; + + this._data.set(name, handle); + return handle; + } + + public async getFileHandle(name: string, options: FileSystemGetFileOptions = {}): Promise { + return this._get('file', name, options); + } + + public async getDirectoryHandle(name: string, options: FileSystemGetDirectoryOptions = {}): Promise { + return this._get('directory', name, options); + } + + public async removeEntry(name: string, options: FileSystemRemoveOptions = {}): Promise { + if (name === '') throw new TypeError('Name can not be an empty string.'); + if (name === '.' || name === '..' || name.includes('/')) throw new TypeError('Name contains invalid characters.'); + const entry = this._data.get(name); + if (!entry) throw error('NotFoundError'); + entry.remove(options); + } + + public async resolve(possibleDescendant: globalThis.FileSystemHandle): Promise { + if (await possibleDescendant.isSameEntry(this)) { + return []; + } + + const stack: [entry: DirectoryHandle, path: string[]][] = [[this, []]]; + + while (stack.length) { + const [current, path] = stack.pop()!; + + for (const [name, entry] of current._data.entries()) { + if (entry === possibleDescendant) return [...path, name]; + + if (entry.kind != 'directory') continue; + + stack.push([entry as DirectoryHandle, [...path, name]]); + } + } + + return null; + } + + public async remove(options?: FileSystemRemoveOptions): Promise { + if (this._data.size && !options?.recursive) throw new DOMException('The object can not be modified in this way.', 'InvalidModificationError'); + + for (const entry of this._data.values()) { + entry.remove({ recursive: true }); + } + + this._data.clear(); + this._parent?._data.delete(this.name); + } + + public async entries() { + return this._data.entries(); + } + + public async keys() { + return this._data.keys(); + } + + public async values() { + return this._data.values(); + } + + public [Symbol.asyncIterator]() { + return this.entries(); + } +} + +const handles = { + file: FileHandle, + directory: DirectoryHandle, +}; + +type ActualHandle = FileHandle | DirectoryHandle; + +export { Handle as FileSystemHandle, FileHandle as FileSystemFileHandle, DirectoryHandle as FileSystemDirectoryHandle, WritableFileStream as FileSystemWritableFileStream }; + +export const handle = new DirectoryHandle('');