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

@ixo/matrix-bot-workers-sdk

v0.7.0

Published

Matrix bot SDK for Cloudflare Workers: an E2EE Matrix bot as a Durable Object — persistent device, sync resume, key backup, cross-signing, autojoin with invite sweep, and MSC4268 history sharing, with no Node runtime.

Readme

@ixo/matrix-bot-workers-sdk

An end-to-end-encrypted Matrix bot SDK for Cloudflare Workers.

Run a fully-fledged E2EE Matrix bot as a Durable Object — no Node.js server, no filesystem, no appservice registration. Subclass one class, override onMessage, deploy.

import { MatrixBotDO, type BotMessage } from '@ixo/matrix-bot-workers-sdk';

export class EchoBot extends MatrixBotDO {
  protected override async onMessage(message: BotMessage): Promise<void> {
    if (message.msgtype !== 'm.text') return;
    await this.sendText(message.roomId, `echo: ${message.body}`);
  }
}

📐 How it works — architecture diagrams comparing this SDK with a classic Node matrix-bot-sdk deployment: the two stacks, message flow, the persistence mirror, restarts, and the keep-alive loop. ⚖️ Parity notes — behavioural differences vs the Node bot-sdk, what's excluded, and the platform constraints. 📊 Benchmarks — measured throughput, restart times at thousands of rooms, and the device-rotation soak on real Cloudflare. 💰 Costs — what a bot costs to run.

What you get

  • Full E2EE via matrix-js-sdk 42 + the Rust crypto crate (@matrix-org/matrix-sdk-crypto-wasm), compiled at deploy time — workerd forbids runtime wasm compilation, so the SDK ships a loader shim for it.
  • A persistent device. The crypto store (Olm account, Megolm sessions, cross-signing state) is mirrored from an in-memory IndexedDB into Durable Object SQLite on every change and restored before the crypto stack wakes up. Evictions, redeploys and password rotations all keep the same device — no "unable to decrypt" trail behind every restart.
  • A thin client that scales with rooms. Like matrix-bot-sdk on Node, the bot runs its own /sync loop and keeps no Room object per joined room: room facts live in SQLite rows, the sync token is persisted after every batch, and a restart continues from that token in seconds whether the bot is in 50 rooms or 5,000. Messages sent while the bot was down are delivered and answered when it comes back — and nothing already answered is answered twice. Memory is proportional to the rooms the bot is talking in (a bounded cache of "hot" rooms built for sending), not to the rooms it is joined to.
  • Key backup — adopted when the account has one, created when it has none and the recovery secret is available. Room keys are fetched from it on demand, one session at a time, so the local store stays small; a bot that reads history at start can opt into a capped bulk restore on a device's first start (backupBulkRestore), committed to the snapshot in batches so it can never trip the Durable Object's storage timeout.
  • Cross-signing — adopted from secret storage, or provisioned from scratch (provisionCrypto) together with secret storage and key backup, using password UIA for the signing-key upload.
  • Recovery passphrase and recovery key — base58 (EsTc LW2K …, the format Element shows) and base64 both decode.
  • Autojoin with retry (500/1000/2000 ms backoff), a startup scan for invites that arrived while the bot was down, and a periodic sweep — one room-less /sync from the live token plus a retry of every invite still pending — that catches invites the live sync missed. Gate joins with shouldJoinRoom.
  • MSC4268 history sharing. inviteUser() shares the room's E2EE history with the invitee (when history visibility allows), so they can read messages sent before they joined. Received bundles are handled automatically on joinRoom.
  • Encrypted mediauploadFileStream / downloadFileStream encrypt and decrypt attachments in E2EE rooms as streams: memory stays O(chunk) whatever the file size, the ciphertext is streamed to and from the homeserver, and the transforms are exported for hosts that want to hash the plaintext on the way through. Prefer them for anything beyond small payloads. The whole-buffer sendFile / uploadFile / downloadFile run the file through the crypto crate's wasm heap, which grows to roughly 3.4× the file size and never shrinks for the life of the isolate — see Media memory below.
  • Keep-alive. A self-re-arming Durable Object alarm keeps the sync loop running (and restarts it after crashes) without any external pinger; a fuse keeps the alarm at most 10 s ahead for as long as the invocation runs, so a Cloudflare eviction costs seconds rather than the rest of the hold window.
  • Durable dedup + optional debounce for inbound messages, and a durable outbox for outbound ones: a reply interrupted by an eviction or restart is re-issued by the next incarnation with the same transaction id (the homeserver deduplicates).
  • Production guards learned on real Cloudflare deployments — see Operational guards: the matrix-js-sdk one-time-key runaway and request-timer leak are neutralised, the fake-indexeddb per-send memory leak is pruned, encryption concurrency is bounded, a hung crypto call cannot wedge the object, the crypto store is bounded by size-based device rotation, and a one-time-key conflict rotates the device automatically.

