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

@dtelecom/secure-chat-client

v0.8.1

Published

TypeScript SDK for dTelecom secure 1:1 chat (E2E via Olm/vodozemac, fanout multi-device).

Readme

@dtelecom/secure-chat-client

TypeScript SDK for end-to-end encrypted 1:1 chat over the dTelecom mesh. Olm via vodozemac, fanout multi-device, multi-device sync (Signal-style), self-echo, offline-fallback delivery, content-protocol forward compat.

Status

v0.7.0 — feature complete.

v0.7.0 changes vs v0.6.0:

  • chat.retrySend(messageId) — re-sends a previously-failed message reusing the same messageId. The peer dedupes by id, so they see one message instead of a duplicate. Local status resets to "pending", then ladders up normally. Throws ChatError("internal", …) if the message doesn't exist, isn't yours, is deleted, or isn't in "failed" state.
  • ChatError taxonomy expanded. New codes: auth_expired (backend 401/403), offline (fetch threw — no network), rate_limited (backend 429), server_error (backend 5xx), internal (SDK-side / crypto / programming errors). peer_unreachable unchanged. ChatError gains optional status?: number and cause?: Error fields.
  • Every public method that touches the wire now throws ONLY ChatErrorHttpError from the transport layer is wrapped at the public-API boundary. FE branches on err.code with a single instanceof ChatError check; no need to handle multiple error classes.

v0.6.0 changes vs v0.5.0:

  • Multi-tab coordination via the Web Locks API. Two tabs of the same (origin, user) no longer kick each other into an infinite reconnect loop — only one tab holds the chat WebSocket at a time. The first tab to call connect() is primary; subsequent tabs become secondary and emit a tabConflict event with { role: "secondary" }. Secondary tabs can still read history + conversations locally, but outbound sends won't reach the wire. Promote a secondary tab to primary via chat.takeOver() — the previous primary gets tabConflict { role: "secondary" } and its WS closes gracefully.
  • New API: chat.isPrimary(): boolean (sync getter), chat.takeOver(): Promise<void>, chat.on("tabConflict", e => …) with e.role: "primary" | "secondary".
  • On browsers without the Web Locks API (deeply old) and in non-browser runtimes (Node tests), the SDK behaves as if always primary — no tabConflict events, takeOver is a no-op resolution. No app changes needed for single-tab cases.

v0.5.0 changes vs v0.4.0:

  • Persisted message status. StoredMessage.status is now mirrored from the in-memory StatusTracker on every statusChange, so last-known delivery state ("sent" / "delivered" / "read") survives reload. After reload, getHistory() returns the message with its last status; further updates still fire statusChange if more events arrive.
  • messageSendFailed event. Fires once when the outbox exhausts its retry budget for a message. The stored row's status is also written to "failed" so the UI can render a failed indicator after reload. Reason: "max_attempts_exceeded".
  • sendText throws ChatError("peer_unreachable", …) when claim_all returns no devices (peer hasn't registered any chat-capable device, OR the peer has blocked the caller — server-side block enforcement). Previously silent. ChatError class is exported. Typing/ephemerals still silently drop.
  • chat.getTotalUnreadCount() + totalUnread field on conversationsChanged event — the nav-level badge now updates from a single listener without re-walking listConversations().

v0.4.0 breaking change vs v0.3.0: HTTP and WS auth are now separate. The chat token from fetchChatToken is reserved for the dtelecom-node WS handshake; HTTP requests use a new required fetchHttpBearer callback (for dmeet this is the Privy access token — same bearer every other /api/* route already expects). The previous design (re-using the chat token for HTTP) only worked against the in-memory mock — the dmeet-backend HTTP routes use Privy auth, so 0.3.0 couldn't actually talk to a real backend. Update consumers:

 const chat = await DTelecomSecureChat.connect({
   apiBaseURL: "https://app.example/api/secure-chat",
   fetchChatToken: async (deviceId) => { /* mint endpoint */ },
+  fetchHttpBearer: async () => getPrivyAccessToken(),
 });

