Datastar HTTP

ascent-datastar-http makes a heddle server "an ascent client": render a UI with ascent-html, push patch-elements / patch-signals through heddle's Datastar SSE generator.

patchSignal

Handler-level assert: the SSE body carries a datastar-patch-signals frame with the named value.

{
  val sse = events {
    AscentDatastar.patchSignal("count", 42)
  }
  (for
    response <- sse.run(Request.get("/sse"))
    body     <- response.body.utf8
  yield (
    response.status == Status.Ok,
    body.contains("event: datastar-patch-signals"),
    body.contains("\"count\":42"),
  )).orDie
}
(true,true,true)

Live counter server

End-to-end against an in-process server (ephemeral port, scoped to the example): POST /increment succeeds while the server is live, then the Scope closes and the port is released.

{
  case class State(count: Ref[Int], pulse: Hub[Unit])

  def pushCount(state: State): ZIO[Datastar, Nothing, Unit] =
    state.count.get.flatMap(c => AscentDatastar.patchSignal("count", c)(using JsonEncoder.int))

  def routes(state: State): Routes[Any, Nothing] =
    Routes(
      Method.GET / "sse" -> events {
        for
          _      <- pushCount(state)
          stream <- state.pulse.subscribe.map(zio.stream.ZStream.fromQueue(_))
          _      <- stream.mapZIO(_ => pushCount(state)).runDrain
        yield ()
      },
      Method.POST / "increment" -> handler { (_: Request) =>
        (state.count.update(_ + 1) *> state.pulse.publish(()).unit).as(Response.ok)
      },
    )

  for
    count <- Ref.make(0)
    pulse <- Hub.unbounded[Unit]
    state = State(count, pulse)
    result <- ZIO
      .scoped {
        Server.install(routes(state)).flatMap { server =>
          server.port.flatMap { port =>
            Client.request(Method.POST, s"http://127.0.0.1:$port/increment").map(_.status)
          }
        }
      }
      .provide(Server.defaultWith(_.port(Port(0))))
  yield result
  end for
}
Status(200,OK)