use std::collections::HashMap;
use std::time::Duration;
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use once_cell::sync::Lazy;
use serde_json::json;
use tower_sessions::Session;
use anvil_core::Container;
use anvil_core::Error;
use crate::component::Ctx;
use crate::morph;
use crate::registry;
use crate::request::UpdateRequest;
use crate::response::{ComponentResult, Effects, IslandHtml, UpdateResponse};
use crate::snapshot::{self, Memo};
pub const RUNTIME_JS: &[u8] = include_bytes!("../dist/spark.min.js");
pub async fn runtime_js() -> impl IntoResponse {
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
RUNTIME_JS,
)
}
const STATUS_PAGE_EXPIRED: u16 = 419;
const STATUS_UPGRADE_REQUIRED: u16 = 426;
static REVISION_TRACKER: Lazy<moka::sync::Cache<String, u64>> = Lazy::new(|| {
moka::sync::Cache::builder()
.max_capacity(50_000)
.time_to_idle(Duration::from_secs(60 * 60 * 24))
.build()
});
pub async fn update(
State(container): State<Container>,
session: Option<Session>,
Json(req): Json<UpdateRequest>,
) -> Result<Response, Error> {
if let Some(session) = session.as_ref() {
let expected = session
.get::<String>(anvil_core::middleware::builtin::CSRF_SESSION_KEY)
.await
.map_err(|e| Error::Internal(e.to_string()))?;
if let Some(expected) = expected {
let submitted = req.csrf_token.as_deref().unwrap_or("");
if !crate::const_eq(expected.as_bytes(), submitted.as_bytes()) {
tracing::debug!("spark /_spark/update: CSRF token mismatch");
let mut resp = Response::new(Body::from("CSRF token mismatch"));
*resp.status_mut() =
StatusCode::from_u16(STATUS_PAGE_EXPIRED).unwrap_or(StatusCode::FORBIDDEN);
return Ok(resp);
}
}
}
let (_app_key, encrypt) = crate::render::signing();
let keyring_owned = crate::render::keyring();
let keyring: Vec<(u8, &str)> = keyring_owned
.iter()
.map(|(k, v)| (*k, v.as_str()))
.collect();
let mut out = UpdateResponse {
components: Vec::with_capacity(req.components.len()),
};
for comp in req.components {
let decode_started = std::time::Instant::now();
let envelope = match snapshot::decode_with_keys(&comp.snapshot, &keyring) {
Ok(env) => env,
Err(crate::Error::SnapshotVersionMismatch { client_v, server_v }) => {
tracing::info!(
client_v,
server_v,
"spark /_spark/update: client snapshot is from a newer build; sending 426"
);
let mut resp = Response::new(Body::from(format!(
"snapshot v{client_v} is newer than this server understands (v{server_v}) — refresh the page"
)));
*resp.status_mut() =
StatusCode::from_u16(STATUS_UPGRADE_REQUIRED).unwrap_or(StatusCode::CONFLICT);
return Ok(resp);
}
Err(e) => return Err(Error::from(e)),
};
let decode_us = decode_started.elapsed().as_micros() as u64;
let span = tracing::info_span!(
"spark.update",
component = %envelope.memo.class,
id = %envelope.memo.id,
rev = envelope.memo.rev,
decode_us,
dispatch_us = tracing::field::Empty,
render_us = tracing::field::Empty,
encode_us = tracing::field::Empty,
);
let _span_guard = span.enter();
let entry = registry::resolve(&envelope.memo.class).map_err(Error::from)?;
let mut boxed = (entry.load)(&envelope.data).map_err(Error::from)?;
let expected_rev = REVISION_TRACKER.get(&envelope.memo.id).unwrap_or(0);
if envelope.memo.rev != expected_rev {
tracing::debug!(
server_rev = expected_rev,
client_rev = envelope.memo.rev,
"spark /_spark/update: stale snapshot rejected"
);
return Err(Error::from(crate::Error::SnapshotStale {
server_rev: expected_rev,
client_rev: envelope.memo.rev,
}));
}
let next_rev = expected_rev.saturating_add(1);
REVISION_TRACKER.insert(envelope.memo.id.clone(), next_rev);
let mut ctx = Ctx::new(Some(container.clone()));
let dispatch_started = std::time::Instant::now();
if !comp.updates.is_empty() {
boxed
.state
.apply_writes(&comp.updates, &mut ctx)
.await
.map_err(Error::from)?;
}
let mut requested_island: Option<String> = None;
for call in comp.calls {
ctx.island = call.island.clone();
let method = call.method.clone();
match boxed
.state
.dispatch_call(&method, call.params, &mut ctx)
.await
{
Ok(()) => {}
Err(spark_err) => {
if is_user_facing(&spark_err) {
tracing::debug!(
method,
error = %spark_err,
"spark action returned user-facing error; surfacing via Effects.errors"
);
ctx.errors
.entry(format!("action:{method}"))
.or_default()
.push(spark_err.to_string());
} else {
return Err(Error::from(spark_err));
}
}
}
if let Some(island) = ctx.island.take() {
requested_island = Some(island);
}
}
span.record("dispatch_us", dispatch_started.elapsed().as_micros() as u64);
let render_started = std::time::Instant::now();
let next_memo = Memo {
id: envelope.memo.id.clone(),
class: envelope.memo.class.clone(),
view: envelope.memo.view.clone(),
listeners: (entry.listeners)(),
errors: if ctx.errors.is_empty() {
None
} else {
Some(serde_json::to_value(&ctx.errors).unwrap_or(serde_json::Value::Null))
},
rev: next_rev,
};
let (html, wire) = crate::render::rerender(&boxed, &next_memo).map_err(Error::from)?;
span.record("render_us", render_started.elapsed().as_micros() as u64);
let encode_started = std::time::Instant::now();
let full_html = crate::render::wrap_rerender(&html, &next_memo, &wire);
span.record("encode_us", encode_started.elapsed().as_micros() as u64);
const SNAPSHOT_WARN_BYTES: usize = 32 * 1024;
if wire.len() > SNAPSHOT_WARN_BYTES {
tracing::warn!(
size = wire.len(),
limit = 64 * 1024,
"spark: snapshot is approaching the 64 KB hard cap — consider trimming \
component state or moving heavy fields to a backing DB row"
);
}
let islands = if let Some(island_name) = requested_island.as_deref() {
if let Some(island_html) = morph::slice_island(&full_html, island_name) {
vec![IslandHtml {
name: island_name.to_string(),
html: island_html,
}]
} else {
Vec::new()
}
} else {
Vec::new()
};
let effects = Effects {
dispatched: std::mem::take(&mut ctx.dispatched),
emitted: std::mem::take(&mut ctx.emitted),
redirect: ctx.redirect.clone(),
errors: std::mem::take(&mut ctx.errors)
.into_iter()
.collect::<HashMap<_, _>>(),
islands,
};
out.components.push(ComponentResult {
snapshot: wire,
html: full_html,
effects,
});
}
let _ = encrypt; Ok(Json(out).into_response())
}
pub async fn channel_auth() -> impl IntoResponse {
(
StatusCode::OK,
Json(json!({
"auth": "spark:placeholder",
"channel_data": null,
})),
)
}
fn is_user_facing(err: &crate::Error) -> bool {
matches!(
err,
crate::Error::InvalidArguments { .. } | crate::Error::UnknownMethod { .. }
)
}