End-to-end encrypted messaging, file storage, and real-time media in the browser - powered by a C++ cryptography core compiled to WebAssembly.
Getting Started · API Reference · PrivMX Bridge docs
PrivMX Web Endpoint is the client-side gateway to a PrivMX Bridge. Your app encrypts and decrypts everything locally - the Bridge is zero-knowledge and only ever stores ciphertext. Under the hood it wraps the native PrivMX Endpoint C++ library via WebAssembly, behind a small, fully-typed JS/TS API.
- End-to-end encryption - all data is encrypted/decrypted client-side; the private key never leaves the browser.
- High-level primitives - Threads (messaging), Stores (files), Inboxes (one-way submissions), KVDBs (key-value), Streams (E2EE WebRTC audio/video).
import { Endpoint, setupAuto } from "@simplito/privmx-webendpoint";
await setupAuto(); // load the WASM core - no asset copying
const conn = await Endpoint.connect(privateKey, solutionId, bridgeUrl);
const threads = await conn.getThreadApi(); // also: conn.getStoreApi(), getInboxApi(), …
await threads.sendMessage( // signed + encrypted client-side
threadId, new Uint8Array(), new Uint8Array(),
new TextEncoder().encode("Hello, E2EE world!"),
);Full runnable walkthrough in Getting started and the
example/viteapp.
Requirements: a modern, cross-origin-isolated browser page (COOP/COEP headers - see below) and a running PrivMX Bridge. The SDK is browser-only.
- How it works
- Core concepts
- Installation
- Getting started: a messaging app with Vite
- Loading the WASM assets
- Common tasks
- Receiving events
- Error handling
- Logging
- Lifecycle
- Production checklist
- Packaging
- Building from source
- Testing
- License
Your app --> @simplito/privmx-webendpoint --> WASM core (C++) --> PrivMX Bridge
(typed TS API) (crypto, on worker threads) (stores ciphertext)
Everything sensitive is encrypted in the browser before it reaches the network. Your private key authenticates you and decrypts data locally; it never leaves the device. The Bridge is effectively zero-knowledge about your plaintext.
You bootstrap through Endpoint (the EndpointFactory static facade) -
setup / setupAuto, connect / connectPublic - then create the per-feature
APIs straight off the connection: connection.getThreadApi(),
getStoreApi(), getInboxApi(), getKvdbApi(), getStreamApi(),
getEventManager(). (Endpoint.createThreadApi(connection) does the same thing
if you prefer the static form.)
| Primitive | Use it for | Key API |
|---|---|---|
| Thread | Encrypted messaging / activity feeds | ThreadApi |
| Store | Encrypted file storage (chunked upload/download) | StoreApi |
| Inbox | One-way submissions from anyone, incl. anonymous guests (contact forms) | InboxApi |
| KVDB | Encrypted key-value records | KvdbApi |
| Stream | Real-time E2EE WebRTC audio/video | StreamApi |
| Events | Server-pushed change notifications | EventQueue / event managers |
A few terms you'll meet immediately:
- Solution / Context - administrative scopes created in the Bridge admin panel. A Context ID is the workspace your users and containers live in.
publicMetavsprivateMeta- most objects carry two metadata blobs.publicMetais stored unencrypted on the server (never put secrets there);privateMetais encrypted client-side. The payload (data) is always encrypted.- Private key (WIF) - the user's identity. Generate or derive it with
CryptoApi; register the matching public key in the Context to grant access.
npm install @simplito/privmx-webendpointYou also need a running PrivMX Bridge - see the
Bridge documentation. From it you'll obtain a Bridge URL,
a Solution ID, a Context ID, and a management API key (apiKeyId +
apiKeySecret) used server-side to register users (step 5).
A complete, runnable version of this guide lives in
example/vite - copy it or follow along below.
npm create vite@latest privmx-demo -- --template vanilla-ts
cd privmx-demo
npm install @simplito/privmx-webendpointThe WASM core runs on worker threads backed by SharedArrayBuffer, which browsers
only expose on a cross-origin isolated page. Add the two headers to the dev
server in vite.config.ts (and serve them in production too):
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
{
name: "cross-origin-isolation",
configureServer(server) {
server.middlewares.use((_req, res, next) => {
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
next();
});
},
},
],
optimizeDeps: { exclude: ["@simplito/privmx-webendpoint"] },
});Skipping this is the #1 setup mistake - it surfaces as
SharedArrayBuffer is not definedwhensetup()runs.
The library needs four runtime files (the WASM binary, its glue, the E2EE worker,
and an audio worklet). With setupAuto() you don't copy them anywhere - it
resolves them via import.meta.url and Vite fingerprints and serves them
automatically:
import { Endpoint, setupAuto } from "@simplito/privmx-webendpoint";
await setupAuto();The private key is the user's identity. Generate it client-side - it never leaves the browser and is never sent anywhere:
const crypto = await Endpoint.createCryptoApi();
const privateKey = await crypto.generatePrivateKey(); // WIF - stays in the browser
const publicKey = await crypto.derivePublicKey(privateKey); // safe to send to your serverFor password-based login use crypto.derivePrivateKey2(password, salt) (deterministic).
To connect, the user's public key must be registered in a Context. That requires the Bridge management API key, which can administer your whole Solution - so it lives on your backend, never in the browser. The browser sends only the public key; the server registers it:
// --- server side (holds the API key) ---
// POST <bridgeUrl>/api (JSON-RPC 2.0)
async function registerUser(userId: string, userPubKey: string) {
const api = (method: string, params: unknown, token?: string) =>
fetch(`${BRIDGE_URL}/api`, {
method: "POST",
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
}).then((r) => r.json());
// 1. exchange the API key for a short-lived token
const { result } = await api("manager/auth", {
grantType: "api_key_credentials",
apiKeyId: API_KEY_ID, apiKeySecret: API_KEY_SECRET, scope: ["solution", "context"],
});
// 2. add the user's public key to the Context
await api("context/addUserToContext", { contextId: CONTEXT_ID, userId, userPubKey }, result.accessToken);
}The browser calls this over HTTP (await fetch("/api/register-user", …)). The
example/vite app keeps this in a separate src/server.ts
module - it mimics the backend in-browser so the demo needs no server process,
but the module is structured to lift onto a real backend unchanged.
const connection = await Endpoint.connect(privateKey, solutionId, bridgeUrl);
const threadApi = await connection.getThreadApi();
const enc = (s: string) => new TextEncoder().encode(s);
const me = { userId: "alice", pubKey: publicKey };
const threadId = await threadApi.createThread(
contextId,
[me], // users (read access)
[me], // managers (manage access)
enc(JSON.stringify({})), // publicMeta - NOT encrypted
enc(JSON.stringify({ title: "Chat" })),// privateMeta - encrypted
);
await threadApi.sendMessage(
threadId,
new Uint8Array(), // publicMeta
new Uint8Array(), // privateMeta
enc("Hello, E2EE world!"), // data - signed & encrypted client-side
);
const page = await threadApi.listMessages(threadId, { skip: 0, limit: 10, sortOrder: "desc" });
for (const msg of page.readItems) {
console.log(new TextDecoder().decode(msg.data));
}
await connection.disconnect();That's the whole loop: generate a key in the browser -> register its public half
via your server -> connect and exchange an end-to-end encrypted message. Run
npm run dev against the example/vite app to see it live.
The three assets are exported at @simplito/privmx-webendpoint/assets/*. Pick the
strategy that fits your setup:
A. Zero-config (recommended, any bundler) - Vite / webpack 5 / Rollup / Parcel / Next:
import { setupAuto } from "@simplito/privmx-webendpoint";
await setupAuto(); // resolves assets via import.meta.urlsetupAuto() is ESM-only (it relies on import.meta.url).
Vite users: exclude the SDK from pre-bundling so
import.meta.urlresolves the assets against the real package location:optimizeDeps: { exclude: ["@simplito/privmx-webendpoint"] }See
example/vite/vite.config.tsfor the full setup.
B. Per-asset URLs - when you want explicit control (any unset URL falls back to
assetsBasePath):
import { Endpoint } from "@simplito/privmx-webendpoint";
await Endpoint.setup({
wasmModuleUrl: new URL("@simplito/privmx-webendpoint/assets/endpoint-wasm-module.js", import.meta.url).href,
wasmUrl: new URL("@simplito/privmx-webendpoint/assets/endpoint-wasm-module.wasm", import.meta.url).href,
workerUrl: new URL("@simplito/privmx-webendpoint/assets/privmx-worker.js", import.meta.url).href,
});wasmUrl is wired into the Emscripten locateFile, so the .wasm can live anywhere.
C. Copy to a served directory - no bundler, or you prefer static hosting:
cp node_modules/@simplito/privmx-webendpoint/assets/* ./public/privmx-assets/await Endpoint.setup({ assetsBasePath: "/privmx-assets" });| Asset | Purpose |
|---|---|
endpoint-wasm-module.js |
Emscripten glue (injected by setup()) |
endpoint-wasm-module.wasm |
The C++ core (~4.4 MB; serve gzip/brotli) |
privmx-worker.js |
E2EE web worker (streaming) |
Framework copy snippets (for strategy C): Vite -
vite-plugin-static-copy;
Next.js - copy into public/ in a postinstall, call setup() client-side only;
webpack - CopyWebpackPlugin.
Upload a file (Store) - createFile -> writeToFile (repeat) -> closeFile:
const storeApi = await connection.getStoreApi();
const handle = await storeApi.createFile(storeId, publicMeta, privateMeta, bytes.length);
await storeApi.writeToFile(handle, bytes);
const fileId = await storeApi.closeFile(handle);Download is the mirror: openFile -> readFromFile (repeat) -> closeFile. The
/extra StreamReader / FileUploader helpers wrap these loops.
Accept anonymous submissions (Inbox) - works on a guest connection from
connectPublic: createFileHandle (per attachment) -> prepareEntry ->
writeToFile -> sendEntry.
Real-time media (Stream) - joinStreamRoom -> createStream -> addStreamTrack
-> publishStream; receive with subscribeToRemoteStreams + addRemoteStreamListener.
See the API reference for the full surface; every method carries inline docs (hover in your IDE).
Build a subscription query, subscribe, then drive the global queue:
import { Types } from "@simplito/privmx-webendpoint";
const query = await threadApi.buildSubscriptionQuery(
Types.ThreadEventType.MESSAGE_CREATE,
Types.ThreadEventSelectorType.THREAD_ID,
threadId,
);
await threadApi.subscribeFor([query]);
const queue = await Endpoint.getEventQueue();
for await (const event of queue) { // ends when queue.emitBreakEvent() fires
console.log(event.channel, event.type, event.data);
}(queue.waitEvent() is still there if you'd rather drive the loop yourself.)
For a higher-level option, every connection exposes a single event manager
(connection.getEventManager()) that runs the loop and dispatches to typed
callbacks for you. Subscribe to events of any module - Threads, Stores, Inboxes,
KVDBs, custom events, user/Context membership and connection-state - through the
one subscribe() call, mixing modules freely:
import {
Types,
createThreadSubscription,
createStoreSubscription,
} from "@simplito/privmx-webendpoint";
const events = await connection.getEventManager();
const ids = await events.subscribe([
createThreadSubscription({
type: Types.ThreadEventType.MESSAGE_CREATE,
selector: Types.ThreadEventSelectorType.THREAD_ID,
id: threadId,
callbacks: [(e) => console.log(e.data)], // e.data is typed as Types.Message
}),
createStoreSubscription({
type: Types.StoreEventType.FILE_CREATE,
selector: Types.StoreEventSelectorType.STORE_ID,
id: storeId,
callbacks: [(e) => console.log(e.data)], // e.data is typed as Types.File
}),
]);
// later
await events.unsubscribe(ids);Build each entry with the typed create*Subscription helper for the module you
want (createThreadSubscription, createStoreSubscription,
createInboxSubscription, createKvdbSubscription, createEventSubscription,
createUserEventSubscription, createConnectionSubscription). PrivmxClient
exposes the same single getEventManager() - see the example and
the API reference.
API methods reject with NativeError for server/crypto failures. Branch on the
exported error-code constants instead of matching message strings:
import { NativeError, StoreErrorCode } from "@simplito/privmx-webendpoint";
try {
await storeApi.closeFile(handle);
} catch (e) {
if (e instanceof NativeError && e.code === StoreErrorCode.FILE_VERSION_MISMATCH) {
// someone updated the file concurrently - re-open and retry
}
}NativeError carries code (number), scope ("Core", "Store", …) and
fullMessage. Code constants are exported per scope: CoreErrorCode,
ConnectionErrorCode, ThreadErrorCode, StoreErrorCode, InboxErrorCode,
KvdbErrorCode, EventErrorCode, StreamRoomErrorCode.
The library is silent by default. Opt into diagnostics (or pipe logs to your own
sink) with setEndpointLogger:
import { setEndpointLogger } from "@simplito/privmx-webendpoint";
setEndpointLogger({ level: "warn" }); // "silent" | "error" | "warn" | "info" | "debug"
setEndpointLogger({ sink: (lvl, label, args) => myLogger.log(label, ...args) });setup() --> connect() / connectPublic() --> connection.getXApi() --> …work… --> connection.disconnect()
(once) (per session) (cached per connection) (frees all APIs + WASM objects)
disconnect() invalidates every API created from that connection (including stream
sessions and the E2EE worker) - no manual per-API cleanup is needed. Calling a method
on an API after disconnect throws.
- Cross-origin isolation: serve
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp(see the Vite guide above). - Worker threads:
setup({ workerCount })sets the async-engine pool (default 4, min 2); raise it for heavy parallel file transfers. - Memory: the WASM heap is fixed at 260 MB - stream large files in chunks rather than buffering whole files in memory on top of it.
- Compression: the
.wasmis ~4.4 MB; serve it gzip/brotli (brotli ≈ −70%). - Errors: treat
NativeErroras your typed failure channel (see above).
The package is ESM-only ("type": "module") - tree-shakeable, with .js
extensions on all internal imports so it resolves under native Node and every
bundler, and import.meta.url-based asset loading via setupAuto(). Use it
from a bundler (Vite, webpack 5, Rollup, Next, …) or native ESM; there is no
CommonJS require build. For <script>-tag / non-bundler usage, a standalone
browser bundle is available at dist/bundle/privmx-endpoint-web.js.
Subresource Integrity (CDN users): the
.wasmis pinned bybuild-manifest.sh. If you serve it from a CDN, generate an SRI hash (openssl dgst -sha384 -binary endpoint-wasm-module.wasm | openssl base64 -A) and use a long-lived immutable cache header.
Only needed if you change the C++ core; most contributors only touch TypeScript.
Prerequisites: Node.js 20+, CMake (for the WASM core), Clang-format v18 (C++ lint).
| Command | Description |
|---|---|
npm run build |
Full release build: clean -> WASM -> compile TS (ESM) -> bundle (Vite) |
npm run build:debug |
Full debug build (see below) |
npm run build:wasm |
Compile C++ -> WebAssembly (release flags) |
npm run build:js |
Compile TypeScript + bundle assets (no WASM recompile) |
npm run compile |
Emit the ESM dist/ output (tsc + .js-extension fixup) |
npm run watch:types |
Watch TypeScript |
Release vs debug - release is -O3 -flto, ASSERTIONS=0, SAFE_HEAP=0. A debug
build (npm run build:debug or PRIVMX_BUILD_TYPE=debug npm run build:wasm) swaps in
-O0 -g -gsource-map (C++ source-mapped in DevTools), ASSERTIONS=2, SAFE_HEAP=1,
STACK_OVERFLOW_CHECK=2, and -DDEBUG. Debug builds are larger and slower - local use only.
Unit (Jest):
npm testEnd-to-end (Playwright + Docker) - spins up Bridge, Janus, Coturn, MongoDB:
cd tests && docker compose up -d && cd .. # start the backend first
npm run test:e2e # Chromium
npm run test:e2e:manybrowsers # all browsersLint & format (oxlint + oxfmt; clang-format for C++):
npm run lint # TypeScript
npm run lint:docs # doc-quality checker
npm run lint:clang-format
npm run format # auto-format TSLicensed under the PrivMX Free License. Copyright © Simplito. All rights reserved.