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

@partylayer/session

v1.1.1

Published

Framework-agnostic session manager over the CIP-0103 provider abstraction (the wagmi-core-equivalent for Canton) — Step 6a core

Readme

@partylayer/session (Step 6a — core)

Framework-agnostic session manager over the CIP-0103 provider abstraction — the wagmi-core-equivalent for Canton. Tracks connection status and the active account/party, reacts to statusChanged / accountsChanged, supports restore/reconnect, and exposes a subscribable store for React useSyncExternalStore (Step 6b) and Vue composables.

Status: private (unpublished), v0.1.0. The API is still forming and the React hooks land in Step 6b (a separate PR). Keeping the package private keeps it out of the published-API snapshot gate until 6b/stabilization (same rationale as @partylayer/testing). No React/Vue/DOM code lives here.

1.0 behavior change — secure by default

As of 1.0, sessions persist encrypted by default. When you omit storage, the store uses createEncryptedIndexedDBStorage() on platforms that support it (IndexedDB + WebCrypto) and falls back to in-memory elsewhere (Node/SSR/test); persistSnapshot now defaults to true, so the full session snapshot — not a bare marker — is what's persisted (encrypted, under the default storage).

Flipping persistSnapshot alone over a plain default storage would persist session data unencrypted — so the encrypted default and persistSnapshot: true ship together as the secure-by-default pair.

Opt out explicitly:

  • persistSnapshot: false — keep only the reconnect marker, no snapshot; or
  • storage: createMemoryStorage() — no persistence at all.

Passing an explicit storage (including a plain localStorage adapter) is still fully respected — secure-by-default only governs the omitted case.

Usage

import { createSessionStore } from '@partylayer/session';

const store = createSessionStore(provider /* any CIP0103Provider */, {
  // storage is OPTIONAL — defaults to in-memory (no DOM access).
  // In a browser, inject a localStorage adapter:
  // storage: { getItem: (k) => localStorage.getItem(k), setItem: (k, v) => localStorage.setItem(k, v), removeItem: (k) => localStorage.removeItem(k) },
});

const unsubscribe = store.subscribe(() => {
  const s = store.getSnapshot(); // { status, account, accounts, networkId, lastError }
  console.log(s.status, s.account?.partyId);
});

await store.init();          // restore/reconnect on mount (probes provider.status())
await store.connect();       // → 'connecting' → 'connected'
await store.disconnect();    // → 'disconnected'
unsubscribe();
store.destroy();             // removes provider listeners

State machine

disconnected ──connect()──▶ connecting ──ok / statusChanged(true)──▶ connected
     ▲                          │                                       │
     │                          └──── error / rejection ────────────────┤
     ├──────── disconnect() / statusChanged(false) ──────────────────────┘
     └──restore()/init()──▶ reconnecting ──active session──▶ connected
                                  └────── none ──▶ disconnected

getSnapshot() returns a stable reference between notifications (swapped only on real change), so it is safe for useSyncExternalStore.

What it tracks (the CIP-0103 surface)

  • Status — from statusChanged (connection.isConnected) + the store's own in-flight state (connecting/reconnecting).
  • Accounts — from accountsChanged (CIP0103Account[]); the active account is the primary one (or the first).
  • NetworknetworkId (CAIP-2), derived from statusChanged.network / getActiveNetwork(). The WC adapter does not emit chainChanged today, so we derive it forward-compatibly and also subscribe to a future chainChanged event (harmless no-op until a provider emits it).

Persistence

Persistence is pluggable — inject a SessionStorage (getItem/setItem/ removeItem, sync or async). The default is in-memory, so the core runs in any runtime (Node/RN/browser) and tests are deterministic. The auto-reconnect marker is written on connect and cleared on disconnect; restore() verifies against the live provider before trusting it.

React integration

  • A provider source for the hooks (e.g. client.asProvider() / createProviderBridge) to pass to createSessionStore.
  • useSyncExternalStore(store.subscribe, store.getSnapshot) for useAccount and friends; store.init() in an effect on mount; store.destroy() on unmount.
  • In @partylayer/react, useSession() is the reactive hook over this core store (UseSessionReturn: status/account/networkId + actions). The legacy SDK Session | null getter is preserved as the deprecated useClientSession(). (The React context still tracks the SDK-level session:connected/disconnected/expired events alongside this core store.)
  • Inject a localStorage-backed SessionStorage in the browser.

