Skip to content
Streamlord

Signals and codecs

Reading the browser's store as a map, or as your own data class.

Signals are Datastar’s client-side store: a small reactive object the browser keeps, sends back with requests, and updates when you patch it. On the server they arrive as JSON, and you decide how much structure you want out of them.

Where they come from

The protocol decides, and the adapters follow it: the datastar query parameter for GET and DELETE, the request body for POST, PUT, PATCH and QUERY. You never write that rule down yourself.

Without a codec

The built-in reader needs no dependency and no data class:

get("/page") {
    val signals = call.readSignals()

    val query = signals.string("search") ?: ""
    val page = signals.int("page") ?: 1
    val advanced = signals.boolean("advanced") ?: false
    val city = signals.path("address", "city")
}

Signals has string, int, long, double, decimal for an exact BigDecimal, boolean, obj for a nested object, array for a nested list, has to ask whether a key is there at all, and path for reaching through several levels. Every accessor returns null rather than throwing, because a signal the browser never set is a normal thing and not an error.

That is the whole of it, and for many endpoints it is enough. Reading two fields out of a request does not need a class.

With a codec

When the shape is worth naming, it takes three things: a codec on the instance, a class, and a read into that class.

One. The codec, installed once for the application. The built-in reader cannot build a data class, so this is what makes the rest of this section possible:

install(StreamlordPlugin) { codec = KotlinxSignalsCodec() }

Two. The class, which names the signals this handler accepts. It is an ordinary @Serializable data class and nothing about it is Streamlord’s:

@Serializable
data class SearchSignals(val query: String = "", val page: Int = 1)

Three. The read, which hands that class to the codec from step one:

post("/search") {
    val signals = call.readSignalsOr(SearchSignals())
    val hits = repository.search(signals.query)
}

signals is a SearchSignals now, so signals.query is a String the compiler knows about, and the JSON the browser sent never appears in your code.

readSignals<T>() returns null when the request carried no signals at all; readSignalsOr(default) saves you the elvis. Defaults on the data class matter more than usual here, because the browser sends only the signals it has: a page that never set page sends no page, and the default is what the handler gets.

The type is the input surface

A page carries every signal it has ever set, and the browser sends the whole store back on every request, after the reader has had every chance to edit it. A signal arriving at a handler is a request body from a stranger.

The type you read into is where you decide what this handler accepts. It is a narrowing, not a registry: a page may carry fifteen signals while a handler that names two sees two, because the codecs ignore keys the type does not name. Declare it per handler rather than once for the application, and the handler documents its own input at the point of use.

An unread signal is harmless by construction. The danger is only ever a signal that is read and then trusted, which is why the type is worth keeping small.

The failure that is not harmless is the opposite one: a signal a handler reads that no page ever declares. That compiles, deploys, and hands the handler a default on every request. signalDrift in streamlord-analysis compares the two sets across your whole project and reports it, which is on Testing.

If you also want it to fail at request time, that is your codec’s own setting rather than something Streamlord adds: KotlinxSignalsCodec(json = Json { ignoreUnknownKeys = false }) refuses a body carrying anything the type does not name. It is worth a test and a poor fit for production, because it makes every handler fail the moment any page gains a signal.

Which codec

ModuleCodecUse it when
streamlord-json-kotlinxKotlinxSignalsCodecyour application already uses kotlinx.serialization
streamlord-json-jacksonJacksonSignalsCodecSpring Boot 4, which ships Jackson 3 (tools.jackson)
streamlord-json-jackson2Jackson2SignalsCodecSpring Boot 3, which ships Jackson 2 (com.fasterxml)
nonethe built-in readeryou are happy reading by name

The two Jackson modules exist because Jackson 3 moved package. Taking the wrong one compiles and then fails at runtime on an ObjectMapper that is not the one Spring configured.

In a native image

A codec finds the serializer for a KType by reading the class, which GraalVM’s native-image cannot see. Name the serializers instead:

val codec = KotlinxSignalsCodec(
    serializers = mapOf(typeOf<SearchSignals>() to SearchSignals.serializer()),
)

Types you leave out still resolve themselves, so this costs nothing on the JVM. The rest of what an image needs (the build file, the resources, the container) is on Native image.

Patching signals back

The same store, in the other direction:

get("/feed") {
    call.respondDatastar {
        patchSignals("total" to 13, "draft" to null)
    }
}

A signal patched to null is removed, because signal patches are RFC 7386 merge patches. That is not a quirk of Streamlord; it is what the protocol says, and it is how you delete.

The size limit

Incoming signals are capped at 1 MiB by default, and the cap is applied while reading. A declared Content-Length above the limit is rejected before a byte is read; a chunked body is cut off one byte past it. Nothing larger than the cap ever sits in memory. Map SignalsTooLargeException to 413 in your framework’s error handling. See Security.

What went over the wire

The frames your last search produced, encoded by the same SseEncoder the golden-file tests check. Not a description of them. The frames.

Nothing yet. Search from the top of the page, and what the server sends will appear here.