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:
- 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 bybundle/accounts-loader.ts. - 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. - 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.tsduringauthenticate().
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-boundreach()getters around webpack-resolved bundle functions (authSlice,loginClient,chatWreq, etc.). Pure pass-through, no consumer-shape adaptation. The barrelregister/index.tsis 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 anindex.tsbarrel. 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 inapi/_helpers.ts. Per-instance state lives onClientContext(_context.ts); managers are stateless beyond aClientContextgetter.
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 sliceAfter 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.