Install

npm install @ixo/matrix-bot-workers-sdk

wrangler.jsonc

{
  "main": "./src/index.ts",
  "compatibility_date": "2026-07-15",
  "compatibility_flags": ["nodejs_compat"],
  // REQUIRED: route the crypto crate through the SDK's workerd-compatible loader.
  "alias": {
    "@matrix-org/matrix-sdk-crypto-wasm": "@ixo/matrix-bot-workers-sdk/crypto-wasm-shim",
  },
  "durable_objects": {
    "bindings": [{ "name": "ECHO_BOT", "class_name": "EchoBot" }],
  },
  // The bot's state lives in DO SQLite — the class must be a SQLite class.
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["EchoBot"] }],
  // The keep-alive alarm is one long invocation; see "CPU limit" below.
  "limits": { "cpu_ms": 300000 },
  "vars": {
    "MATRIX_HOMESERVER_URL": "https://matrix.example.org",
    "MATRIX_USER_ID": "@echo-bot:example.org",
  },
}

Secrets: wrangler secret put MATRIX_PASSWORD (or MATRIX_ACCESS_TOKEN), MATRIX_RECOVERY_PASSPHRASE (or MATRIX_RECOVERY_KEY), and MATRIX_STORAGE_SECRET to seal what the bot keeps in storage.

CPU limit. The keep-alive alarm is one long invocation (a 5-minute hold while active), and a first-ever boot that provisions secret storage, cross-signing and key backup runs a 500k-iteration PBKDF2 in pure JS. Measured on Cloudflare, a busy bot spends ~8% of a hold window on CPU (9.3 s per 118 s under a 20-user load) and a fresh provisioning boot ~5 s — so keep "limits": { "cpu_ms": 300000 } in the Worker config rather than relying on the 30 s default, which a long active window can approach.

A complete deployable example lives in examples/echo-bot.

Quick start

  1. Create the bot account on your homeserver — any ordinary user account. A bot can also self-register on its first deploy with the exported registerAccount() (registration-token and dummy flows), or you create it in your admin tool.
  2. Write the Worker: the echo bot at the top of this page is complete; pair it with the wrangler.jsonc above.
  3. Configure it: MATRIX_HOMESERVER_URL and MATRIX_USER_ID as vars; MATRIX_PASSWORD (preferred — it lets the bot rotate its device) or MATRIX_ACCESS_TOKEN as a secret; and MATRIX_RECOVERY_PASSPHRASE (or MATRIX_RECOVERY_KEY) so the bot can unlock the account's key backup and cross-signing. On a brand-new account add MATRIX_PROVISION_CRYPTO=true and the bot creates secret storage, cross-signing and key backup itself on first start.
  4. Deploy and wake it: wrangler deploy, then send the Worker one request that calls ensureStarted() (the example does so on any request). From then on the keep-alive alarm keeps the bot syncing; it needs no pinger.
  5. Invite it to a room. It autojoins (gate that with shouldJoinRoom), and onMessage fires for every decrypted message.

The first start of a fresh account takes a few seconds (a 500k-iteration PBKDF2 plus the crypto provisioning); every later start is a restore of the crypto snapshot and one /sync, typically well under a second locally and a few seconds on Cloudflare.

