Drop #410 (2024-01-29): Your Real-time App Stack

Fir; Alpine.js; Gofir

Along with making some special dinners and desserts for $SPOUSE this weekend, I finally managed to hack on a project that’s been on the TODO for quite some time.

Today’s Drop covers that, but it first introduces the components it was built with.

No “TL;DR” as it doesn’t really make sense for this Drop.

Approximate reading time: 8 minutes.

Fir

Fir is a Go toolkit made by Adnaan Badr that lets developers build progressively enhanced reactive HTML apps. The framework is designed for Go developers with moderate HTML/CSS & JS skills who want to build reactive web apps without mastering complex web frameworks (/me raises hand). It uses html/template (from Go’s standard library), and Alpine.js to create these web interfaces.

As noted, the concept of progressive enhancement is at the heart of Fir. This is a design philosophy that provides a baseline of essential content and functionality to as many users as possible, while delivering the best possible experience only to users of the most modern browsers that can run advanced features. This approach ensures that web applications are functional and accessible to all web browsers, and then leverages whatever advanced features are available in any given visitor’s browser.

Fir makes it possible to add reactivity to Go apps without much JavaScript knowledge or experience with complex frameworks. For example, you can create a basic counter application with Fir (that’s the default example), where you can increment and decrement a count value. This is done by defining a route with an OnLoad function to initialize the count and OnEvent functions to handle increment and decrement events.

It operates over WebSockets, and masks just about all the complexity of them away from you. Client-side events come in via WebSocket events and are pushed back to the client via similar channels.

Essentially, you create one or more data models within the Go application, wire up some events, then use structured HTML templates to pass values to/from the client side. Fir renders the templates on the server and sends the updated content to the client, allowing for efficient updates of specific areas on the page. Fir also supports customized experiences via unique per-user sessions.

Adnaan has a spiffy “How Fir Works” post along with some well-documented demos that will get you up and running in no time.

Alpine.js

Digging into Fir meant finally getting around to me poking at Alpine.js (GH) (docs). It’s sort of the new kid on the JavaScript framework block, and it’s quickly gaining attention for its simplicity and lightweight footprint. At its core, Alpine.js is a front-end framework that provides the reactive and declarative nature of larger frameworks like Vue.js or React but with a much smaller impact on a given project’s bandwidth.

Alpine.js apart takes a very minimalist approach. It’s designed to be a drop-in solution for adding interactivity to your HTML without the need for a complex build step or a profound understanding of modern JavaScript tooling. With it, we can sprinkle in functionality right into our markup using directives that feel familiar if you’ve worked with Vue.js. For example, you can create a simple counter (why is this always the example?) with just a few lines of code, binding a button click to increment a value displayed on the screen.

It’s perfect for those scenarios where you need to add a bit of interactivity to a site without going overboard. Think of it as a more modern replacement for jQuery. It’s small (just 8kb minified and gzipped), fast, and plays nicely with server-side rendered HTML, reducing issues like Cumulative Layout Shift (which I find particularly annoying on many sites) and improving crawlability for search engines.

Having never used it before and only picking it up in a Fir context, I found Alpine.js to be very straightforward to get started with. Its API surface is small, with only 15 attributes, 6 properties, and 2 methods, meaning one can get up to speed quickly and start enhancing web pages without a steep learning curve.

For developers who are already familiar with Vue.js, the transition to Alpine.js is even smoother. The syntax and directives are quite similar, and Alpine.js uses Vue’s reactivity system under the hood. This means you can apply your Vue knowledge directly to Alpine.js, making it an even more attractive option for quickly adding interactivity to your projects.

If you’re working on a project that needs some interactive flair without the overhead of a larger framework, Alpine.js is definitely worth considering.

Gofir

This terribly named project (I made a play project to figure out how Fir worked and then made so much progress that I just kept going and checked the thing into Codeberg) has been an idea ever since I snagged a Weatherflow Tempest weather station (~2 years ago).

These devices have a local network UDP broadcast mode where JSON records are blasted into the ether whenever the station has new data from one of the sensors (or sends a heartbeat). This is the obs_st record (when a core sensor has new data/observation):

{
  "serial_number": "ST-00000512",
  "type": "obs_st",
  "hub_sn": "HB-00013030",
  "obs":
  [
    [ 
      1588948614, # Time Epoch  Seconds
      0.18,       # Wind Lull (minimum 3 second sample) m/s
      0.22,       # Wind Avg (average over report interval) m/s
      0.27,       # Wind Gust (maximum 3 second sample) m/s
      144,        # Wind Direction  Degrees
      6,          # Wind Sample Interval  seconds
      1017.57,    # Station Pressure  MB
      22.37,      # Air Temperature C
      50.26,      # Relative Humidity %
      328,        # Illuminance Lux
      0.03,       # UV  Index
      3,          # Solar Radiation W/m^2
      0.000000,   # Rain amount over previous minute  mm
      0,          # Precipitation Type
      0,          # Lightning Strike Avg Distance km
      0,          # Lightning Strike Count  
      2.410,      # Battery Volts
      1           # Report Interval Minutes
    ]
  ],
  "firmware_revision": 129
}

More on UDP and Golang in a bit.

