datastar

Version, currently main branch1 version
  • main branchlatestDec 24, 2025

github.com/watzon/datastar.cr

Datastar SDK for Crystal. What else is there to know?

8 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  datastar:
    github: watzon/datastar.cr
    branch: main

main is a branch, not a release, so this tracks it rather than pinning a version.

Then run:

shards install

shard.yml

Crystal
>= 1.18.2
License
MIT
Author
Chris Watson <cawatson1993@gmail.com>

Dependencies

Development Dependencies

  • athena*github: athena-framework/athenadev
  • blueprint*github: stephannv/blueprintdev

README

# Datastar.cr

[![Standard Readme](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg)](https://github.com/RichardLitt/standard-readme)
[![Crystal](https://img.shields.io/badge/crystal-%3E%3D1.18.2-black)](https://crystal-lang.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A Crystal SDK for the Datastar hypermedia framework.

Datastar is a lightweight (~10KB) framework that brings reactive UI updates to server-rendered applications using Server-Sent Events (SSE) and HTML data attributes. This SDK provides a type-safe Crystal API for streaming DOM updates, managing reactive signals, and executing scripts in the browser—all from your server-side code.

## Table of Contents

- [Datastar.cr](#datastarcr)
  - [Table of Contents](#table-of-contents)
  - [Background](#background)
  - [Examples](#examples)
  - [Install](#install)
  - [Usage](#usage)
    - [Quick Start](#quick-start)
    - [Streaming Mode](#streaming-mode)
    - [One-Off Mode](#one-off-mode)
  - [API](#api)
    - [DOM Manipulation](#dom-manipulation)
      - [`#patch_elements`](#patch_elements)
      - [`#remove_elements`](#remove_elements)
    - [Signal Management](#signal-management)
      - [`#patch_signals`](#patch_signals)
      - [`#remove_signals`](#remove_signals)
      - [Reading Signals](#reading-signals)
    - [Script Execution](#script-execution)
      - [`#execute_script`](#execute_script)
      - [`#redirect`](#redirect)
    - [Connection Management](#connection-management)
  - [Framework Integration](#framework-integration)
    - [Kemal](#kemal)
    - [Athena](#athena)
    - [Blueprint](#blueprint)
    - [Request Detection](#request-detection)
    - [Custom Components](#custom-components)
  - [Pub/Sub for Multi-Session Sync](#pubsub-for-multi-session-sync)
    - [Setup](#setup)
    - [Subscribe to Topics](#subscribe-to-topics)
    - [Broadcast Updates](#broadcast-updates)
    - [Custom Backend](#custom-backend)
  - [Configuration](#configuration)
    - [Global](#global)
    - [Per-Instance](#per-instance)
  - [Maintainers](#maintainers)
  - [Contributing](#contributing)
  - [License](#license)

## Background

[Datastar](https://data-star.dev) combines the simplicity of server-side rendering with the interactivity of modern frontend frameworks. Instead of sending JSON and rebuilding the UI in JavaScript, Datastar streams HTML fragments directly from the server using SSE.

This SDK implements the [Datastar SSE protocol](https://data-star.dev/reference/sse_events) for Crystal, inspired by the official [Ruby SDK](https://github.com/starfederation/datastar-ruby).

**Key features:**

- Stream real-time UI updates via SSE
- Concurrent streaming with fiber-based concurrency
- Automatic heartbeat and connection health monitoring
- Built-in adapters for Kemal, Athena, and Blueprint frameworks
- Pub/sub system for multi-session synchronization
- Flexible rendering with the `Renderable` protocol

## Examples

The `examples/` directory contains working TodoMVC implementations:

- **[kemal-todomvc](examples/kemal-todomvc/)** - TodoMVC with Kemal, demonstrating pub/sub synchronization across browser sessions
- **[athena-todomvc](examples/athena-todomvc/)** - TodoMVC with Athena framework and Blueprint components

Each example demonstrates the full feature set including SSE streaming, pub/sub for multi-session sync, and reactive UI updates.

## Install

Add the dependency to your `shard.yml`:

```yaml
dependencies:
  datastar:
    github: watzon/datastar.cr
```

Then run:

```bash
shards install
```

## Usage

### Quick Start

```crystal
require "datastar"

def handle_events(request, response)
  sse = Datastar::ServerSentEventGenerator.new(request, response)

  sse.stream do |stream|
    stream.patch_elements(%(<div id="greeting">Hello, Datastar!</div>))
  end
end
```

### Streaming Mode

Use `stream` for long-lived connections with multiple updates:

```crystal
sse.stream do |stream|
  10.times do |i|
    sleep 1.second
    stream.patch_elements(%(<div id="count">#{i}</div>))
  end
end
```

The stream block sets SSE headers, manages concurrency, and handles cleanup automatically.

### One-Off Mode

For single updates without persistent connections:

```crystal
sse.patch_elements(%(<div id="notification">Task completed!</div>))
sse.finish
```

## API

### DOM Manipulation

#### `#patch_elements`

Patch HTML fragments into the DOM:

```crystal
# Basic usage
sse.patch_elements(%(<div id="message">Hello!</div>))

# Target a specific element
sse.patch_elements(%(<p>Updated</p>), selector: "#target")

# Append to a list
sse.patch_elements(%(<li>New item</li>), selector: "#list", mode: Datastar::FragmentMergeMode::Append)

# Multiple fragments
sse.patch_elements([%(<div id="a">A</div>), %(<div id="b">B</div>)])
```

**Merge modes:** `Outer` (default), `Inner`, `Replace`, `Prepend`, `Append`, `Before`, `After`, `Remove`

#### `#remove_elements`

```crystal
sse.remove_elements("#notification")
```

### Signal Management

#### `#patch_signals`

Update reactive signals:

```crystal
sse.patch_signals(count: 42, user: {name: "Alice"})
sse.patch_signals({enabled: true}, only_if_missing: true)
```

#### `#remove_signals`

```crystal
sse.remove_signals(["user.name", "user.email"])
```

#### Reading Signals

```crystal
# As JSON::Any
signals = sse.signals
count = signals["count"].as_i

# As typed struct
user = sse.signals(UserSignals)
```

### Script Execution

#### `#execute_script`

```crystal
sse.execute_script(%(console.log("Hello!")))
sse.execute_script("initWidget()", auto_remove: false)
sse.execute_script(%(import('./mod.js')), attributes: {"type" => "module"})
```

#### `#redirect`

```crystal
sse.redirect("/dashboard")
```

### Connection Management

```crystal
# Lifecycle callbacks
sse.on_connect { puts "Connected" }
sse.on_client_disconnect { puts "Client left" }
sse.on_server_disconnect { puts "Done streaming" }
sse.on_error { |ex| Log.error { ex.message } }

# Manual connection check
sse.check_connection!  # Raises IO::Error if closed

# Check connection state
sse.closed?
```

## Framework Integration

### Kemal

```crystal
require "kemal"
require "datastar/adapters/kemal"

# Streaming endpoint
get "/events" do |env|
  env.datastar_stream do |sse|
    10.times do |i|
      sleep 1.second
      sse.patch_elements(%(<div id="count">#{i}</div>))
    end
  end
end

# HTML response
get "/" do |env|
  env.datastar_render("<h1>Hello, Datastar!</h1>")
end

# Check if request is from Datastar
get "/page" do |env|
  if env.datastar_request?
    env.datastar_render("<div>Fragment</div>")
  else
    env.datastar_render("<html><body>Full page</body></html>")
  end
end

# Broadcast to all subscribed clients
post "/update" do |env|
  env.datastar_broadcast("my-topic") do |sse|
    sse.patch_elements("<div id='content'>Updated!</div>")
  end
end

Kemal.run
```

### Athena

```crystal
require "athena"
require "datastar"
require "datastar/adapters/athena"

class EventsController < ATH::Controller
  include Datastar::Athena::LiveController

  @[ARTA::Get("/events")]
  def stream_events(request : ATH::Request) : ATH::StreamedResponse
    datastar_stream(request) do |stream|
      10.times do |i|
        sleep 1.second
        stream.patch_elements(%(<div id="count">#{i}</div>))
      end
    end
  end
end
```

The `LiveController` mixin also provides `datastar_render` for HTML responses and `datastar_broadcast` for pub/sub:

```crystal
@[ARTA::Get("/")]
def index : ATH::Response
  datastar_render("<h1>Hello</h1>")
end

@[ARTA::Post("/update")]
def update : ATH::Response
  datastar_broadcast("my-topic") do |sse|
    sse.patch_elements("<div id='content'>Updated!</div>")
  end
  ATH::Response.new(status: :ok)
end
```

### Blueprint

Use [Blueprint](https://github.com/stephannv/blueprint) components with Datastar:

```crystal
require "datastar"
require "datastar/adapters/blueprint"

class GreetingCard
  include Blueprint::HTML

  def initialize(@name : String); end

  def blueprint
    div id: "greeting" do
      h1 { "Hello, #{@name}!" }
    end
  end
end

sse.patch_elements(GreetingCard.new("World"))
```

### Request Detection

Use `Datastar::RequestDetection` to tell whether a request came from Datastar:

```crystal
request = HTTP::Request.new("GET", "/?datastar=%7B%7D")
Datastar.datastar_request?(request) # => true
```

The Athena adapter exposes the same helper:

```crystal
if datastar_request?(request)
  datastar_render("<div>Datastar response</div>")
else
  datastar_render("<html>Full page</html>")
end
```

### Custom Components

Implement `Datastar::Renderable`:

```crystal
class MyComponent
  include Datastar::Renderable

  def initialize(@title : String); end

  def to_datastar_html : String
    %(<h1>#{@title}</h1>)
  end
end

sse.patch_elements(MyComponent.new("Hello"))
```

## Pub/Sub for Multi-Session Sync

Enable real-time synchronization across multiple browser sessions. When one client makes a change, all clients subscribed to the same topic receive updates automatically.

### Setup

```crystal
require "datastar/pubsub"

# Configure at app startup
Datastar::PubSub.configure

# With lifecycle callbacks
Datastar::PubSub.configure do |config|
  config.on_subscribe do |topic, conn_id|
    Log.info { "Client #{conn_id} joined #{topic}" }
  end
  config.on_unsubscribe do |topic, conn_id|
    Log.info { "Client #{conn_id} left #{topic}" }
  end
end
```

### Subscribe to Topics

```crystal
get "/subscribe/:list_id" do |env|
  list_id = env.params.url["list_id"]

  env.datastar_stream do |sse|
    # Subscribe to receive broadcasts for this list
    sse.subscribe("todos:#{list_id}")

    # Send initial state (fragment includes its own ID)
    sse.patch_elements(render_todos(list_id))

    # Connection stays open, broadcasts arrive automatically
  end
end
```

### Broadcast Updates

```crystal
post "/todos/:list_id" do |env|
  list_id = env.params.url["list_id"]
  todo = create_todo(env.params.json)

  # All subscribed clients receive this update
  Datastar::PubSub.broadcast("todos:#{list_id}") do |sse|
    sse.patch_elements(render_todos(list_id))
  end

  env.response.status_code = 201
end
```

### Custom Backend

For multi-server deployments, implement a custom backend:

```crystal
class RedisBackend < Datastar::PubSub::Backend
  def initialize(@redis : Redis::PooledClient)
  end

  def publish(topic : String, payload : String) : Nil
    @redis.publish("datastar:#{topic}", payload)
  end

  def subscribe(topic : String, &block : String ->) : String
    id = UUID.random.to_s
    spawn do
      @redis.subscribe("datastar:#{topic}") do |on|
        on.message { |_, msg| block.call(msg) }
      end
    end
    id
  end

  def unsubscribe(subscription_id : String) : Nil
    # Cancel the subscription fiber
  end
end

Datastar::PubSub.configure(backend: RedisBackend.new(redis))
```

## Configuration

### Global

```crystal
Datastar.configure do |config|
  config.heartbeat = 5.seconds
  config.on_error = ->(ex : Exception) { Log.error { ex.message } }
end
```

### Per-Instance

```crystal
sse = Datastar::ServerSentEventGenerator.new(request, response, heartbeat: 10.seconds)
sse = Datastar::ServerSentEventGenerator.new(request, response, heartbeat: false)
```

## Maintainers

[@watzon](https://github.com/watzon)

## Contributing

PRs accepted.

1. Fork it (<https://github.com/watzon/datastar.cr/fork>)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Write tests for your changes
4. Ensure all tests pass (`crystal spec`)
5. Format your code (`crystal tool format`)
6. Commit your changes (`git commit -am 'Add some feature'`)
7. Push to the branch (`git push origin my-new-feature`)
8. Create a new Pull Request

See the [Datastar documentation](https://data-star.dev) for more information about the protocol.

## License

MIT © Chris Watson