Architecture
dotwire is one .NET 10 Native AOT binary. A config flag selects which parts of the system each process runs. It depends on two services:
- NATS with JetStream. JetStream is both the fanout bus and the durability mechanism for messages and audit events. NATS core carries live and ephemeral traffic.
- PostgreSQL with the Timescale extension. Messages are stored in a
messageshypertable keyed on(room_id, time), with the JetStream sequence stored on each row.
Realtime delivery runs on SignalR over WebSockets. Native AOT limits SignalR to the JSON hub protocol; MessagePack is not available.
Overview
host backend or browser
│ POST /rooms/{roomId}/messages (RS256 JWT)
▼
┌─ dotwire: Api or All role ──────────────────────────────┐
│ verify JWT → role cross-check → membership check │
│ presend webhook (optional) → encrypt (AES-256-GCM) │
│ publish to JetStream subject room.{roomId} │
└──────────┬──────────────────────────────────┬───────────┘
│ JetStream ack → 202 { seq, time } │ republish on NATS core
▼ ▼ room.{roomId}.live
┌──────────────────┐ ┌─ dotwire: Gateway or All role ─┐
│ JetStream stream │ │ decrypt once per message │
│ ROOMS │ │ → SignalR hub (/hub/rooms) │
└────────┬─────────┘ │ → SSE streams (/events) │
│ durable consumer └────────────────────────────────┘
│ batches of up to 500 messages or 1 s
▼
┌──────────────────┐
│ TimescaleDB │ ◄── history reads (read pool)
│ messages │
└──────────────────┘
Write path
When you send a message, dotwire:
- Authorizes the request. See Auth.
- Validates the content. Empty content, or content larger than
Dotwire:MaxContentBytes(64 KiB of UTF-8 by default), is rejected with400 invalid_content. - Calls the presend webhook, if you configured one. It can reject or rewrite the message. See Moderation webhooks.
- Encrypts the content and publishes it to the room's JetStream subject,
room.{roomId}. - Waits for the JetStream ack and returns
202 Acceptedwith the message'sseqandtime. - Republishes the message on the NATS core subject
room.{roomId}.livefor live fanout, and queues the postsend webhook if one is configured.
A durable consumer, the Postgres writer, batch-inserts messages into Postgres behind the ack. Postgres is not on the send path. If the database is slow or down, sends and live delivery continue.
Contract (ARCHITECTURE.md, "What sent means")
The ack ladder, stated plainly so it isn't relitigated: client ack = JetStream persisted. That is the only guarantee "sent" carries. Fanout to other connected clients is best-effort-immediate — it happens right after JetStream ack, off the same event, but it is not itself part of the ack contract. History durability in Postgres lags behind by roughly one batch interval, per the write path above; a message being "sent" never implies it is already queryable from Postgres.
Ordering and gap-fill
Each message's ordering token is its JetStream stream sequence, seq. Each room has its own subject, so ordering is per room, not global. Sequence numbers increase within a room, but they are not contiguous per room, because every room shares the ROOMS stream.
Recovery has one mechanism: a client that reconnects or falls behind asks for "room X, seq greater than N". @dotwire/client does this for you after an automatic reconnect. The SSE endpoint does it when you pass Last-Event-ID or afterSeq. SignalR's stateful reconnect buffering is turned off, because sequence-based gap-fill already covers it.
Ephemeral traffic
Presence, typing indicators, retractions, and membership revocations travel on NATS core subjects under room.{roomId}.ephemeral.*. They are never stored.
Contract (ARCHITECTURE.md, "Ephemeral traffic rule")
Ephemeral traffic rule (contract, not a suggestion): presence, typing indicators, and read receipts ride NATS core, never JetStream. They are never persisted and never sit in any ack path.
The ROOMS stream's subject filter, Nats:RoomsSubjectFilter, defaults to room.*. That matches exactly one token after room., so it captures room.{roomId} and nothing under it. Don't widen it to room.>, which would pull live and ephemeral traffic onto the stream.
Read path
There are three ways to read a room. All of them require room membership.
- History is a REST call,
GET /rooms/{roomId}/messages, served from Postgres through a dedicated read connection pool. It also reads the room's tail from JetStream, so messages that are acked but not yet inserted by the Postgres writer still appear. If that JetStream read fails, the endpoint serves Postgres rows only. - Live delivery over SignalR is the primary path for apps. Clients connect to
/hub/roomsand subscribe to rooms. A connected client never polls the database. See Hub events. - Live delivery over SSE,
GET /rooms/{roomId}/events, is a plain-HTTP alternative for agents and scripts. It can replay history from a sequence number before it switches to live delivery off the same NATS fanout, so its ordering matches SignalR. See HTTP API.
Content is decrypted only at delivery: at the fanout edge for live messages, and on the read itself for history.
Node roles
Every node runs the same binary. Dotwire:Role selects what it does:
| Role | Serves | Background work |
|---|---|---|
All (default) | Everything below | Postgres writer, audit writer |
Api | Message send and history, participants, /admin, /audit | Postgres writer, audit writer |
Gateway | The SignalR hub at /hub/rooms and SSE at /rooms/{roomId}/events | None |
Every role serves GET /healthz. Nodes are stateless, so you scale socket capacity with more Gateway nodes and API load with more Api nodes. See Deployment.
dotwire ships its own SignalR backplane over NATS core. It is interest-based: a node subscribes only to the subjects of rooms that have at least one locally connected member. Backplane traffic grows with local interest, not with cluster size.
Fanout and connection costs
dotwire treats concurrency and fanout as the expensive axis. The design rules include:
- WebSockets only, with negotiation skipped. There is no long-polling fallback and no sticky-session requirement at the load balancer.
- No per-message WebSocket compression, and a 30-second server keep-alive interval.
- Membership is checked once when a connection subscribes to a room, not on every message. A revocation removes the subscription immediately. See Rooms and membership.
- Each message is decrypted and serialized once, then shared across every recipient.
- Each connection has a bounded send queue. A connection that can't keep up is disconnected, then recovers through gap-fill.
- Presence changes are coalesced per room into periodic deltas, and typing notifications are throttled.
Rate limits
dotwire enforces in-process token-bucket limits, on by default:
send,read, andadminbuckets are keyed per user (sub).readcovers history, participants, SSE connects, and/audit.admincovers every/adminroute.- The
hubbucket is keyed per connection and covers the hub'sSubscribeandUnsubscribemethods. Typing has its own cooldown.
A rejected request gets 429 rate_limited with a Retry-After header. Counters are per node and nodes don't coordinate them, so with N nodes behind a load balancer the effective limit is N times the configured value. Bucket settings are in the configuration reference.
Related
- Auth: what happens before step 1 of the write path.
- Audit log: the separate audit stream and its single writer.
- Encryption and retention: what is encrypted and how long each store keeps it.