Chatpack
Core Concepts

Architecture

The three interfaces that carry the whole design, and the request flow from browser to database.

The 30-second version

Three 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). The default is in-process (one node); swap in @chatpack/transport-redis for several. Either way the SSE endpoint recovers missed messages on reconnect from storage via Last-Event-ID.
  • PresenceStore - expiring SSE connection leases and global online/offline transitions. The default is process-local; use redisPresenceStore() with the Redis transport for multi-node presence (ADR 0025).

The core engine (@chatpack/core) contains all domain logic: 1:1 and group conversations, membership and role rules, 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 · Supabase · your own
      │
      ▼
Your database

Every HTTP request follows the same path:

  1. The handler parses the request. It's a single Web-standard function (Request → Response) that serves every route under basePath (default /api/chat) - including the SSE stream.
  2. Your auth hook resolves the current user. Auth runs before routing - an unauthenticated request to a wrong path still gets a 401.
  3. chat.api.* applies domain rules: input validation, participant checks, permission hooks, sender-only edit/delete, admin-only group management.
  4. The storage adapter persists and retrieves. It never enforces permissions - core already did.
  5. For writes, the engine publishes an event on the transport after the storage write succeeds, and connected SSE streams deliver it.

What lives where

LayerOwnsNever does
Your appUsers, sessions, frontend, LLM calls-
chat.handler()HTTP parsing, auth hook, JSON envelopes, SSEDomain rules
chat.api.*Validation, permissions, pair keys, roles, telemetry countersPersistence mechanics
StorageAdapterUniqueness on pairKey, seq assignment, pagination, unread countsPermission checks, role rules
TransportLive event fan-outStorage (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:

lib/chat.ts
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, canManage }, // defaults: participants; admins for canManage
  hooks: { beforeMessageSend, afterMessageMutation }, // default: none
  plugins: [typing(), presence(), receipts()], // default: none
  transport: inProcessTransport(), // default: in-process; redisTransport() for multi-node
  telemetry: true, // default: true (opt-out)
});

Prop

Type

Design rationale (ADRs)

Non-obvious decisions are recorded as short ADRs in docs/decisions/:

ADRDecision
0001Storage is an interface; core never touches a database
0002Deterministic pair key prevents duplicate DMs
0003Per-conversation monotonic seq as the message sort key
0004Telemetry counters land early; the flusher lands late
0005One Web-standard handler instead of per-framework routers
0006SSE Last-Event-ID gap-fill from storage
0007Postgres adapter: atomic seq via UPDATE … RETURNING
0008Ephemeral events + plugins live in core
0009Unread counts computed from read-state at query time
0010First-party client package (@chatpack/client)
0011Message hooks: in-process before/after, not webhooks
0012Redis transport: separate package, bring your own client
0013Reactions and quote-replies ship; threads stay out
0014afterMessageMutation covers send, edit, and delete
0015Message search is an optional adapter capability
0016Client polling fallback; no incremental server route
0017Groups are a type on Conversation, not a second entity
0018Filepack-backed, conversation-authorized attachments
0019Invite links and join requests as an optional capability
0020Public channels are a visibility flag on groups
0021Core moderation suite as an optional capability
0022Message formatting belongs to the application
0023Mentions are supplied ids, never parsed from the body
0024Forwarding copies the message and freezes its provenance
0025Multi-node presence uses atomic expiring leases
0026Dual-mode init; user existence is a host-owned hook

On this page