Core Concepts
Architecture
The two interfaces that carry the whole design, and the request flow from browser to database.
The 30-second version
Two interfaces carry the whole design:
StorageAdapter- durable reads/writes (conversations, messages, read-state). Core depends on the interface, never on a specific database.Transport- publish/subscribe of live events to connected SSE clients. The engine publishes only after the storage write succeeds (durable-first); v0 ships a single-node in-process transport, and the SSE endpoint recovers missed messages on reconnect from storage viaLast-Event-ID.
The core engine (@chatpack/core) contains all domain logic: 1:1
conversations, permission checks, validation. Adapters contain no domain
logic - they only persist and retrieve.
Request flow
Your frontend ── fetch("/api/chat/…") + EventSource("/api/chat/stream")
│
▼
chat.handler() one Web-standard handler (Request → Response)
│
├── auth hook your session → { id: userId } (you own users)
▼
chat.api.* domain logic, permissions (also callable directly)
│
▼
StorageAdapter memory · Drizzle/Postgres · your own
│
▼
Your databaseEvery HTTP request follows the same path:
- The handler parses the request. It's a single Web-standard function
(
Request → Response) that serves every route underbasePath(default/api/chat) - including the SSE stream. - Your
authhook resolves the current user. Auth runs before routing - an unauthenticated request to a wrong path still gets a401. chat.api.*applies domain rules: input validation, participant checks, permission hooks, sender-only edit/delete.- The storage adapter persists and retrieves. It never enforces permissions - core already did.
- For writes, the engine publishes an event on the transport after the storage write succeeds, and connected SSE streams deliver it.
What lives where
| Layer | Owns | Never does |
|---|---|---|
| Your app | Users, sessions, frontend, LLM calls | - |
chat.handler() | HTTP parsing, auth hook, JSON envelopes, SSE | Domain rules |
chat.api.* | Validation, permissions, pair keys, telemetry counters | Persistence mechanics |
StorageAdapter | Uniqueness on pairKey, seq assignment, pagination, unread counts | Permission checks |
Transport | Live event fan-out | Storage (durable events replay from storage) |
One instance, everywhere
Create the chatpack() instance in a single module and import it into the
route file - never one instance per route. Under dev-server HMR (Vite,
next dev), guard it with globalThis so module re-evaluation doesn't reset
in-memory state:
import { chatpack, type ChatpackInstance } from "@chatpack/core";
const g = globalThis as typeof globalThis & { __chatpack__?: ChatpackInstance };
export const chat = (g.__chatpack__ ??= chatpack({ storage, auth }));Configuration
Everything is configured through the single chatpack() factory:
import { chatpack } from "@chatpack/core";
import { typing, presence, receipts } from "@chatpack/core/plugins";
const chat = chatpack({
storage: memoryAdapter(), // required - the only required option
auth: async (req) => resolveUser(req), // required for HTTP; api.* takes explicit ids
permissions: { canRead, canWrite }, // default: participants only
plugins: [typing(), presence(), receipts()], // default: none
transport: inProcessTransport(), // default: in-process, single node
telemetry: true, // default: true (opt-out)
});Prop
Type
Design rationale (ADRs)
Non-obvious decisions are recorded as short ADRs in
docs/decisions/:
| ADR | Decision |
|---|---|
| 0001 | Storage is an interface; core never touches a database |
| 0002 | Deterministic pair key prevents duplicate DMs |
| 0003 | Per-conversation monotonic seq as the message sort key |
| 0004 | Telemetry counters land early; the flusher lands late |
| 0005 | One Web-standard handler instead of per-framework routers |
| 0006 | SSE Last-Event-ID gap-fill from storage |
| 0007 | Postgres adapter: atomic seq via UPDATE … RETURNING |
| 0008 | Ephemeral events + plugins live in core |