Skip to content

johntom/datastar-fastify-sdk

Repository files navigation

Datastar Fastify SDK

Unofficial Datastar SDK for Fastify - Build reactive web applications with Server-Sent Events.

This package provides a Node.js/Fastify SDK for working with Datastar.

Version

Field Value
VERSION 2.0.1
VERSION_date 05/16/26
VERSION_mess Uses Datastar 1.0.1

Requirements

  • Node.js 20+ (required by Fastify 5)
  • Tested with Node.js 24+
  • Fastify 5.x
  • Datastar 1.0.1 (client-side)

Installation

# Install from GitHub
npm install github:johntom/datastar-fastify-sdk

# Or add to package.json
"@johntom/datastar-fastify": "github:johntom/datastar-fastify-sdk"

Import examples:

const { datastar } = require('@johntom/datastar-fastify');
const { PatchMode } = require('@johntom/datastar-fastify');
const { datastar, GetSSE, PostSSE, escapeHtml, PatchMode } = require('@johntom/datastar-fastify');
const { datastar, PostSSE, DeleteSSE, PatchMode, escapeHtml } = require('@johntom/datastar-fastify');

Quick Start

const Fastify = require('fastify');
const { datastar, GetSSE, PostSSE } = require('@johntom/datastar-fastify');

const app = Fastify({ logger: true });

// Register the Datastar plugin
app.register(datastar);

// Serve HTML with Datastar attributes
app.get('/', async (request, reply) => {
  const html = `
    <!DOCTYPE html>
    <html>
    <head>
      <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.1/bundles/datastar.js"></script>
    </head>
    <body>
      <div data-signals='{"count": 0}'>
        <p>Count: <span data-text="$count">0</span></p>
        <button data-on:click="${GetSSE('/api/increment')}">Increment</button>
      </div>
    </body>
    </html>
  `;
  reply.type('text/html').send(html);
});

// Handle Datastar requests with SSE responses
app.get('/api/increment', async (request, reply) => {
  const { signals } = await request.readSignals();
  const newCount = (signals?.count || 0) + 1;

  await reply.datastar((sse) => {
    sse.patchSignals({ count: newCount });
  });
});

app.listen({ port: 3000 });

API Reference

Plugin Registration

app.register(datastar, {
  defaultRetryDuration: 1000 // Default SSE retry duration in ms
});

Request Decorators

request.readSignals()

Reads Datastar signals from the request (query params for GET, body for POST/PUT/PATCH/DELETE).

const { success, signals, error } = await request.readSignals();
if (success) {
  console.log(signals.count);
}

request.isDatastarRequest()

Returns true if the request includes the datastar-request: true header.

Reply Decorators

reply.datastar(callback, options)

Starts an SSE stream that auto-closes after the callback completes.

await reply.datastar((sse) => {
  sse.patchSignals({ count: 1 });
  sse.patchElements('<div id="output">Updated!</div>');
});

Options:

  • onError(error) - Error callback
  • onAbort() - Connection abort callback
  • keepAlive - Keep stream open after callback (default: false)

reply.datastarStream(options)

Starts a persistent SSE stream that must be manually closed. This is the equivalent of Go SDK's datastar.NewSSE(w, r) - use it for real-time features like chat, notifications, live updates.

const sse = reply.datastarStream({
  onAbort: () => console.log('Client disconnected')
});

// Send updates over time
sse.patchSignals({ status: 'processing' });

// Later...
sse.close();

Options:

  • onAbort() - Called when client disconnects (cleanup subscriptions, timers, etc.)

Comparison with Go SDK:

Go SDK Fastify SDK
sse := datastar.NewSSE(w, r) const sse = reply.datastarStream()
sse.MarshalAndPatchSignals(...) sse.patchSignals(...)
sse.PatchElementTempl(...) sse.patchElements(...)
sse.ExecuteScript(...) sse.executeScript(...)
sse.Send(eventType, dataLines, opts) sse.send(eventType, dataLines, options)
N/A sse.sendEvent(eventType, data, options)
<-r.Context().Done() { onAbort: () => {...} }

