Alexander Gromnitsky's Blog

A Minimalistic Weather App

Latest update:

This tweet caught my attention:

Programmer Memes ~
@iammemeloper, 10:39 PM ยท Jul 25, 2023

When you ask backend developer to make frontend

Most comments from the folks I follow were of a "THAT'S ME" nature.

I liked the mockup too, yet I had a couple of questions. Why do we need a button? Presumably, there is no JavaScript on the page at all--it's a classic HTML form. That's fine, there's nothing wrong with that, but what happens when a user wants data for Paris in Illinois? Would the app return a list of links then?

What if we, contrary to the perception of a grumpy 'backend developer' in a vacuum, use JavaScript? Then we can get rid of the button, listen on the 'input' event, & even display popup completions. Can a guy write such an app without using any web frameworks, any web components, or any third-party code & still keep the app tiny?

This is what I came up with (I think it's way too big):

$ wc -lc *js index.html
  24  682 mkdb.js
 121 3566 server.js
  96 3334 web.js
  21  634 index.html
 262 8216 total
$ scc *.js index.html | awk '/Total/ { print $6 }'
227
$ terser web.js -mc | openssl zlib | wc -c
1127

The server part is written in Nodejs with 0 npm dependencies:

$ cat package.json
{"type": "module"}

This is how it looks:

It saves its state in the url search params, thus it's possible to bookmark a location.

Data sources

Where does a guy gets weather data? There are, of course, a multitude of subscription-based options, but for casual use--checking the temperature a couple of times a day--they seem superfluous.

At first, I recalled that >10 years ago I was using a dockapp called wmweather. There is no such program in Fedora repos, but one can fetch it from Debian. It retrieves weather data from noaa.gov, downloading a METAR report for a specific airport, if you feed it with an ICAO location indicator. E.g., for an airport near Kyiv, it's UKBB, so I typed

$ wmweather -s UKBB

and saw strange results (the screenshot is heavily magnified):

It's the 2nd part of summer. Why is it showing 1ยฐC? Looking at the exact URL that wmweather used, it immediately becomes clear why:

$ curl https://tgftp.nws.noaa.gov/data/observations/metar/decoded/UKBB.TXT
Boryspil, Ukraine (UKBB) 50-20N 030-58E 122M
Feb 23, 2022 - 10:30 PM EST / 2022.02.24 0330 UTC
Wind: from the S (190 degrees) at 2 MPH (2 KT):0
Visibility: 3 mile(s):0
Sky conditions: mostly cloudy
Weather: light rain; mist
Temperature: 33 F (1 C)
Dew Point: 33 F (1 C)
Relative Humidity: 100%
Pressure (altimeter): 30.12 in. Hg (1020 hPa)
ob: UKBB 240330Z 19001MPS 5000 -RA BR SCT008 BKN016 01/01 Q1020 TEMPO 1000 BR
cycle: 3

Yeah. Just out of curiosity, I checked some Russian airports, and noaa.gov happily returned fresh, up-to-date data, hinting that everything is going well over there, which made me quite disgusted.

Then I recalled that GNOME has libgweather library. I doubt they pay some company for an API key (otherwise it would've been abused immediately), hence there must be a free solution. There is one: the Norwegian Meteorological Institute.

It works like this:

  • you obtain, by whatever means, a pair of lat/lon coordinates for a location;
  • you ask api.met.no to return a forecast for the lat/lon pair;
  • keep your requests rate <= 20 queries/sec;
  • limit your geolocation coordinates to a max of 4 decimals.

In a more sane world, each country would have a free-for-everyone endpoint to examine weather data in a standardised format for its own locations only. Then it would be possible to maintain a list of such endpoints without relying on an intermediary.

libgweather also conveniently maintains a list of cities with corresponding coordinates. Unfortunately, it's in XML format, which introduces difficulties into our no-3rd-party-code JS game. However, there are other lists of cities on GitHub already available in JSON format. I selected what I thought was one of the most comprehensive ones & augmented it with country flags emojis (maybe it's too comprehensive: e.g., they have "West New York", "East New York", & "New York City" entries).

Server

Do we need a server part? Unfortunately, we do, for api.met.no doesn't allow CORS requests.

We also need geo data. At first, just like any simpleton would've done, I loaded a pre-generated module locations.js, that contained 144,579 entries of city names & their coordinates, into memory. Searching was very fast (duh), but the process was consuming > 300MB of RAM, which is insane.

Then I had 3 choices:

  • use sqlite;
  • write a primitive b-tree backed key-value storage (certanly doable, but it would've been the biggest/ugliest part of the app);
  • put all 144,579 entries into locations.jsonl & do a linear search line-by-line.

Guess what did I choose as the most simple approach.

Nodejs isn't your friend when you search through a 7MB file. Just interating with fs.readLines() (& doing nothing with the data) was consuming ~140ms. For contrast: grepping with 'qwerty*q' (the RE that could not match anything) in the same file took 6ms. Maybe I should run grep for each query--even with the fork overhead, that would be faster that anything I can write in JavaScript. (I didn't do that.)

Then I tried reading the file chunk-by-chunk with fs.readSync(). When doing nothing but looking for the last \n in each chunk to reposition the next fs.readSync() call, the operation took 37ms.

I guess a user can live with that?

The next discovery was that JSON.parse() isn't free, & having locations.txt with the following structure:

$ grep '^.. Paris' _out/locations.txt | head -3
๐Ÿ‡ง๐Ÿ‡ท Parisi; Brazil|-20.2655|-50.0390
๐Ÿ‡จ๐Ÿ‡ฆ Paris; Ontario; Canada|43.2000|-80.3833
๐Ÿ‡ซ๐Ÿ‡ท Paris; Ile-de-France; France|48.8534|2.3486

was noticebly faster to parse. Fruitlessly looping through the "DB" & sending a network reply took ~60ms. Not great, but acceptable.

$ time curl -s 'http://127.0.0.1:8080/api?city=qwerty'
[]
real    0m0.059s
user    0m0.001s
sys     0m0.007s

These numbers, of course, should be multiplied by 5 when using a VPS with a virtual potato instead of a CPU.

Anyway, how would a little server work? It needs to do 3 things:

  1. act as a static server, to return index.html & web.js files;

  2. respond with a list of completions when a user types something, e.g.:

    $ curl -s 'http://127.0.0.1:8080/api?city=paris;' | json
    [
      "๐Ÿ‡จ๐Ÿ‡ฆ Paris; Ontario; Canada",
      "๐Ÿ‡ซ๐Ÿ‡ท Damparis; France",
      "๐Ÿ‡ซ๐Ÿ‡ท Paris; Ile-de-France; France",
      "๐Ÿ‡ต๐Ÿ‡ฆ Paris; Herrera Province; Panama",
      "๐Ÿ‡บ๐Ÿ‡ธ Paris; Arkansas; United States",
      "๐Ÿ‡บ๐Ÿ‡ธ Paris; Idaho; United States",
      "๐Ÿ‡บ๐Ÿ‡ธ Paris; Illinois; United States",
      "๐Ÿ‡บ๐Ÿ‡ธ New Paris; Indiana; United States",
      "๐Ÿ‡บ๐Ÿ‡ธ Paris; Kentucky; United States",
      "๐Ÿ‡บ๐Ÿ‡ธ Paris; Maine; United States"
    ]
    

    I hardcoded max 10 items as a limit.

  3. return weather data for an item from the completion:

    $ curl -s 'http://192.168.197.98:8080/api?l=%F0%9F%87%AB%F0%9F%87%B7+Paris;+Ile-de-France;+France' | json
    {
      "time": "2023-08-02T13:00:00Z",
      "details": {
        "air_pressure_at_sea_level": 995.2,
        "air_temperature": 22.4,
        "cloud_area_fraction": 39.8,
        "relative_humidity": 52.5,
        "wind_from_direction": 237.2,
        "wind_speed": 10.6
      },
      "co": {
        "lat": "48.8534",
        "lon": "2.3486"
      }
    }
    

    This is a small chunk of what api.met.no returns.

The 'router' of the server:

let server = http.createServer( (req, res) => {
  let url; try {
    url = new URL(req.url, `http://${req.headers.host}`)
  } catch {
    return usage(res)
  }

  if (req.method === 'GET' && url.pathname === '/api') {
    let city = url.searchParams.get('city')
    let location = url.searchParams.get('l')

    if (city != null) {
      cities(res, city)
    } else if (location != null) {
      weather(res, location)
    } else
      usage(res)
  } else
    serve_static(res, url.pathname)
})

usage() (that sets 400 status code & ends a connection) & serve_static() are not relevant. cities() returns completions:

function cities(res, query) {
  let r = find(query, 10, (v, q) => {
    v = v.slice(0, v.indexOf('|')) // faster then v.split("|")[0]
    return v.toLowerCase().includes(q) ? v : null
  })

  res.setHeader("Expires", new Date(Date.now() + 300*1000).toUTCString())
  res.end(JSON.stringify(r))
}

find() does the linear search:

function find(q, limit, callback) {
  let r = []
  q = (q || '').trim().toLowerCase(); if (!q) return r

  let buf = Buffer.alloc(1024*50)
  let pos = 0
  while (fs.readSync(lfd, buf, 0, buf.length, pos)) {
    let ln = buf.lastIndexOf(10)
    if (ln < 0) throw new Error("better not happen")
    pos += ln+1

    let lines = buf.subarray(0, ln).toString().split("\n")
    for (let line of lines) {
      let match = callback(line, q)
      if (match) {
        r.push(match)
        limit--
      }
      if (!limit) break
    }
    if (!limit) break
  }

  return r
}

weather() makes a request to api.met.no & reformats the reply:

function weather(res, query) {
  let co = find(query, 1, (v, q) => {
    if (v.slice(0, v.indexOf('|')).toLowerCase() === q) {
      v = v.split("|")
      return {lat: v[1], lon:v[2]}
    }
  })[0]
  if (!co) return err(res, 'invalid location')

  fetch(`https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${co.lat}&lon=${co.lon}`).then( async v => {
    if (!v.ok) throw new Error(v.statusText)
    return { expires: v.headers.get('expires'), json: await v.json() }
  }).then( v => {
    let r = v.json?.properties?.timeseries?.[0]
    if (v.expires) res.setHeader("Expires", v.expires)
    res.end(JSON.stringify({
      time: r?.time,
      details: r?.data?.instant?.details,
      co
    }))
  }).catch( e => err(res, `MET Norway: ${e.message}`, 500))
}

Another blunder: the value for the /api?l= url search parameter must match exactly the entry in the "DB". You can see a problem here when an impatient user decides to type "paris" & press Enter (without waiting for a completion popup) only to see an 'invalid location' error. A more useful app would employ an approximate string matching algorithm.

Folks from MET Norway recommend saving the Last-Modified timestamp from an api.met.no request & including If-Modified-Since when repeating the same query. Our weather() does nothing of the sort, it's as dumb as it gets: we simply copy the value of the Expires header to a reply & hope that a web browser abides by it.

I'm not discussing the serving of static files here, only want to note that in a more complex app, instead of the half-witted serve_static(), it's better to use isaacs/st or cloudhead/node-static or search for a similar npm module that ideally has exactly 1 dependency: broofa/mime (surely, there must be something sleek among 7364 dependants of it).

Client

<p id="form">
  <span>City:</span>
  <input type="search" list="cities" spellcheck="false">
  <datalist id="cities"></datalist>
</p>
<p id="result"></p>

<script type="module" src="web.js"></script>

The main part of the app's UI is a completion widget. HTML provides us with <datalist> element, which practically nobody uses due to its behavior differing enough in various browsers to discard it. A classic vicious circle follows: developers ignore it because it's "unfinished," & web browser vendors don't try to fix subtle bugs because hardly anyone uses it.

This following is a quote from Lea Verou. It's from 2015(!) & everything she was writing about <datalist> back than is relevant & true in 2023:

"the more I played with it [<datalist>], the more my excitement was dying a slow death, taking my open web standards dreams and hopes along with it. Not only itโ€™s incredibly inconsistent across browsers (e.g. Chrome matches only from the start, Firefox anywhere!), itโ€™s also not hackable or customizable in any way. Not even if I got my hands dirty and used proprietary CSS, I still couldnโ€™t do anything as simple as changing how the matching happens, styling the dropdown or highlighting the matching text!"

Still, it's fine for such a simple app. So far, it works ~reliably in

  • Chrome for Desktop
  • Chrome for Android
  • Firefox for Desktop

It's missing on Firefox for Android & it has too many bugs in WebKit.

Chrome for Desktop doesn't render emojis in the completion menu. For Android it draws a horizontally scrollable widget at the bottom a screen, that is small, annoying, & easily missed.

The most bothersome differences between Chrome & Firefox boil down to event handling. While Chrome immediately fires 'change' event for <input> when a user has selected an item, Firefox emits 'insertReplacementText'. Sometimes 'change' is fired even if the user inserted the same completion, &c.

let city = document.querySelector("#form input")
let datalist = document.querySelector("#form datalist")
let result = document.querySelector("#result")

city.addEventListener("input", debounce(function(evt) {
  log(evt.inputType)
  if (evt.inputType === "insertReplacementText") { // firefox
    this.dispatchEvent(new Event('change'))
    datalist.innerHTML = ''
  } else if (!evt.inputType?.match(/^(insertText|delete|insertFromPaste)/)) {
    datalist.innerHTML = ''
  } else {
    completion(this.value).then( v => datalist.innerHTML = v)
      .catch(e => err(this, e))
  }
}, 250))

let prev_change_value
city.addEventListener("change", function() {
  if (prev_change_value === this.value) return
  prev_change_value = this.value
  log("CHANGE")

  result.innerText = "Loading..."
  this.disabled = true
  fetch_json('api?l='+this.value).then( v => {
    // here we render 'v' into a <table>
    // ...

    let params = (new URL(location.href)).searchParams
    params.set('l', this.value)
    history.replaceState({}, '', '?'+params.toString())
  }).catch(e => {
    err(this, e)
  }).finally( () => {
    this.disabled = false
    this.focus()
  })
})

completion() only fetches json & returns new <option> tags:

async function completion(query) {
  return (await fetch_json('api?city='+query)).map( v => {
    return html`<option value="${v}">`
  }).join`\n`
}

What can one do differently?

  1. Use a 3rd-party completion widget instead of <datalist>.

  2. Render a full forecast that api.met.no provides, not just numbers from 1 hour ago.

  3. Instead of a database with 144,579 entries, use a much smaller list that can be loaded into memory. E.g., Locations.xml from libgweather has < 5000 cities, & nobody is complaining how unsatisfactory that is.

  4. Rewrite server.js in C. GNOME libsoup can handle gory HTTP details (+ Gio library underneath automatically creates a thread pool).

  5. Utilize SQLite's FTS.

  6. Do #4 + #5, & charge a $20 monthly subscription for it.

SQLite update

It reduces the worst-case scenario from 60ms to 7ms. Check out sqlite branch branch of the github repo. Using its FTS simplifies queries: api?l=paris+france matches the real EU city. The only downside is completion: while sqlite supports "pari*" queries, & api?city=pari* returns 10 entries, <datalist> won't show them, for, say, "Paringa; Australia" string doesn't contain "pari*" substring.


Tags: ะพะนั‚ั–
Authors: ag