@verse8/local-cache
v0.1.0
Published
Verse8 device-local, best-effort cache for games — never synced across devices or platforms, may be evicted at any time
Downloads
124
Readme
@verse8/local-cache
This is a device-local, best-effort cache. It is never synced across devices, browsers, or platforms (web / app / OneStore) and any entry can be evicted at any time. Do not store player progress here — use the game server. Use it for data you can regenerate: seeded terrain, decoded assets, precomputed tables.
Verse8 games run as a cross-site iframe ({id}.verse8.games inside
verse8.io). WebKit (Safari, every iOS browser) makes third-party IndexedDB
and localStorage ephemeral — writes succeed, but nothing reaches disk and
it all disappears when the page is discarded. This package moves the bytes to
the hosting shell's first-party origin over a MessageChannel, where they
persist, and falls back to in-frame storage everywhere else.
Two surfaces ship in one package:
| Entry | Runs in | Global (CDN) |
|---|---|---|
| @verse8/local-cache | the game | window.Verse8LocalCache |
| @verse8/local-cache/parent | the shell (verse8.io, onestore.verse8.io) | window.Verse8LocalCacheParent |
Install (game)
pnpm add @verse8/local-cache
# npm i @verse8/local-cache / yarn add @verse8/local-cache<!-- or CDN -->
<script src="https://unpkg.com/@verse8/local-cache@latest/dist/index.global.js"></script>Quick start (game)
import { Verse8LocalCache } from "@verse8/local-cache";
async function loadTerrain(seed: number): Promise<ArrayBuffer> {
const key = `terrain:${seed}`;
const cached = await Verse8LocalCache.get(key);
if (cached) return cached; // hit
const fresh = generateTerrain(seed); // miss → regenerate (always possible)
const r = await Verse8LocalCache.set(key, fresh);
if (!r.ok) console.debug("terrain not cached:", r.reason); // fine — still playable
return fresh;
}No init() call is required — the first call bootstraps. Call
Verse8LocalCache.init({ ... }) only to pass options (see below).
init() timing. The backend decision runs once, on the first init()
or the first data call. Options passed after that (silent,
handshakeTimeoutMs, limits, …) cannot affect a handshake that already
happened — only debug applies immediately. If you need options, call
init() before your first get/set.
API
All methods are async. Failures are values, never exceptions (except
programming errors such as a non-string key).
init(opts?: {
parentOrigin?: string; // auto-resolved; see "How the host is found"
handshakeTimeoutMs?: number; // default 1500
limits?: Partial<Limits>; // in-frame fallback only
dbName?: string; // in-frame fallback only
debug?: boolean;
silent?: boolean; // suppress the one-time "host unsupported" warning
}): void
ready(): Promise<'host' | 'local' | 'memory'> // resolves once the backend is decided
getBackend(): 'host' | 'local' | 'memory' | null
get(key): Promise<ArrayBuffer | null> // null = miss (normal path)
set(key, value: ArrayBuffer | ArrayBufferView, opts?: { transfer?: boolean }): Promise<
{ ok: true } | { ok: false; reason: 'QUOTA_EXCEEDED' | 'TOO_LARGE' | 'UNAVAILABLE' }>
// default: value is copied (your buffer stays usable)
// { transfer: true }: raw ArrayBuffer is moved zero-copy and DETACHED (byteLength 0) afterwards
delete(key): Promise<void>
keys(prefix?): Promise<string[]>
clear(): Promise<void>
quota(): Promise<{ usedBytes: number; limitBytes: number }>
getJSON<T>(key): Promise<T | null> // UTF-8 JSON convenience
setJSON(key, value): Promise<SetResult>Backends — what ready() tells you
| Backend | When | Durability |
|---|---|---|
| host | Game is framed by a Verse8 shell that runs @verse8/local-cache/parent | Durable (shell's first-party IndexedDB) |
| local | Game is the top-level document (Verse8 mobile app WebView, standalone) — or the host is silent/declines | Durable when top-level. Ephemeral on Safari/iOS when cross-site framed |
| memory | IndexedDB unusable (private mode, storage blocked) | Lost on reload |
The SDK prints one console line describing its decision:
- top-level →
console.info(...first-party context...) - host silent / declined / untrusted parent →
console.warn(...host does not support the local cache; falling back to in-frame IndexedDB. On Safari/iOS this storage is EPHEMERAL...) - no IndexedDB →
console.warn(...in-memory...)
Pass init({ silent: true }) to suppress it. Either way, getBackend() lets
you branch (e.g. skip caching multi-MB blobs on memory).
If the shell tears the bridge down mid-session (route change, stop()), the
SDK receives a BYE, fails in-flight calls fast, warns once, and continues on
the in-frame fallback for the rest of the session — writes from then on are
not on the shell origin, which is fine for a best-effort cache.
How the host is found
init({ parentOrigin }) wins. Otherwise, in order:
window.verse8.parentOrigin (set by the shell-served v8-inject.js) →
location.ancestorOrigins[0] if it is *.verse8.io → ?parentOrigin= query
if *.verse8.io / local dev. Nothing trusted → no handshake → fallback.
Top-level documents never handshake.
Namespacing
You get one namespace per game; the shell derives it from the browser-stamped
origin of your CONNECT message, so games cannot read each other's cache. Inside
your namespace, keys are yours — prefix them yourself if you want per-account
separation (\${account}:terrain:${seed}``).
Policy (v1)
| | Default |
|---|---|
| Per game (namespace) | 64 MB soft cap → QUOTA_EXCEEDED |
| Records per game | 10 000 → QUOTA_EXCEEDED |
| Single value | 32 MB → TOO_LARGE |
| Key length | 512 UTF-16 units → TOO_LARGE |
| Shell total (all games) | 512 MB → least-recently-used games evicted on next connect |
usedBytes counts value bytes plus key bytes and a flat 128-byte
per-record allowance, so it tracks real footprint rather than payload only.
Guarantee level: best-effort. Entries disappear when: the shell total cap
is exceeded (LRU), the user clears site data, the browser purges the shell
origin (Safari ITP purges script-writable storage of sites without user
interaction for 7 days of Safari use), or the app is uninstalled. The shell
calls navigator.storage.persist() once to reduce browser-initiated eviction;
it is advisory.
Shell integration (/parent)
import { startLocalCacheParent } from "@verse8/local-cache/parent";
const handle = startLocalCacheParent({
isAllowedGameOrigin: (origin) => /^https:\/\/[a-z0-9-]+\.verse8\.games$/.test(origin),
// limits, dbName, resolveNamespace, requestPersistentStorage, onTelemetry — optional
});
// on unmount / navigation:
handle.stop();One listener serves every game iframe on the page. Each CONNECT brings its
own MessagePort; the port is bound to the namespace derived from
event.origin and served from the shell's IndexedDB
(verse8-local-cache). Data never leaves the shell origin. A newer CONNECT
from the same browsing context (event.source) supersedes the older
connection, so SDK retries and iframe reloads never accumulate live ports
(two SDK copies in one game document would fight — don't do that). If the
shell has no IndexedDB, the parent declines every CONNECT (READY
ok:false) rather than pretending to be durable; the game then falls back
and warns.
Mount exactly one parent per shell page (a module-level singleton / ref count
if several components can render games) — a second parent never sees ports
the first one adopted, and its stop() would BYE connections it doesn't own.
Telemetry events: connect, reject, evict, request, disconnect,
error.
Wire protocol
See PROTOCOL.md.
Development
pnpm install
pnpm typecheck && pnpm build && pnpm testLicense
MIT
