@evoluncite/larder
v0.1.0
Published
Offline-first HTTP cache for Angular 21+ using signals, resource(), and IndexedDB (Dexie). Stale-while-revalidate with auto-invalidation.
Downloads
255
Maintainers
Readme
@evoluncite/larder
Reactive data layer for Angular 21+. Offline-first HTTP cache plus a multi-backend WebSocket client, signal-first, with a helper that wires the two together so a list stays live without any boilerplate.
What's in the box
Three primitives, in increasing order of integration:
cachedResource— HTTP GET with stale-while-revalidate on IndexedDB. Use for any cacheable read.RealtimeClient+provideRealtime— Socket.io client abstracted over auth + URL. Use when you need raw subscriptions (e.g. custom events that don't map to a list).realtimeBackedResource— Combines (1) and (2) in a single declaration. The default for list views that mutate live.
Plus glue: CachedHttp for mutations with auto-invalidation,
OfflineCache for logout/clear flows, and listById / keyedBy
helpers that eliminate boilerplate in the realtime apply handlers.
Decision tree
What kind of data?
├── Static reference data (catalog, lookup)
│ → cachedResource only, no realtime
│
├── List that mutates from other users / tabs
│ → realtimeBackedResource with apply = listById<T>()
│
├── Detail page (single entity, lives off a list)
│ → cachedResource + subscribe via inject(REALTIME_*) for cache invalidation
│
├── Custom event stream (typing indicators, chat presence)
│ → inject(REALTIME_*) and RealtimeClient.on() directly
│
└── App is offline-only (no realtime backend)
→ cachedResource + CachedHttp, skip realtime entirelyInstall
pnpm add @evoluncite/larder dexie socket.io-clientBootstrap
Register everything once in app.config.ts:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import {
provideOfflineCache,
provideRealtime,
realtimeToken,
} from '@evoluncite/larder';
// Token names are app-specific — declare them next to your other DI tokens.
export const REALTIME_MAIN = realtimeToken('main');
const tokenProvider = async () => /* fetch your auth token */;
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
provideOfflineCache({
dbName: 'MyAppCache',
defaultTtl: 60_000,
}),
provideRealtime({
token: REALTIME_MAIN,
name: 'main',
config: {
serverUrl: environment.apiUrl,
tokenProvider,
requireAuthToConnect: true, // recommended — see below
},
}),
],
});You can register multiple realtime backends in the same call — one per backend, each with its own token.
Cached reads — cachedResource
import { Component, inject } from '@angular/core';
import { cachedResource, CachedHttp } from '@evoluncite/larder';
interface User { id: string; name: string }
@Component({
template: `
@if (users.hasValue()) {
@if (users.isRevalidating()) { <small>Updating…</small> }
<ul>
@for (u of users.value(); track u.id) {
<li>{{ u.name }}</li>
}
</ul>
} @else if (users.isLoading()) {
<p>Loading…</p>
}
`,
})
export class UsersComponent {
private api = inject(CachedHttp);
users = cachedResource<User[]>({
url: () => '/api/users',
ttl: 60_000,
tags: ['users'],
});
async add() {
await this.api.post('/api/users', { name: 'New' });
// Cache for /api/users is auto-invalidated and `users` reloads.
}
}Options
| Option | Type | Default |
| ------------------- | --------------------------------------------------- | ------------------------ |
| url | () => string \| undefined | — (required) |
| params | () => Record<string, string \| number \| boolean> | undefined |
| headers | () => Record<string, string> | undefined |
| ttl | number (ms) | global defaultTtl |
| tags | string[] | [] |
| headerWhitelist | string[] | global headerWhitelist |
| loader | () => Promise<T> (bypasses HttpClient) | undefined |
url, params, headers are functions so the resource re-runs when
any signal they read changes.
Returned API
| Field | Type |
| ----------------- | ----------------------------------------------------- |
| value | Signal<T \| undefined> |
| hasValue | Signal<boolean> |
| isLoading | Signal<boolean> |
| isRevalidating | Signal<boolean> |
| isStale | Signal<boolean> |
| error | Signal<unknown> |
| cacheKey | Signal<string \| undefined> |
| reload() | Force a network refetch ignoring TTL. |
| set(data) | Replace value in memory + IndexedDB. No network. |
| mutate(updater) | Transform current value and persist. No network. |
set and mutate are key for live UX: when a mutation succeeds or a
realtime event arrives with the new payload, apply it to the cache
without a round-trip.
Mutations — CachedHttp
private api = inject(CachedHttp);
// Invalidates /api/users by default (collection inferred from URL)
await this.api.post<User>('/api/users', { name: 'Alice' });
// Explicit invalidation patterns
await this.api.patch<User>('/api/users/123', { name: 'A' }, {
invalidate: ['/api/users', '/api/users/123'],
});
// Skip auto-invalidation
await this.api.post('/api/audit', payload, { skipInvalidate: true });Realtime — RealtimeClient + tokens
For data that mutates from other tabs or users, subscribe to socket.io events from the backend gateway.
import { Component, DestroyRef, inject } from '@angular/core';
import { REALTIME_MAIN } from './tokens'; // your realtimeToken('main')
@Component({...})
export class ChatComponent {
private realtime = inject(REALTIME_MAIN);
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.realtime.connect(); // idempotent
const unsub = this.realtime.onCreate<Message>(
'message',
(message) => console.log('new message', message),
{ roomId: this.roomId },
);
this.destroyRef.onDestroy(unsub);
}
}Tokens, per-backend
Each call to provideRealtime registers one RealtimeClient instance
under its token. Consumers inject(TOKEN) to get the right backend.
Apps that talk to multiple backends (e.g. main API + webhook service)
declare one token per backend; the rest of the code is identical.
requireAuthToConnect: true
Recommended for apps with login. Without it, connect() opens the
socket immediately. If no auth token is available yet, socket.io
retries forever with exponential backoff — a feature mounted before
the user logs in starts hammering the gateway.
With the flag, connect() consults tokenProvider() first and
aborts silently when it returns ''. Subsequent calls retry — the
first one after login succeeds and opens the socket.
Low-level API
| Method | What it does |
| ------------------------------------------ | ------------------------------------------------------------------ |
| connect() | Open the socket (idempotent). |
| disconnect() | Close + clean local state. |
| onCreate<T>(model, cb, filters?) | Subscribe to model:created; returns unsubscribe. |
| onUpdate<T>(model, cb, filters?) | Subscribe to model:updated. |
| onDelete<T>(model, cb, filters?) | Subscribe to model:deleted. |
| onModel<T>(model, handlers, filters?) | Composes the three above; one combined unsubscribe. |
| onReconnect(cb) | Fires on reconnect (NOT initial connect). Use for catch-up. |
| on<T>(eventName, cb) | Subscribe to an arbitrary socket.io event. |
| connected: Signal<boolean> | Live connection status. |
| connectionError: Signal<string \| null> | Last connection error message. |
The full pattern — realtimeBackedResource
When you have a list that mutates live, you almost always want:
HTTP fetch + cache + subscribe to CRUD events + apply event to cache.
That's realtimeBackedResource in a single declaration.
import { realtimeBackedResource, listById } from '@evoluncite/larder';
import { REALTIME_MAIN } from './tokens';
@Component({...})
export class AgentsComponent {
private session = inject(SessionStore);
agents = realtimeBackedResource<Agent[], Agent>({
url: () => '/api/agents',
params: () => ({ companyId: this.session.activeCompany()?.id ?? '' }),
tags: ['agent'],
realtime: {
token: REALTIME_MAIN,
model: 'agent',
filters: () => {
const companyId = this.session.activeCompany()?.id;
return companyId ? { companyId } : undefined;
},
apply: listById<Agent>(),
},
});
}What the helper does for you:
- Calls
RealtimeClient.connect()(idempotent). - Subscribes via
onModelwith the filters; re-subscribes when the filters signal changes (e.g. the user switches active company). - Applies each event to the cache via
mutate()(no network) using theapplyhandlers. Missing handlers fall back toreload(). - Calls
reload()after socket reconnection (toggle withpullOnReconnect: false). - Cleans up on
DestroyRef.onDestroy.
apply shortcuts
For lists keyed by id:
apply: listById<Agent>(),For lists keyed by a different field:
apply: keyedBy<Order>('orderNumber'),For custom logic (idempotency, filtering, transformations):
apply: {
onCreate: (list, item) => {
if (item.state !== 'ACTIVE') return list ?? [];
if ((list ?? []).some((x) => x.id === item.id)) return list ?? [];
return [...(list ?? []), item];
},
onUpdate: (list, item) =>
(list ?? []).map((x) => (x.id === item.id ? item : x)),
// onDelete omitted — falls back to reload()
},If you omit apply entirely, every event triggers reload() — the
simplest option for small lists or when the event payload alone isn't
enough to reconstruct state.
Manual control — OfflineCache
private cache = inject(OfflineCache);
// On logout
await this.cache.clearAll();
// On a "Refresh everything" button
await this.cache.clearAndReload();
// Invalidate by pattern (URL prefix or tag prefix)
await this.cache.invalidate('users');
// Online status as a signal
isOnline = this.cache.isOnline;Configuration
provideOfflineCache({
dbName: 'AppHttpCache', // IndexedDB database name
defaultTtl: 60_000, // ms before an entry is considered stale
headerWhitelist: ['X-Locale'], // headers that affect the cache key
revalidateOnReconnect: true, // auto-revalidate stale entries on reconnect
debug: false, // enable console.debug logging
});
provideRealtime({
token: REALTIME_MAIN,
name: 'main',
config: {
serverUrl: 'https://api.example.com',
namespace: '/realtime', // default: '/realtime'
tokenProvider: async () => '...',
requireAuthToConnect: true, // recommended for authenticated apps
logPrefix: 'Realtime[main]', // default: `Realtime[<name>]`
},
});SSR
On the server, all IndexedDB operations are no-ops and cachedResource
resolves without persisting. The first browser render still kicks off
the network fetch normally. Realtime is browser-only — RealtimeClient
will not open a socket on the server.
License
MIT
