- Rust 62%
- JavaScript 19.3%
- CSS 8.4%
- Nix 5.6%
- HTML 4%
- Other 0.7%
Rust server (axum) polls the Acartia open data cooperative every 5 minutes, filters new+fresh sightings against user-drawn circle areas, and sends web push (VAPID, pure-Rust crypto via web-push-native) to an installable PWA — vanilla JS + vendored Leaflet, no build step. Ships a nix flake with devShell, package, and a hardened NixOS module (services.whale), plus a Dockerfile/fly.toml alternative. Tested against a committed snapshot of the real feed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HN9HFmh9iKxeNEnnWbpcbY |
||
|---|---|---|
| src | ||
| static | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| fly.toml | ||
| README.md | ||
whale 🐋
Shore-based whale sighting alerts for the Salish Sea. A Rust server polls the Acartia open data cooperative every 5 minutes, checks new sightings against alert areas we draw on a map, and sends web push notifications to an installable PWA — usually within minutes of a whale being reported. Walk outside, look at the water.
How it works
phone (installed PWA: map, areas, push) ⇄ Rust server (axum)
• polls acartia.io /sightings/current
• pure decision core: new? fresh? inside an area?
• web push (VAPID, pure-Rust crypto)
• one small JSON state file
Notifications fire only for sightings that are new (never seen before),
fresh (reported within FRESH_MINUTES, default 120 — the feed has a long
tail of hours-late batch uploads), and inside an enabled area (circle;
optional species and trust filters). More than 3 alerts in one cycle coalesce
into a summary; at most 10 pushes per hour.
Development
nix develop # rustc, cargo, clippy, rust-analyzer, flyctl
cargo test # parsing quirks, decision rules, store — all offline fixtures
cargo run -- gen-vapid # one-time keypair for push
env $(cargo run -q -- gen-vapid) PORT=8090 cargo run
Without WHALE_TOKEN auth is disabled (fine on localhost). Web push works on
http://localhost in Chromium — enable notifications in settings, then:
curl -X POST localhost:8090/api/inject -H 'content-type: application/json' \
-d '{"lat":48.43,"lon":-123.30,"species":"Orca","count":3}'
/api/inject fabricates a fresh sighting through the full pipeline (it stays
in the deployed build on purpose — it's the end-to-end test tool).
Deploy (NixOS)
The flake exports nixosModules.default with a services.whale module
(systemd unit, DynamicUser, state in /var/lib/whale, hardened). In the
consuming flake:
inputs.whale = {
url = "git+https://forgejo.ellie.town/wizzy/whale.git";
inputs.nixpkgs.follows = "nixpkgs";
};
{ config, whale, ... }: {
imports = [ whale.nixosModules.default ];
services.whale = {
enable = true;
port = 8090;
# WHALE_TOKEN, VAPID_PRIVATE_KEY, optional VAPID_SUBJECT — e.g. a sops template
environmentFile = config.sops.templates."whale.env".path;
};
}
Put an HTTPS reverse proxy in front (service workers and web push require a
secure context). Binding defaults to 0.0.0.0; isolate with the firewall
rather than binding a WireGuard address that races interface bring-up.
Deploy (Fly.io, alternative)
fly launch --copy-config --no-deploy # pick a unique app name
fly volumes create whale_data --region sea --size 1
cargo run -- gen-vapid # then paste into:
fly secrets set WHALE_TOKEN=$(openssl rand -hex 24) \
VAPID_PUBLIC_KEY=... VAPID_PRIVATE_KEY=... \
VAPID_SUBJECT=mailto:you@example.com
fly deploy
auto_stop_machines = "off" in fly.toml is load-bearing — the poller dies if
Fly stops the machine. Expect a few dollars a month; note Fly has no spending
cap.
Phone setup
Open https://<app>.fly.dev/#t=<WHALE_TOKEN> once per device (the fragment
becomes a year-long cookie and never appears in server logs). Install the PWA —
Android offers it; on iPhone use Share → Add to Home Screen (iOS only
allows push for installed PWAs). Launch from the home screen, enable
notifications in settings, send the test push, then draw an alert area.
Environment
| var | default | |
|---|---|---|
PORT |
8080 |
|
DATA_DIR |
data |
state.json lives here (the Fly volume) |
STATIC_DIR |
static |
the PWA |
POLL_INTERVAL_SECS |
300 |
|
FRESH_MINUTES |
120 |
max sighting age that can still push |
ACARTIA_URL |
acartia.io | point at a local file server for offline dev |
WHALE_TOKEN |
(unset = auth off) | magic-link shared secret |
VAPID_PRIVATE_KEY |
(unset = push off) | from gen-vapid, base64url |
VAPID_SUBJECT |
mailto:whale@example.com |
contact claim push services see |