Decode Datastar signals without the SDK

The SDK was used for exactly one call, ReadSignals, which is a JSON decode of
a query parameter. It brought four modules with it, including an HTTP
compression stack, for an SSE generator this app never touches: the search
handlers answer with plain text/html and let Datastar match the fragment by
id.

Five lines replace it. Absent or empty is deliberately not an error — the
first request carries no signals, and rejecting it would 400 the initial
load. Tests cover absent, empty, malformed, and extra signals present.

Nothing changes on the client: Datastar still sends the same
?datastar={"haku":"..."}, and the smoke checks that assert that wire format
are what make the swap safe.
This commit is contained in:
Esa Kataja
2026-09-05 22:00:40 +03:00
parent 0f2cd9f0a9
commit 1ce2d2b9f5
4 changed files with 47 additions and 34 deletions
+17 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
"encoding/json"
"errors"
"io"
"log"
@@ -11,7 +12,6 @@ import (
"time"
"github.com/a-h/templ"
"github.com/starfederation/datastar-go/datastar"
)
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
@@ -167,6 +167,20 @@ type searchSignals struct {
Haku string `json:"haku"`
}
// readSignals decodes that parameter.
//
// ponytail: the Datastar SDK does this too, but pulling it in for one JSON
// decode dragged along four modules — an HTTP compression stack among them —
// for an SSE generator this app never uses. Absent or empty is not an error:
// the first request carries no signals.
func readSignals(r *http.Request, into any) error {
raw := r.URL.Query().Get("datastar")
if raw == "" {
return nil
}
return json.Unmarshal([]byte(raw), into)
}
// fragment renders a piece of a page for Datastar to patch in. A plain
// text/html response is enough — Datastar matches the returned element by its
// id and replaces it, so there is no SSE stream to manage.
@@ -180,7 +194,7 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
// searchBoard re-renders the dish board as the search box is typed into.
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
var signals searchSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
if err := readSignals(r, &signals); err != nil {
http.Error(w, "bad signals", http.StatusBadRequest)
return
}
@@ -204,7 +218,7 @@ func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
// searchCatalog re-renders the catalog lists as the search box is typed into.
func (a *app) searchCatalog(w http.ResponseWriter, r *http.Request) {
var signals searchSignals
if err := datastar.ReadSignals(r, &signals); err != nil {
if err := readSignals(r, &signals); err != nil {
http.Error(w, "bad signals", http.StatusBadRequest)
return
}
+30
View File
@@ -3,6 +3,7 @@ package main
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
@@ -75,6 +76,35 @@ func TestDateRejectsTheFuture(t *testing.T) {
}
}
func TestReadSignals(t *testing.T) {
cases := []struct {
name string
query string
want string
wantErr bool
}{
{"a signal", `/etsi?datastar=` + url.QueryEscape(`{"haku":"keitto"}`), "keitto", false},
{"other signals are ignored", `/etsi?datastar=` + url.QueryEscape(`{"haku":"kala","muu":1}`), "kala", false},
// The first request carries no signals at all; that is not a failure.
{"no parameter", "/etsi", "", false},
{"empty parameter", "/etsi?datastar=", "", false},
{"malformed json", "/etsi?datastar=%7Bnope", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got searchSignals
err := readSignals(httptest.NewRequest(http.MethodGet, c.query, nil), &got)
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
}
if got.Haku != c.want {
t.Errorf("haku = %q, want %q", got.Haku, c.want)
}
})
}
}
func TestMigrateCreatesSchema(t *testing.T) {
db, err := openDB(t.TempDir() + "/test.db")
if err != nil {