Future: query-cache integration

A marker in src/store.ts (restore()) notes where TanStack Query cache wiring will attach. Not built yet.

Encrypted persistence

Two additive SessionStorage backends encrypt the persisted session at rest with AES-GCM-256, conforming to the existing SessionStorage contract (getItem/setItem/removeItem, MaybePromise-aware):

import {
  createEncryptedIndexedDBStorage, // default
  createEncryptedLocalStorage,
  encodeSessionEnvelope,
  restoreSession,
  reconcileSession,
} from '@partylayer/session';

const storage = createEncryptedIndexedDBStorage(); // origin-bound
await storage.setItem('partylayer.session', encodeSessionEnvelope(snapshot));

// later (e.g. after reload):
const restored = await restoreSession(storage, 'partylayer.session'); // snapshot | null
if (restored) {
  const diff = reconcileSession(restored, { account: liveAccount, networkId });
  if (!diff.matches) { /* user changed account/network while away */ }
}

Key-handling invariant (the security floor)

The AES-GCM-256 CryptoKey is always generated non-extractable and always stored in IndexedDB (via structured clone — localStorage can only hold strings, never a CryptoKey). Only the ciphertext blob location varies by backend. Each write uses a fresh random 12-byte IV stored beside the ciphertext. Storage is origin-bound: key/DB/blob names embed the origin, and this layer never embeds cross-origin data (browsers also partition storage per origin).

Backend matrix

| Backend | Ciphertext blob | AES key location | Key extractable | |---|---|---|---| | createEncryptedIndexedDBStorage (default) | IndexedDB | IndexedDB | no | | createEncryptedLocalStorage | localStorage | IndexedDB | no |

Versioned envelope + migration

The persisted plaintext is a versioned envelope ({ version: 1, account, accounts, networkId, connectedAt, expiresAt? }). migrateSessionEnvelope is a switch-on-version scaffold: known versions map forward into the current snapshot; an unknown future version returns null and restoreSession clears it. (Distinct from the crypto-envelope format version that governs the at-rest ciphertext shape.)

Restore safety

getItem/restoreSession return null and clear the entry — never throw into app code — on a corrupted blob, a wrong/rotated key, an unknown future version, or an expired snapshot.

Honest threat model — what this does and does NOT protect

  • Protects: persisted session data at rest and against casual inspection (devtools, disk, another app reading raw storage) — the value is ciphertext and the key is non-extractable.
  • Does NOT protect against same-origin XSS. In-page JavaScript on your origin can use the same non-extractable key through the very same encrypt/decrypt APIs (the key handle is reachable from the page). This layer is not a defense against script injection — fix XSS at the source (CSP, input handling). No overclaiming.

Session lifecycle scenarios

| ID | Scenario | Backends | |---|---|---| | SCENARIO-1 | persist → simulated reload → restore happy path | IndexedDB + localStorage | | SCENARIO-2 | reconcile snapshot vs live status → structured diff (no crash) | n/a (pure) | | SCENARIO-3 | corrupt / wrong-key / unknown-version / expired → null + cleared | both | | (inv) | per-write IV uniqueness; key non-extractability; localStorage zero key material | both |

Resilience: reconnect + expiry re-auth

Additive — opt-in via SessionStoreOptions; omitting them preserves the legacy behavior exactly.

const store = createSessionStore(provider, {
  reconnect: { baseDelayMs: 500, factor: 2, maxDelayMs: 30_000, maxAttempts: 5 },
  expiry: { ttlMs: 60 * 60_000, onReauthRequired: async () => { await reconnect(); } },
});
store.on('reconnect:scheduled', (e) => console.log(`retry #${e.attempt} in ${e.delayMs}ms`));
store.on('session:expired', () => showReauthPrompt());

// New ops during re-auth: queued, resumed on success, rejected on failure/overflow.
const receipt = await store.enqueue(() => submitTx());

Automatic reconnect (exponential backoff)

Fires only on a TRANSIENT disconnect — a provider-driven statusChanged(isConnected:false) while a session was active that was not an explicit store.disconnect(). Never reconnects after a user disconnect.

| RetryPolicy field | Default | Meaning | |---|---|---| | baseDelayMs | 500 | delay before retry #1 | | factor | 2 | delay = base * factor^(attempt-1) | | maxDelayMs | 30000 | cap on any single delay | | maxAttempts | 5 | give up after this many | | jitter? | false | randomize each delay into [50%,100%] (opt-in) |

