This guide explains how to implement the unified realtime architecture across backend (Go) and frontend (Gleam/Lustre), aligned with the current repository layout.
- Backend:
server/ - Frontend:
jst_lustre/
It complements ARCHITECTURE_AND_REFACTOR_PLAN.md with concrete steps, code touchpoints, and commands.
- Backend (env + run):
- Copy
.env_templateto.envand fill values - Run:
cd server && go run . -proxy -log debug
- Copy
- Frontend (dev):
- Run dev server (hot reload):
cd jst_lustre && gleam run -m lustre/dev start --tailwind-entry=./src/styles.css - Build static to Go app:
cd jst_lustre && gleam run -m lustre/dev build --minify --tailwind-entry=./src/styles.css --outdir=../server/web/static
- Run dev server (hot reload):
- Go 1.22+
- Gleam 1.3+ and Lustre
- Node (for Tailwind via Lustre dev pipeline)
- NATS account (JWT + NKEY) or use
-localto run an embedded NATS server - NATS CLI (optional, for testing):
natscommand
Environment variables (see server/.env_template):
NATS_JWTandNATS_NKEY: credentials for NGS/globalJWT_SECRET: for server-side JWT signing/verificationWEB_HASH_SALT: for password hashingNTFY_TOKEN: optional notifications token (currently required byconf.go)FLY_APP_NAME,PRIMARY_REGION: used to form app namePORT: HTTP port
Runtime flags (see server/conf.go):
-localrun embedded NATS-proxyproxy frontend to dev server (localhost:1234)-log {debug|info|warn|error|fatal}set log level
The current WebSocket implementation in server/web/socket.go provides the unified protocol described in ARCHITECTURE_AND_REFACTOR_PLAN.md with NATS Core, KV, and JetStream integration.
Architecture Decision:
- WebSocket: Real-time data synchronization and updates only
- HTTP REST API: Request-response operations (CRUD, commands, queries)
- Frontend State: Updated ONLY from WebSocket subscriptions, never from HTTP responses
- Fallback: Long polling if WebSocket issues arise
- ✅
server/web/socket.go- Full WebSocket protocol implementation - ✅
server/web/routes.go- WebSocket endpoint mounted - ✅ Protocol envelopes and validation implemented
- ✅ Capability model and matching implemented
- ✅ JetStream integration implemented
- ✅ KV integration implemented
Missing: Command/reply operations (cmd/reply) - These will be implemented via HTTP REST API instead
Note: HTTP responses won't update frontend state - all updates come through WebSocket
The protocol envelope is fully implemented with operation-specific payloads.
-
Envelope (implemented):
op:"sub" | "unsub" | "kv_sub" | "js_sub" | "cap_update" | "error"target: subject/bucket/streaminbox: correlation id (currently unused, planned for future)data: operation-specific object
-
Server Operations (implemented):
sub_msg: NATS subject messageskv_msg: KV update messages with{op, rev, key, value}js_msg: JetStream messagescap_update: Capability updateserror: Error messages
-
Missing:
cmd/replyoperations for request/response pattern - These will be implemented via HTTP REST API -
Note: HTTP responses won't update frontend state - all updates come through WebSocket
Fully implemented in server/web/socket.go:
-
Struct (implemented):
Subjects []string- Allowed NATS subjectsBuckets map[string][]string- bucket pattern → allowed key patternsCommands []string- Allowed command targets (currently unused)Streams map[string][]string- stream pattern → allowed filter subject patterns
-
Matching (implemented):
- Custom NATS-style wildcard matcher (
*,>) implemented insubjectMatch()function - Pattern matching for subjects, KV buckets, and streams
- Custom NATS-style wildcard matcher (
-
Source (implemented):
- JWT-based user identification via
whoApi.JwtVerifymiddleware - Capabilities fetched from NATS KV bucket
auth.userskey<user_id> - Real-time capability updates via
watchAuthKV()with automatic subscription revocation
- JWT-based user identification via
Fully implemented in rtClient struct:
-
Fields (implemented):
ctx,cancel- Context for lifecycle managementsendCh chan serverMsg(bounded to 256) - Outbound message queuesubsregistry - NATS subscriptionskvWatchersregistry - KV watchers
-
Writer goroutine (implemented):
writeLoop()processes messages fromsendCh- Backpressure: 250ms timeout, then connection closed with error
-
Cleanup (implemented):
- Context cancellation on disconnect
- Automatic cleanup of all NATS subscriptions and KV watchers
- Proper resource cleanup in
unsubscribeAll()
Fully implemented:
-
On
sub(implemented):isAllowedSubject(target)capability check- NATS Core subscription created and stored in
client.subs[target] - Messages forwarded as
sub_msgwithtargetand payload
-
On
unsub(implemented):- Subscription found and properly unsubscribed
- Registry cleaned up automatically
Architecture Decision: Commands will be implemented via HTTP REST API, not WebSocket.
- Rationale: WebSocket is for real-time data synchronization only
- Implementation: Standard REST endpoints for all CRUD operations and commands
- Benefits: Simpler protocol, better tooling support, standard HTTP semantics
- Frontend State: HTTP responses acknowledged but don't update frontend data models
- Data Flow: HTTP API → Backend → WebSocket → Frontend state update
Current status: Command handling code is commented out in socket.go. HTTP REST API will handle all request-response operations.
Fully implemented:
- Capability check:
isAllowedKV(bucket, pattern)enforced - KV bucket acquisition: Via JetStream API (
js.KeyValue(bucket)) - Pattern support:
WatchKeys(pattern)if pattern provided, elseWatchAll() - Event emission:
kv_msgwithtarget: bucketanddata: {op, rev, key, value} - Registry management: Watchers stored in
client.kvWatchers[bucket] - Operations supported:
put,delete,purge,in_sync
Fully implemented:
- Capability check:
isAllowedStream(stream, filter)enforced - Consumer creation: Durable consumer with
BindStream(stream) - Filter support:
FilterSubjectrequired (no default fallback) - Start position:
start_seqsupport for resume functionality - Batch processing: Configurable batch size (default 50)
- Message emission:
js_msgwithtarget: streamand raw message data - Acknowledgment: Automatic
Ack()after successful send - Registry management: Stored in
client.subs[stream] - Resume support: Durable consumer naming per user/stream/filter
Fully implemented:
- Route:
GET /wsendpoint mounted in routes - Handler:
HandleRealtimeWebSocket()function processes connections - Integration: Direct integration with NATS connection and JetStream context
- No legacy hub: Modern implementation without deprecated hub structure
All request-response operations (CRUD, commands, queries) will use standard HTTP REST API endpoints instead of WebSocket commands.
GET /api/articles- List all articlesGET /api/articles/{id}- Get specific articlePOST /api/articles- Create new articlePUT /api/articles/{id}- Update articleDELETE /api/articles/{id}- Delete articleGET /api/articles/{id}/revisions- Get article revision historyGET /api/articles/{id}/revisions/{revision}- Get specific revision
POST /api/auth/login- User loginPOST /api/auth/logout- User logoutGET /api/auth/me- Get current user info
GET /api/users- List users (admin only)POST /api/users- Create user (admin only)PUT /api/users/{id}- Update userDELETE /api/users/{id}- Delete user (admin only)
POST /api/commands/{command}- Execute business command- Example:
POST /api/commands/publish-articlewith article ID in body
- Example:
- Standard Semantics: Familiar HTTP methods and status codes
- Better Tooling: Standard HTTP clients, testing tools, documentation
- Caching: HTTP caching headers and CDN support
- Security: Standard HTTP security practices and middleware
- Monitoring: Standard HTTP metrics and logging
- WebSocket Only for State Updates: All frontend data model changes come through WebSocket subscriptions
- HTTP for Operations: HTTP REST API handles all CRUD operations and commands
- No HTTP State Updates: HTTP responses confirm success but don't modify frontend data
- Real-time Sync: Changes made via HTTP automatically appear through WebSocket
User Action → HTTP API → Backend → Database → WebSocket → Frontend State Update
- Consistent State: Single source of truth from WebSocket
- Real-time Updates: Immediate UI updates without manual state management
- Clean Separation: HTTP for operations, WebSocket for data sync
- Fallback Ready: Can implement long polling if WebSocket issues arise
src/sync.gleam exists and provides KV-focused WebSocket functionality.
- Model fields (implemented):
id: String- Unique subscription identifierstate: KVState- Connection and sync statebucket: String- KV bucket namefilter: Option(String)- Optional key pattern filterrevision: Int- Current revision numberdata: Dict(key, value)- Local data cache- Missing: Subject subscriptions, JetStream subscriptions, capabilities
Note: This module handles KV data synchronization. HTTP operations are handled separately.
-
Public helpers (current implementation):
- ✅
connect(path: String) -> Effect(Msg)- Connect to WebSocket - ✅
subscribe(subject: String) -> Effect(Msg)- Subscribe to NATS subject - ✅
kv_subscribe(bucket: String) -> Effect(Msg)- Subscribe to KV bucket - ❌
kv_subscribe_pattern(bucket: String, pattern: String)- Not implemented - ❌
js_subscribe(stream: String, start_seq: Int, batch: Int, filter: String)- Not implemented - ❌
js_resume(stream: String, last_seq: Int, batch: Int, filter: String)- Not implemented - ❌
send_command(target: String, payload: json.Json, cb: fn(json.Json) -> Msg)- Will use HTTP REST API instead - Note: HTTP operations handled separately, WebSocket only for data sync
- ✅
-
Message handling (partially implemented):
- ✅ WebSocket text message parsing
- ✅ Basic message routing
- ❌
replyhandling - Not implemented (nopending_cmds) - Will use HTTP REST API instead
-
Note: No reply handling needed - WebSocket only for data sync
- ❌
cap_updatehandling - Not implemented (no capabilities field) - ❌
errorhandling - Basic error dispatch exists
- ❌
Fully implemented:
- Connection:
ws://<host>/wswith JWT cookies - Auto-subscription: On connect, automatically resubscribes to all subjects and KV buckets
- Reconnection: Exponential backoff with automatic retry
- Resubscription: Core subjects and KV buckets automatically resubscribed on reconnect
- Missing: JetStream resume with
last_seqnot implemented - Fallback Strategy: Long polling can be implemented if WebSocket connection issues persist
Partially implemented:
- Model integration:
sync.gleamKV types used for data synchronization - Initialization: Basic KV subscription initialization in place
- Event mapping: WebSocket message handling for KV updates implemented
- Missing: Subject subscriptions integration not implemented
- Missing: JetStream subscriptions integration not implemented
Partially implemented:
- Envelope encoding: Basic envelope creation for
sub,kv_suboperations - Message parsing: WebSocket text message parsing implemented
- Missing: Full envelope encoding/decoding for all operations
- Missing:
reply,cap_updatemessage handling - Missing: Unit tests for codec definitions
-
Local development:
- With embedded NATS:
cd server && go run . -local -proxy -log debug - Frontend dev server (hot reload):
cd jst_lustre && gleam run -m lustre/dev start --tailwind-entry=./src/styles.css - Go server proxies
GET /and static tohttp://127.0.0.1:1234in-proxymode (seeroutes.go)
- With embedded NATS:
-
Static build for production:
cd jst_lustre && gleam run -m lustre/dev build --minify --tailwind-entry=./src/styles.css --outdir=../server/web/static- Start server without
-proxyso embedded static is served
-
Env var notes (
server/conf.go):- All required env vars must be set; current code treats
NTFY_TOKENas required - HTTP server binds to
0.0.0.0:8080(seeweb/web.go)
- All required env vars must be set; current code treats
-
Core subject subscription ❌ Not implemented
- Subject subscriptions not yet implemented in frontend
- Backend supports it but frontend uses
sync.gleamfor KV only
-
Command/reply ❌ Not implemented
- Command handling code is commented out in
socket.go - Architecture: Commands will use HTTP REST API instead of WebSocket
- Command handling code is commented out in
-
KV subscription ✅ Testable
- Create bucket:
nats kv add todos - Frontend: Uses
sync.gleamKV types for subscription - CLI:
nats kv put TODOS user.123.task1 '{"done":false}'and verifykv_msgevent
- Create bucket:
-
JetStream subscription ❌ Not implemented
- Backend supports it but frontend not yet implemented
- Will need to extend
sync.gleamor create separate module
-
Backpressure
- Temporarily reduce
sendChbuffer and inject large bursts; verify connection closes after the configured timeout with anerror
- Temporarily reduce
-
Capabilities ✅ Testable
- Seed capabilities into KV (e.g., bucket
auth.users, key<user_id>) - Connect as the user; verify allowed vs denied operations and that
cap_updatetriggers revocations
- Seed capabilities into KV (e.g., bucket
- CRUD Operations - Test all article endpoints
- Authentication - Test login/logout and JWT validation
- Authorization - Test permission-based access control
- User Management - Test user CRUD operations
- Commands - Test business command endpoints
- ✅ Completed: The existing
WebSocketMessage{ Type, Topic, Data, ... }andMsgType*constants have been replaced by the unified envelope (op,target,data, ...) - ✅ Completed: Modern implementation without deprecated hub structure (
Hub,Client,readPump,writePump) - ✅ Completed: Protocol parsing/dispatch implemented in
readLoop()andhandleMessagefunctions - ✅ Completed: No legacy handling needed - clean implementation
- Backend entrypoint:
server/main.go - HTTP server and routing:
server/web/web.go,server/web/routes.go - WebSocket implementation:
server/web/socket.go - Frontend entry HTML:
jst_lustre/index.html - Frontend app:
jst_lustre/src/jst_lustre.gleam - Frontend sync:
jst_lustre/src/sync.gleam - Frontend session/auth:
jst_lustre/src/session.gleam
- Time-based resume for JetStream when
last_seq == 0 - Durable consumer naming per user/session and stream
- At-least-once delivery with explicit acks and retry loops
- Protocol versioning in envelope to allow incremental evolution
- Heartbeat ops (
ping/pong) at the protocol level for fast dead-connection detection
- Implement all planned endpoints (articles, auth, users, commands)
- Add comprehensive JWT authorization and permission system
- Consider implementing WebSocket commands in the future for real-time command execution
-
Evan Czaplicki’s “Life of a File”
- Keep infra in one place (
realtime.gleam) and let each domain own its state machine (data + logic + view) to avoid premature fragmentation. This reduces cross-file hops and makes updates localized.
- Keep infra in one place (
-
modem + plinth
- Routing and SPA boot are handled by dedicated libraries so the realtime layer remains orthogonal. Domains consume
Events fromrealtimeand render; routing stays pure.
- Routing and SPA boot are handled by dedicated libraries so the realtime layer remains orthogonal. Domains consume
-
Unified envelope (op/target/inbox/data)
- A single shape across sub/unsub/kv_sub/js_sub/cmd/reply/msg avoids ad-hoc handlers and makes client/server evolvable.
inboxis client-side correlation only; never used as a NATS subject.
- A single shape across sub/unsub/kv_sub/js_sub/cmd/reply/msg avoids ad-hoc handlers and makes client/server evolvable.
-
Capabilities as patterns
- Subjects, KV keys, and JS filter subjects are governed by NATS-style wildcards (
*,>). This gives least-privilege control without enumerating every resource. - Example check (Go):
- Subjects, KV keys, and JS filter subjects are governed by NATS-style wildcards (
func matchPattern(pattern, subject string) bool {
ok, err := nats.Match(pattern, subject)
return err == nil && ok
}-
Auth change handling via Auth KV watch
- Watching
auth.users/<user_id>ensures revocation and grants apply mid-connection. On change, we diff, revoke live subs, and pushcap_updateto the client.
- Watching
-
KV as projections (WatchAll/WatchKeys)
- KV provides full-state hydration plus incremental updates.
WatchKeys(pattern)reduces noise and bandwidth when only a subset is needed.
- KV provides full-state hydration plus incremental updates.
-
JetStream resumeability and pagination
- Default to sequence-based resume so domains can reliably “pick up where we left off”. Store
last_seqin domain models. - Optional filter subjects let consumers focus on a slice of the stream (e.g.,
chat.room.123). - Time-based resume is a pragmatic fallback when
last_seqis 0.
- Default to sequence-based resume so domains can reliably “pick up where we left off”. Store
-
Command correlation with client-generated inbox
- The client sets
inboxfor correlation over WebSocket; server echoes it inreply. Server usesnats.RequestWithContextinternally; the client token is never used as a NATS subject.
- The client sets
// reply with echoed inbox
c.send(ServerMsg{ Op: "reply", Target: target, Inbox: inbox, Data: payload })- Backpressure policy
- A bounded
sendChwith a 250 ms enqueue timeout guards server resources and prevents head-of-line blocking. If blocked, we send a terminal error frame and close.
- A bounded
select {
case c.sendCh <- msg:
case <-time.After(250 * time.Millisecond):
c.closeWithError("backpressure timeout")
}-
Context propagation and cleanup
- All goroutines (KV watchers, JS subs, command requests) are tied to
Client.ctx. Disconnect cancels context to ensure drains and resource release.
- All goroutines (KV watchers, JS subs, command requests) are tied to
-
Domain-responsible sequence tracking (frontend)
- Domains persist
last_seqand usejs_resume(stream, last_seq, batch, filter)after reconnects so history gaps are avoided without server-side state.
- Domains persist
-
Simplicity first, durability as needed
- Start with ephemeral subs and client-held resume; add durable consumers for long-lived or mission-critical feeds where pagination/rewind is required.