snapcap
Internals

Architecture

The mental model: layered architecture from consumer API down through SnapcapClient, MessagingSession, Sandbox + Shims, to Snap's bundle JS + WASM.

@snapcap/native is a thin orchestration layer over Snap's actual web JavaScript bundle. We do not reimplement Snap's protocols — we run them. The SDK's job is to make the bundle think it's running in a browser, and to provide the I/O the bundle expects (cookies, storage, gRPC) without rerouting through a browser shell.

Layered diagram

┌─────────────────────────────────────────────────────────────────────────┐
│ CONSUMER (your app)                                                     │
│   import { SnapcapClient, FileDataStore } from "@snapcap/native"        │
│   const client = new SnapcapClient({ dataStore, browser, credentials }) │
│   await client.authenticate(); await client.friends.list()              │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│ SnapcapClient                            (src/client.ts)                │
│  ─ owns: per-instance Sandbox, lazy ClientContext                       │
│  ─ auth: authenticate, logout, refreshAuthToken, isAuthenticated…       │
│  ─ managers: friends / messaging / presence / stories / inbox / media   │
└─────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ Bundle-driven api layer  (src/api/* + src/bundle/register.ts)           │
│  ─ tier-1 registry: flat verbs that pass through to webpack methods     │
│  ─ tier-2 api files: compose registry verbs, marshal UUID/proto shapes  │
│  ─ ClientContext: per-instance sandbox + jar + dataStore + UA           │
└─────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────┐
│ Sandbox (src/shims/sandbox.ts)                                          │
│   Empty vm.Context with V8 built-ins                                    │
│     + happy-dom Window own-props projected on top                       │
│     + SDK_SHIMS overrides at every I/O boundary                         │
│     + Snap-bundle stubs (chrome, requestIdleCallback, importScripts…)   │
│                                                                         │
│  SDK_SHIMS = [                            (src/shims/index.ts)          │
│    CookieContainerShim,    // happy-dom outgoing fetch → tough-cookie   │
│    DocumentCookieShim,     // JS-level document.cookie → tough-cookie   │
│    WebSocketShim,          // ws + cookie-jar + cross-realm projection  │
│    LocalStorageShim,       // local_*  via DataStore                    │
│    SessionStorageShim,     // session_* via DataStore                   │
│    IndexedDbShim,          // indexdb_<db>__<store>__<key>              │
│  ]                                                                      │
└─────────────────────────────────────────────────────────────────────────┘
        │                                              │
        ▼                                              ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Snap's actual code                                                      │
│   ─ accounts bundle (~5 MB JS) + kameleon WASM (814 KB)                 │
│   ─ chat bundle (~1.5 MB JS) + Fidelius WASMs (12 MB + 814 KB)          │
│   ─ webpack runtimes patched to leak __webpack_require__ (=__snapcap_p) │
│   ─ Bundle session (createMessagingSession) brought up lazily on first  │
│     messaging.on(...) — drives Fidelius decrypt + duplex WS             │
└─────────────────────────────────────────────────────────────────────────┘
        │                                              │
        ▼                                              ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Snap's servers                                                          │
│   accounts.snapchat.com → WebLoginService                               │
│   session.snapchat.com  → WebAttestationService                         │
│   web.snapchat.com      → AtlasGw, MessagingCore, Fidelius…             │
│   aws.duplex.snapchat.com → presence/typing WS                          │
│   cf-st.sc-cdn.net      → media uploads                                 │
└─────────────────────────────────────────────────────────────────────────┘

What runs where

Three layers of foreign code execute inside the sandbox's vm.Context:

  1. Snap's accounts bundle (~5 MB webpacked JS) — contains the WebLoginService gRPC client, the protobuf encoders for every Janus auth message, and the loader for kameleon.wasm. Loaded by bundle/accounts-loader.ts.
  2. The kameleon WebAssembly module (814 KB Emscripten) — generates the attestation token Snap's server uses to decide whether the client is a legitimate browser. Booted once per Sandbox by bundle/accounts-loader.ts. See the kameleon chapter.
  3. The chat bundle (~1.5 MB JS + 12 MB Fidelius WASM + 814 KB sibling) — contains the AtlasGw client, MessagingCoreService, the upload helpers, and the Fidelius E2E key management. Loaded by bundle/chat-loader.ts during authenticate().

All three run in the sandbox. The host realm's globalThis is never modified — see Why we don't use happy-dom's GlobalRegistrator.

Bundle-driven api layer

The SDK no longer hand-rolls protobuf encoders or gRPC-Web framing for outbound calls. Instead, every public method calls into Snap's webpack methods directly through a two-tier registry:

  • Tier 1 — src/bundle/register/. Per-domain files (auth.ts, friends.ts, messaging.ts, …) export late-bound reach() getters around webpack-resolved bundle functions (authSlice, loginClient, chatWreq, etc.). Pure pass-through, no consumer-shape adaptation. The barrel register/index.ts is the entry point.
  • Tier 2 — src/api/<domain>/. Each domain has its own folder (api/auth/, api/friends/, api/messaging/) with a manager (e.g. Friends, Messaging), per-concern siblings, and an index.ts barrel. Managers compose tier-1 verbs and translate between consumer-friendly shapes (UUID strings, plain numbers) and the bundle-realm shapes the registry expects (16-byte buffers, {highBits, lowBits} pairs). Helpers in api/_helpers.ts. Per-instance state lives on ClientContext (_context.ts); managers are stateless beyond a ClientContext getter.

Inbound decryption flows through the bundle session naturally because it's Snap's own code. The migration off hand-rolled gRPC is complete — the old src/transport/grpc-web.ts and most of src/transport/proto-encode.ts have been retired.

Real network I/O leaves the sandbox

src/transport/native-fetch.ts resolves Node's fetch per call. Outgoing requests go straight to Node, with cookies attached by transport/cookies.ts. The bundle's own fetch is shimmed to route through nativeFetch too — the bundle's GrpcManager.registerWebFactory is wired so its outbound gRPC traffic uses the same path. See I/O overrides.

The auth flow

Three phases on cold start:

1. attestation
   ───────────
   kameleon.wasm reads navigator + screen + performance (in vm realm)
   → AttestationSession.instance().finalize(username)
   → 1032-char base64 token

2. WebLogin (2-step)
   ──────────────────
   POST WebLogin { username + attestation }    ← native-fetch + jar
   ← challengeData.passwordChallenge + sessionPayload

   POST WebLogin { sessionPayload + password }
   ← Set-Cookie: __Host-sc-a-auth-session     (long-lived → cookie_jar)

3. SSO bearer mint
   ────────────────
   GET /accounts/sso?client_id=…
   ← 303 Location: https://www.snapchat.com/web#ticket=<bearer>
   GET https://www.snapchat.com/web (follow redirect)
   ← Set-Cookie: sc-a-nonce, _scid, sc_at      (parent-domain → cookie_jar)
   bearer    → bundle Zustand auth slice

After phase 3 the cookie jar holds one host-scoped refresh cookie, three parent-domain cookies that gate web.snapchat.com gRPC calls, and a Bearer string the bundle has stashed in its Zustand auth slice. From there every API call routes through bundle code that attaches Authorization: Bearer … and the cookie header automatically.

Done — until 401, which transparently re-mints from the cookie jar. See SSO flow.

Multi-account in one process

Each SnapcapClient constructs its own per-instance Sandbox — its own vm.Context, happy-dom Window, shimmed I/O layer, and per-Sandbox bring-up caches (kameleon boot, chat bundle eval, chat WASM Module, throttle gate). Two clients in the same process never share Zustand state, bearer tokens, or webpack runtime caches; they're isolated at the V8 vm.Context boundary.

A single process can drive many accounts simultaneously — see Multi-tenant for the recommended runner shape, including shared throttle gates so aggregate request rate stays constant in N.

What's deferred

  • Inbound presence delegate. messaging.on("typing" | "viewing" | "read", ...) is subscribable but the bundle's inbound presence delegate slot isn't hooked yet — events don't fire. Outbound (setTyping / setViewing / setRead) is wired end-to-end through the convMgr + presence-slice dual path.
  • Wire-tested media sends. messaging.sendImage(...) / sendSnap(...) / stories.post(...) compile + bring up cleanly through Snap's bundle send pipeline, but the integration suite hasn't covered them yet.
  • Per-domain manager surfaces for presence / media. Stub classes today — the surfaces are designed at migration time, not pre-emptively.

On this page