v0.3.0 changes vs v0.2.0:

  • Add chat.listConversations() + the conversationsChanged event — a per-peer threads index derived from the local message store + a per-peer read watermark, persisted via the configured KV adapter. Unread counts converge across own devices because markRead self-echoes.
  • Add chat.on("connectionStateChange", …) — surfaces the WS state ("connecting" | "open" | "reconnecting" | "closed") so the UI can render an offline / reconnecting banner.
  • Add chat.currentUserId getter (parsed from the chat token's sub claim).
  • Add chat.deleteConversation(peerUserId) — wipes the thread's stored messages + index row locally. Doesn't tear down the Olm session, so future inbound from the peer will re-create the thread (use the host's block UX if you want the peer's traffic dropped entirely).
  • Local-side effect on chat.markRead() — now always advances the local "last-read-from-peer" watermark (driving the unread count) even when outbound read receipts are disabled via setReadReceiptsEnabled(false).
  • Remove chat.blockUser() / chat.unblockUser() / chat.getBlockedUsers(). Hosts with their own user-block UX (e.g. dmeet's /api/users/block-user) write to the same row the chat handlers query, so chat-specific block methods were duplicate surface. The contract's /blocks endpoints stay in the in-memory mock for smoke tests; production backends don't need them.
  • Add chat.setBlockedUserIds(ids[]) + chat.getLocallyBlockedUserIds() + connect({ initialBlockedUserIds }). The block list itself lives in the host (e.g. dmeet) — the SDK only keeps a local mirror so inbound messages from a now-blocked peer arriving over an EXISTING Olm session (which is NOT torn down on block, by design — see plan §14) get dropped before they hit the UI. Persisted in KV so a cold-start drain doesn't briefly leak blocked content before the host re-pushes its view.

v0.2.0 breaking change vs v0.1.0: apiBaseURL is now the FULL endpoint prefix (host + path), and the SDK appends bare relative paths (/token, /keys/upload, /envelopes/pending, etc.) instead of hardcoding /api/chat/. Update consumers from apiBaseURL: "https://app.example"apiBaseURL: "https://app.example/api/secure-chat" (or wherever the backend mounts the API).

  • 96/96 unit + 2/2 browser + 18/18 integration smokes against the deployed dTelecom mesh on Solana devnet
  • vodozemac (Rust → WASM, our @dtelecom/vodozemac-wasm crate) — libolm-compatible wire format
  • Browser (Chrome/Edge/Safari/Firefox via Vitest browser-mode) and Node (tsx + Vitest) both validated
  • React Native: works on RN 0.84+ / Hermes V1 (WebAssembly support); UniFFI native binding deferred

Install

npm install @dtelecom/secure-chat-client

@dtelecom/vodozemac-wasm is a peer-of-this-package dep (resolved transitively).

Quick start

import { DTelecomSecureChat } from "@dtelecom/secure-chat-client";

const chat = await DTelecomSecureChat.connect({
  // Full endpoint prefix — host + path. The SDK appends bare relative
  // paths under it (e.g. /token, /keys/upload, /envelopes/pending).
  apiBaseURL: "https://your-tenant-backend.example/api/secure-chat",
  fetchChatToken: async (deviceId) => {
    // Call your tenant backend; it should mint a chat-token JWT
    // signed with the tenant wallet (Ed25519 via Solana registry).
    // The returned chatToken is used ONLY on the WebSocket to the
    // dtelecom node — it doesn't auth the HTTP API (see fetchHttpBearer).
    const r = await fetch("/api/secure-chat/token", {
      method: "POST",
      headers: { Authorization: `Bearer ${await getPrivyAccessToken()}` },
      body: JSON.stringify({ deviceId }),
    });
    return r.json(); // { chatToken, chatNodeWsUrl, expiresAt }
  },
  // The bearer for every HTTP request to the tenant backend (`/keys/*`,
  // `/envelopes/*`). For dmeet this is the Privy access token — exactly
  // what every other `/api/*` route already accepts. Called per-request;
  // let the host's session library handle caching/refresh.
  fetchHttpBearer: async () => getPrivyAccessToken(),
  // Optional. The current user-block set, sourced from your host backend
  // (e.g. dmeet's /api/users/block-user UX). Inbound messages from these
  // peers arriving over a previously-established Olm session are dropped
  // BEFORE they surface to the UI. Push updates via chat.setBlockedUserIds.
  initialBlockedUserIds: ["bad-user-1", "bad-user-2"],
});

// Who is the signed-in user? (parsed from the chat token's `sub` claim)
chat.currentUserId;

chat.on("message", (e) => console.log(e.peerUserId, "→", e.message.text));
chat.on("messageEdited", (e) => /* ... */);
chat.on("messageDeleted", (e) => /* ... */);
chat.on("statusChange", (e) => /* sent → delivered → deliveredAll → read */);
chat.on("typing", (e) => /* started / stopped */);
chat.on("readReceipt", (e) => /* upTo a given message id */);
chat.on("peerNewDevice", (e) => /* TOFU UI */);
chat.on("conversationsChanged", () => /* re-render the chat list */);
chat.on("connectionStateChange", (e) => /* "connecting" | "open" | "reconnecting" | "closed" */);
chat.on("tabConflict", (e) => /* "primary" | "secondary" — show "open elsewhere" overlay when secondary */);

await chat.sendText("bob-user-id", "hi bob");
await chat.editMessage("bob-user-id", messageId, "edited");
await chat.deleteMessage("bob-user-id", messageId);
await chat.markRead("bob-user-id", messageId);
chat.setTyping("bob-user-id", true);

// Conversation list for the chat tab. Each entry has lastMessageAt + a
// snapshot of the latest message + an unread count. Sorted most-recent-
// first. Empty on a brand-new device (no historical sync).
const convs = await chat.listConversations();

const history = await chat.getHistory("bob-user-id", { limit: 50 });

// "Remove from list" UX — wipes the thread's stored messages + index row
// locally. The Olm session stays alive; future inbound from this peer
// re-creates the thread.
await chat.deleteConversation("bob-user-id");

// Push the host's current block list whenever it changes (e.g. user
// hits "Block" in dmeet's profile UI).
await chat.setBlockedUserIds(["bad-user-1"]);

await chat.setReadReceiptsEnabled(false);
await chat.markPeerDeviceVerified("bob", "bob-phone", true);
const fingerprint = await chat.getPeerDeviceFingerprint("bob", "bob-phone");

// Block / unblock is intentionally NOT in this SDK — host apps with their
// own block UX (e.g. dmeet's /api/users/block-user) write the same rows
// the chat backend reads for silent-filter on claim_all + envelope POST.

Architecture

The SDK does NOT bundle Solana RPC or STUN. Node discovery is delegated to the tenant backend: POST /api/chat/token returns a chat-token JWT plus the closest dtelecom node's WebSocket URL (chatNodeWsUrl), computed server-side via @dtelecom/server-sdk-js. This keeps the browser bundle small and reuses the same node-selection logic as room WebRTC.

Wire contract: chat-wire-contract.md (in the dTelecom monorepo). Architecture: secure-chat-plan.md.

Bundle size

| Artefact | Raw | Gzipped | |---|---|---| | dist/index.js (ESM) | 73 kB | 17.6 kB | | dist/index.cjs (CJS) | 73 kB | 17.8 kB | | dist/index.d.ts | 17 kB | 5.2 kB | | vodozemac-wasm .wasm | 401 kB | 184.8 kB | | vodozemac-wasm JS glue | 25 kB | 5.0 kB | | Total runtime cost | — | ~207 kB gzipped |

Budget was 1.5 MB; we're well under. The WASM is lazy-loaded on first chat.connect() — initial app boot pays only the SDK JS (~17.6 kB gz).

Tests

Unit + browser

npm test                  # 96 Node-mode tests
npm run test:browser      # 2 browser tests in real Chromium (Playwright)

Wallet (no network)

cp .env.test.example .env.test    # fill in the test wallet vars
npm run smoke:wallet

Confirms LK_API_KEY/LK_API_SECRET sign + verify a chat-token JWT locally.

Stage D integration matrix (real mesh)

Run all scenarios with npm run smoke:all. Three of them (offline-fallback, push-gating, crash-recovery) require the deployed nodes to POST back to the mock's webhook endpoint, so the mock must be started with a public tunnel: cd ../secure-chat-mock && TUNNEL=1 npm start (uses cloudflared quick tunnels — no auth needed, install via brew install cloudflared).

| Smoke | What it covers | |---|---| | smoke:auth | Chat-token JWT happy path + reject expired / wrong typ / unregistered signer | | smoke:transport | Same-node alice→bob round-trip, low-level WS + chatSendResult | | smoke:cross-node | alice + bob on distinct nodes; gossipsub-routed delivery + ACK | | smoke:fanout | bob with 3 devices; alice's status walks sent → delivered → deliveredAll | | smoke:offline-fallback | bob offline → mock stores envelope → bob reconnects → decrypts | | smoke:push-gating | push=false when sibling device live; push=true when all offline | | smoke:ephemeral | typing event drops on offline-fallback path (no mock POST) | | smoke:edit-delete-authz | edits/deletes from non-author dropped; legitimate ones applied | | smoke:read-typing | read watermark, typing throttle (3s), auto-stop (5s) | | smoke:fwd-compat | unknown content type / v: 2 silently dropped; v1 keeps flowing | | smoke:crash-recovery | mid-pull crash → reconnect → idempotent dedupe; message fires once | | smoke:node-failure | client-side WS drop → auto-reconnect → resume send/receive | | smoke:idle | 50 idle WS connections produce zero offline-fallback / pushes (mesh-only presence) | | smoke:block | claim_all filters; offline-fallback dropped:true; unblock restores | | smoke:history-reload | getHistory survives disconnect+reconnect with same store; fresh = empty | | smoke:multi-device-sender | alice with 2 devices; bidirectional fanout; alice-A's send self-echoes to alice-B | | smoke:self-echo | text/edit/delete/read all sync to other own devices | | smoke:peer-new-device | peerNewDevice fires once on new bob device; subsequent fanout includes it | | smoke:otk-exhaustion | OTK pool drains → claim returns oneTimeKey: null → fallback prekey works; auto-topup refills on reconnect | | smoke:read-receipts-gating | setReadReceiptsEnabled(false) suppresses outbound read; re-enable restores |

To skip Solana discovery and point at a specific node (local dev or a known test node), set CHAT_NODE_WS_URL_OVERRIDE=wss://node.example in the mock's environment before starting it.

License

Apache-2.0