From 804148a5f5f85e5b06bbb1d726dbb9f983104b21 Mon Sep 17 00:00:00 2001 From: James Prevett Date: Thu, 10 Oct 2024 12:40:10 -0500 Subject: [PATCH] Implemented backend --- package-lock.json | 205 +++++++++++++++++++-------- package.json | 5 +- readme.md | 4 +- src/backend.ts | 0 src/index.ts | 348 +++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 499 insertions(+), 63 deletions(-) delete mode 100644 src/backend.ts diff --git a/package-lock.json b/package-lock.json index ad89e15..c7b5489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,11 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "utilium": "^0.7.1" + "dropbox": "^10.34.0" }, "devDependencies": { "@eslint/js": "^9.11.1", "@fal-works/esbuild-plugin-global-externals": "^2.1.2", - "@stylistic/eslint-plugin": "^2.8.0", "@types/eslint__js": "^8.42.3", "@types/node": "^20.12.7", "esbuild": "^0.24.0", @@ -29,7 +28,7 @@ "node": ">= 18" }, "peerDependencies": { - "@zenfs/core": "^1.0.2" + "@zenfs/core": "^1.0.4" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -728,39 +727,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@stylistic/eslint-plugin": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.8.0.tgz", - "integrity": "sha512-Ufvk7hP+bf+pD35R/QfunF793XlSRIC7USr3/EdgduK9j13i2JjmsM0LUz3/foS+jDYp2fzyWZA9N44CPur0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.4.0", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.1.0", - "estraverse": "^5.3.0", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "peerDependencies": { - "eslint": ">=8.40.0" - } - }, - "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", - "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -825,6 +791,17 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.11.tgz", @@ -1039,9 +1016,9 @@ "dev": true }, "node_modules/@zenfs/core": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@zenfs/core/-/core-1.0.2.tgz", - "integrity": "sha512-LMTD4ntn6Ag1y+IeOSVykDDvYC12dsGFtsX8M/54OQrLs7v+YnX4bpo0o2osbm8XFmU2MTNMX/G3PLsvzgWzrg==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@zenfs/core/-/core-1.0.5.tgz", + "integrity": "sha512-0IihdXr4v/n9Paeo/yk7PSlLNUmRgC609ddIAHD1IOwjHcTSD5b9YTZVdzzWrCKzduQgHb9WUyrh+QlNinRkPw==", "license": "MIT", "peer": true, "dependencies": { @@ -1051,7 +1028,7 @@ "eventemitter3": "^5.0.1", "minimatch": "^9.0.3", "readable-stream": "^4.5.2", - "utilium": "^0.7.0" + "utilium": "^0.7.4" }, "bin": { "build": "scripts/build.js", @@ -1143,6 +1120,13 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "peer": true + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1290,6 +1274,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "peer": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1345,6 +1342,16 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1369,6 +1376,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dropbox": { + "version": "10.34.0", + "resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz", + "integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1" + }, + "engines": { + "node": ">=0.10.3" + }, + "peerDependencies": { + "@types/node-fetch": "^2.5.7" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1649,7 +1671,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/events": { "version": "3.3.0", @@ -1782,6 +1805,21 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "peer": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2273,6 +2311,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2301,6 +2362,26 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/oniguruma-to-js": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz", @@ -2392,19 +2473,6 @@ "node": ">=8" } }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2731,6 +2799,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -3393,10 +3467,11 @@ } }, "node_modules/utilium": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/utilium/-/utilium-0.7.1.tgz", - "integrity": "sha512-2ocvTkI7U8LERmwxL0LhFUvEfN66UqcjF6tMiURvUwSyU7U1QC9gST+3iSUSiGccFfnP3f2EXwHNXOnOzx+lAg==", + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/utilium/-/utilium-0.7.7.tgz", + "integrity": "sha512-NIRaFZgHuFDwJ090Iu+Fp605yigQmHxVAUd1aLSWaOZ+aDexvZzGq6tXZWdZBlLpIjSEoErfXjd6O+6JLeJE1A==", "license": "MIT", + "peer": true, "dependencies": { "eventemitter3": "^5.0.1" } @@ -3431,6 +3506,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "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 57692c7..9901a01 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "devDependencies": { "@eslint/js": "^9.11.1", "@fal-works/esbuild-plugin-global-externals": "^2.1.2", - "@stylistic/eslint-plugin": "^2.8.0", "@types/eslint__js": "^8.42.3", "@types/node": "^20.12.7", "esbuild": "^0.24.0", @@ -56,9 +55,9 @@ "typescript-eslint": "^8.8.0" }, "peerDependencies": { - "@zenfs/core": "^1.0.2" + "@zenfs/core": "^1.0.4" }, "dependencies": { - "utilium": "^0.7.1" + "dropbox": "^10.34.0" } } diff --git a/readme.md b/readme.md index be142c1..226abe0 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # ZenFS Dropbox -***Work in progress*** +**_Work in progress_** > [!IMPORTANT] > Please read the [ZenFS core documentation](https://zenfs.dev/core)! @@ -22,4 +22,4 @@ npm install @zenfs/dropbox > For CJS, you can `require` the package. > For a browser environment without support for `type=module` in `script` tags, you can add a `script` tag to your HTML pointing to the `browser.min.js` and use the global `ZenFS_Dropbox` object. -*Work in progress* +_Work in progress_ diff --git a/src/backend.ts b/src/backend.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/index.ts b/src/index.ts index 30ca8fa..4c9dc7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,347 @@ -export * from './backend.js'; +import { Async, Errno, ErrnoError, FileSystem, PreloadFile, Stats, type Backend, type File } from '@zenfs/core'; +import { S_IFDIR, S_IFLNK, S_IFREG } from '@zenfs/core/emulation/constants.js'; +import { dirname } from '@zenfs/core/emulation/path.js'; +import { Buffer } from 'buffer'; +import type * as DB from 'dropbox'; + +/** + * Dropbox paths do not begin with a /, + * they just begin with a folder at the root node. + * @param path An absolute path + */ +function fixPath(path: string): string { + return path == '/' ? '' : path; +} + +/** + * Errors that can be converted + */ +type ConvertableError = + | DB.files.LookupError + | DB.files.WriteError + | DB.files.ListFolderError + | DB.files.DeleteError + | DB.files.UploadError + | DB.files.GetMetadataError + | DB.files.RelocationError; + +type DBError = ConvertableError | DB.Error; +type DBReject = DBError | DB.DropboxResponseError; +/** + * Converts a Dropbox error into an `ErrnoError`. + * + * Consider changing the behavior from returning the error to just throwing it. + */ +function convertError(error: DBReject, path: string, syscall: string, message?: string): ErrnoError { + if ('status' in error) { + error = error.error; + } + + if (!('.tag' in error)) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + return convertError(error.error, path, syscall, error.user_message?.text || error.error_summary || error.error.toString()); + } + switch (error['.tag']) { + case 'path': + return convertError('path' in error ? error.path : error.reason, path, syscall, message); + case 'path_lookup': + return convertError(error.path_lookup, path, syscall, message); + case 'path_write': + return convertError(error.path_write, path, syscall, message); + case 'malformed_path': + case 'disallowed_name': + case 'cant_move_folder_into_itself': + case 'duplicated_or_nested_paths': + return new ErrnoError(Errno.EBADF, message, path, syscall); + case 'not_found': + return ErrnoError.With('ENOENT', path, syscall); + case 'not_file': + return ErrnoError.With('EISDIR', path, syscall); + case 'not_folder': + return ErrnoError.With('ENOTDIR', path, syscall); + case 'restricted_content': + case 'conflict': + case 'no_write_permission': + case 'team_folder': + case 'cant_copy_shared_folder': + case 'cant_nest_shared_folder': + return ErrnoError.With('EPERM', path, syscall); + case 'insufficient_space': + case 'insufficient_quota': + case 'too_many_files': + return new ErrnoError(Errno.ENOSPC, message, path, syscall); + case 'too_many_write_operations': + return ErrnoError.With('EAGAIN', path, syscall); + case 'locked': + return ErrnoError.With('EBUSY', path, syscall); + case 'content_hash_mismatch': + return ErrnoError.With('EBADMSG', path, syscall); + case 'unsupported_content_type': + return ErrnoError.With('ENOMSG', path, syscall); + case 'payload_too_large': + return ErrnoError.With('EMSGSIZE', path, syscall); + case 'from_lookup': + return convertError(error.from_lookup, path, syscall, message); + case 'from_write': + return convertError(error.from_write, path, syscall, message); + case 'to': + return convertError(error.to, path, syscall, message); + case 'cant_transfer_ownership': + case 'internal_error': + case 'cant_move_shared_folder': + case 'cant_move_into_vault': + case 'cant_move_into_family': + case 'operation_suppressed': + case 'template_error': + case 'properties_error': + case 'other': + return new ErrnoError(Errno.EIO, message, path, syscall); + default: + return new ErrnoError(Errno.EINVAL, 'Unknown error tag: ' + error['.tag'], path, syscall); + } +} + +export class DropboxFS extends Async(FileSystem) { + public constructor(public readonly client: DB.Dropbox) { + super(); + } + + public async rename(oldPath: string, newPath: string): Promise { + // Since you can't rename over things with Dropbox, the destination is deleted if it exists + try { + const stats = await this.stat(newPath); + + if (stats.isDirectory()) { + throw ErrnoError.With('EISDIR', newPath, 'rename'); + } + + await this.unlink(newPath); + } catch (_) { + if (oldPath === newPath) { + throw ErrnoError.With('ENOENT', newPath, 'rename'); + } + } + + await this.client + .filesMoveV2({ + from_path: fixPath(oldPath), + to_path: fixPath(newPath), + }) + .catch((error: DBReject) => { + throw convertError(error, oldPath, 'rename'); + }); + } + + public async stat(path: string): Promise { + if (path === '/') { + // Dropbox doesn't support stating the root directory. + return new Stats({ mode: 0o666 | S_IFDIR }); + } + + const { result } = await this.client + .filesGetMetadata({ + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'stat'); + }); + + switch (result['.tag']) { + case 'file': + return new Stats({ + mode: result.symlink_info ? S_IFLNK : S_IFREG, + size: result.symlink_info?.target?.length || result.size, + atimeMs: Date.now(), + mtimeMs: Date.parse(result.server_modified), + }); + case 'folder': + return new Stats({ mode: S_IFDIR }); + case 'deleted': + throw ErrnoError.With('ENOENT', path, 'stat'); + default: + throw new ErrnoError(Errno.EINVAL, 'Invalid file type', path, 'stat'); + } + } + + public async openFile(path: string, flag: string): Promise { + const { result } = await this.client + .filesDownload({ + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'openFile'); + }); + return new PreloadFile( + this, + path, + flag, + new Stats({ + mode: result.symlink_info ? S_IFLNK : S_IFREG, + size: result.symlink_info?.target?.length || result.size, + atimeMs: Date.now(), + mtimeMs: Date.parse(result.server_modified), + }), + (result as DB.files.FileMetadata & { fileBinary: Uint8Array }).fileBinary + ); + } + + public async createFile(path: string, flag: string, mode: number): Promise { + const data = Buffer.alloc(0); + + const { result } = await this.client + .filesUpload({ + contents: new Blob([data], { type: 'octet/stream' }), + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'createFile'); + }); + return new PreloadFile( + this, + path, + flag, + new Stats({ + mode: 0o644 | S_IFREG, + size: result.size, + atimeMs: Date.now(), + mtimeMs: Date.parse(result.server_modified), + }), + data + ); + } + + public async unlink(path: string): Promise { + if ((await this.stat(path)).isDirectory()) { + throw ErrnoError.With('EISDIR', path, 'unlink'); + } + await this.client + .filesDeleteV2({ + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'unlink'); + }); + } + + public async rmdir(path: string): Promise { + const paths = await this.readdir(path); + if (paths.length > 0) { + throw ErrnoError.With('ENOTEMPTY', path, 'rmdir'); + } + await this.client + .filesDeleteV2({ + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'rmdir'); + }); + } + + public async mkdir(path: string): Promise { + // Dropbox's folder creations is recursive, so we check to make sure the parent exists + const parent = dirname(path); + const stats = await this.stat(parent); + if (stats && !stats.isDirectory()) { + throw ErrnoError.With('ENOTDIR', parent, 'mkdir'); + } + + await this.client + .filesCreateFolderV2({ + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'rmdir'); + }); + } + + public async readdir(path: string): Promise { + const names = ({ entries }: DB.files.ListFolderResult) => { + return entries.map(e => e.path_display).filter((p): p is string => !!p); + }; + + let { result } = await this.client + .filesListFolder({ + path: fixPath(path), + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'readdir'); + }); + + const entries = names(result); + + // To prevent an infinite loop + let i = 0; + + while (result.has_more && i < 100) { + if (++i >= 100) { + throw new ErrnoError(Errno.EIO, 'Infinite loop prevented', path, 'readdir'); + } + + const response = await this.client + .filesListFolderContinue({ + cursor: result.cursor, + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'readdir'); + }); + + result = response.result; + entries.push(...names(result)); + } + + return entries; + } + + /** + * @internal + * Syncs file to Dropbox. + */ + public async sync(path: string, data: Buffer): Promise { + await this.client + .filesUpload({ + contents: new Blob([data], { type: 'octet/stream' }), + path: fixPath(path), + mode: { + '.tag': 'overwrite', + }, + }) + .catch((error: DBReject) => { + throw convertError(error, path, 'rmdir'); + }); + } + + public link(target: string): Promise { + throw ErrnoError.With('ENOTSUP', target, 'link'); + } +} + +export interface DropboxOptions { + /** + * A v2 Dropbox client + */ + client: DB.Dropbox; +} + +export const _Dropbox = { + name: 'Dropbox', + + options: { + client: { + type: 'object', + required: true, + description: 'A v2 Dropbox client', + }, + }, + + isAvailable(): boolean { + return 'Dropbox' in globalThis; + }, + + create(options) { + return new DropboxFS(options.client); + }, +} satisfies Backend; +type _Dropbox = typeof _Dropbox; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface Dropbox extends _Dropbox {} +export const Dropbox: Dropbox = _Dropbox;