npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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:

  1. cachedResource — HTTP GET with stale-while-revalidate on IndexedDB. Use for any cacheable read.
  2. 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).
  3. 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 entirely

Install

pnpm add @evoluncite/larder dexie socket.io-client

Bootstrap

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 onModel with 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 the apply handlers. Missing handlers fall back to reload().
  • Calls reload() after socket reconnection (toggle with pullOnReconnect: 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