2 Commits
Author SHA1 Message Date
sbstp f24d8aa062 vendor assets with versionning
ci/woodpecker/tag/release Pipeline was successful
2026-08-08 12:21:33 -04:00
sbstp 5233b1e6fd real-time list counts 2026-08-08 11:36:28 -04:00
7 changed files with 137 additions and 23 deletions
+13
View File
@@ -20,12 +20,25 @@ test("a list updates live for another user via websocket", async ({ page, browse
// Give the websocket connections a moment to establish. // Give the websocket connections a moment to establish.
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Both start with an empty list.
await expect(pageB.locator("#list-meta")).toHaveText("0 items left out of 0");
// User A adds an item. // User A adds an item.
await addItem(page, "Apple", "2"); await addItem(page, "Apple", "2");
// It should appear on User B's page without any reload. // It should appear on User B's page without any reload.
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" })).toBeVisible(); await expect(pageB.locator(".item-row").filter({ hasText: "Apple" })).toBeVisible();
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" }).locator(".item-qty")).toHaveText("(2)"); await expect(pageB.locator(".item-row").filter({ hasText: "Apple" }).locator(".item-qty")).toHaveText("(2)");
// The left/total count should also update live for User B.
await expect(pageB.locator("#list-meta")).toHaveText("1 items left out of 1");
// User A removes the item.
await page.locator(".item-actions-button").first().click();
await page.locator("[id^='item-edit-delete']").click();
// The item and the count should update live on User B's page.
await expect(pageB.locator(".item-row").filter({ hasText: "Apple" })).toHaveCount(0);
await expect(pageB.locator("#list-meta")).toHaveText("0 items left out of 0");
await contextB.close(); await contextB.close();
}); });
+79
View File
@@ -0,0 +1,79 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use sha2::{Digest, Sha256};
/// A single static asset: its embedded bytes and a content hash used to
/// version its URL.
pub struct StaticAsset {
pub data: &'static [u8],
pub hash: String,
}
/// A registry of the app's static assets, keyed by filename. Each asset's
/// content hash is computed once on first use and cached, so the versioned URL
/// changes automatically whenever the underlying file changes.
pub struct StaticAssetStore {
assets: HashMap<&'static str, StaticAsset>,
}
impl StaticAssetStore {
fn new(entries: &[(&'static str, &'static [u8])]) -> Self {
let assets = entries
.iter()
.map(|(name, data)| {
let hash = content_hash(data);
(*name, StaticAsset { data, hash })
})
.collect();
Self { assets }
}
/// Looks up an asset by its filename (e.g. `"style.css"`).
pub fn get(&self, name: &str) -> Option<&StaticAsset> {
self.assets.get(name)
}
/// Returns the versioned URL for an asset, e.g. `/static/style.css?v=<hash>`.
pub fn url(&self, name: &str) -> Option<String> {
self.assets
.get(name)
.map(|asset| format!("/static/{name}?v={}", asset.hash))
}
}
fn content_hash(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
/// Constructs a `StaticAssetStore` from `name => path` pairs, embedding each
/// file's bytes at compile time via `include_bytes!`.
macro_rules! static_assets {
($($name:literal => $path:literal),* $(,)?) => {
StaticAssetStore::new(&[
$(($name, include_bytes!($path))),*
])
};
}
/// The app's static assets, loaded once on first use.
pub static STORE: LazyLock<StaticAssetStore> = LazyLock::new(|| {
static_assets! {
"style.css" => "../static/style.css",
"passkey-login.js" => "../static/passkey-login.js",
"passkey-register.js" => "../static/passkey-register.js",
"password-toggle.js" => "../static/password-toggle.js",
"htmx.min.js" => "../static/htmx.min.js",
"htmx-ws.min.js" => "../static/htmx-ws.min.js",
}
});
/// Returns the versioned URL for a known asset, panicking if the name isn't
/// registered (a programmer error, since these are compile-time constants).
pub fn url(name: &str) -> String {
STORE
.url(name)
.unwrap_or_else(|| panic!("unknown static asset: {name}"))
}
+17 -14
View File
@@ -20,19 +20,13 @@ use thiserror::Error;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, error, warn}; use tracing::{Level, error, warn};
use crate::assets;
use crate::domain::{DomainError, SessionUser}; use crate::domain::{DomainError, SessionUser};
use crate::ports::{HubEvent, RealtimeNotifier}; use crate::ports::{HubEvent, RealtimeNotifier};
use crate::services::{AuthService, InvitationService, ListService, MealService}; use crate::services::{AuthService, InvitationService, ListService, MealService};
use crate::views; use crate::views;
use crate::webauthn::WebAuthnService; use crate::webauthn::WebAuthnService;
/// The contents of `static/`, embedded into the binary at compile time so the
/// app can be shipped as a single executable without a separate static directory.
const STYLE_CSS: &[u8] = include_bytes!("../static/style.css");
const PASSKEY_LOGIN_JS: &[u8] = include_bytes!("../static/passkey-login.js");
const PASSKEY_REGISTER_JS: &[u8] = include_bytes!("../static/passkey-register.js");
const PASSWORD_TOGGLE_JS: &[u8] = include_bytes!("../static/password-toggle.js");
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub auth: Arc<AuthService>, pub auth: Arc<AuthService>,
@@ -340,15 +334,24 @@ async fn home() -> Redirect {
} }
/// Serves a file embedded in the binary from the `static/` folder. /// Serves a file embedded in the binary from the `static/` folder.
///
/// Every asset is served with an immutable, long-lived cache header. Because
/// the HTML references each asset under a URL that is versioned by its content
/// hash, a changed file gets a new URL and the cache is never stale.
async fn static_asset(Path(path): Path<String>) -> Response { async fn static_asset(Path(path): Path<String>) -> Response {
let (mime, data) = match path.as_str() { let Some(asset) = assets::STORE.get(&path) else {
"style.css" => ("text/css", STYLE_CSS), return StatusCode::NOT_FOUND.into_response();
"passkey-login.js" => ("application/javascript", PASSKEY_LOGIN_JS),
"passkey-register.js" => ("application/javascript", PASSKEY_REGISTER_JS),
"password-toggle.js" => ("application/javascript", PASSWORD_TOGGLE_JS),
_ => return StatusCode::NOT_FOUND.into_response(),
}; };
([(header::CONTENT_TYPE, mime)], data).into_response() let mime = match path.as_str() {
"style.css" => "text/css",
_ => "application/javascript",
};
let mut response = ([(header::CONTENT_TYPE, mime)], asset.data).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
response
} }
async fn log_response_status(request: Request, next: Next) -> Response { async fn log_response_status(request: Request, next: Next) -> Response {
+1
View File
@@ -1,3 +1,4 @@
mod assets;
mod domain; mod domain;
mod http; mod http;
mod hub; mod hub;
+25 -9
View File
@@ -37,8 +37,8 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" } button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" }
p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } } p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } }
} }
script src="/static/password-toggle.js" {} script src=(crate::assets::url("password-toggle.js")) {}
script src="/static/passkey-login.js" {} script src=(crate::assets::url("passkey-login.js")) {}
}, },
) )
} }
@@ -72,7 +72,7 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup {
} }
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } } p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
} }
script src="/static/password-toggle.js" {} script src=(crate::assets::url("password-toggle.js")) {}
}, },
) )
} }
@@ -164,8 +164,8 @@ pub fn account_page(
} }
} }
} }
script src="/static/password-toggle.js" {} script src=(crate::assets::url("password-toggle.js")) {}
script src="/static/passkey-register.js" {} script src=(crate::assets::url("passkey-register.js")) {}
}, },
) )
} }
@@ -795,7 +795,7 @@ pub fn list_page(
div { div {
p class="eyebrow" { "SHARED LIST" } p class="eyebrow" { "SHARED LIST" }
h1 { (list.name) } h1 { (list.name) }
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" } (list_meta_fragment(items, false))
} }
div class="list-topbar-actions" { div class="list-topbar-actions" {
@if list.archived_at.is_some() { @if list.archived_at.is_some() {
@@ -1095,6 +1095,21 @@ pub fn categories_panel(categories: &[Category], csrf_token: &str, out_of_band:
} }
} }
fn list_meta_fragment(items: &[Item], out_of_band: bool) -> Markup {
let left = items.iter().filter(|item| !item.checked).count();
let total = items.len();
let meta = html! {
p id="list-meta" class="list-meta" { (left) " items left out of " (total) }
};
if out_of_band {
html! {
p id="list-meta" class="list-meta" hx-swap-oob="outerHTML" { (left) " items left out of " (total) }
}
} else {
meta
}
}
pub fn live_list_fragments( pub fn live_list_fragments(
list: &GroceryList, list: &GroceryList,
items: &[Item], items: &[Item],
@@ -1104,6 +1119,7 @@ pub fn live_list_fragments(
) -> Markup { ) -> Markup {
let editable = list.archived_at.is_none(); let editable = list.archived_at.is_none();
html! { html! {
(list_meta_fragment(items, true))
(list_items_fragment(list, items, categories, csrf_token, true, editable)) (list_items_fragment(list, items, categories, csrf_token, true, editable))
(list_meals_panel(list_meals, list.id, csrf_token, true, editable)) (list_meals_panel(list_meals, list.id, csrf_token, true, editable))
} }
@@ -1292,9 +1308,9 @@ fn page(title: &str, user: Option<&User>, content: Markup) -> Markup {
meta charset="utf-8"; meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1"; meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) " · Sustenance" } title { (title) " · Sustenance" }
link rel="stylesheet" href="/static/style.css"; link rel="stylesheet" href=(crate::assets::url("style.css"));
script src="https://unpkg.com/htmx.org@2.0.10" {} script src=(crate::assets::url("htmx.min.js")) {}
script src="https://unpkg.com/htmx-ext-ws@2.0.4/ws.js" {} script src=(crate::assets::url("htmx-ws.min.js")) {}
} }
body hx-boost="true" { body hx-boost="true" {
header class="site-header" { header class="site-header" {
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long