diff --git a/.prettierignore b/.prettierignore index b3bfee53a..643d06223 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,6 @@ node_modules CHANGELOG.md pnpm-lock.yaml docs/2.drivers/0.index.md +test/browser-extension/node_modules +test/browser-extension/.output +test/browser-extension/.wxt diff --git a/docs/2.drivers/0.index.md b/docs/2.drivers/0.index.md index e07eead06..068ba2497 100644 --- a/docs/2.drivers/0.index.md +++ b/docs/2.drivers/0.index.md @@ -151,4 +151,13 @@ icon: icon-park-outline:hard-disk --- Store data in Vercel KV. :: + ::card + --- + icon: mdi:puzzle-outline + to: /drivers/web-extension + title: Web Extension Storage + color: gray + --- + Store data in browser extension storage areas. + :: :: diff --git a/docs/2.drivers/web-extension.md b/docs/2.drivers/web-extension.md new file mode 100644 index 000000000..75955ed6e --- /dev/null +++ b/docs/2.drivers/web-extension.md @@ -0,0 +1,45 @@ +--- +icon: mdi:puzzle-outline +--- + +# Web Extension Storage + +> Store data in browser extension storage areas (`local`, `session`, `sync`, `managed`). + +## Usage + +**Driver name:** `web-extension-storage` + +::read-more{to="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage"} +Learn more about the Web Extension Storage API. +:: + +```js +import { createStorage } from "unstorage"; +import webExtensionStorageDriver from "unstorage/drivers/web-extension-storage"; + +const storage = createStorage({ + driver: webExtensionStorageDriver({ storageArea: "local" }), +}); +``` + +::note +Ensure the `"storage"` permission is declared in your extension's `manifest.json`: +```json +{ + "permissions": ["storage"] +} +``` +:: + +**Options:** + +- `storageArea`: Storage area to use. Can be `"local"` (default), `"session"`, `"sync"`, or `"managed"`. +- `base`: Add `${base}:` prefix to all keys to avoid collisions. + +## Storage Areas + +- **`local`**: Persistent storage, data survives browser restarts. ~5MB limit. +- **`session`**: Cleared when the browser session ends. ~10MB limit. +- **`sync`**: Synced across devices when user is signed in. ~100KB limit. +- **`managed`**: Read-only, set by enterprise policies. diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d93e1dba..f8018105a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,7 @@ import unjs from "eslint-config-unjs"; export default unjs({ - ignores: ["drivers", "/server*", "docs/.*"], + ignores: ["drivers", "/server*", "docs/.*", "test/browser-extension"], rules: { "unicorn/no-null": 0, "unicorn/prevent-abbreviations": 0, diff --git a/src/_drivers.ts b/src/_drivers.ts index 13ac1f028..7e6a539cc 100644 --- a/src/_drivers.ts +++ b/src/_drivers.ts @@ -32,8 +32,9 @@ import type { UpstashOptions as UpstashOptions } from "unstorage/drivers/upstash import type { VercelBlobOptions as VercelBlobOptions } from "unstorage/drivers/vercel-blob"; import type { VercelKVOptions as VercelKVOptions } from "unstorage/drivers/vercel-kv"; import type { VercelCacheOptions as VercelRuntimeCacheOptions } from "unstorage/drivers/vercel-runtime-cache"; +import type { WebExtensionStorageOptions as WebExtensionStorageOptions } from "unstorage/drivers/web-extension-storage"; -export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache"; +export type BuiltinDriverName = "azure-app-configuration" | "azureAppConfiguration" | "azure-cosmos" | "azureCosmos" | "azure-key-vault" | "azureKeyVault" | "azure-storage-blob" | "azureStorageBlob" | "azure-storage-table" | "azureStorageTable" | "capacitor-preferences" | "capacitorPreferences" | "cloudflare-kv-binding" | "cloudflareKVBinding" | "cloudflare-kv-http" | "cloudflareKVHttp" | "cloudflare-r2-binding" | "cloudflareR2Binding" | "db0" | "deno-kv-node" | "denoKVNode" | "deno-kv" | "denoKV" | "fs-lite" | "fsLite" | "fs" | "github" | "http" | "indexedb" | "localstorage" | "lru-cache" | "lruCache" | "memory" | "mongodb" | "netlify-blobs" | "netlifyBlobs" | "null" | "overlay" | "planetscale" | "redis" | "s3" | "session-storage" | "sessionStorage" | "uploadthing" | "upstash" | "vercel-blob" | "vercelBlob" | "vercel-kv" | "vercelKV" | "vercel-runtime-cache" | "vercelRuntimeCache" | "web-extension-storage" | "webExtensionStorage"; export type BuiltinDriverOptions = { "azure-app-configuration": AzureAppConfigurationOptions; @@ -85,6 +86,8 @@ export type BuiltinDriverOptions = { "vercelKV": VercelKVOptions; "vercel-runtime-cache": VercelRuntimeCacheOptions; "vercelRuntimeCache": VercelRuntimeCacheOptions; + "web-extension-storage": WebExtensionStorageOptions; + "webExtensionStorage": WebExtensionStorageOptions; }; export const builtinDrivers = { @@ -139,4 +142,6 @@ export const builtinDrivers = { "vercelKV": "unstorage/drivers/vercel-kv", "vercel-runtime-cache": "unstorage/drivers/vercel-runtime-cache", "vercelRuntimeCache": "unstorage/drivers/vercel-runtime-cache", + "web-extension-storage": "unstorage/drivers/web-extension-storage", + "webExtensionStorage": "unstorage/drivers/web-extension-storage", } as const; diff --git a/src/drivers/web-extension-storage.ts b/src/drivers/web-extension-storage.ts new file mode 100644 index 000000000..a0d1955a4 --- /dev/null +++ b/src/drivers/web-extension-storage.ts @@ -0,0 +1,98 @@ +import { createError, defineDriver, normalizeKey } from "./utils/index.ts"; + +/* BEGIN Web Extension Storage types */ + +interface StorageAreaInstance { + get(keys: string | string[] | null): Promise>; + set(items: Record): Promise; + remove(keys: string | string[]): Promise; + clear(): Promise; +} + +interface BrowserStorage { + local: StorageAreaInstance; + session: StorageAreaInstance; + sync: StorageAreaInstance; + managed: StorageAreaInstance; +} + +interface Browser { + storage: BrowserStorage; +} + +declare const browser: Browser | undefined; +declare const chrome: Browser | undefined; + +/* END Web Extension Storage types */ + +export type StorageArea = "local" | "session" | "sync" | "managed"; + +export interface WebExtensionStorageOptions { + /** Storage area to use. Defaults to "local". */ + storageArea?: StorageArea; + /** Optional base/prefix for keys */ + base?: string; +} + +const DRIVER_NAME = "web-extension-storage"; + +export default defineDriver((opts: WebExtensionStorageOptions = {}) => { + const storageArea = opts.storageArea || "local"; + const base = opts.base ? normalizeKey(opts.base) : ""; + const r = (key: string) => (base ? `${base}:` : "") + key; + + const _browser: Browser | undefined = + // eslint-disable-next-line unicorn/no-typeof-undefined + typeof browser === "undefined" ? chrome : browser; + + const storage = _browser?.storage?.[storageArea]; + if (!storage) { + throw createError( + DRIVER_NAME, + `\`browser.storage.${storageArea}\` is not available. Ensure "storage" permission is declared in the extension manifest.` + ); + } + + return { + name: DRIVER_NAME, + options: opts, + getInstance: () => storage, + async hasItem(key) { + const result = await storage.get(r(key)); + return r(key) in result; + }, + async getItem(key) { + const result = await storage.get(r(key)); + return result[r(key)] ?? null; + }, + async setItem(key, value) { + await storage.set({ [r(key)]: value }); + }, + async removeItem(key) { + await storage.remove(r(key)); + }, + async getKeys() { + const result = await storage.get(null); + const allKeys = Object.keys(result); + return base + ? allKeys + .filter((key) => key.startsWith(`${base}:`)) + .map((key) => key.slice(base.length + 1)) + : allKeys; + }, + async clear(prefix) { + const _base = [base, prefix].filter(Boolean).join(":"); + if (_base) { + const result = await storage.get(null); + const keysToRemove = Object.keys(result).filter((key) => + key.startsWith(`${_base}:`) + ); + if (keysToRemove.length > 0) { + await storage.remove(keysToRemove); + } + } else { + await storage.clear(); + } + }, + }; +}); diff --git a/test/browser-extension/.gitignore b/test/browser-extension/.gitignore new file mode 100644 index 000000000..a25695382 --- /dev/null +++ b/test/browser-extension/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.output +stats.html +stats-*.json +.wxt +web-ext.config.ts + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/test/browser-extension/entrypoints/background.ts b/test/browser-extension/entrypoints/background.ts new file mode 100644 index 000000000..2588718c3 --- /dev/null +++ b/test/browser-extension/entrypoints/background.ts @@ -0,0 +1,11 @@ +export default defineBackground(() => { + console.log("Hello background!", { id: browser.runtime.id }); + + browser.storage.local.onChanged.addListener((changes) => { + for (const [key, { oldValue, newValue }] of Object.entries(changes)) { + console.log( + `[storage] "${key}": ${JSON.stringify(oldValue)} → ${JSON.stringify(newValue)}` + ); + } + }); +}); diff --git a/test/browser-extension/entrypoints/popup/index.html b/test/browser-extension/entrypoints/popup/index.html new file mode 100644 index 000000000..5a2184e15 --- /dev/null +++ b/test/browser-extension/entrypoints/popup/index.html @@ -0,0 +1,13 @@ + + + + + + Default Popup Title + + + +
+ + + diff --git a/test/browser-extension/entrypoints/popup/main.ts b/test/browser-extension/entrypoints/popup/main.ts new file mode 100644 index 000000000..c71544611 --- /dev/null +++ b/test/browser-extension/entrypoints/popup/main.ts @@ -0,0 +1,51 @@ +import { storage } from "@/lib/storage"; + +const app = document.querySelector("#app")!; + +app.innerHTML = ` +
+

unstorage web-extension-storage driver

+ + + + + + + +

+  
+`; + +const keyInput = document.querySelector("#key")!; +const valueInput = document.querySelector("#value")!; +const output = document.querySelector("#output")!; + +const log = (msg: string) => { + output.textContent = msg; + console.log(msg); +}; + +document.querySelector("#set")!.addEventListener("click", async () => { + await storage.setItem(keyInput.value, valueInput.value); + log(`Set "${keyInput.value}" = "${valueInput.value}"`); +}); + +document.querySelector("#get")!.addEventListener("click", async () => { + const val = await storage.getItem(keyInput.value); + log(`Get "${keyInput.value}" = ${JSON.stringify(val)}`); +}); + +document.querySelector("#remove")!.addEventListener("click", async () => { + await storage.removeItem(keyInput.value); + log(`Removed "${keyInput.value}"`); +}); + +document.querySelector("#keys")!.addEventListener("click", async () => { + const keys = await storage.getKeys(); + log(`Keys: ${JSON.stringify(keys)}`); +}); + +document.querySelector("#clear")!.addEventListener("click", async () => { + await storage.clear(); + log("Cleared all"); +}); diff --git a/test/browser-extension/lib/storage.ts b/test/browser-extension/lib/storage.ts new file mode 100644 index 000000000..93bdebb7a --- /dev/null +++ b/test/browser-extension/lib/storage.ts @@ -0,0 +1,6 @@ +import { createStorage } from "unstorage"; +import webExtensionStorageDriver from "unstorage/drivers/web-extension-storage"; + +export const storage = createStorage({ + driver: webExtensionStorageDriver({ storageArea: "local" }), +}); diff --git a/test/browser-extension/package.json b/test/browser-extension/package.json new file mode 100644 index 000000000..d47564c76 --- /dev/null +++ b/test/browser-extension/package.json @@ -0,0 +1,24 @@ +{ + "name": "wxt-starter", + "description": "manifest.json description", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "wxt", + "dev:firefox": "wxt -b firefox", + "build": "wxt build", + "build:firefox": "wxt build -b firefox", + "zip": "wxt zip", + "zip:firefox": "wxt zip -b firefox", + "compile": "tsc --noEmit", + "postinstall": "wxt prepare" + }, + "devDependencies": { + "typescript": "^5.9.2", + "wxt": "^0.20.6" + }, + "dependencies": { + "unstorage": "link:../.." + } +} diff --git a/test/browser-extension/tsconfig.json b/test/browser-extension/tsconfig.json new file mode 100644 index 000000000..008bc3c7d --- /dev/null +++ b/test/browser-extension/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.wxt/tsconfig.json" +} diff --git a/test/browser-extension/wxt.config.ts b/test/browser-extension/wxt.config.ts new file mode 100644 index 000000000..749dc0bcb --- /dev/null +++ b/test/browser-extension/wxt.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "wxt"; + +// See https://wxt.dev/api/config.html +export default defineConfig({ + manifest: { + permissions: ["storage"], + }, +});