Skip to content

EventRail is a Go library for real-time event delivery using Server-Sent Events (SSE), designed to scale horizontally and powered by Redis as an event broker. It keeps the backend as the single source of truth and fits naturally with server-side rendering and htmx-style architectures.

License

Notifications You must be signed in to change notification settings

PabloPavan/eventrail

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

26 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EventRail logo

Go Version License SSE CI Release Redis

Event-driven realtime delivery over Server-Sent Events (SSE), powered by Redis.

eventrail is a Go library for building scalable real-time event delivery using Server-Sent Events (SSE). It is designed for horizontal scalability, works naturally with server-side rendering (e.g. htmx), and keeps the backend as the single source of truth.

The library focuses on event invalidation, not state replication.

Why eventrail?

  • No polling
  • No SPA complexity
  • No sticky sessions
  • No shared in-memory state between instances
  • Works well with SSR + htmx-style partial refresh
CRUD → Redis → SSE → Browser → Fragment refresh

Core Concept: Event Invalidation

eventrail does not push state or diffs to the client.

Instead:

  1. The backend emits a lightweight event
  2. Browsers receive the event via SSE
  3. The frontend decides what to refresh (HTML fragments, tables, dashboards)

This model is robust, scalable, and easy to reason about.


High-Level Architecture

flowchart LR
    Browser -->|SSE| LoadBalancer
    LoadBalancer --> API1[API Instance #1]
    LoadBalancer --> API2[API Instance #2]
    LoadBalancer --> API3[API Instance #N]

    API1 --> Redis[(Redis)]
    API2 --> Redis
    API3 --> Redis
Loading

Key properties:

  • Each API instance manages its own SSE connections
  • Redis is the only coordination point
  • No instance-to-instance communication
  • No sticky sessions required

Event Flow (CRUD → UI Update)

sequenceDiagram
    participant UserA as User A
    participant API as API Instance
    participant Redis as Redis
    participant BrowserB as Browser B

    UserA->>API: Update student
    API->>Redis: Publish gym:1:students
    Redis-->>API: Fan-out event
    API-->>BrowserB: SSE event students.changed
    API-->>UserA: SSE event stundents.changed 
    BrowserB->>API: htmx GET /students/fragment
    UserA->>API: htmx GET /students/fragment
Loading

Official Conventions (Contract)

Redis Channel Naming (Mandatory)

All Redis channels must follow:

{scope}:{scope_id}:{topic}

Examples:

gym:1:students
org:42:plans
tenant:9:payments
  • scope: tenant type (gym, org, tenant, etc.)
  • scope_id: tenant identifier
  • topic: affected domain or aggregate

Never publish global channels.


SSE Event Naming

{EventNamePrefix}.{topic}.{action}

Examples:

app.students.changed
app.plans.created

If EventNamePrefix is empty, the event name is just {topic}.{action}.


Event Payload Structure

{
  "event_type": "students.changed",
  "data": {
    "id": 123
  }
}

Installation

go get github.com/PabloPavan/eventrail@latest

Basic Usage

1. Create the SSE Server

rdb := redis.NewClient(&redis.Options{
    Addr: "127.0.0.1:6379",
})

// sseredis is github.com/PabloPavan/eventrail/sse/redis
broker := sseredis.NewBrokerPubSub(rdb)

server, err := sse.NewServer(broker, sse.Options{
    Resolver: myResolver,
    Router: func(p *sse.Principal) []string {
        return []string{
            fmt.Sprintf("gym:%d:*", p.UserID),
        }
    },
    EventNamePrefix: "app",
})
if err != nil {
    panic(err)
}

If you don't want Redis (single-process only):

// ssememory is github.com/PabloPavan/eventrail/sse/memory
broker := ssememory.NewBrokerInMemory()

2. Register the SSE Route (Chi)

r := chi.NewRouter()

r.Get("/events", func(w http.ResponseWriter, r *http.Request) {
    server.Handler().ServeHTTP(w, r)
})

Graceful Shutdown

Use a base context to tie broker subscriptions to your app lifecycle, then call Close() when shutting down. Active SSE handlers also stop when this context is canceled.

ctx, cancel := context.WithCancel(context.Background())

server, err := sse.NewServer(broker, sse.Options{
    Context:  ctx,
    Resolver: myResolver,
    Router:   myRouter,
})
if err != nil {
    panic(err)
}

// On shutdown:
cancel()
server.Close()

3. Publish Events from Your CRUD

pub := server.Publisher()

channel := fmt.Sprintf("gym:%d:students", gymID)

_ = pub.PublishEvent(ctx, channel, sse.Event{
    EventType: "students.changed",
    Data:      json.RawMessage(`{"id":123}`),
})

4. Frontend Example (SSE + htmx)

<div id="students-list"
     hx-get="/students/fragment"
     hx-trigger="load, refresh"
     hx-swap="outerHTML">
</div>

<script>
  const es = new EventSource("/events");

  es.addEventListener("app.students.changed", () => {
    htmx.trigger("#students-list", "refresh");
  });
</script>

5. Avoid Self-Notify (htmx-only POST + SSE filter)

Use a hidden origin_id generated by the server so the tab that sent the POST can ignore its own SSE event.

<input type="hidden" id="origin-id" name="origin_id" value="{{.OriginID}}">

<form hx-post="/students"
      hx-include="#origin-id"
      hx-swap="none">
  ...
</form>

<script>
  const originId = document.querySelector("#origin-id").value;
  const es = new EventSource("/events");

  es.addEventListener("app.students.changed", (evt) => {
    const data = JSON.parse(evt.data);
    if (data.origin_id === originId) return;
    htmx.trigger("#students-list", "refresh");
  });
</script>

Backend: read origin_id from the POST and include it in the event payload (data.origin_id).

func createStudent(w http.ResponseWriter, r *http.Request) {
    originID := r.FormValue("origin_id")
    // ... create the student ...

    payload, _ := json.Marshal(map[string]any{
        "id":        studentID,
        "origin_id": originID,
    })
    _ = pub.PublishEvent(r.Context(), channel, sse.Event{
        EventType: "students.changed",
        Data:      json.RawMessage(payload),
    })
}

Examples

  • examples/basic: runnable SSE server with in-memory broker.

Authentication & Authorization

eventrail is authentication-agnostic.

You provide a PrincipalResolver:

type PrincipalResolver interface {
    Resolve(r *http.Request) (*Principal, error)
}

The resolver defines:

  • who the caller is
  • which scope they belong to

Scalability Characteristics

  • One SSE connection per browser
  • One Redis subscription per scope per instance
  • Linear horizontal scalability
  • Works behind standard HTTP load balancers

Contributing

Contributions are welcome. By submitting a PR, you agree that your work is licensed under the MIT license and may be modified or rejected by maintainers. Please keep changes focused, include tests where it makes sense, and run the checks in the Development section.


Development

Run checks locally:

go vet ./...
golangci-lint run

Or via Makefile:

make vet
make lint
make check

License

MIT

About

EventRail is a Go library for real-time event delivery using Server-Sent Events (SSE), designed to scale horizontally and powered by Redis as an event broker. It keeps the backend as the single source of truth and fits naturally with server-side rendering and htmx-style architectures.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published