Configuration

By default the bot reads conventional MATRIX_* bindings (see optionsFromEnv). For programmatic control, override resolveOptions:

export class MyBot extends MatrixBotDO {
  protected override async resolveOptions(): Promise<MatrixBotOptions> {
    return {
      homeserverUrl: 'https://matrix.example.org',
      userId: '@bot:example.org',
      password: await this.loadFromSomewhere(),
      recoveryPassphrase: '…',
      provisionCrypto: true, // create 4S/cross-signing/backup when the account has none
      messageDebounceMs: 500, // coalesce rapid-fire messages
    };
  }
}

| Option | Default | What it does | | -------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | homeserverUrl, userId | — | required | | accessToken | — | token login; the token's device is adopted and pinned | | password | — | password login; the issued device id is stored and re-used forever | | recoveryPassphrase | — | unlocks (or keys, with provisionCrypto) secret storage | | recoveryKey | — | same, as a base58 or base64 recovery key; tried before the passphrase. When it does not unlock secret storage it is tried as the key-backup decryption key (the Node bot-sdk's "recovery key" is exactly that): verified against the backup's public key first, installed only on a match — status().backupKeySource says which | | provisionCrypto | false | create 4S + cross-signing + key backup when the account has none | | authPassword | password | password for the cross-signing upload UIA | | backupBulkRestore | false | on a device's first start (a new account, a redeploy without a snapshot, a rotation), restore every room key the server-side backup holds in one go instead of one download per session as events need them — for a bot that reads history at start. Only when the backup holds at most backupBulkRestoreMaxKeys keys: each restored key stays in the crypto store for the life of the device, against the deviceRotateBytes budget. Needs the backup decryption key (secret storage or recoveryKey); once per device and backup version, marked done only after the keys are in the snapshot. status().backupBulkRestore says what happened | | backupBulkRestoreMaxKeys | 5000 | largest backup restored in bulk, in room keys (~1.6 KB of crypto store each, so 5,000 are ~8 MB of the 32 MiB rotation budget); a larger backup is skipped with a warning and keys stay on demand | | autojoin | true | accept invites (with retry); veto per-room via shouldJoinRoom | | inviteSweep | true | periodic sweep for missed invites: one room-less /sync from the live token plus a retry of pending invites | | followRoomUpgrades | true | join the replacement room on m.room.tombstone | | resumeSync | true | resume from the persisted sync token on restart | | messageDebounceMs | 0 | coalesce consecutive messages per room/thread/sender | | sendConcurrency | 4 | concurrent encryptions (matrix-js-sdk encrypts at sendEvent time; the slot is released as soon as the event is queued to send) and rooms in flight at once | | sendRatePerSecond, sendBurst | 5, 20 | token bucket for m.room.message sends across all rooms — set to the homeserver's rc_message for the bot; 429s pause and trim it automatically | | durableSends | true | persist sendText/sendNotice in the outbox until acknowledged | | sendWatchdogMs | 45000 | a send the crypto WASM never answers fails (and the object restarts) after this | | deviceRotateBytes | 32 MiB | retire the device and log in a fresh one when the crypto store outgrows this (needs password); the store is what a restart restores and what lives in memory per room | | recycleAfterSends | 0 (off) | planned in-place restart after N sends, once idle | | keepAliveFuseMs | 10000 | how far ahead the object's single alarm is kept while a keep-alive alarm invocation runs, re-armed every half-fuse — recovery from a Cloudflare host eviction costs about this long (plus alarm delivery and the start) instead of the rest of the hold window. 0 (or below) turns the fuse off and leaves the single safety-net alarm; a positive value under 2000 is raised to it. Applies to both hold windows (60 s idle, 5 min active); costs ≈ $0.52/month per bot at the default (one SQLite row write per re-arm) | | storageSecret | — | AES-GCM-seal the device token and cached 4S keys in storage | | hotRooms | 64 | rooms kept built (members loaded) for sending; least recently used or idle 10 min are released — memory is proportional to this, not to joined rooms | | backfillMaxEvents | 0 | cap on the events replayed per room per gap after downtime; 0 replays every missed event (paged, resumable), N replays only the newest N of a gap and logs the rest | | syncTimelineLimit | 30 | timeline events per room per /sync; a room with more since the last batch comes back limited and is caught up through /messages | | syncBytesCap | 16 MiB | largest /sync response held in memory (0 = no cap); over it the same batch is asked for again at timeline limit 1, so busy rooms come back limited and their events go through the byte-capped catch-up. Budget it for the whole isolate, not the body: parsed JSON costs two to three times the body, next to the wasm heap. Over the cap even at limit 1 the sync fails and retries with back-off — an account whose one-event-per-room batch is that large (tens of thousands of busy rooms) needs a higher cap, or 0 | | catchupPageSize | 100 | /messages page size while catching up a gap | | pageBytesCap | 10 MiB | largest /messages page held while catching up (0 = no cap): a page over it is refused unread and fetched again smaller, down to one event; an event over the cap on its own is skipped, counted in status().oversizedEvents and never delivered. Also the default cap of the protected fetchCapped / pageCapped | | maxDecryptBytes | 0 (off) | refuse to decrypt an event whose ciphertext is larger than this (decrypting costs the event's size several times over in wasm memory that never shrinks); skipped events are counted in status().oversizedDecrypts and not delivered | | logLevel | 'info' | debug | info | warn | error | silent |

Environment variables

optionsFromEnv (the default resolveOptions) reads these bindings. Booleans accept true/1 and false/0.

| Variable | Option | | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | MATRIX_HOMESERVER_URL (or MATRIX_BASE_URL), MATRIX_USER_ID | homeserverUrl, userId — required | | MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD | accessToken, password | | MATRIX_DEVICE_DISPLAY_NAME | deviceDisplayName — the device name shown in clients (password logins) | | MATRIX_RECOVERY_PASSPHRASE, MATRIX_RECOVERY_KEY | recoveryPassphrase, recoveryKey | | MATRIX_PROVISION_CRYPTO, MATRIX_AUTH_PASSWORD | provisionCrypto, authPassword | | MATRIX_BACKUP_BULK_RESTORE, MATRIX_BACKUP_BULK_RESTORE_MAX_KEYS | backupBulkRestore, backupBulkRestoreMaxKeys | | MATRIX_AUTOJOIN, MATRIX_INVITE_SWEEP, MATRIX_FOLLOW_ROOM_UPGRADES, MATRIX_RESUME_SYNC | autojoin, inviteSweep, followRoomUpgrades, resumeSync | | MATRIX_MESSAGE_DEBOUNCE_MS | messageDebounceMs | | MATRIX_SEND_CONCURRENCY, MATRIX_SEND_RATE_PER_SECOND, MATRIX_SEND_BURST | sendConcurrency, sendRatePerSecond, sendBurst | | MATRIX_DURABLE_SENDS, MATRIX_RECYCLE_AFTER_SENDS | durableSends, recycleAfterSends | | MATRIX_KEEPALIVE_FUSE_MS | keepAliveFuseMs0 disables the fuse (safety-net alarm only); below 2000 is raised to it | | MATRIX_DEVICE_ROTATE_BYTES, MATRIX_HOT_ROOMS | deviceRotateBytes, hotRooms | | MATRIX_BACKFILL_MAX_EVENTS | backfillMaxEvents | | MATRIX_SYNC_TIMELINE_LIMIT, MATRIX_SYNC_BYTES_CAP | syncTimelineLimit, syncBytesCap | | MATRIX_CATCHUP_PAGE_SIZE, MATRIX_PAGE_BYTES_CAP, MATRIX_MAX_DECRYPT_BYTES | catchupPageSize, pageBytesCap, maxDecryptBytes (a cap of 0 switches it off) | | MATRIX_STORAGE_SECRET | storageSecret — set it: it seals the device token and cached secret-storage keys at rest | | LOG_LEVEL | logLevel |

Put credentials in Worker secrets (wrangler secret put …), never in vars.

Handlers (override on your subclass)

| Handler | Called with | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | onMessage(message) | decrypted room message (deduped, debounced): roomId, sender, body, msgtype, threadRootId?, eventIds, full content | | onEvent(event) | every decrypted live timeline event (reactions, redactions, custom types, …) | | onRoomJoined(roomId, source) | after a join — source is 'invite' \| 'startup' \| 'sweep' \| 'upgrade' | | onStarted(result) | after every successful start of the object instance — a request, the keep-alive alarm bringing it back after a reset (deploy, eviction, memory limit), a restart, a device rotation. Detached: ensureStarted() does not wait for it. Resume resumable work here (an index rebuild, a backfill) instead of waiting for the next caller | | shouldJoinRoom(roomId, inviter) | return false to leave an invite pending | | onAccountData(type, content) | global account-data changes | | onRoomAccountData(roomId, type, content) | per-room account-data changes | | resolveOptions() | supply configuration programmatically |

Handlers run inside the Durable Object with the full toolkit available: the protected this.startedClient() returns the underlying matrix-js-sdk client, so anything the SDK doesn't wrap (presence, receipts, power levels, …) is still one call away — see parity notes.

A bot that pages history itself (/messages, /relations, /state) should do it through the protected this.fetchCapped(path, params?, { capBytes?, tokenKey?, readTailWhenOversized? }) and this.pageCapped(path, params, { limit, tokenKey, capBytes? }): the same byte guard the SDK's own catch-up uses (pageBytesCap by default). pageCapped fetches one page, refetches it smaller when the homeserver's body is over the cap (down to a single event), and reports a lone event over the cap as skipped with the continuation token still known, so the caller advances past what it will never be able to hold. Rooms carry encrypted events of several MB from the days files were inlined; a 200-event page of those is tens of MB, a few times that once parsed, and the isolate is gone. The building blocks are exported too (readCappedBody, fetchJsonCapped, fetchPageAdaptive, nextPageLimit, tokenFromTail, ciphertextBytes).

Bot API (callable from your handlers or over DO RPC)

Lifecycle: ensureStarted() · restart() · stop() · rotateDevice(reason?) · status() — messaging: sendText · sendNotice · sendMessage · sendEvent · setTyping (text sends support threads, rich replies, intentional mentions and a caller-pinned transaction id via SendTextOpts; sendEvent(roomId, type, content, { txnId? }) takes a pinned id too) — rooms: joinRoom · leaveRoom · createRoom · inviteUser · shareRoomHistory · getJoinedRooms · getJoinedRoomMembers · resolveAlias · isRoomEncrypted — state/events: getRoomState · getRoomStateEvent · sendStateEvent · getEvent — account data: getAccountData · setAccountData · getRoomAccountData · setRoomAccountData (reads are server-authoritative, so a bot always sees its own writes) — profile: getUserProfile · setDisplayName · setAvatarUrl — media: sendFile · downloadFile · uploadFileStream / downloadFileStream (streaming, O(chunk) memory; size required on upload; a download's hash is verified when the stream ends, so it errors after delivering the bytes on a mismatch; across a Durable Object RPC boundary the error's reason is lost and the runtime logs the errored stream as an uncaught exception in the object, so over RPC pass { raw: true } to get the ciphertext plus the file info and decrypt with the exported createAttachmentDecryptor in your own isolate) · uploadFile (upload without sending — embed the returned source in custom event types).

sendText/sendNotice resolve with the event id — or, when a durable send was interrupted by a restart or rotation, with its transaction id (mbw-…): the message sits in the durable outbox and the next incarnation re-issues it with that same id, so a handler that already committed to a reply is never failed for an infrastructure hiccup. Pass txnId to pin the transaction id yourself: the homeserver deduplicates by it, so a caller that retries a send after its own restart gets the same event back instead of a duplicate (per device, and Synapse remembers ids for about a day). sendEvent accepts the same txnId for custom events, so a best-effort post retried across a restart (an audit log, a prompt) lands once.

The package also exports registerAccount(homeserverUrl, { username, password, registrationToken? }) — UIA self-registration (dummy and registration-token flows) for bots that create their own account on first deploy.

Event/state content crosses the RPC boundary as JSON strings (JsonString) — Durable Object RPC rejects some deeply nested structured values.

MSC4268 — sharing history with invitees

await bot.inviteUser(roomId, '@friend:example.org'); // shares E2EE history automatically

Two conditions, both checked by matrix-js-sdk: the room's history visibility must be shared or world_readable, and the invitee only accepts the bundle when the bot's device is cross-signed — so set up recoveryPassphrase/recoveryKey (+ provisionCrypto on a fresh account). The bundle is also only encrypted to devices cross-signed by the invitee, which every mainstream client satisfies.

Monitoring

status() is cheap and safe to poll from a health route or a cron. The fields worth watching:

| Field | Healthy when | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | running, syncState, lastSyncAt | true, SYNCING, and recent — the long-poll returns at least every 30 s | | syncFailures | 0; it counts consecutive failures and resets on the next good batch | | joinedRooms, invitedRooms | what you expect; a growing invitedRooms means invites are being declined or autojoin is off | | cryptoStoreBytes, wasmHeapBytes | below deviceRotateBytes; both fall back after a rotation. wasmHeapBytes is a high-water mark: a jump after a whole-buffer media call is expected and stays for the life of the isolate — stream media instead | | syncShrinks, pagesRefused | 0 on a quiet bot. A shrink means a /sync response was over syncBytesCap; a refused page means a /messages page was over pageBytesCap — both are handled (smaller requests), but say the rooms carry very large events | | oversizedEvents, oversizedDecrypts | 0. Events the bot could not hold (over pageBytesCap on their own) or refused to decrypt (over maxDecryptBytes): they were never delivered to the handlers, so each one is a message the bot did not act on. oversizedEvents is persisted (cumulative for the object) | | keepAlive | fuseMs is the resolved keepAliveFuseMs (0 when off); holding is true only while a keep-alive cycle's hold is running; fuseRearmsSinceBoot and fuseRearmFailuresSinceBoot count the fuse's alarm re-arms since this object instance booted — failures are expected while the instance is being detached and otherwise rare | | unexpectedResets, lastUnexpectedReset | 0. A start that followed a reset without a clean stop() — a deploy, an eviction, or the isolate over its 128 MB — and whatever detached work the previous instance was doing died with it; wrangler tail shows no outcome for such a death, this counter and the warning logged at start do. Persisted; the log line names the previous instance, its uptime and when it was last seen — that "last alive" stamp is refreshed every 30 s (not only at the end of a hold), so the age reads real downtime plus at most 30 s rather than the whole hold window; recovery after a Cloudflare eviction is now about the keep-alive fuse plus the start | | oneTimeKeys.uploads | a batch or two right after boot, then only occasional top-ups | | sendQueue, sendScheduler | inFlight and queued near zero when idle; a rising rateLimited means the homeserver's per-sender limit is the ceiling | | keyBackupVersion, backupKeySource | a version and secret-storage or recovery-key; none with a version present means history cannot be decrypted — the configured secret unlocks neither secret storage nor the backup (see the start-up warning) | | deviceRotations, lastStart | rotations are rare (size- or conflict-driven); lastStart.totalMs stays at a few seconds regardless of room count | | catchup | rooms and gaps fall back to zero after downtime; a room stuck there means /messages keeps failing (see the logs) | | roomKeys | backedUp catches up with total within seconds of any send or received key; a lasting gap means the key backup is unreachable | | backupBulkRestore | with backupBulkRestore on: imported equal to total on the device's first start, then skipped: 'already-restored' on every later start. failures (when present) counts keys the import could not take — a corrupt or undecryptable entry, a store error mid-way; matrix-js-sdk logs each chunk — which stay on demand. over-cap means the backup outgrew backupBulkRestoreMaxKeys and the bot is on the on-demand path; no-key means the backup cannot be decrypted (see backupKeySource); failed names a restore that threw (logged) | | interruptedFlushes | 0. A start that found the previous flush cut between its batches (an instance died mid-way through a multi-transaction flush): the snapshot mixed two flushes, which the crypto crate tolerates; persisted, cumulative | | lastRetire | after a rotation: what the old device drained before logout — pages is 1 unless keys were queuing up for it | | lastError | absent — otherwise the last error-level log line |

Log lines are prefixed [matrix-bot] and never contain message bodies, tokens or secrets; logLevel: 'debug' adds per-sync and per-room detail and switches matrix-js-sdk's own logger to debug.

How persistence works

workerd has no IndexedDB, but the Rust crypto crate only persists through IndexedDB. The SDK runs fake-indexeddb in memory and mirrors every mutation into Durable Object SQLite (dirty-tracked, typically <2 ms per flush), restoring the exact database — schema, records, key generators — before initRustCrypto on the next start. Flushes happen after syncs, sends, joins, on a periodic timer, on the keep-alive alarm, and before a sync token that acknowledges to-device events is persisted. A flush whose changed values exceed 4 MiB — a bulk key-backup restore, a migration — is committed in several storage transactions rather than one: a single transaction of tens of MB runs past the Durable Object's storage timeout and resets the object (eleven resets in a row on a 35 MB snapshot, on a real deployment). The stores are written largest first, so the small ones that must agree with each other — the Olm account and its sessions — land together in the final transaction; an instance that dies between batches leaves the large stores partly ahead of the small ones, which the crate tolerates, and the next start reports it (status().interruptedFlushes).

Everything else the bot remembers is plain SQLite: the sync token (written after every batch), a rooms table (membership, encryption settings, history visibility, inviter — updated from each sync delta), processed event ids and the durable outbox. There is no cached room state to rebuild on a restart: the bot asks the homeserver for everything since its token and carries on. When a room saw more events during the downtime than one sync batch carries (30), the gap becomes a durable catch-up item: a background pump pages it through /messages oldest first, one page at a time, persisting its cursor after every page, while that room's live events queue behind it — so every missed message is answered in order however long the outage was, a restart mid-way resumes where it left off, and nothing overtakes an older message (status().catchup shows the rooms still catching up; backfillMaxEvents caps a gap if you want one); the very first start of an account lists its joined rooms and takes a fresh token instead of an initial sync, so it is as fast for a bot already in a hundred thousand rooms as for one in ten (room facts are fetched the first time each room is sent into).

Diagrams of the whole pipeline (and how it compares to a Node matrix-bot-sdk deployment) are in docs/how-it-works.md.

Operational guards

Everything below was learned the hard way on real Cloudflare deployments of this stack (the ixo oracle gateway and the SupaMoto worker) and is on by default. status() exposes the counters that prove each one is working.

| Guard | What it prevents | Where | | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | One-time-key count fix (runtime shim + source patch) | matrix-js-sdk fed the crypto crate an empty key count on every sync carrying a to-device event; the crate uploaded 50 fresh keys each time, the account's key counter outran every snapshot, and the next restart hit "One time key … already exists" forever → device reset. status().oneTimeKeys shows uploads: a healthy device uploads a batch or two at boot, then only tops up. | sdk-runtime-patches.ts | | Snapshot cadence | A snapshot that is behind the last key upload re-uploads key ids the server already holds. Flushes: 2 s after any store mutation, synchronously before every /keys/upload request (the freshly generated one-time keys are durable before the homeserver learns them, so a reset after the send re-uploads identical keys — which Synapse accepts — instead of generating different keys under the same ids, which it rejects and which would otherwise cost a device rotation; the pre-upload flush is incremental, a few rows, and uploads scale with new peer devices rather than with messages, so the cost is negligible), 750 ms after each /keys/upload, 1 s after each sync, every 30 s, before every stop — and, whenever a sync batch carried to-device event