snapcap
Guide

Getting started

Install @snapcap/native, construct a SnapcapClient with a DataStore + browser context, call authenticate(), and make your first request.

@snapcap/native is one TypeScript class. You hand it a DataStore, a BrowserContext (UA required), and — on first run — credentials. It gives you a small auth surface plus per-domain managers (client.friends, etc.). Everything else — the kameleon WASM boot, the WebLogin two-step, the SSO bearer mint, the cookie seeding, the bundle-native messaging session — is owned by the SDK.

Install

pnpm add @snapcap/native
# or
npm install @snapcap/native

Requires Node 22+ or Bun 1.3+ — the bundle uses top-level await, the WASM imports rely on modern fetch, and the SDK depends on Node's vm.Context global-builtins behaviour (V8 fills the new realm with Object/Array/Promise/WebAssembly/...) that landed in those versions.

First call

import { SnapcapClient, FileDataStore } from "@snapcap/native";

const client = new SnapcapClient({
  dataStore: new FileDataStore("./auth.json"),
  credentials: {
    username: process.env.SNAP_USER,
    password: process.env.SNAP_PASS!,
  },
  browser: {
    userAgent:
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
  },
});

await client.authenticate();

const friends = await client.friends.list();
console.log(friends.map((f) => f.username));

That is the entire pattern. The constructor does no I/O — it just constructs a per-instance Sandbox (vm.Context + happy-dom Window + shimmed I/O layer) wired to your DataStore. The first network call happens inside authenticate().

The constructor throws if browser.userAgent is missing. There's no shared default — every consumer defaulting to the same UA would itself become a snapcap fingerprint. Pass a recent realistic UA from a real browser; in multi-tenant deployments, vary it per tenant.

credentials are not persisted to the DataStore. Pass them again on every boot if you want automatic recovery from server-side session expiry.

Choosing a DataStore

Two implementations ship out of the box:

  • FileDataStore(path) — single-JSON-file persistence on disk. Survives process restarts. Good for development and single-tenant production.
  • MemoryDataStore() — ephemeral in-process map. Cold-starts (full WebLogin + kameleon boot) every run. Good for tests and short-lived scripts.

For Redis / Postgres / KMS-wrapped persistence, implement the DataStore interface — see Persistence → Plugging in your own backend.

Clean shutdown

There's no client.dispose() to call. The per-instance Sandbox is GC-tracked; once the SnapcapClient reference is released and any messaging.on(...) subscriptions have been unsubscribed, the realm and all in-process state get collected.

If you want a clean network shutdown — closing the bundle's duplex WebSocket, tearing down the Zustand auth slice — call await client.logout(). It is safe to call even if the bundle session never came up.

A complete script

import { SnapcapClient, FileDataStore } from "@snapcap/native";
import { readFileSync } from "node:fs";

// `.snapcap-creds.json` is just `{"username":"…","password":"…"}` — local
// file, gitignored. Replace with your own credential-loading pattern in
// production.
const { username, password } = JSON.parse(readFileSync(".snapcap-creds.json", "utf8"));

const client = new SnapcapClient({
  dataStore: new FileDataStore("./.tmp/auth/auth.json"),
  credentials: { username, password },
  browser: {
    userAgent:
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
  },
});

await client.authenticate();

// List the social graph.
const friends = await client.friends.list();
console.log(`${friends.length} mutual friends`);

// Search Snap's user index, then send a friend request to the first hit.
const hits = await client.friends.search("perdyjamie");
if (hits.length > 0) {
  await client.friends.sendRequest(hits[0].userId);
  console.log(`requested ${hits[0].username}`);
}

// Subscribe to real-time graph changes.
const unsub = client.friends.onChange((snap) => {
  console.log(`mutuals=${snap.mutuals.length} received=${snap.received.length}`);
});

// ...later
unsub();

Cold start vs warm start

Reuse the DataStore across processes (or workers, or PM restarts) to skip the kameleon boot + full WebLogin flow:

// Process 1 — fresh disk:
const a = new SnapcapClient({
  dataStore: new FileDataStore("./auth.json"),
  credentials: { username, password },
  browser: { userAgent },
});
await a.authenticate();   // ~5 s — kameleon WASM + WebLoginService 2-step + SSO bearer mint

// Process 2 — same auth.json, credentials still required for refresh recovery:
const b = new SnapcapClient({
  dataStore: new FileDataStore("./auth.json"),
  credentials: { username, password },
  browser: { userAgent },
});
await b.authenticate();   // ~100 ms — warm SSO redirect, bundle still loads
  • Cold start (auth.json missing or empty): kameleon WASM boots (~5 MB JS + 814 KB WASM), WebLoginService two-step runs, SSO redirect mints a bearer, parent-domain cookies seed the jar. Persisted to the DataStore. Wall-clock around 5 seconds.
  • Warm start (auth.json already has valid cookies): the SSO redirect short-circuits in roughly one redirect, ~100 ms. The bundle still loads — authenticate() always brings up the chat bundle so the friend graph and Zustand auth slice are populated.

authenticate() also warms the friend-graph cache by calling client.friends.list() once before resolving. The fetch is best-effort — a friends sync failure is logged but never poisons the auth result — and seeds the persisted graph baseline so a later subscriber's first replay correctly identifies "new since last session" mutuals/requests instead of treating every existing entry as added.

If the bearer in the DataStore has expired, the next gRPC call gets a 401 and the SDK transparently mints a fresh bearer from the long-lived __Host-sc-a-auth-session cookie — no caller-side handling required. For a manual bearer-only refresh, call await client.refreshAuthToken().

Re-authenticate

There's no force flag. To re-run the login flow — say after a password rotation, or to recover from server-side session invalidation that didn't surface as a clean error — just call authenticate() again:

await client.authenticate();

For a bearer-only refresh in place (cookies still valid), use refreshAuthToken() instead.

Logout

await client.logout();
// or, force the local teardown even if the server-side revoke fails:
await client.logout(true);

logout() calls Snap's own state.auth.logout thunk to tear down the bundle's Zustand slice, then deletes cookie_jar from the DataStore. Bundle-owned local_* / session_* / indexdb_* entries — including the bundle-minted Fidelius wrapped identity at local_uds_uds.e2eeIdentityKey.shared — are intentionally left intact. Wiping them would force the next authenticate() to re-bootstrap WASM state from scratch, which costs an extra round-trip and ~250 ms. If you want a true wipe, drop the underlying DataStore.

Multi-account in one process

Each SnapcapClient owns its own Sandbox — its own vm.Context, its own happy-dom Window, its own shimmed I/O layer, its own bundle bring-up caches. Two clients in the same process do not share Zustand state, bearer tokens, or webpack runtime caches. They're isolated at the V8 vm.Context boundary.

In practice this means:

  • One process can drive many accounts simultaneously.
  • Each client needs its own DataStore — they collide on the shared persistence keys otherwise.
  • Each client should use a different browser.userAgent for fingerprint diversity.
const a = new SnapcapClient({
  dataStore: new FileDataStore("./auth/alice.json"),
  credentials: { username: "alice", password: aPass },
  browser: { userAgent: alicesUA },
});
const b = new SnapcapClient({
  dataStore: new FileDataStore("./auth/bob.json"),
  credentials: { username: "bob", password: bPass },
  browser: { userAgent: bobsUA },
});

await Promise.all([a.authenticate(), b.authenticate()]);

See Multi-tenant for the recommended layout, including shared throttle gates so aggregate request rate stays constant in N.

What's next

  • Auth model — what authenticate() actually does, the auth-state enum, refresh paths.
  • Persistence — the DataStore key layout and how to plug in a custom backend.
  • Friends — graph reads, typed events, refresh cadence, offline replay.
  • Messaging — live decrypted inbound, conversation listing, raw envelope backfill.
  • Multi-tenant — running many accounts in one process.
  • Throttling — opt-in HTTP rate limiting, per-instance and shared gates.
  • Logging — structured network observability via SNAP_NETLOG=1 and setLogger.
  • API reference — every public method.
  • Internals — sandbox, shim layer, kameleon, SSO, bundle session.

On this page