From babcc1699e649effcfa0ed12c4d0736228e82834 Mon Sep 17 00:00:00 2001 From: loilo Date: Thu, 12 Oct 2023 23:55:55 +0200 Subject: [PATCH 1/5] feat: add `opfs` driver --- .../6.drivers/origin-private-file-system.md | 28 ++ package.json | 1 + pnpm-lock.yaml | 36 ++ src/drivers/opfs.ts | 109 +++++ src/drivers/utils/opfs-utils.ts | 394 ++++++++++++++++++ test/drivers/opfs.test.ts | 40 ++ 6 files changed, 608 insertions(+) create mode 100644 docs/content/6.drivers/origin-private-file-system.md create mode 100644 src/drivers/opfs.ts create mode 100644 src/drivers/utils/opfs-utils.ts create mode 100644 test/drivers/opfs.test.ts diff --git a/docs/content/6.drivers/origin-private-file-system.md b/docs/content/6.drivers/origin-private-file-system.md new file mode 100644 index 00000000..daee7276 --- /dev/null +++ b/docs/content/6.drivers/origin-private-file-system.md @@ -0,0 +1,28 @@ +--- +navigation.title: Origin Private File System +--- + +# Origin Private File System + +Maps data to the [origin private file system (OPFS)](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) using directory structure for nested keys. + +This driver implements meta for each key including `mtime` (last modified time), `type` (mime type) and `size` (file size) of the underlying [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) object. + +The origin private file system cannot be watched. + +```js +import { createStorage } from "unstorage"; +import opfsDriver from "unstorage/drivers/opfs"; + +const storage = createStorage({ + driver: opfsDriver({ base: "tmp" }), +}); +``` + +**Options:** + +- `base`: Base directory to isolate operations on this directory +- `ignore`: Ignore patterns for key listing +- `readOnly`: Whether to ignore any write operations +- `noClear`: Whether to disallow clearing the storage +- `fs`: An alternative file system handle using the [`FileSystemDirectoryHandle`](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle) interface (e.g. the user's native file system using `window.showDirectoryPicker()`) diff --git a/package.json b/package.json index 193b3bfc..700e1159 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "eslint": "^8.51.0", "eslint-config-unjs": "^0.2.1", "fake-indexeddb": "^4.0.2", + "file-system-access": "^1.0.4", "idb-keyval": "^6.2.1", "ioredis-mock": "^8.9.0", "jiti": "^1.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a77530a..7f982af6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ devDependencies: fake-indexeddb: specifier: ^4.0.2 version: 4.0.2 + file-system-access: + specifier: ^1.0.4 + version: 1.0.4 idb-keyval: specifier: ^6.2.1 version: 6.2.1 @@ -1673,6 +1676,10 @@ packages: '@types/webidl-conversions': 7.0.1 dev: true + /@types/wicg-file-system-access@2020.9.8: + resolution: {integrity: sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==} + dev: true + /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.51.0)(typescript@5.2.2): resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3686,6 +3693,14 @@ packages: tmp: 0.0.33 dev: true + /fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.2.1 + dev: true + /figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -3700,6 +3715,17 @@ packages: flat-cache: 3.1.0 dev: true + /file-system-access@1.0.4: + resolution: {integrity: sha512-JDlhH+gJfZu/oExmtN4/6VX+q1etlrbJbR5uzoBa4BzfTRQbEXGFuGIBRk3ZcPocko3WdEclZSu+d/SByjG6Rg==} + engines: {node: '>=14'} + dependencies: + '@types/wicg-file-system-access': 2020.9.8 + fetch-blob: 3.2.0 + node-domexception: 1.0.0 + optionalDependencies: + web-streams-polyfill: 3.2.1 + dev: true + /fill-range@7.0.1: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} @@ -5367,6 +5393,11 @@ packages: resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} dev: false + /node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: true + /node-fetch-native@1.4.0: resolution: {integrity: sha512-F5kfEj95kX8tkDhUCYdV8dg3/8Olx/94zB8+ZNthFs6Bz31UpUi8Xh40TN3thLwXgrwXry1pEg9lJ++tLWTcqA==} @@ -7161,6 +7192,11 @@ packages: '@zxing/text-encoding': 0.9.0 dev: true + /web-streams-polyfill@3.2.1: + resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} + engines: {node: '>= 8'} + dev: true + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: true diff --git a/src/drivers/opfs.ts b/src/drivers/opfs.ts new file mode 100644 index 00000000..646965ff --- /dev/null +++ b/src/drivers/opfs.ts @@ -0,0 +1,109 @@ +import { defineDriver } from "./utils"; +import { + DRIVER_NAME, + exists, + getFileObject, + joinPaths, + normalizePath, + readFile, + readdirRecursive, + remove, + removeChildren, + unlink, + writeFile, +} from "./utils/opfs-utils"; + +export interface OPFSStorageOptions { + /** + * The filesystem root to use + * Defaults to the OPFS root at `navigator.storage.getDirectory()` + */ + fs?: FileSystemDirectoryHandle | Promise; + + /** + * The base path to use for all operations + * Defaults to the root directory (empty string) + */ + base?: string; + + /** + * A callback to ignore certain files in getKeys() + */ + ignore?: (path: string) => boolean; + + /** + * Whether to ignore any write operations + */ + readOnly?: boolean; + + /** + * Whether to disallow clearing the storage + */ + noClear?: boolean; +} + +export default defineDriver( + (opts: OPFSStorageOptions = {}) => { + const fsPromise = Promise.resolve( + opts.fs ?? navigator.storage.getDirectory() + ); + + opts.base = normalizePath(opts.base ?? ""); + + const resolve = (path: string) => joinPaths(opts.base!, path); + + return { + name: DRIVER_NAME, + options: opts, + async hasItem(key) { + return exists(await fsPromise, resolve(key), "file"); + }, + async getItem(key) { + if (!(await exists(await fsPromise, resolve(key), "file"))) return null; + return readFile(await fsPromise, resolve(key), "utf8"); + }, + async getItemRaw(key) { + if (!(await exists(await fsPromise, resolve(key), "file"))) return null; + return readFile(await fsPromise, resolve(key)); + }, + async getMeta(key) { + const file = await getFileObject(await fsPromise, resolve(key)); + if (!file) return null; + + return { + mtime: new Date(file.lastModified), + size: file.size, + type: file.type, + }; + }, + async setItem(key, value) { + if (opts.readOnly) return; + + return writeFile(await fsPromise, resolve(key), value); + }, + async setItemRaw(key, value) { + if (opts.readOnly) return; + + return writeFile(await fsPromise, resolve(key), value); + }, + async removeItem(key) { + if (opts.readOnly) return; + + return unlink(await fsPromise, resolve(key)); + }, + async getKeys() { + return readdirRecursive(await fsPromise, resolve(""), opts.ignore); + }, + async clear() { + if (opts.readOnly || opts.noClear) return; + + if (opts.base!.length === 0) { + // We cannot delete an OPFS root, so we just empty it + await removeChildren(await fsPromise, resolve("")); + } else { + await remove(await fsPromise, resolve("")); + } + }, + }; + } +); diff --git a/src/drivers/utils/opfs-utils.ts b/src/drivers/utils/opfs-utils.ts new file mode 100644 index 00000000..babfcb85 --- /dev/null +++ b/src/drivers/utils/opfs-utils.ts @@ -0,0 +1,394 @@ +import { createError } from "."; + +export const DRIVER_NAME = "opfs"; + +function ignoreNotfoundError(error: any): null { + if (error.name === "NotFoundError") return null; + throw error; +} + +/** + * Normalize a path, removing empty segments and leading/trailing slashes + */ +export function normalizePath(path: string): string { + const normalizedWrappedPath = `/${path}/` + // Replace colons with slashes + .replace(/:/g, "/") + + // Remove . segments + .replace(/\/\.\//g, "/") + + // Remove duplicate slashes + .replace(/\/{2,}/g, "/"); + + // Disallow .. segments + if (normalizedWrappedPath.includes("/../")) { + throw createError( + DRIVER_NAME, + `Invalid key: ${JSON.stringify(path)}. It must not contain .. segments` + ); + } + + return ( + normalizedWrappedPath + // Remove leading slashes + .replace(/^\//g, "") + + // Remove trailing slashes + .replace(/\/$/g, "") + ); +} + +/** + * Join path segments and normalize the result + * Does not support resolving `.` or `..` segments + */ +export function joinPaths(...segments: string[]): string { + return normalizePath(segments.join("/")); +} + +/** + * Get the directory name of a path + */ +export function dirname(path: string): string { + const normalizedPath = normalizePath(path); + if (!normalizedPath.includes("/")) return ""; + return normalizedPath.slice(0, normalizedPath.lastIndexOf("/")); +} + +/** + * These correspond with the handle's `kind` property + */ +type SpecificHandleType = "file" | "directory"; +type UnspecificHandleType = SpecificHandleType | "any"; + +/** + * Options for the `getHandle` function + */ +type GetHandleOptions = { + /** + * Whether to create the handle if it doesn't exist + * Setting to `true` requires setting a specific `type` + * + * @default false + */ + create?: boolean; + + /** + * Which kind of handle is desired ('file', 'directory', or 'any') + * If a handle of the provided type cannot be obtained, an error will be thrown + * + * @default "any" + */ + type?: UnspecificHandleType; +}; + +/** + * This is a special subset of the `GetHandleOptions` type for improved + * type inference when overloading the `getHandle` function + * + * It represents options with the `create` option set to `true`, + * which requires setting a known `type` + */ +type GetHandleWithCreateOptions = { + create: true; + type: T; +}; + +/** + * This is a special subset of the `GetHandleOptions` type for improved + * type inference when overloading the `getHandle` function + * + * It represents options with the `create` option set to false or missing, + * which allows for a less specific `type` option + */ +type GetHandleWithoutCreateOptions = { + create?: false; + type?: T; +}; + +type HandleTypesByName = { + file: FileSystemFileHandle; + directory: FileSystemDirectoryHandle; + any: FileSystemHandle; +}; + +async function getHandle( + root: FileSystemDirectoryHandle, + path: string | string[], + opts: GetHandleWithCreateOptions +): Promise; +async function getHandle( + root: FileSystemDirectoryHandle, + path: string | string[], + opts?: GetHandleWithoutCreateOptions +): Promise; + +/** + * Get a FileSystemHandle for a given path + * + * @param root The directory handle to start at + * @param path The path to the desired handle + * @param opts Options for obtaining the handle + */ +async function getHandle( + root: FileSystemDirectoryHandle, + path: string | string[], + opts: GetHandleOptions = {} +): Promise { + // Split the path + const segments = Array.isArray(path) ? path : normalizePath(path).split("/"); + + // If there are no segments, return the root handle + if (segments.length === 0) return root; + + const create = Boolean(opts.create); + const handleType: UnspecificHandleType = opts.type ?? "any"; + + // If possibly creating the handle, a specific type must be provided + if (create && handleType !== "file" && handleType !== "directory") { + throw createError( + DRIVER_NAME, + 'Invalid handle type, must be "file" or "directory" when creating' + ); + } + + // Resolve remaining segments recursively + if (segments.length > 1) { + const child = await root.getDirectoryHandle(segments[0], { create: true }); + return getHandle(child, segments.slice(1), { + create: create as any, + type: handleType, + }); + } + + // Get the handle for the last segment + + // If the segment is empty, return the root handle + if (segments[0].length === 0) { + if (handleType === "directory" || handleType === "any") { + return root; + } else { + throw createError( + DRIVER_NAME, + "Cannot get a file handle for the root directory" + ); + } + } + + try { + if (handleType === "directory") { + return await root.getDirectoryHandle(segments[0], { create }); + } else if (handleType === "file") { + return await root.getFileHandle(segments[0], { create }); + } else { + return await root.getFileHandle(segments[0]); + } + } catch (error: any) { + if (handleType === "any" && error?.name === "TypeMismatchError") { + return await root.getDirectoryHandle(segments[0]); + } else { + throw error; + } + } +} + +/** + * Check whether a handle exists at a given path + */ +export async function exists( + root: FileSystemDirectoryHandle, + path: string, + type: UnspecificHandleType = "any" +): Promise { + try { + await getHandle(root, path, { type }); + return true; + } catch { + return false; + } +} + +/** + * Write to a file at a given path + * If the file does not exist, it will be created + */ +export async function writeFile( + root: FileSystemDirectoryHandle, + path: string, + data: FileSystemWriteChunkType +): Promise { + await ensureDirectory(root, dirname(path)); + const handle = await getHandle(root, path, { create: true, type: "file" }); + + const writableStream = await handle.createWritable(); + await writableStream.write(data); + await writableStream.close(); +} + +/** + * Get a File object from a given path + */ +export async function getFileObject( + root: FileSystemDirectoryHandle, + path: string +): Promise { + const handle = await getHandle(root, path, { type: "file" }); + return await handle.getFile().catch(ignoreNotfoundError); +} + +export async function readFile( + root: FileSystemDirectoryHandle, + path: string +): Promise; +export async function readFile( + root: FileSystemDirectoryHandle, + path: string, + encoding: string +): Promise; + +/** + * Read a file at a given path + * + * @param root The root handle to read from + * @param path The path to the file to read + * @param encoding The encoding to use when reading the file. Can be omitted to return an Uint8Array. + */ +export async function readFile( + root: FileSystemDirectoryHandle, + path: string, + encoding?: string +): Promise { + const handle = await getHandle(root, path, { type: "file" }); + let file = await handle.getFile().catch(ignoreNotfoundError); + + if (!file) return null; + + const arrayBuffer = await file.arrayBuffer(); + if (!encoding) return new Uint8Array(arrayBuffer); + + const decoder = new TextDecoder(encoding); + return decoder.decode(arrayBuffer); +} + +/** + * Get file handles in a given directory + */ +export async function readdir( + root: FileSystemDirectoryHandle, + directoryPath: string +): Promise { + const handle = await getHandle(root, directoryPath, { + type: "directory", + }).catch(ignoreNotfoundError); + if (!handle) return []; + + const entries: FileSystemHandle[] = []; + + for await (const entry of (handle as any).values()) { + entries.push(entry); + } + + return entries; +} + +/** + * Ensure that a directory exists at a given path + * Throws if a file exists at the given path + */ +async function ensureDirectory( + root: FileSystemDirectoryHandle, + directoryPath: string +): Promise { + await getHandle(root, directoryPath, { create: true, type: "directory" }); +} + +/** + * Get all file paths in the given directory and its subdirectories + */ +export async function readdirRecursive( + root: FileSystemDirectoryHandle, + directoryPath: string, + ignore?: (path: string) => boolean +): Promise { + if (ignore && ignore(directoryPath)) { + return []; + } + const entries = await readdir(root, directoryPath); + const files: string[] = []; + await Promise.all( + entries.map(async (entry) => { + const entryPath = joinPaths(directoryPath, entry.name); + if (entry.kind === "directory") { + const dirFiles = await readdirRecursive(root, entryPath, ignore); + files.push(...dirFiles.map((f) => `${entry.name}/${f}`)); + } else { + if (!ignore?.(entry.name)) { + files.push(entry.name); + } + } + }) + ); + return files; +} + +/** + * Delete a file + * Ignores non-existent files, throws if a directory exists at the given path + */ +export async function unlink( + root: FileSystemDirectoryHandle, + file: string +): Promise { + const handle = await getHandle(root, file, { type: "file" }).catch( + ignoreNotfoundError + ); + if (!handle) return; + if (handle.name === "") { + throw createError(DRIVER_NAME, "Cannot delete root directory"); + } + const parentDirectoryHandle = await getHandle(root, dirname(file), { + type: "directory", + }); + await parentDirectoryHandle.removeEntry(handle.name); +} + +/** + * Remove contents from a directory without deleting the directory itself + */ +export async function removeChildren( + root: FileSystemDirectoryHandle, + directoryPath: string +): Promise { + const handle = await getHandle(root, directoryPath, { + type: "directory", + }).catch(ignoreNotfoundError); + if (!handle) return; + + const files = await readdir(handle, ""); + await Promise.all( + files.map((file) => handle.removeEntry(file.name, { recursive: true })) + ); +} + +/** + * Remove a file or directory with all of its contents + */ +export async function remove( + root: FileSystemDirectoryHandle, + path: string +): Promise { + if (!(await exists(root, path))) return; + + const handle = await getHandle(root, path); + if (handle.name === "") { + throw createError(DRIVER_NAME, "Cannot delete root directory"); + } + + const parentDirectoryHandle = await getHandle(root, dirname(path), { + type: "directory", + }); + await parentDirectoryHandle.removeEntry(handle.name, { + recursive: true, + }); +} diff --git a/test/drivers/opfs.test.ts b/test/drivers/opfs.test.ts new file mode 100644 index 00000000..db4c9cce --- /dev/null +++ b/test/drivers/opfs.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import * as opfsPonyfill from "file-system-access"; +import opfsPonyfillMemoryAdapter from "file-system-access/lib/adapters/memory.js"; + +import { readFile } from "../../src/drivers/utils/opfs-utils"; +import { testDriver } from "./utils"; +import driver from "../../src/drivers/opfs"; + +describe("drivers: opfs", async () => { + const opfs = await opfsPonyfill.getOriginPrivateDirectory( + opfsPonyfillMemoryAdapter + ); + + testDriver({ + driver: driver({ fs: opfs }), + additionalTests(ctx) { + it("check filesystem", async () => { + expect(await readFile(opfs, "s1/a", "utf8")).toBe("test_data"); + }); + + it("native meta", async () => { + const meta = await ctx.storage.getMeta("/s1/a"); + expect(meta.mtime?.constructor.name).toBe("Date"); + expect(meta.size).toBeGreaterThan(0); + }); + + const invalidKeys = ["../foobar", "..:foobar", "../", "..:", ".."]; + for (const key of invalidKeys) { + it("disallow path travesal: ", async () => { + await expect(ctx.storage.getItem(key)).rejects.toThrow("Invalid key"); + }); + } + + it("allow double dots in filename: ", async () => { + await ctx.storage.setItem("s1/te..st..js", "ok"); + expect(await ctx.storage.getItem("s1/te..st..js")).toBe("ok"); + }); + }, + }); +}); From 2b225be49475b26287e577bc0b41c27ff5ee92a4 Mon Sep 17 00:00:00 2001 From: loilo Date: Fri, 13 Oct 2023 12:55:50 +0200 Subject: [PATCH 2/5] feat: reduce number of regex operations for normalizing paths --- src/drivers/utils/opfs-utils.ts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/drivers/utils/opfs-utils.ts b/src/drivers/utils/opfs-utils.ts index babfcb85..4dc057a1 100644 --- a/src/drivers/utils/opfs-utils.ts +++ b/src/drivers/utils/opfs-utils.ts @@ -11,15 +11,11 @@ function ignoreNotfoundError(error: any): null { * Normalize a path, removing empty segments and leading/trailing slashes */ export function normalizePath(path: string): string { - const normalizedWrappedPath = `/${path}/` - // Replace colons with slashes - .replace(/:/g, "/") - - // Remove . segments - .replace(/\/\.\//g, "/") - - // Remove duplicate slashes - .replace(/\/{2,}/g, "/"); + // Wrap path in slashes, remove . segments and collapse subsequent namespace separators + const normalizedWrappedPath = `/${path}/`.replace( + /[/:]+(\.[/:]+)*[/:]*/g, + "/" + ); // Disallow .. segments if (normalizedWrappedPath.includes("/../")) { @@ -29,14 +25,7 @@ export function normalizePath(path: string): string { ); } - return ( - normalizedWrappedPath - // Remove leading slashes - .replace(/^\//g, "") - - // Remove trailing slashes - .replace(/\/$/g, "") - ); + return normalizedWrappedPath.slice(1, -1); } /** From 6b662688901f1421bcbbe275221934d17406024a Mon Sep 17 00:00:00 2001 From: loilo Date: Fri, 13 Oct 2023 13:04:07 +0200 Subject: [PATCH 3/5] chore: replace quotes with backticks in error message --- src/drivers/utils/opfs-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drivers/utils/opfs-utils.ts b/src/drivers/utils/opfs-utils.ts index 4dc057a1..893b7a05 100644 --- a/src/drivers/utils/opfs-utils.ts +++ b/src/drivers/utils/opfs-utils.ts @@ -138,7 +138,7 @@ async function getHandle( if (create && handleType !== "file" && handleType !== "directory") { throw createError( DRIVER_NAME, - 'Invalid handle type, must be "file" or "directory" when creating' + "Invalid handle type, must be `file` or `directory` when creating" ); } From f043491b60e3071fbbc9163c0d141ca176910fa2 Mon Sep 17 00:00:00 2001 From: loilo Date: Fri, 13 Oct 2023 13:08:41 +0200 Subject: [PATCH 4/5] feat(opfs): remove `readOnly` and `noClear` options --- .../6.drivers/origin-private-file-system.md | 2 -- src/drivers/opfs.ts | 18 ------------------ 2 files changed, 20 deletions(-) diff --git a/docs/content/6.drivers/origin-private-file-system.md b/docs/content/6.drivers/origin-private-file-system.md index daee7276..5479b250 100644 --- a/docs/content/6.drivers/origin-private-file-system.md +++ b/docs/content/6.drivers/origin-private-file-system.md @@ -23,6 +23,4 @@ const storage = createStorage({ - `base`: Base directory to isolate operations on this directory - `ignore`: Ignore patterns for key listing -- `readOnly`: Whether to ignore any write operations -- `noClear`: Whether to disallow clearing the storage - `fs`: An alternative file system handle using the [`FileSystemDirectoryHandle`](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle) interface (e.g. the user's native file system using `window.showDirectoryPicker()`) diff --git a/src/drivers/opfs.ts b/src/drivers/opfs.ts index 646965ff..d284616c 100644 --- a/src/drivers/opfs.ts +++ b/src/drivers/opfs.ts @@ -30,16 +30,6 @@ export interface OPFSStorageOptions { * A callback to ignore certain files in getKeys() */ ignore?: (path: string) => boolean; - - /** - * Whether to ignore any write operations - */ - readOnly?: boolean; - - /** - * Whether to disallow clearing the storage - */ - noClear?: boolean; } export default defineDriver( @@ -77,26 +67,18 @@ export default defineDriver( }; }, async setItem(key, value) { - if (opts.readOnly) return; - return writeFile(await fsPromise, resolve(key), value); }, async setItemRaw(key, value) { - if (opts.readOnly) return; - return writeFile(await fsPromise, resolve(key), value); }, async removeItem(key) { - if (opts.readOnly) return; - return unlink(await fsPromise, resolve(key)); }, async getKeys() { return readdirRecursive(await fsPromise, resolve(""), opts.ignore); }, async clear() { - if (opts.readOnly || opts.noClear) return; - if (opts.base!.length === 0) { // We cannot delete an OPFS root, so we just empty it await removeChildren(await fsPromise, resolve("")); From 889df3973b9adfac545fc972e1845c301125c60f Mon Sep 17 00:00:00 2001 From: loilo Date: Fri, 13 Oct 2023 13:25:54 +0200 Subject: [PATCH 5/5] fix(opfs): lazily access the OPFS root and throw proper error if not available --- src/drivers/opfs.ts | 49 ++++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/drivers/opfs.ts b/src/drivers/opfs.ts index d284616c..46d9d8fc 100644 --- a/src/drivers/opfs.ts +++ b/src/drivers/opfs.ts @@ -1,4 +1,4 @@ -import { defineDriver } from "./utils"; +import { defineDriver, createError } from "./utils"; import { DRIVER_NAME, exists, @@ -32,32 +32,45 @@ export interface OPFSStorageOptions { ignore?: (path: string) => boolean; } -export default defineDriver( - (opts: OPFSStorageOptions = {}) => { - const fsPromise = Promise.resolve( - opts.fs ?? navigator.storage.getDirectory() +let defaultFsHandle: Promise | undefined; +async function getDefaultFs() { + // Use memoized OPFS handle if available + if (typeof defaultFsHandle !== "undefined") return defaultFsHandle; + + // If no file system is provided, OPFS needs to be available + if (typeof globalThis?.navigator?.storage !== "object") { + throw createError( + DRIVER_NAME, + "No filesystem provided and navigator.storage is not available" ); + } - opts.base = normalizePath(opts.base ?? ""); + defaultFsHandle = navigator.storage.getDirectory(); + return defaultFsHandle; +} +export default defineDriver( + (opts: OPFSStorageOptions = {}) => { + opts.base = normalizePath(opts.base ?? ""); + const getFs = () => opts.fs ?? getDefaultFs(); const resolve = (path: string) => joinPaths(opts.base!, path); return { name: DRIVER_NAME, options: opts, async hasItem(key) { - return exists(await fsPromise, resolve(key), "file"); + return exists(await getFs(), resolve(key), "file"); }, async getItem(key) { - if (!(await exists(await fsPromise, resolve(key), "file"))) return null; - return readFile(await fsPromise, resolve(key), "utf8"); + if (!(await exists(await getFs(), resolve(key), "file"))) return null; + return readFile(await getFs(), resolve(key), "utf8"); }, async getItemRaw(key) { - if (!(await exists(await fsPromise, resolve(key), "file"))) return null; - return readFile(await fsPromise, resolve(key)); + if (!(await exists(await getFs(), resolve(key), "file"))) return null; + return readFile(await getFs(), resolve(key)); }, async getMeta(key) { - const file = await getFileObject(await fsPromise, resolve(key)); + const file = await getFileObject(await getFs(), resolve(key)); if (!file) return null; return { @@ -67,23 +80,23 @@ export default defineDriver( }; }, async setItem(key, value) { - return writeFile(await fsPromise, resolve(key), value); + return writeFile(await getFs(), resolve(key), value); }, async setItemRaw(key, value) { - return writeFile(await fsPromise, resolve(key), value); + return writeFile(await getFs(), resolve(key), value); }, async removeItem(key) { - return unlink(await fsPromise, resolve(key)); + return unlink(await getFs(), resolve(key)); }, async getKeys() { - return readdirRecursive(await fsPromise, resolve(""), opts.ignore); + return readdirRecursive(await getFs(), resolve(""), opts.ignore); }, async clear() { if (opts.base!.length === 0) { // We cannot delete an OPFS root, so we just empty it - await removeChildren(await fsPromise, resolve("")); + await removeChildren(await getFs(), resolve("")); } else { - await remove(await fsPromise, resolve("")); + await remove(await getFs(), resolve("")); } }, };