@diablo-oss/electron-store
v0.1.0
Published
Effect-native Electron store plugin with a Tauri-style API and SuperJSON serialization
Readme
@diablo-oss/electron-store
An Effect-native Electron store plugin with a Tauri-style API.
It gives you typed, key-value persistence that works the same way from the main process and from every renderer. Persistence and IPC both use SuperJSON, so values like Date, BigInt, Map, Set, RegExp, URL, undefined, typed arrays, and special number values (NaN, Infinity) survive the round-trip instead of being flattened to JSON.
You can use it with plain Promises (no Effect knowledge required) or with Effect layers, streams, and schemas.
Why this package
- Tauri-style plugin shape — install once in main, expose in preload, call from the renderer.
- Rich serialization — SuperJSON end-to-end, not plain JSON.
- Two renderer APIs — Promise client for everyday use, Effect API for apps already on Effect.
- Safe by default — store paths are jailed under a root directory; resource IDs are bound to the renderer that created them.
- Testable without Electron — in-memory client and Effect layer for unit tests.
Requirements
| Package | Role |
| ------------------ | ------------------ |
| electron >= 28 | peer dependency |
| effect ^3.14 | peer dependency |
| superjson | bundled dependency |
Installation
npm install @diablo-oss/electron-store effectEntry points:
| Import | Process |
| ------------------------------------------- | ---------------- |
| @diablo-oss/electron-store/main | Main |
| @diablo-oss/electron-store/preload | Preload |
| @diablo-oss/electron-store/renderer | Renderer / tests |
Quick start
Wire the three Electron sides once:
Main
import { installStorePlugin } from "@diablo-oss/electron-store/main";
// Waits for app.whenReady() internally — safe at top level
const stores = await installStorePlugin();Preload
import { exposeStorePlugin } from "@diablo-oss/electron-store/preload";
exposeStorePlugin();Renderer (Promise API)
import { createStoreClient } from "@diablo-oss/electron-store/renderer";
const stores = createStoreClient();
const settings = stores.lazy("settings.store", {
defaults: { theme: "light", lastOpened: new Date() },
autoSave: 250,
});
await settings.set("theme", "dark");
const theme = await settings.get<"light" | "dark">("theme");Main process
installStorePlugin(options?)
Registers IPC handlers and returns a StorePlugin. It always awaits app.whenReady() first, so you can call it at the top of your main entry file.
import { installStorePlugin } from "@diablo-oss/electron-store/main";
const stores = await installStorePlugin({
// Defaults to app.getPath("userData")
root: undefined,
// Deny paths that should stay main-only
scope: (storePath) => !storePath.startsWith("private/"),
// Defaults to console.error
onError: (cause) => console.error(cause),
});| Option | Type | Description |
| --------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| root | string? | Directory that stores are resolved under. Defaults to app.getPath("userData"). Paths that escape this root are rejected. |
| scope | (storePath, event) => boolean | Per-request access check. Return false to deny a renderer. |
| onError | (cause) => void | Called for background errors (for example failed auto-save or quit flush). |
Pinning a store from main
stores.open(path, options?) loads a store and pins it: it stays in memory even after every renderer handle is closed. Changes still broadcast to renderers that hold a handle.
const settings = await stores.open("settings.store", {
defaults: { theme: "light", lastOpened: new Date() },
autoSave: true,
});
settings.set("theme", "dark");Other plugin methods:
saveAll()— flush every open store to diskdispose()— remove IPC handlers, close stores, and detach thebefore-quitlistener
On before-quit, the plugin prevents quit once, flushes all dirty stores, then allows the app to exit.
Preload
import { exposeStorePlugin } from "@diablo-oss/electron-store/preload";
// Default global: window.electronStore
exposeStorePlugin();
// Or a custom name (must match the renderer client)
exposeStorePlugin("myStoreBridge");exposeStorePlugin puts a small bridge on globalThis via contextBridge. The bridge only transports SuperJSON-encoded strings (invoke + onChange).
If the BrowserWindow uses sandbox: true, bundle the preload script (esbuild, Vite, etc.). Sandboxed preload cannot require npm packages from node_modules.
Renderer — Promise API
No Effect knowledge required. The client is constructed synchronously; I/O happens when you call methods.
import { createStoreClient } from "@diablo-oss/electron-store/renderer";
const stores = createStoreClient();
// Or: createStoreClient({ globalName: "myStoreBridge" })Loading stores
// Eager — opens immediately
const settings = await stores.load("settings.store", {
defaults: { theme: "light" },
autoSave: 250,
});
// Lazy — handle is sync; load runs on first method call (or init())
const prefs = stores.lazy("prefs.store", { defaults: { locale: "en" } });
await prefs.init(); // optional explicit load
// Attach only if main (or another renderer) already has it open
const existing = await stores.getStore("settings.store");Reading and writing
await settings.set("theme", "dark");
await settings.set("lastOpened", new Date());
const theme = await settings.get<"light" | "dark">("theme");
const exists = await settings.has("theme");
await settings.delete("theme");
await settings.clear(); // empty the store
await settings.reset(); // restore defaults
const keys = await settings.keys();
const values = await settings.values();
const entries = await settings.entries();
const count = await settings.length();
await settings.save(); // flush now
await settings.reload(); // re-read from disk
await settings.reload({ ignoreDefaults: true });Change listeners
const stopAll = await settings.onChange((key, value) => {
console.log(key, value);
});
const stopTheme = await settings.onKeyChange<"light" | "dark">("theme", (value) =>
console.log("theme:", value),
);
stopTheme();
stopAll();Listeners resolve only after the subscription is active, so events are not lost between calling onChange / onKeyChange and the first callback.
Cleanup
await settings.close();
await stores.dispose();Closing a store releases that renderer’s resource ID. Disposing the client closes its scope and tears down the ManagedRuntime.
Renderer — Effect API
import { Console, Effect, Option, Schema, Stream } from "effect";
import { layerBridge, load } from "@diablo-oss/electron-store/renderer";
const Theme = Schema.Literal("light", "dark");
const program = Effect.gen(function* () {
const store = yield* load("settings.store", {
defaults: { theme: "light" },
});
const themeChanges = yield* store.changesOf("theme");
yield* themeChanges.pipe(
Stream.runForEach((value) => Console.log(value)),
Effect.forkScoped,
);
yield* store.set("theme", "dark");
const theme = yield* store.getSchema("theme", Theme);
if (Option.isSome(theme)) {
yield* Console.log(theme.value);
}
}).pipe(Effect.scoped, Effect.provide(layerBridge()));
await Effect.runPromise(program);Useful pieces:
| Export | Role |
| ----------------------------- | --------------------------------------------- |
| layerBridge(globalName?) | StoreIpc layer over the preload bridge |
| load(path, options?) | Open a store; closes on scope finalizer |
| getStore(path) | Option of an already-open store |
| store.get / getSchema | Read as Option or decode with Effect Schema |
| store.changes / changesOf | Scoped effects that yield change streams |
| StoreBridgeUnavailableError | Thrown when the preload global is missing |
| StoreIpcError | Wraps failed invoke / change decode errors |
Pass a matching globalName if you customized exposeStorePlugin:
Effect.provide(layerBridge("myStoreBridge"));Store options
Shared by main open / renderer load / lazy:
| Option | Type | Default | Description |
| ------------------ | ------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| defaults | Record<string, unknown> | {} | Initial values when the file is missing or a key is absent. |
| autoSave | boolean \| number | true (100 ms) | true or omitted → 100 ms debounce. A number sets the debounce in ms. false disables auto-save (call save() yourself). |
| createNew | boolean | false | Ignore any existing file and start from defaults. |
| overrideDefaults | boolean | false | When loading from disk, prefer defaults over stored values for overlapping keys. |
Reload options:
| Option | Description |
| ---------------- | ----------------------------------------- |
| ignoreDefaults | Reload the file without merging defaults. |
Testing without Electron
Use the in-memory client or Effect layer — same renderer API, no IPC and no disk.
Promise
import { createMemoryStoreClient } from "@diablo-oss/electron-store/renderer";
const stores = createMemoryStoreClient();
const store = await stores.load("test.store");
await store.set("date", new Date());
const date = await store.get<Date>("date");Effect
import { Effect } from "effect";
import { layerMemory, load } from "@diablo-oss/electron-store/renderer";
const program = Effect.gen(function* () {
const store = yield* load("test.store");
yield* store.set("n", 1);
}).pipe(Effect.scoped, Effect.provide(layerMemory));Behavior and guarantees
- Path jail — store paths resolve under
root(defaultuserData). Relative segments that escape (..) or equal the root are rejected. - Resource ownership — each
load/getStorereturns a resource ID owned by that renderer’sWebContents. Other windows cannot use or close it. Destroyed windows release their handles automatically. - Change fan-out — mutations broadcast only to windows that hold a handle for that store.
- Pinning — main-process
open()keeps the store loaded after the last renderer handle closes. Unpinned stores close when the last handle is released. - Quit flush —
before-quitwaits for pending writes before exit. - Serializable values only — anything you
setmust be SuperJSON- serializable.
Project scripts
npm test # vitest
npm run typecheck
npm run build # vite + tsc declarations