Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions docs/2.drivers/0.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
::
::
45 changes: 45 additions & 0 deletions docs/2.drivers/web-extension.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
7 changes: 6 additions & 1 deletion src/_drivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -85,6 +86,8 @@ export type BuiltinDriverOptions = {
"vercelKV": VercelKVOptions;
"vercel-runtime-cache": VercelRuntimeCacheOptions;
"vercelRuntimeCache": VercelRuntimeCacheOptions;
"web-extension-storage": WebExtensionStorageOptions;
"webExtensionStorage": WebExtensionStorageOptions;
};

export const builtinDrivers = {
Expand Down Expand Up @@ -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;
98 changes: 98 additions & 0 deletions src/drivers/web-extension-storage.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
remove(keys: string | string[]): Promise<void>;
clear(): Promise<void>;
}

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();
}
},
};
});
26 changes: 26 additions & 0 deletions test/browser-extension/.gitignore
Original file line number Diff line number Diff line change
@@ -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?
11 changes: 11 additions & 0 deletions test/browser-extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -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)}`
);
}
});
});
13 changes: 13 additions & 0 deletions test/browser-extension/entrypoints/popup/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>
51 changes: 51 additions & 0 deletions test/browser-extension/entrypoints/popup/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { storage } from "@/lib/storage";

const app = document.querySelector<HTMLDivElement>("#app")!;

app.innerHTML = `
<div>
<h3>unstorage web-extension-storage driver</h3>
<input id="key" placeholder="key" value="test-key" />
<input id="value" placeholder="value" value="hello world" />
<button id="set">Set</button>
<button id="get">Get</button>
<button id="remove">Remove</button>
<button id="keys">Get Keys</button>
<button id="clear">Clear</button>
<pre id="output"></pre>
</div>
`;

const keyInput = document.querySelector<HTMLInputElement>("#key")!;
const valueInput = document.querySelector<HTMLInputElement>("#value")!;
const output = document.querySelector<HTMLPreElement>("#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");
});
6 changes: 6 additions & 0 deletions test/browser-extension/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createStorage } from "unstorage";
import webExtensionStorageDriver from "unstorage/drivers/web-extension-storage";

export const storage = createStorage({
driver: webExtensionStorageDriver({ storageArea: "local" }),
});
24 changes: 24 additions & 0 deletions test/browser-extension/package.json
Original file line number Diff line number Diff line change
@@ -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:../.."
}
}
3 changes: 3 additions & 0 deletions test/browser-extension/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "./.wxt/tsconfig.json"
}
8 changes: 8 additions & 0 deletions test/browser-extension/wxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "wxt";

// See https://wxt.dev/api/config.html
export default defineConfig({
manifest: {
permissions: ["storage"],
},
});