diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c0518..beff1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,28 @@ so this log can be regenerated from history (e.g. with `git-cliff`). rather than set directly, so existing `[data-theme="dark"]` overrides keep working. JetBrains Mono, which code blocks have asked for since the start, is now actually bundled. +- **Opening a document downloads less and waits less.** The SPA's hashed + bundles are brotli-compressed (the 749 KB editor chunk goes over the wire as + 241 KB) and cached as `immutable`; `index.html` is `no-cache`, so a deploy is + picked up on the next load. The editor chunk now downloads alongside the + document's metadata instead of after it. An image or attachment carries its + sha256 as `ETag`, so a revalidation after the one-minute `max-age` is a 304 + answered without reading the bytes. +- **Two new histograms time opening a document:** + `knot_collab_initial_sync_seconds` (socket upgraded → document state queued + to the client) and `knot_room_hydrate_seconds` (loading a room that was not + in memory). The HTTP histogram stops at the WebSocket upgrade, so neither + was visible before. ### Fixed +- **Every latency panel, SLO query and the latency alert showed nothing.** The + Prometheus exporter was installed without buckets, so it exported every + histogram as a summary: `knot_http_request_duration_seconds_bucket` never + existed for the dashboard's `histogram_quantile` to read. Histograms now + export buckets whose edges include each SLO threshold. +- **A deep link such as `/docs/` answered 404** (with the app's HTML, so + it still rendered). Client-side routes now answer 200, and a missing + `/assets/*` chunk is a plain 404 instead of `index.html` served as + JavaScript. - **A document title longer than about fifteen characters was cut off behind the tool icons.** On the desktop column the title shared its line with the header's action row, and the row's ~490px left the title only 208px of the diff --git a/Cargo.lock b/Cargo.lock index 75aa7ff..243081a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2060,6 +2060,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "brotli", "chrono", "clap", "cookie", @@ -2088,6 +2089,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", + "tempfile", "thiserror 2.0.20", "tokio", "tokio-tungstenite 0.30.0", diff --git a/crates/knot-crdt/src/room.rs b/crates/knot-crdt/src/room.rs index aaf974b..cb80721 100644 --- a/crates/knot-crdt/src/room.rs +++ b/crates/knot-crdt/src/room.rs @@ -132,6 +132,7 @@ impl Room { dirty_tx: Option>, ) -> Result { // Hydrate the doc. + let hydrate_started = std::time::Instant::now(); let doc = engine.new_doc(); let mut last_applied_seq: i64 = 0; if let Ok(Some(snap)) = snapshots.latest(doc_id).await { @@ -146,6 +147,8 @@ impl Room { } } } + metrics::histogram!("knot_room_hydrate_seconds") + .record(hydrate_started.elapsed().as_secs_f64()); // Spawn the actor with the hydrated doc + watermark. let (tx, rx) = mpsc::channel::(256); diff --git a/crates/knot-obs/src/metrics.rs b/crates/knot-obs/src/metrics.rs index ab587eb..a4f9b53 100644 --- a/crates/knot-obs/src/metrics.rs +++ b/crates/knot-obs/src/metrics.rs @@ -13,6 +13,13 @@ pub enum MetricsError { Install(String), } +/// Shared by every histogram (all of them are latencies in seconds). The +/// edges include each SLO threshold in docs/SLO.md so a target reads off a +/// bucket boundary instead of an interpolation. +const LATENCY_BUCKETS_SECONDS: [f64; 13] = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 30.0, +]; + /// Install the global metrics recorder and start the HTTP exporter /// on `addr` (e.g. ":9090" or "0.0.0.0:9090"). pub fn init(addr: &str) -> Result<(), MetricsError> { @@ -20,8 +27,13 @@ pub fn init(addr: &str) -> Result<(), MetricsError> { .parse() .map_err(|e| MetricsError::Address(format!("{addr}: {e}")))?; + // Without buckets the exporter renders every histogram as a summary, and + // the dashboard, SLO queries and alert rule (`histogram_quantile` over + // `_bucket`) all come back empty. PrometheusBuilder::new() .with_http_listener(sa) + .set_buckets(&LATENCY_BUCKETS_SECONDS) + .map_err(|e| MetricsError::Install(e.to_string()))? .install() .map_err(|e| MetricsError::Install(e.to_string()))?; @@ -47,6 +59,16 @@ pub fn init(addr: &str) -> Result<(), MetricsError> { "CRDT updates applied to rooms, by source (local|peer)" ); describe_counter!("knot_room_snapshots_total", "Snapshots written to storage"); + describe_histogram!( + "knot_room_hydrate_seconds", + Unit::Seconds, + "Loading a cold room: latest snapshot + replay of later updates" + ); + describe_histogram!( + "knot_collab_initial_sync_seconds", + Unit::Seconds, + "Collab socket upgraded -> initial document state queued to the client" + ); // Storage / pool describe_gauge!("knot_db_pool_size", "Total connections in the pool"); diff --git a/crates/knot-server/Cargo.toml b/crates/knot-server/Cargo.toml index aaf6e4c..1c2c613 100644 --- a/crates/knot-server/Cargo.toml +++ b/crates/knot-server/Cargo.toml @@ -79,4 +79,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"] } knot-auth = { path = "../knot-auth", features = ["fast-params"] } http-body-util = "0.1" +tempfile = "3" +brotli = "8" knot-test-support = { path = "../knot-test-support" } diff --git a/crates/knot-server/src/lib.rs b/crates/knot-server/src/lib.rs index 37eeb54..1aa33d6 100644 --- a/crates/knot-server/src/lib.rs +++ b/crates/knot-server/src/lib.rs @@ -17,7 +17,6 @@ use knot_storage::{ PgShareTokenStore, PgUserStore, PgWorkspaceStore, Pool, SearchStore, SessionStore, ShareTokenStore, UserStore, WorkspaceStore, }; -use tower_http::services::{ServeDir, ServeFile}; use uuid::Uuid; /// Max inbound collab/board WS message. CRDT updates and board ops are far @@ -35,6 +34,7 @@ pub mod reindex; pub mod room; pub mod routes; pub mod security_headers; +pub mod static_files; use auth::SessionDeps; @@ -184,10 +184,6 @@ pub fn router() -> Router { pub fn router_with_state(state: AppState) -> Router { let web_dist = std::env::var("KNOT_WEB_DIST").unwrap_or_else(|_| "/web/dist".into()); - let index_path = format!("{web_dist}/index.html"); - let spa = ServeDir::new(&web_dist) - .append_index_html_on_directories(true) - .not_found_service(ServeFile::new(&index_path)); // WS routes: NO timeout / body-limit (long-lived, streamed). let collab = Router::new() @@ -200,7 +196,7 @@ pub fn router_with_state(state: AppState) -> Router { .merge(routes::auth::router()) .merge(routes::public::router()) .merge(routes::api::router(state.clone())) - .fallback_service(spa) + .merge(static_files::router(&web_dist)) .layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024)) .layer(tower_http::timeout::TimeoutLayer::with_status_code( axum::http::StatusCode::REQUEST_TIMEOUT, diff --git a/crates/knot-server/src/room.rs b/crates/knot-server/src/room.rs index 47634aa..3f264f7 100644 --- a/crates/knot-server/src/room.rs +++ b/crates/knot-server/src/room.rs @@ -23,6 +23,7 @@ pub async fn serve( can_write: bool, shutdown: CancellationToken, ) { + let join_started = std::time::Instant::now(); let handle = match rooms.acquire(doc_id).await { Ok(h) => h, Err(e) => { @@ -52,6 +53,11 @@ pub async fn serve( _ => return, }; let _ = out_tx.send(initial).await; + // Server-side share of "open a doc, wait for content": includes the + // hydrate of a cold room (see knot_room_hydrate_seconds) and the wait for + // the room actor's reply. + metrics::histogram!("knot_collab_initial_sync_seconds") + .record(join_started.elapsed().as_secs_f64()); let (mut sink, mut stream) = socket.split(); let writer_shutdown = shutdown.clone(); diff --git a/crates/knot-server/src/routes/api/blobs.rs b/crates/knot-server/src/routes/api/blobs.rs index 54cd9b4..9cdb81c 100644 --- a/crates/knot-server/src/routes/api/blobs.rs +++ b/crates/knot-server/src/routes/api/blobs.rs @@ -205,6 +205,26 @@ async fn download(State(state): State, Path(id): Path, req: Requ Err(_) => return internal(), } + // A blob id never changes content, so its sha256 is a strong ETag. The + // short max-age keeps an ACL revoke effective within a minute; after that + // the browser revalidates, and a match is answered from metadata alone, + // skipping the byte read. Only after the ACL check above. + let etag = format!( + "\"{}\"", + meta.sha256 + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + if if_none_match_hits(req.headers(), &etag) { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .header(header::CACHE_CONTROL, "private, max-age=60") + .body(Body::empty()) + .unwrap(); + } + let bytes = match store.get(id).await { Ok(b) => b, Err(knot_storage::BlobStoreError::NotFound) => { @@ -223,11 +243,24 @@ async fn download(State(state): State, Path(id): Path, req: Requ .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") .header(header::CONTENT_DISPOSITION, disposition) .header(header::CACHE_CONTROL, "private, max-age=60") + .header(header::ETAG, &etag) .header(header::CONTENT_LENGTH, meta.byte_size) .body(Body::from(bytes)) .unwrap() } +/// RFC 9110 §13.1.2: `If-None-Match` uses weak comparison, so a `W/` prefix +/// on the client's tag still matches, and `*` matches any existing blob. +fn if_none_match_hits(headers: &axum::http::HeaderMap, etag: &str) -> bool { + headers + .get_all(header::IF_NONE_MATCH) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|candidate| candidate == "*" || candidate.trim_start_matches("W/") == etag) +} + async fn delete_blob( State(state): State, Path(id): Path, diff --git a/crates/knot-server/src/static_files.rs b/crates/knot-server/src/static_files.rs new file mode 100644 index 0000000..5571c74 --- /dev/null +++ b/crates/knot-server/src/static_files.rs @@ -0,0 +1,56 @@ +//! Serves the built SPA (`web/dist`). +//! +//! Vite content-hashes every file under `/assets`, so an asset URL never +//! changes content: those are cached for a year, and a missing one is a real +//! 404 rather than `index.html` served as JavaScript. Everything else — +//! `index.html` for client-side routes, favicons, the manifest — is `no-cache` +//! so a deploy is picked up on the next load instead of a heuristically cached +//! shell pointing at chunks that no longer exist. +//! +//! Compression is applied here and not to the API: these bodies are public, +//! so compressing them cannot leak a secret the way BREACH exploits. + +use axum::Router; +use axum::http::{HeaderValue, Response, StatusCode, header}; +use tower::ServiceBuilder; +use tower_http::compression::CompressionLayer; +use tower_http::services::{ServeDir, ServeFile}; + +pub fn router(web_dist: &str) -> Router +where + S: Clone + Send + Sync + 'static, +{ + let assets = ServiceBuilder::new() + .map_response(cache_forever_when_found) + .service(ServeDir::new(format!("{web_dist}/assets"))); + // `fallback` (not `not_found_service`) so client-side routes answer 200. + let pages = ServiceBuilder::new() + .map_response(always_revalidate) + .service( + ServeDir::new(web_dist) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(format!("{web_dist}/index.html"))), + ); + Router::new() + .nest_service("/assets", assets) + .fallback_service(pages) + .layer(CompressionLayer::new()) +} + +fn cache_forever_when_found(mut response: Response) -> Response { + let status = response.status(); + if status.is_success() || status == StatusCode::NOT_MODIFIED { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + } + response +} + +fn always_revalidate(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response +} diff --git a/crates/knot-server/tests/blobs_integration.rs b/crates/knot-server/tests/blobs_integration.rs index 555c04f..5e67a00 100644 --- a/crates/knot-server/tests/blobs_integration.rs +++ b/crates/knot-server/tests/blobs_integration.rs @@ -309,3 +309,185 @@ async fn empty_file_is_400() { let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(v["error"]["code"], "blob.empty"); } + +// --------------------------------------------------------------------------- +// Conditional GET: a blob id never changes content, so its sha256 is a strong +// ETag and a revalidation can be answered without reading the bytes. +// --------------------------------------------------------------------------- + +/// sha256(TINY_PNG), computed independently (python hashlib). +const TINY_PNG_ETAG: &str = "\"90c86432df503e9bea47ad2d4989a0b5c5911eb60e01fb1baf78c17151cce11f\""; + +/// Wraps the real store and counts byte reads, so a test can prove a 304 +/// was answered from metadata alone. +struct CountingStore { + inner: Arc, + gets: std::sync::atomic::AtomicUsize, +} + +#[async_trait::async_trait] +impl knot_storage::BlobStore for CountingStore { + async fn put( + &self, + id: Uuid, + bytes: &[u8], + content_type: &str, + ) -> Result<(), knot_storage::BlobStoreError> { + self.inner.put(id, bytes, content_type).await + } + async fn get(&self, id: Uuid) -> Result, knot_storage::BlobStoreError> { + self.gets.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.inner.get(id).await + } + async fn delete(&self, id: Uuid) -> Result<(), knot_storage::BlobStoreError> { + self.inner.delete(id).await + } +} + +async fn get_blob( + app: &axum::Router, + sid_kv: &str, + blob_url: &str, + if_none_match: Option<&str>, +) -> axum::response::Response { + let mut request = Request::builder() + .method("GET") + .uri(blob_url) + .header("cookie", sid_kv); + if let Some(tag) = if_none_match { + request = request.header("if-none-match", tag); + } + app.clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() +} + +async fn upload_tiny_png(app: &axum::Router, sid: &str, csrf: &str, doc_id: Uuid) -> String { + let r = upload( + app, + sid, + csrf, + doc_id, + multipart_body("tiny.png", "image/png", TINY_PNG), + ) + .await; + assert_eq!(r.status(), StatusCode::CREATED); + let bytes = r.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + v["url"].as_str().unwrap().to_string() +} + +#[tokio::test(flavor = "multi_thread")] +async fn download_carries_the_content_sha256_as_etag() { + let (state, _ws, doc_id, _u) = state_with_seeded(WorkspaceRole::Owner).await; + let app = router_with_state(state); + let (sid, csrf) = login_alice(&app).await; + let url = upload_tiny_png(&app, &sid, &csrf, doc_id).await; + + let r = get_blob(&app, &sid, &url, None).await; + assert_eq!(r.status(), StatusCode::OK); + assert_eq!(r.headers().get("etag").unwrap(), TINY_PNG_ETAG); +} + +#[tokio::test(flavor = "multi_thread")] +async fn matching_if_none_match_is_304_without_reading_the_bytes() { + let (mut state, _ws, doc_id, _u) = state_with_seeded(WorkspaceRole::Owner).await; + let counting = Arc::new(CountingStore { + inner: state.blob_store.clone().unwrap(), + gets: Default::default(), + }); + state.blob_store = Some(counting.clone()); + let app = router_with_state(state); + let (sid, csrf) = login_alice(&app).await; + let url = upload_tiny_png(&app, &sid, &csrf, doc_id).await; + + let r = get_blob(&app, &sid, &url, Some(TINY_PNG_ETAG)).await; + assert_eq!(r.status(), StatusCode::NOT_MODIFIED); + assert_eq!(r.headers().get("etag").unwrap(), TINY_PNG_ETAG); + assert!(r.headers().get("cache-control").is_some()); + assert!(r.into_body().collect().await.unwrap().to_bytes().is_empty()); + assert_eq!(counting.gets.load(std::sync::atomic::Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_if_none_match_gets_the_full_body() { + let (state, _ws, doc_id, _u) = state_with_seeded(WorkspaceRole::Owner).await; + let app = router_with_state(state); + let (sid, csrf) = login_alice(&app).await; + let url = upload_tiny_png(&app, &sid, &csrf, doc_id).await; + + let r = get_blob(&app, &sid, &url, Some("\"not-this-one\", W/\"nor-this\"")).await; + assert_eq!(r.status(), StatusCode::OK); + assert_eq!( + r.into_body().collect().await.unwrap().to_bytes().as_ref(), + TINY_PNG + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn matching_if_none_match_does_not_bypass_the_acl() { + let (state, _ws, _doc, _u) = state_with_seeded(WorkspaceRole::Owner).await; + // A blob in another workspace: alice has no role there. + let other_ws = state + .workspaces + .as_ref() + .unwrap() + .create("other", "Other") + .await + .unwrap(); + let outsider = state + .users + .as_ref() + .unwrap() + .create_local("bob@example.com", "Bob", "$h$") + .await + .unwrap(); + let other_doc = state + .docs + .as_ref() + .unwrap() + .create(other_ws.id, None, "Secret", "m", outsider.id) + .await + .unwrap(); + let blob_id = Uuid::new_v4(); + state + .blob_meta + .as_ref() + .unwrap() + .insert(&knot_storage::BlobMetadata { + id: blob_id, + workspace_id: other_ws.id, + doc_id: other_doc.id, + content_type: "image/png".into(), + byte_size: TINY_PNG.len() as i64, + sha256: vec![ + 0x90, 0xc8, 0x64, 0x32, 0xdf, 0x50, 0x3e, 0x9b, 0xea, 0x47, 0xad, 0x2d, 0x49, 0x89, + 0xa0, 0xb5, 0xc5, 0x91, 0x1e, 0xb6, 0x0e, 0x01, 0xfb, 0x1b, 0xaf, 0x78, 0xc1, 0x71, + 0x51, 0xcc, 0xe1, 0x1f, + ], + original_name: None, + created_by: outsider.id, + created_at: chrono::Utc::now(), + }) + .await + .unwrap(); + state + .blob_store + .as_ref() + .unwrap() + .put(blob_id, TINY_PNG, "image/png") + .await + .unwrap(); + let app = router_with_state(state); + let (sid, _csrf) = login_alice(&app).await; + + let r = get_blob( + &app, + &sid, + &format!("/api/blobs/{blob_id}"), + Some(TINY_PNG_ETAG), + ) + .await; + assert_eq!(r.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/knot-server/tests/metrics_integration.rs b/crates/knot-server/tests/metrics_integration.rs index 08a1e9a..0401d97 100644 --- a/crates/knot-server/tests/metrics_integration.rs +++ b/crates/knot-server/tests/metrics_integration.rs @@ -23,7 +23,22 @@ fn install_exporter() -> u16 { *EXPORTER_PORT.get_or_init(|| { let port = pick_free_port(); let addr = format!("127.0.0.1:{port}"); - knot_obs::metrics::init(&addr).expect("install exporter"); + // The exporter's HTTP listener is a task on the runtime that installs + // it. Each #[tokio::test] runtime dies with its test, so the listener + // gets a runtime of its own that outlives every test in this file. + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + knot_obs::metrics::init(&addr).expect("install exporter"); + ready_tx.send(()).unwrap(); + std::future::pending::<()>().await; + }); + }); + ready_rx.recv().unwrap(); port }) } @@ -81,3 +96,176 @@ async fn metrics_endpoint_lists_described_names_after_traffic() { // knot_room_active is a gauge that only appears after a sample is recorded; // skip asserting on it here since no room traffic is generated in this test. } + +async fn scrape(port: u16) -> String { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + reqwest::get(format!("http://127.0.0.1:{port}/metrics")) + .await + .unwrap() + .text() + .await + .unwrap() +} + +/// Sum of every sample line of `series` (e.g. a histogram's `_count`). +fn sample_total(body: &str, series: &str) -> f64 { + body.lines() + .filter(|line| line.starts_with(series)) + .filter_map(|line| line.rsplit(' ').next()?.parse::().ok()) + .sum() +} + +/// The dashboard, the SLO doc and the PrometheusRule all query +/// `histogram_quantile(..., ..._bucket)`: a summary exports no buckets, so +/// every latency panel and alert would silently show nothing. +#[tokio::test(flavor = "multi_thread")] +async fn http_latency_is_exported_as_a_bucketed_histogram() { + let port = install_exporter(); + let db = fresh_db().await; + let mut state = knot_server::AppState::with_pool(db.pool.clone()); + state.session_key = b"test-key-32-bytes-aaaaaaaaaaaaaa".to_vec(); + let app = knot_server::router_with_state(state); + let r = app + .oneshot( + Request::builder() + .uri("/api/healthz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r.status(), StatusCode::OK); + + let body = scrape(port).await; + assert!( + body.contains("# TYPE knot_http_request_duration_seconds histogram"), + "not a histogram\n{body}" + ); + assert!( + body.contains("knot_http_request_duration_seconds_bucket{"), + "no buckets\n{body}" + ); +} + +/// Opening a document over the collab socket is where the editor waits for +/// content, but the HTTP histogram stops at the 101 upgrade. These two +/// series cover the rest: the DB hydrate of a cold room, and upgrade → +/// initial SyncStep2 queued for every join. +#[tokio::test(flavor = "multi_thread")] +async fn opening_a_document_records_hydrate_and_initial_sync_latency() { + use futures_util::StreamExt; + use std::sync::Arc; + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + + let port = install_exporter(); + let db = fresh_db().await; + let mut state = knot_server::AppState::with_pool(db.pool.clone()); + state.hasher = Arc::new(knot_auth::Hasher::fast_for_tests()); + state.session_key = b"test-key-32-bytes-aaaaaaaaaaaaaa".to_vec(); + state.cookie_secure = false; + state.rooms_v2 = Some(Arc::new(knot_crdt::Rooms::new( + Arc::new(knot_crdt::YrsEngine), + Arc::new(knot_crdt::MemBus::new()), + Arc::new(knot_storage::PgUpdatesStore::new(db.pool.clone())), + Arc::new(knot_storage::PgSnapshotStore::new(db.pool.clone())), + knot_crdt::SnapshotPolicy { + every_n: 100, + idle: std::time::Duration::from_secs(60), + }, + std::time::Duration::from_secs(300), + ))); + let workspace = state + .workspaces + .as_ref() + .unwrap() + .create("default", "W") + .await + .unwrap(); + let hash = state.hasher.hash("metricspass").unwrap(); + let user = state + .users + .as_ref() + .unwrap() + .create_local("metrics@ws.test", "M", &hash) + .await + .unwrap(); + state + .workspaces + .as_ref() + .unwrap() + .add_member(workspace.id, user.id, knot_storage::WorkspaceRole::Owner) + .await + .unwrap(); + let doc = state + .docs + .as_ref() + .unwrap() + .create(workspace.id, None, "Doc", "m", user.id) + .await + .unwrap(); + + let app = knot_server::router_with_state(state); + let login = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({"email": "metrics@ws.test", "password": "metricspass"}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login.status(), StatusCode::NO_CONTENT); + let sid_cookie = login + .headers() + .get_all("set-cookie") + .iter() + .map(|value| value.to_str().unwrap().to_string()) + .find(|cookie| cookie.starts_with("sid=")) + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let before = scrape(port).await; + let mut request = format!("ws://{addr}/collab/doc/{}", doc.id) + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert("cookie", sid_cookie.parse().unwrap()); + let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap(); + let first_frame = tokio::time::timeout(std::time::Duration::from_secs(5), socket.next()) + .await + .expect("initial sync frame") + .unwrap() + .unwrap(); + assert_eq!( + &first_frame.into_data()[..2], + &[0u8, 1u8], + "SyncStep2 first" + ); + + let after = scrape(port).await; + for series in [ + "knot_room_hydrate_seconds_count", + "knot_collab_initial_sync_seconds_count", + ] { + assert!( + sample_total(&after, series) >= sample_total(&before, series) + 1.0, + "{series} did not grow\n{after}" + ); + let buckets = series.replace("_count", "_bucket{"); + assert!(after.contains(&buckets), "{buckets} missing\n{after}"); + } +} diff --git a/crates/knot-server/tests/static_files_integration.rs b/crates/knot-server/tests/static_files_integration.rs new file mode 100644 index 0000000..3fd0ad9 --- /dev/null +++ b/crates/knot-server/tests/static_files_integration.rs @@ -0,0 +1,123 @@ +//! SPA static serving: cache headers and compression for `web/dist`. + +use std::io::Read; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +const INDEX_HTML: &str = ""; + +/// A bundle big and repetitive enough that any real compressor shrinks it. +fn bundle_source() -> String { + "export const greeting = 'hello from the knot editor bundle';\n".repeat(2_000) +} + +fn dist_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("index.html"), INDEX_HTML).unwrap(); + std::fs::create_dir(dir.path().join("assets")).unwrap(); + std::fs::write(dir.path().join("assets/index-Abc123.js"), bundle_source()).unwrap(); + dir +} + +async fn get( + dist: &tempfile::TempDir, + path: &str, + accept_encoding: Option<&str>, +) -> axum::http::Response { + let app: axum::Router = knot_server::static_files::router(dist.path().to_str().unwrap()); + let mut request = Request::builder().uri(path); + if let Some(encodings) = accept_encoding { + request = request.header(header::ACCEPT_ENCODING, encodings); + } + app.oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() +} + +fn cache_control(response: &axum::http::Response) -> Option<&str> { + response + .headers() + .get(header::CACHE_CONTROL) + .map(|value| value.to_str().unwrap()) +} + +async fn body_bytes(response: axum::http::Response) -> Vec { + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec() +} + +#[tokio::test] +async fn hashed_asset_is_cacheable_forever() { + let dist = dist_dir(); + let response = get(&dist, "/assets/index-Abc123.js", None).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + cache_control(&response), + Some("public, max-age=31536000, immutable") + ); +} + +#[tokio::test] +async fn index_html_must_be_revalidated() { + let dist = dist_dir(); + let response = get(&dist, "/", None).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(cache_control(&response), Some("no-cache")); + assert_eq!(body_bytes(response).await, INDEX_HTML.as_bytes()); +} + +#[tokio::test] +async fn client_side_route_serves_index_html_that_must_be_revalidated() { + let dist = dist_dir(); + let response = get(&dist, "/docs/0b6f3a56-0000-0000-0000-000000000000", None).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(cache_control(&response), Some("no-cache")); + assert_eq!(body_bytes(response).await, INDEX_HTML.as_bytes()); +} + +#[tokio::test] +async fn missing_asset_is_404_not_a_cached_index_html() { + let dist = dist_dir(); + let response = get(&dist, "/assets/KnotEditor-Gone999.js", None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let cache = cache_control(&response).unwrap_or_default().to_owned(); + assert!(!cache.contains("immutable"), "cache-control was {cache:?}"); + assert_ne!(body_bytes(response).await, INDEX_HTML.as_bytes()); +} + +#[tokio::test] +async fn asset_is_brotli_compressed_when_the_client_accepts_it() { + let dist = dist_dir(); + let response = get(&dist, "/assets/index-Abc123.js", Some("gzip, deflate, br")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_ENCODING) + .map(|value| value.to_str().unwrap()), + Some("br") + ); + let compressed = body_bytes(response).await; + assert!(compressed.len() < bundle_source().len() / 10); + let mut decompressed = String::new(); + brotli::Decompressor::new(compressed.as_slice(), 4096) + .read_to_string(&mut decompressed) + .unwrap(); + assert_eq!(decompressed, bundle_source()); +} + +#[tokio::test] +async fn asset_is_sent_uncompressed_when_the_client_does_not_ask() { + let dist = dist_dir(); + let response = get(&dist, "/assets/index-Abc123.js", None).await; + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert_eq!(body_bytes(response).await, bundle_source().as_bytes()); +} diff --git a/web/src/features/docs/DocPage.preload.test.tsx b/web/src/features/docs/DocPage.preload.test.tsx new file mode 100644 index 0000000..0f7ddde --- /dev/null +++ b/web/src/features/docs/DocPage.preload.test.tsx @@ -0,0 +1,43 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SessionProvider } from "../../auth/SessionContext"; + +// The factory runs the first time anything imports the editor module, so the +// spy records the moment the (lazily split) editor bundle is requested. +const editorModule = vi.hoisted(() => ({ requested: vi.fn() })); +vi.mock("../editor/KnotEditor", () => { + editorModule.requested(); + return { KnotEditor: () => null }; +}); + +import DocPage from "./DocPage"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("DocPage editor preload", () => { + it("requests the editor bundle while the doc metadata is still loading", async () => { + // Every API call hangs: the page can only ever show its loading state. + vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); + + render( + + + + + } /> + + + + , + ); + + expect(screen.getByText("Loading…")).toBeInTheDocument(); + await vi.waitFor(() => expect(editorModule.requested).toHaveBeenCalled()); + }); +}); diff --git a/web/src/features/docs/DocPage.tsx b/web/src/features/docs/DocPage.tsx index baf22ab..c508ad2 100644 --- a/web/src/features/docs/DocPage.tsx +++ b/web/src/features/docs/DocPage.tsx @@ -17,8 +17,12 @@ import { HistoryDrawer } from "./HistoryDrawer"; import { DocWidthToggle } from "./DocWidthToggle"; import { ImportMarkdownButton } from "./ImportMarkdownButton"; +// DocPage starts this download on mount (see the effect below) so the ~750 KB +// editor chunk overlaps the doc-metadata request instead of waiting for it. +// `import()` is cached: the preload and `lazy` share one fetch. +const loadKnotEditor = () => import("../editor/KnotEditor"); const KnotEditor = lazy(() => - import("../editor/KnotEditor").then((m) => ({ default: m.KnotEditor })), + loadKnotEditor().then((m) => ({ default: m.KnotEditor })), ); export function DocTitle({ @@ -69,6 +73,11 @@ export default function DocPage() { const notify = useUi((s) => s.notify); const [status, setStatus] = useState("connecting"); const [pendingBytes, setPendingBytes] = useState(0); + useEffect(() => { + // A failed preload is not reported here: the `lazy` import meets the same + // failure when the editor renders, and surfaces it there. + loadKnotEditor().catch(() => {}); + }, []); const [historyOpen, setHistoryOpen] = useState(false); const [mdView, setMdView] = useState(false); // View mode by default — viewers are always read-only anyway; editors and