Real-Time Example (Chat/Notifications)

Use datastarStream() for persistent connections where you need to push updates over time:

// Simple PubSub for broadcasting
class PubSub {
  constructor() { this.subs = new Set(); }
  subscribe(cb) { this.subs.add(cb); return () => this.subs.delete(cb); }
  publish(data) { this.subs.forEach(cb => cb(data)); }
}
const messageFeed = new PubSub();

// SSE endpoint - persistent connection
fastify.get('/sse', async (request, reply) => {
  let unsubscribe = null;
  let heartbeat = null;

  // Create persistent SSE stream (like Go's datastar.NewSSE)
  const sse = reply.datastarStream({
    onAbort: () => {
      // Cleanup when client disconnects
      if (heartbeat) clearInterval(heartbeat);
      if (unsubscribe) unsubscribe();
      console.log('Client disconnected');
    }
  });

  // Subscribe to message feed
  unsubscribe = messageFeed.subscribe((msg) => {
    if (sse.isClosed) return;

    // Push update to client using SDK methods
    sse.patchElements(`<div class="message">${msg.text}</div>`, {
      selector: '#messages',
      mode: 'append'
    });
    sse.executeScript('document.getElementById("messages").scrollTop = document.getElementById("messages").scrollHeight');
  });

  // Heartbeat to keep connection alive
  heartbeat = setInterval(() => {
    if (!sse.isClosed) {
      sse.reply.raw.write(': heartbeat\n\n');
    }
  }, 15000);
});

// Post a message - broadcasts to all connected clients
fastify.post('/send', async (request, reply) => {
  const { signals } = await request.readSignals();

  // Broadcast to all subscribers
  messageFeed.publish({ text: signals.message });

  // Clear input for sender
  await reply.datastar((sse) => {
    sse.patchSignals({ message: '' });
  });
});

Frontend (connects via EventSource):

<div data-signals='{"message": ""}'>
  <div id="messages"></div>
  <input data-bind="message" data-on:keydown.enter="$message && @post('/send')" />
  <button data-on:click="$message && @post('/send')">Send</button>
</div>

<script>
  // Connect to SSE feed
  const feed = new EventSource('/sse');

  // Listen for Datastar events
  feed.addEventListener('datastar-patch-elements', (e) => {
    // Parse and apply (or let Datastar handle natively)
  });
</script>

Key Differences from reply.datastar():

reply.datastar() reply.datastarStream()
Auto-closes after callback Stays open until sse.close() or client disconnect
For request/response patterns For real-time push updates
One-shot updates Multiple updates over time

SSE Generator Methods

sse.patchElements(html, options)

Patches HTML elements into the DOM.

sse.patchElements('<div id="content">Hello!</div>');

// With options
sse.patchElements('<li>New item</li>', {
  selector: '#list',
  mode: 'append' // outer, inner, replace, prepend, append, before, after, remove
});

// SVG (or mathml) namespace — added in Datastar 1.0.0-RC.7
sse.patchElements('<circle cx="10" cy="10" r="5" fill="red"/>', {
  selector: '#vis',
  mode: 'append',
  namespace: 'svg' // 'html' (default), 'svg', 'mathml'
});

sse.patchSignals(signals, options)

Updates client-side signals.

sse.patchSignals({ count: 42, message: 'Hello' });

// Only set if not already present
sse.patchSignals({ defaults: true }, { onlyIfMissing: true });

sse.executeScript(script, options)

Executes JavaScript in the browser.

sse.executeScript('alert("Hello!")');
sse.consoleLog('Debug message');

sse.removeElements(selector)

Removes elements from the DOM.

sse.removeElements('#temporary-message');

sse.redirect(url) / sse.redirectf(format, ...args)

Redirects the browser.

sse.redirect('/dashboard');
sse.redirectf('/users/%s', userId);

sse.send(eventType, dataLines, options) - Custom Events

