IAM, shortcuts, and hot-reload
After deploying ppl a few weeks ago I've been continuously adding small features. Some of these turned into larger efforts and all of a sudden I had things to write about. Here's a devlog-style post, without any expectation that there will be more coming.
Keyboard Shortcuts
ppl is built on htmx, which has features for triggering events from the keyboard.
This works great for links or buttons, but I couldn't find a way of handling focus.
I wanted jk
for navigating in the list of people, as well as /
for focusing the search field,
and so I had to built something myself.
At first I inserted inline <script>
tags into the maud marco, like so:
input #search type="text" placeholder="search" {}
script { (PreEscaped(r#"
function keydown(evt) {
// ...
}
document.getElementById("search").addEventListener('keydown', keydown);
"#))}
This was fine for focusing the search field, but navigating the search results
got somewhat complicated, and a little too much JS to have inline as a String
.
In addition, I
had some state-related issues when htmx swapped in the script
tags, as well
as with my own hot-reloading system, in which I either ended up with no event
listener, or mutliple copies of the same listener.
I pulled out the logic into its own file shortcut.js
and made the API htmx-like.
Now I annotate the HTML with sx-
attributes instead, and the 300 lines of JS in shortcut.js
handles the rest.
Here's the markup for the search box:
form #search {
label for="search-box" { "Search" }
input #search-box type="search" name="search" placeholder="/ to search"
hx-trigger="keyup changed delay:500ms"
hx-target="#ppl-list"
hx-swap="innerHTML"
hx-post="/search"
sx-focus="/" // <--- here
sx-blur="Escape" // <--- here
autocomplete="off"
{}
}
and here it is for the people list:
html! {
ul #ppl-list
hx-boost="true"
sx-listnext="j or j[ctrl]"
sx-listprev="k or k[ctrl]"
sx-blur="Escape" {
@for persona in &personas {
li {
a href=(format!("/persona/{}", persona.id)) {
(persona.name)
}
}
}
}
}
Annotating a node with sx-focus
registers a keydown
listener that assigns focus to that node when the key is pressed.
sx-blur
works similarly; if the current focus is in the sub-tree and you press the key, .blur
it.
Modifier syntax is [ctrl,alt]
(both ctrl
and alt
), and you can separate key options with " or "
.
I made special events for lists because it was easier to handle the previous and next logic at that node level,
as well as the "no former focus and up means that we should select the last element" logic.
In terms of readability, I don't think I can beat this. In terms of hidden bugs and future potential maintainance cost, there's some. For instance, I'm not sure what happens if you register two nodes with the same shortcut. Maybe they both fire? Probably. So I don't do that.
Authentication
When I first wrote ppl
I used the HTTP basic authentication scheme, which is really simple:
it sends username and password as plain text in a header.
This is probably not Secure with a capital S, but it's trivial to implement,
and it does block out bots and crawlers and the occational human stumbling
around. The good think about it is that your browser will prompt you with a
builtin login form without you having to do anything. Cool! Great for v0.
The main problem was that on Safari mobile I had to log in every time I visited the page. I was hard-coding in credentials straight into my custom axum middleware, but mainly, having to log in again was the catalyst for change.
I'm not very excited by auth, so I wanted to solve this once and hopefully not have to think about it again.
I decided to try to make a more general auth system, which I now call iam
.
iam
uses passkeys for auth using the webauthn-rs crate to do basically all of the heavy lifting.
It is multi-user, and you can have multiple passkeys per user.
Now I have a URL I can redirect to for login, and after successfully authenticating I set a cookie for the whole domain
and redirect back.
There's also an endpoint for the JWKS used for signing the token so that I could easily verify that I didn't mess anything up.
This was actually useful when developing, since I did end up accidentally creating tokens with a different keyset than
the one I tried to verify with.
It also made it possible to use jwt.io to check that I didn't mess anything up.
On the JS side I had to include some of webauthn-json to deal transforming the json
payloads I got from webauth-rs to the APIs in
navigator.credentials
; apparently the spec says to accept Uint8Buffer
s, so one cannot simply pass json
through
from fetch
to the credentials APIs.
There are APIs for doing the conversion, but they are not yet supported in Safari on iOS.
The very browser I was wrangling.
This was a source to much confusion as I accidentally double-base64-encoded strings and tried to authenticate
with kid
s that were either base64-encoded or -decoded one too many times.
Hot-reloading
ppl
was the second axum-based web server I made that I wanted hot-reloading
of static assets (css
and any js
files) for, and iam
was the third. Time
to finally split out my bespoke hot-reloading system into a crate to avoid
copying the same files over and over, and not backport any fixes that i do.
It's very simple: use notify to listen to all changes in a directory, send messages over websockets when they change,
and have the receipient swap out the <script>
or <link>
node in <head>
and insert a new one with a dummy query parameter in the href
.
Finally, have a convention of having JS modules define a __cleanup
function that is ran before it is removed, to remove any DOM state.
For CSS, if you set a timeout before removing the old node you avoid a white-flash while the new style sheet is being fetched.
Usage code in ppl
is now pretty simple. First, conditionally insert <script>
to handle the client-end of the WS connection (maud#446):
head {
// ...
@if let Some(n) = hot_reload::script() { (n) }
}
add the server side of the WS connection:
.nest_service("/dev-hr", hot_reload::router("static"))
and strip it all away when compiling with --release
.
#[cfg(not(debug_assertions))]
mod hot_reload {
use axum::Router;
use maud::Markup;
pub fn router(_: &str) -> Router<()> {
Router::new()
}
pub fn script() -> Option<Markup> {
None
}
}
I also tend to use cargo-watch (which apparently is on life support),
so to avoid triggering a recompile when an asset change I use -i
to ignore.
My justfile recipe looks like:
dev:
cargo watch -i 'static/*' -x 'run'
Operations
Two services is a crowd, and so now they both live in the same git repo, they are both built using Docker,
and I have a docker-compose
to deploy them both.
Getting this set up properly was a bit of work since sqlx
doesn't work all that well in a Cargo workspace, and especially with multiple different databases.
I've ended up kinda making everything work with some combination of
SQLX_OFFLINE=true
SQLX_OFFLINE_DIR=$PWD/.sqlx
in .env
, opening each crate separately in zed
, and not changing anything while things still kinda work.
Kinda, because at some point rust-analyzer
either couldn't find any tables in the database.
In addition, tower-sessions-sqlx-store
, which iam
uses, depends on sqlx
with the time
feature, so now
all queries that deals with dates and times default to time
types, whereas I'm using chrono
.
This is sqlx#3412 and
tower-sessions-stores#42.
My workaround is to explicitly name types in queries:
let q = sqlx::query!(
r#"select name,
birthdate as "birthdate: NaiveDate",
Ugly, but not too bad.
That's it for now. Next up, I'd like to
- Set up automatic backup of all databases
- Create an RSS service so that I can get off r2e
- Create a frontend for my local-gym-scraper
- Figure out how to write nicer look handlers with reasonable error handling
- Pull out shared CSS to make creating new things that look okay even easier
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License