Encrypted, cache-first object storage with a persistent write queue for browser applications. Objects are available locally immediately; the application owns the worker that synchronizes queued changes to their HTTP(S) URLs.
npm install @sovereignbase/storage
# or
pnpm add @sovereignbase/storage
# or
yarn add @sovereignbase/storage
# or
bun add @sovereignbase/storage
# or
deno add jsr:@sovereignbase/storage
# or
vlt install jsr:@sovereignbase/storagestoreObject and deleteObject commit locally and append an operation to
WriteQueue. Register a callback that drains the queue and performs your remote
writes. Call finalize() only after the server accepts an operation, so a failed
write remains available for retry.
import { WriteQueue } from '@sovereignbase/storage'
let syncing = false
async function syncWrites() {
if (syncing) return
syncing = true
try {
let queued = await WriteQueue.dequeue()
while (queued) {
const { operation, finalize } = queued
if (operation.kind === 'store') {
const cached = await caches.match(operation.url)
if (!cached) throw new Error(`Missing cached object: ${operation.url}`)
const response = await fetch(operation.url, {
method: 'PUT',
headers: { 'content-type': 'application/octet-stream' },
body: await cached.arrayBuffer(),
})
if (!response.ok) throw new Error(`Store failed: ${response.status}`)
} else {
const response = await fetch(operation.url, { method: 'DELETE' })
if (!response.ok) throw new Error(`Delete failed: ${response.status}`)
}
await finalize()
queued = await WriteQueue.dequeue()
}
} finally {
syncing = false
}
}
WriteQueue.onQueued = () => void syncWrites()
void syncWrites() // resume operations left over from an earlier sessionQueue operations contain URLs, not secret keys or plaintext values. A store consumer obtains the already encrypted bytes from the Cache API.
import { storeObject } from '@sovereignbase/storage'
import { Cryptographic } from '@sovereignbase/cryptosuite'
const url = 'https://objects.example/profile'
const cipherKey = await Cryptographic.cipherMessage.generateKey()
await storeObject(url, { name: 'Ada' }, cipherKey)The returned promise settles after the encrypted cache entry and its persistent queue operation have both been written.
import { loadObject } from '@sovereignbase/storage'
const objectPromise = loadObject(url, cipherKey)
// Build the UI while loading is already in progress.
const output = document.createElement('output')
output.textContent = 'Loading…'
document.body.append(output)
const object = (await objectPromise) as { name: string } | undefined
if (object) output.textContent = object.nameloadObject reads from the cache first and fetches url on a miss. Successful
reads refresh cache freshness metadata in the background. Start the load as early
as useful and await its promise when the value is needed. A non-successful HTTP
response resolves to undefined. Initial network, cache-read, and decoding errors
reject the promise; background cache-write failures do not.
Pass true as the third argument to avoid all network access. A cache miss then
resolves to undefined, even if a callback was provided:
const cachedObject = await loadObject(url, cipherKey, true)Pass an optional fourth argument, onNetworkResponse, to fetch a network replica
after a cache hit. The promise returns the local object first. The background
fetch starts in a later task, so initialize local state directly after awaiting
the promise, before doing any further asynchronous work.
For example, an application using @sovereignbase/sequencer can hydrate a
Projection from a stored sequence and then merge the network sequence:
import { loadObject, storeObject } from '@sovereignbase/storage'
import { Projection } from '@sovereignbase/sequencer'
let list: Projection<string>
const objectPromise = loadObject(
url,
cipherKey,
false,
async (data: unknown) => {
try {
// Combine the replica with local edits, then persist the merged sequence.
if (!list.merge(data)) throw new Error('Invalid network sequence')
output.textContent = JSON.stringify(list.values())
await storeObject(url, list.sequence(), cipherKey)
} catch (error) {
console.error('Could not merge or persist the network sequence', error)
}
}
)
// Build the UI while loading is already in progress.
const output = document.createElement('output')
output.textContent = 'Loading…'
document.body.append(output)
const data = await objectPromise
list = new Projection<string>(0, data)
output.textContent = JSON.stringify(list.values())Use your application's actor ID in place of 0. @sovereignbase/sequencer is
an application dependency, not a dependency of this storage package.
| Cache | cacheOnly |
Callback provided | Promise result | Background network callback |
|---|---|---|---|---|
| Hit | false |
No | Local object | None; no network request |
| Hit | false |
Yes | Local object | Once, with a successfully decoded network object |
| Miss | false |
Either | Network object, or undefined for an HTTP error |
None; the promise delivers the network result |
| Either | true |
Either | Local object, or undefined on a miss |
None; no network request |
The callback receives decrypted, decoded data as unknown and may return a
promise. It runs for every successful background response, even if the data is
unchanged. There is one fetch per qualifying load, with no polling or automatic
retry. Invalid cached data rejects the initial load without starting a background
fetch.
The network replica does not automatically replace the cached object or enqueue
a write: it may lack local edits. The application owns validation, merging, UI
updates, and persistence. In the example, storeObject caches the merged sequence
and queues it for upload through the application's write synchronization worker.
Background HTTP, network, body-reading, and decoding failures skip the callback
and leave the local data usable. Callback exceptions and rejected callback
promises are also contained; catch errors inside the callback when the application
needs to report or retry them. These background failures do not reject the
already completed loadObject promise.
import { deleteObject } from '@sovereignbase/storage'
await deleteObject(url)Deletion removes the local cache entry and persists a delete operation for the sync worker.
storeObject and loadObject accept a cryptosuite CipherKey JWK. Keep the key
secret and persist it separately from the encrypted object. Loading requires the
same key that was used for storage. Generate a random key with
Cryptographic.cipherMessage.generateKey() or derive one with
Cryptographic.cipherMessage.deriveKey(sourceKeyMaterial, salt).
- Browser-only ESM.
- Requires the Cache API, IndexedDB, Fetch, Web Crypto, and
Blob. - Accepts an absolute
http://orhttps://URL as each object's identity and remote location. - Uses MessagePack, gzip compression, 1 KiB length-hiding padding, and
cryptosuite encryption. Objects whose compressed representation exceeds 24
MiB are rejected with error code
MAX_OBJECT_SIZE_EXCEEDED; malformed decrypted padding is rejected withINVALID_PADDING. - Cache hits take precedence over the network. Successful reads refresh
Cache-Control,Date, andExpireswith a 90-day freshness hint. - An optional
loadObjectcallback receives a decoded network replica after a cache hit. Merging and persisting it are the application's responsibility. - Cache-only loads never make a network request.
- Remote responses need to allow the browser origin when used cross-origin.
- Write operations are stored in IndexedDB in FIFO order and remain queued until
their
finalize()function succeeds. Enqueuing another operation for the same URL replaces the older pending operation. - IndexedDB failures reject with an operation-specific
StorageError; the browser's original error is available through itscauseproperty. - Dependencies are not bundled.
- Unit and integration tests run in Vitest with TypeScript.
- Browser E2E tests run in Playwright on Chromium, Firefox, WebKit, Pixel 7 mobile Chromium, mobile Firefox emulation, and iPhone 15 mobile WebKit.
- Statement, branch, function, and line coverage are all held at 100%.
Apache-2.0