Send custom SSE events (matches Go SDK's Send method). Use this for event types not covered by the standard Datastar methods.

// Send a custom event with raw data lines
sse.send('connected', ['{"clientId": "abc123", "boardId": "board1"}']);

// Send with options
sse.send('notification', ['{"message": "Hello"}'], { eventId: '123' });

sse.sendEvent(eventType, data, options) - Custom Events (convenience)

Convenience method that auto-serializes data to JSON.

// Send a custom event with object data (auto-serialized)
sse.sendEvent('connected', { clientId: 'abc123', boardId: 'board1' });

// Send card lock notification
sse.sendEvent('card-locked', { cardId: '123', userName: 'John' });

// Send with options
sse.sendEvent('user-joined', { name: 'Alice' }, { eventId: 'evt-001' });

Comparison with Go SDK:

Go SDK Fastify SDK
sse.Send(eventType, dataLines, opts) sse.send(eventType, dataLines, options)
N/A sse.sendEvent(eventType, data, options) (convenience)

Helper Functions

Template helpers for generating Datastar attributes:

const { GetSSE, PostSSE, PutSSE, PatchSSE, DeleteSSE, escapeHtml } = require('@johntom/datastar-fastify');

// Generate action attributes
GetSSE('/api/data')      // "@get('/api/data')"
PostSSE('/api/submit')   // "@post('/api/submit')"

// With format strings
GetSSE('/api/users/%s', userId)

// HTML escaping
escapeHtml('<script>alert("xss")</script>')

Examples

Run the included examples:

# Basic counter and message demo
npm run example

# Full TodoMVC implementation
npm run example:todo

Production: enabling SSE compression

Unlike the Datastar Go SDK, this Fastify SDK does not bundle compression. The recommended approach is to layer the official @fastify/compress plugin in front of it:

const Fastify = require('fastify');
const compress = require('@fastify/compress');
const { datastar } = require('@johntom/datastar-fastify');

const app = Fastify();
await app.register(compress, {
  encodings: ['br', 'gzip', 'deflate'],
  threshold: 0,
  customTypes: /^text\/event-stream$/,
});
app.register(datastar);

Two settings are easy to miss and both matter for SSE:

  • threshold: 0@fastify/compress skips small payloads by default. SSE events are typically small, so without this they ship uncompressed.
  • customTypes: /^text\/event-stream$/ — the default content-type filter excludes event streams, so they need to be opted in explicitly.

@fastify/compress negotiates the codec via the request's Accept-Encoding header, matching the Go SDK's behavior.

When this isn't enough: @fastify/compress doesn't expose per-event flush controls, so very latency-sensitive feeds may experience buffering. If that becomes a problem, full SDK-level compression with per-event flushing is tracked in todo.md (Option B).

Tests

The test server emulates the Datastar SDK for Go test suite, providing the same functionality as the Go version but implemented in Node.js with Fastify.

Created Files

  1. testserver.js - Main test server that mirrors the Go implementation

    • Listens on port 7331 (configurable via TEST_PORT env var)
    • Handles POST requests to /test endpoint
    • Processes three event types:
      • patchElements - Patches HTML elements into the DOM
      • patchSignals - Updates client-side signals
      • executeScript - Executes JavaScript in the browser
    • Supports all options: selector, mode, useViewTransition, onlyIfMissing, autoRemove, attributes, eventId, retryDuration
    • Handles multiline scripts and signals (using signals-raw field)
  2. test-request.js - Test client to verify the server works correctly

    • Tests all three event types
    • Tests multiple events in a single request
    • Successfully validated all functionality

Key Features

  • Signal Reading: Uses request.readSignals() from the Fastify SDK
  • SSE Streaming: Uses reply.datastar() to create SSE connections
  • Event Processing: Handles arrays of events sequentially
  • Connection Monitoring: Checks if SSE connection is closed before processing each event
  • Error Handling: Proper error responses with appropriate HTTP status codes
  • Logging: Uses Fastify's built-in logger

How to Use

# Run automated tests (starts server, runs tests, stops server)
npm test

# Or manually:
# 1. Start the test server in one terminal
npm run testserver

# 2. In another terminal, run test requests
node test-request.js

# Custom port
TEST_PORT=8080 npm run testserver

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors