Skip to main content

spark/
http.rs

1//! HTTP handlers for Spark: the `/_spark/update` round-trip endpoint and the
2//! `/_spark/spark.js` runtime asset.
3
4use std::collections::HashMap;
5
6use axum::extract::State;
7use axum::http::{header, StatusCode};
8use axum::response::IntoResponse;
9use axum::Json;
10use serde_json::json;
11
12use anvil_core::Container;
13use anvil_core::Error;
14
15use crate::component::Ctx;
16use crate::morph;
17use crate::registry;
18use crate::request::UpdateRequest;
19use crate::response::{ComponentResult, Effects, IslandHtml, UpdateResponse};
20use crate::snapshot::{self, Memo};
21
22pub const RUNTIME_JS: &[u8] = include_bytes!("../dist/spark.min.js");
23
24/// `GET /_spark/spark.js` — serve the embedded JS runtime.
25pub async fn runtime_js() -> impl IntoResponse {
26    (
27        StatusCode::OK,
28        [
29            (
30                header::CONTENT_TYPE,
31                "application/javascript; charset=utf-8",
32            ),
33            (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
34        ],
35        RUNTIME_JS,
36    )
37}
38
39/// `POST /_spark/update` — decode each component's snapshot, apply property writes,
40/// dispatch the requested method, and return refreshed HTML + new snapshots.
41pub async fn update(
42    State(container): State<Container>,
43    Json(req): Json<UpdateRequest>,
44) -> Result<impl IntoResponse, Error> {
45    let (app_key, encrypt) = crate::render::signing();
46    let mut out = UpdateResponse {
47        components: Vec::with_capacity(req.components.len()),
48    };
49
50    for comp in req.components {
51        let envelope = snapshot::decode(&comp.snapshot, &app_key).map_err(Error::from)?;
52        let entry = registry::resolve(&envelope.memo.class).map_err(Error::from)?;
53        let mut boxed = (entry.load)(&envelope.data).map_err(Error::from)?;
54
55        let mut ctx = Ctx::new(Some(container.clone()));
56
57        if !comp.updates.is_empty() {
58            boxed
59                .state
60                .apply_writes(&comp.updates, &mut ctx)
61                .await
62                .map_err(Error::from)?;
63        }
64
65        let mut requested_island: Option<String> = None;
66        for call in comp.calls {
67            ctx.island = call.island.clone();
68            boxed
69                .state
70                .dispatch_call(&call.method, call.params, &mut ctx)
71                .await
72                .map_err(Error::from)?;
73            if let Some(island) = ctx.island.take() {
74                requested_island = Some(island);
75            }
76        }
77
78        // Build the next snapshot from the current state.
79        let next_memo = Memo {
80            id: envelope.memo.id.clone(),
81            class: envelope.memo.class.clone(),
82            view: envelope.memo.view.clone(),
83            listeners: (entry.listeners)(),
84            errors: if ctx.errors.is_empty() {
85                None
86            } else {
87                Some(serde_json::to_value(&ctx.errors).unwrap_or(serde_json::Value::Null))
88            },
89        };
90        let (html, wire) = crate::render::rerender(&boxed, &next_memo).map_err(Error::from)?;
91        let full_html = crate::render::wrap_rerender(&html, &next_memo, &wire);
92
93        let islands = if let Some(island_name) = requested_island.as_deref() {
94            if let Some(island_html) = morph::slice_island(&full_html, island_name) {
95                vec![IslandHtml {
96                    name: island_name.to_string(),
97                    html: island_html,
98                }]
99            } else {
100                Vec::new()
101            }
102        } else {
103            Vec::new()
104        };
105
106        let effects = Effects {
107            dispatched: std::mem::take(&mut ctx.dispatched),
108            emitted: std::mem::take(&mut ctx.emitted),
109            redirect: ctx.redirect.clone(),
110            errors: std::mem::take(&mut ctx.errors)
111                .into_iter()
112                .collect::<HashMap<_, _>>(),
113            islands,
114        };
115
116        out.components.push(ComponentResult {
117            snapshot: wire,
118            html: full_html,
119            effects,
120        });
121    }
122
123    let _ = encrypt; // already applied inside snapshot::encode via `signing()`.
124    Ok(Json(out))
125}
126
127/// `POST /_spark/auth` — stub auth endpoint for private channels. v1 always
128/// returns 200 with a dummy auth payload. Real authorization lands in v1.1 via
129/// a `SparkAuthorizer` trait.
130pub async fn channel_auth() -> impl IntoResponse {
131    (
132        StatusCode::OK,
133        Json(json!({
134            "auth": "spark:placeholder",
135            "channel_data": null,
136        })),
137    )
138}