Events: reconnect:scheduled {attempt, delayMs}reconnect:attempt {attempt}reconnect:succeeded {attempt} (state restored) or reconnect:gaveup {attempts, lastError} (terminal disconnected). reconnect omitted or false ⇒ disabled.

Runtime expiry → graceful re-auth

When expiry.ttlMs is set, an active session arms a timer; on expiry the store emits session:expired {expiredAt} and invokes onReauthRequired({reason, expiredAt}). During re-auth, operations submitted via store.enqueue(op) are held in a bounded queue (pendingQueueSize, default 32):

  • re-auth succeeds → queued ops resume (in order) on the fresh session;
  • re-auth fails → queued ops reject with a clear error;
  • queue overflow → that op rejects immediately with a clear error.

Honest limit (no overclaiming)

This preserves queued intent + session context across re-auth. It does NOT resurrect a transaction already handed to the wallet — once a request is inside the wallet, its fate is the wallet's. enqueue is for operations you route through the store, not for in-flight wallet prompts.

Session lifecycle scenarios (7 covered)

| ID | Scenario | |---|---| | SCENARIO-1 | persist → reload → restore (both backends) | | SCENARIO-2 | reconcile snapshot vs live → structured diff | | SCENARIO-3 | corrupt / wrong-key / unknown-version / expired → null + cleared | | SCENARIO-4 | runtime expiry → session:expired + onReauthRequired + state preserved → resume | | SCENARIO-5 | transient disconnect → backoff at exact offsets (incl. cap) → success restores | | SCENARIO-6 | maxAttempts exhausted → reconnect:gaveup (terminal); manual cancel mid-backoff | | SCENARIO-7 | enqueue during re-auth → resume / overflow / re-auth-failure | | invariant | explicit user disconnect NEVER schedules a reconnect |

Multi-tab sync + party/network invalidation

Additive + opt-in. Origin-bound BroadcastChannel sync, party-switch + network-change detection with an invalidation hook, and optional full-snapshot persistence.

const store = createSessionStore(provider, {
  broadcast: true,                 // sync across tabs (default channel)
  persistSnapshot: true,           // rewrite the S1 snapshot on party/network change
  onInvalidate: ({ type, previous, current }) => queryClient.invalidateQueries(),
});
store.on('party:changed', (e) => console.log(`party ${e.previous} → ${e.current}`));
store.on('network:changed', (e) => console.log(`network ${e.previous} → ${e.current}`));

Multi-tab (BroadcastChannel)

broadcast: true opens an origin-bound channel (partylayer.session.sync::<origin>::<storageKey>, the S1 originTag pattern); pass { channelFactory } to customize (tests inject an in-memory hub). A disconnect in one tab propagates to all tabs; party/network updates propagate too. A RECEIVING tab applies the change without rebroadcasting (loop-safe — verified: BroadcastChannel never echoes to the sender). When BroadcastChannel is unavailable (SSR / Node) it is a graceful no-op — single-tab behavior is unchanged.

Party-switch

On accountsChanged, the store compares the primary partyId. A change from a prior non-null primary emits party:changed {previous, current}, calls onInvalidate, and (with persistSnapshot) rewrites the persisted snapshot. A list reorder that keeps the same primary is NOT a switch (no event).

Network / synchronizer change

A statusChanged.network (or chainChanged) networkId delta emits network:changed {previous, current}, calls onInvalidate, and rewrites the snapshot. (Cache wiring — React-Query — lands in S4/S6; the session layer only emits + invalidates.)

persistSnapshot

When true, the store persists the full S1 session envelope at storageKey (rewritten on party/network change) instead of the legacy '1' marker. Default false (marker behavior preserved — purely additive).

Session lifecycle scenarios (11 covered)

| ID | Scenario | |---|---| | 1–3 | persist/restore, reconcile, corrupt/wrong-key/unknown-version/expired (S1) | | 4–7 | expiry re-auth, reconnect backoff, give-up/cancel, enqueue queue (S2) | | SCENARIO-8 | disconnect in tab A → tab B disconnected, no rebroadcast | | SCENARIO-9 | party switch → party:changed + snapshot rewrite; reorder → no event | | SCENARIO-10 | network change → network:changed + onInvalidate + snapshot update | | SCENARIO-11 | no BroadcastChannel → single-tab still works (graceful no-op) |