In fir, you set up a route handler (in this case for /) via a call like this:

http.Handle("/", controller.Route(NewWxIndex(pubsubAdapter)))

In it, the main app model is instantiated and returned. It’s here we can kick off a forever-running UDP listener that will fire off events whenever a new UDP packet is found:

func NewWxIndex(pubsub pubsub.Adapter) *index {

  // the app model
	c := &index{
		model:       &App{},
		pubsub:      pubsub,
		eventSender: make(chan fir.Event),
		id:          "wx-app",
	}

  // our UDP packet handler that runs continuously
	go func() {

		addr, _ := net.ResolveUDPAddr("udp", ":50222")
		conn, _ := net.ListenUDP("udp", addr)
		defer conn.Close()

		buf := make([]byte, 1024)
		var obsInstance ObsSt

		for {
			n, _, _ := conn.ReadFromUDP(buf)

			data := buf[:n]
			msg := make(map[string]interface{})
			json.Unmarshal(data, &msg)

			if ttype, ok := msg["type"]; ok {
				if ttype == "obs_st" {

					json.Unmarshal(data, &obsInstance)

          // this will fire off a new event that fir will handle
					c.eventSender <- fir.NewEvent("updated", obsInstance)

				}
			}
		}
	}()

	return c
}

In readings.html — the file that displays the observation cards — I have a few Lit elements (WebComponents) set up to re-display observation data when new events are triggered:

{{ define "content" }}
{{ block "readings-update" . }}
<div x-data @fir:updated:ok::readings-update="$fir.replace()">
  <div class="status-bar">
    <p>📡 {{ .hub }} • 🔋 {{ .batt }} • 🕑 {{ .when }}</p>
  </div>
  <section>
    <div class="flex-container" id="weather-data">
      <tempest-reading id="temp"  title="Temperature" reading='{{ .temp  }}'></tempest-reading>
      <tempest-reading id="humid" title="Humidity"    reading='{{ .humid }}'></tempest-reading>
      <tempest-reading id="press" title="Pressure"    reading='{{ .press }}'></tempest-reading>
      <tempest-reading id="lumos" title="Illuminance" reading='{{ .lumos }}'></tempest-reading>
      <tempest-reading id="insol" title="Insolation"  reading='{{ .insol }}'></tempest-reading>
      <tempest-reading id="ultra" title="U/V Index"   reading='{{ .ultra }}'></tempest-reading>
      <tempest-reading id="wind"  title="Wind Avg."   reading='{{ .wind  }}'></tempest-reading>
      <tempest-reading id="wdir"  title="Whence"      reading='{{ .wdir  }}'></tempest-reading>
    </div>
  </section>
</div>
{{ end }}
{{ end }}

The <div x-data @fir:updated:ok::readings-update="$fir.replace()"> declares a new Alpine component, telling the app that this is for data updates, and tying it to that specific event. When this is triggered, the Fir app will send new HTML over the WebSocket interface to replace what’s there:

func (i *index) updated(ctx fir.RouteContext) error {
	reading := &ObsSt{}

	err := ctx.Bind(reading)
	if err != nil {
		return err
	}

	return ctx.Data(map[string]any{
		"hub":   reading.HubSn,
		"batt":  fmt.Sprintf("%.1f volts", reading.Obs[0][16]),
		"temp":  fmt.Sprintf("%.1f<span style='vertical-align: super; font-size: 9pt;'>°F</span>", reading.Obs[0][7]*1.8+32),
		"humid": fmt.Sprintf("%.1f%%", reading.Obs[0][8]),
		"lumos": Format(int64(reading.Obs[0][9])),
		"press": Format(int64(reading.Obs[0][6])) + "<span style='font-size:9pt'> mb</span>",
		"insol": Format(int64(reading.Obs[0][11])) + "<span style='font-size:9pt'> W/m^2</span>",
		"ultra": Format(int64(reading.Obs[0][10])),
		"wind":  fmt.Sprintf("%.1f<span style='font-size:9pt'> mph</span>", reading.Obs[0][2]*2.236936),
		"wdir":  DegToCompass(reading.Obs[0][4]),
		"when":  time.Now().Format("2006-01-02 15:04:05"),
	})
}

I need to refactor things a bit (there are some dreadful practices in the code), but it’s been working great. As you’ll see in the repo, the idea was to use a ~5-year-old, super cheap Windows tablet as a self-contained weather display for this, and the Go code magically works there, too.

Because I run my own Tailscale tailnet, I can configure one of my web servers to let you “interact” with this live by proxying to it from a public IP!

Hit up https://rud.is/tempestwx to see how cold and dark it is where I live. You will need to be patient. On every new load (or, if you manually refresh) the display will revert the observations to “⌛️” until the next UDP packet is processed (compensating for that is on the TODO).

If you have a lightweight project that either works in this fashion or has some minimal routes and modest display needs, I highly suggest using Fir for it. I’ll dig a bit deeper into it and Alpine.js and likely make a forthcoming WPE centered on them.

FIN

Remember, you can follow and interact with the full text of The Daily Drop’s free posts on Mastodon via @dailydrop.hrbrmstr.dev@dailydrop.hrbrmstr.dev ☮️

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.