heartwood every commit a ring

Add stable /static/collector.js route and refresh home/docs copy

4a3d2238 by Isaac Bythewood · 4 days ago

Add stable /static/collector.js route and refresh home/docs copy

Vite content-hashes the collector entry, so the path embedded in
existing site tags (analytics property modal, blog, isaacbythewood)
stopped resolving after the rewrite. Add a route that resolves the
manifest at request time and serves the bundle inline with a 5-min
cache TTL, then point the Proprium self-tracking snippet at the same
stable URL. Also strip lingering Django/MaxMind/US-state/JSON-first
references from the home and documentation pages so they describe
the current Rust + typed-schema architecture.
modified src/main.rs
@@ -201,6 +201,9 @@ async fn main() -> anyhow::Result<()> {        .route("/collect", post(collector::collect).options(collector::options))        .route("/collect/", post(collector::collect).options(collector::options))        .layer(collector_cors)        // Stable URL for the embed snippet. Resolves the hashed Vite output        // at request time so consumers can hardcode this path forever.        .route("/static/collector.js", get(pages::collector_alias))        // Property dashboard uses a UUID path segment. Keep it last so the named        // routes above take precedence.        .route("/{property_id}", get(views::property))
modified src/pages.rs
@@ -114,6 +114,56 @@ pub async fn documentation(State(state): State<AppState>, cookies: Cookies) -> R    )}// Stable URL for the collector embed script. Vite content-hashes the// `collector` entry, but every embed snippet in the wild (and the one shown// to users in the dashboard) hardcodes `/static/collector.js`. This handler// reads the Vite manifest, resolves the hashed asset, and serves it with a// short cache TTL so updates propagate without forcing consumers to re-embed.pub async fn collector_alias(State(state): State<AppState>) -> Response {    let dist_dir = state.config.root.join("dist");    let manifest_path = dist_dir.join(".vite/manifest.json");    let manifest_text = match std::fs::read_to_string(&manifest_path) {        Ok(t) => t,        Err(e) => {            tracing::error!("collector manifest read: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let manifest: serde_json::Value = match serde_json::from_str(&manifest_text) {        Ok(v) => v,        Err(e) => {            tracing::error!("collector manifest parse: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let rel = manifest        .get("static_src/collector/index.js")        .and_then(|c| c.get("file"))        .and_then(|v| v.as_str());    let Some(rel) = rel else {        tracing::error!("collector entry missing from manifest");        return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();    };    let asset_path = dist_dir.join(rel);    let bytes = match std::fs::read(&asset_path) {        Ok(b) => b,        Err(e) => {            tracing::error!("collector read {asset_path:?}: {e}");            return (StatusCode::SERVICE_UNAVAILABLE, "collector unavailable").into_response();        }    };    let mut h = HeaderMap::new();    h.insert(        header::CONTENT_TYPE,        "application/javascript; charset=utf-8".parse().unwrap(),    );    h.insert(        header::CACHE_CONTROL,        "public, max-age=300, must-revalidate".parse().unwrap(),    );    (StatusCode::OK, h, bytes).into_response()}pub async fn favicon() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());
modified templates/includes/collector.html
@@ -3,7 +3,7 @@  (function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;  m.collectorServer = c; m.collectorId = s; var script = e.createElement(t);  script.src = c + i; e.head.appendChild(script);  })(window,document,'script',[],'{{ vite_asset("static_src/collector/index.js") }}',  })(window,document,'script',[],'/static/collector.js',  '{{ collector_server }}','{{ collector_id }}');</script>{% endif %}
modified templates/pages/documentation.html
@@ -79,9 +79,10 @@          </h2>          <div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree" data-bs-parent="#docsAccordion">            <div class="accordion-body">              <p>Every event is stored as JSON in a <code>data</code> field.                Add whatever keys you need — no migration required, query with                Django ORM <code>data__user_segment</code> lookups later.</p>              <p>Every event is stored as JSON in an <code>extra</code>                column. Add whatever keys you need, no migration required,                query with SQLite JSON1                <code>json_extract(extra, '$.user_segment')</code> later.</p>              <textarea class="codebox" rows="3" readonly><script>collectorQueue.push({event: 'admin_page_view', data: {user_segment: 'admins'}});</script></textarea>            </div>          </div>
modified templates/pages/home.html
@@ -124,9 +124,9 @@        <div class="feature-label">geo + device</div>        <div class="feature-title">Maps, browsers, platforms</div>        <p class="feature-desc">          A US state choropleth, plus doughnut breakdowns for device,          browser, platform, and screen size. GeoIP runs against a local          MaxMind DB — IPs never leave your host.          A Natural Earth world map with click-through admin-1 drill-down,          plus doughnut breakdowns for device, browser, platform, and          screen size. GeoIP runs locally, IPs never leave your host.        </p>      </div>    </div>
@@ -144,22 +144,25 @@    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">schema</div>        <div class="feature-title">JSON-first event store</div>        <div class="feature-title">Typed schema, JSON extras</div>        <p class="feature-desc">          Every event lands in a single JSONField. New attributes need no          migrations — send them from the client and query them with Django          ORM lookups. Bot traffic is filtered before it ever hits the DB.          Hot fields (url, referrer, geo, UA, UTMs) live in typed columns          for fast queries. Anything custom lands in an <code>extra</code>          JSON column, no migration required, queryable with SQLite JSON1          (json_extract). Bot traffic is routed to a separate table so          human dashboards never have to filter it.        </p>      </div>    </div>    <div class="col-12 col-md-6 col-lg-4">      <div class="feature">        <div class="feature-label">ownership</div>        <div class="feature-title">BSD, single container</div>        <div class="feature-label">stack</div>        <div class="feature-title">Built in Rust, ultralight</div>        <p class="feature-desc">          Django + SQLite + one collector endpoint. Ships as a Docker image          behind your own reverse proxy. Your domain, your retention policy,          your compliance story. No vendor, no pixel, no lock-in.          A single statically linked Rust binary plus SQLite. Ultra          performant and very resource friendly, the whole stack runs as a          tiny Docker image behind your own reverse proxy. Your domain,          your retention policy. No vendor, no pixel, no lock-in.        </p>      </div>    </div>