use crate::server::ServerState;
use crate::server::config::Config;
use crate::server::handlers::rsc_handlers::{
immediate_component_reregistration, reload_component_from_dist,
};
use crate::server::utils::component_utils::extract_component_id;
use axum::{extract::State, http::StatusCode, response::Json};
use cow_utils::CowUtils;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::error;
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub enum HmrRequest {
Register {
#[serde(rename = "file_path")]
file_path: String,
},
Invalidate {
#[serde(rename = "componentId")]
component_id: String,
#[serde(rename = "filePath")]
file_path: Option<String>,
},
Reload {
#[serde(rename = "componentId")]
component_id: String,
#[serde(rename = "filePath")]
file_path: String,
},
InvalidateApiRoute {
#[serde(rename = "filePath")]
file_path: String,
},
ReloadComponent {
#[serde(rename = "component_id")]
component_id: String,
#[serde(rename = "bundle_path")]
bundle_path: String,
},
}
#[derive(Debug, Serialize)]
pub struct HmrResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(flatten)]
pub data: Option<Value>,
}
#[axum::debug_handler]
pub async fn handle_hmr_action(
State(state): State<ServerState>,
Json(request): Json<HmrRequest>,
) -> Result<Json<Value>, StatusCode> {
match request {
HmrRequest::Register { file_path } => handle_register(state, file_path).await,
HmrRequest::Invalidate { component_id, file_path } => {
handle_invalidate(state, component_id, file_path).await
}
HmrRequest::Reload { component_id, file_path } => {
handle_reload(state, component_id, file_path).await
}
HmrRequest::InvalidateApiRoute { file_path } => {
handle_invalidate_api_route(state, file_path).await
}
HmrRequest::ReloadComponent { component_id, bundle_path } => {
handle_reload_component(state, component_id, bundle_path).await
}
}
}
async fn invalidate_component_cache(
cache: &crate::server::cache::response_cache::ResponseCache,
component_id: &str,
) {
let cache_key_prefix = format!("/_rari/stream/{}", component_id);
let all_keys = cache.get_all_keys();
for key in all_keys {
if key.starts_with(&cache_key_prefix) {
cache.invalidate(&key).await;
}
}
}
async fn handle_register(state: ServerState, file_path: String) -> Result<Json<Value>, StatusCode> {
use crate::server::utils::path_validation::{
normalize_component_path, validate_component_path,
};
let file_path = normalize_component_path(&file_path);
if let Err(e) = validate_component_path(&file_path) {
error!(
file_path = %file_path,
error = %e,
"Component path validation failed"
);
return Err(StatusCode::BAD_REQUEST);
}
let component_id = match extract_component_id(&file_path) {
Ok(id) => id,
Err(e) => {
error!("Failed to extract component ID from {}: {}", file_path, e);
return Err(StatusCode::BAD_REQUEST);
}
};
let path = std::path::Path::new(&file_path);
{
let renderer = state.renderer.lock().await;
{
let mut registry = renderer.component_registry.lock();
registry.mark_module_stale(&component_id);
}
let clear_cache_script = r#"
if (typeof globalThis['~rari']?.lazy?.clear === 'function') {
globalThis['~rari'].lazy.clear();
}
"#;
if let Err(e) = renderer
.runtime
.execute_script("clear_resolved_cache".to_string(), clear_cache_script.to_string())
.await
{
error!("Failed to clear resolved cache: {}", e);
}
}
let reload_result = reload_component_from_dist(&state, &file_path, &component_id).await;
let mut reload_error_details: Option<serde_json::Value> = None;
match &reload_result {
Ok(_) => {}
Err(e) => {
error!(
component_id = component_id,
file_path = file_path,
error = %e,
"Failed to reload component from dist, preserving last known good version"
);
#[allow(clippy::disallowed_methods)]
{
reload_error_details = Some(serde_json::json!({
"stage": "dist_reload",
"message": e.to_string(),
"component_id": component_id,
"file_path": file_path,
"preserved_last_good": true
}));
}
}
}
if reload_result.is_err()
&& let Err(e) = immediate_component_reregistration(&state, &file_path).await
{
error!(
component_id = component_id,
file_path = file_path,
error = %e,
"Failed to immediately re-register component, preserving last known good version"
);
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"file_path": file_path,
"component_id": component_id,
"reloaded": false,
"preserved_last_good": true,
"error": {
"stage": "fallback_registration",
"message": e.to_string(),
"previous_error": reload_error_details,
"suggestion": "Component reload failed. Last known good version is still available. Consider checking for syntax errors or manual page refresh."
}
})));
}
let mut reloaded = reload_result.is_ok();
let mut module_reload_error: Option<String> = None;
if state.config.hmr_reload_enabled() {
match state.module_reload_manager.reload_module_debounced(&component_id, path).await {
Ok(()) => {
reloaded = true;
}
Err(e) => {
error!(
component_id = component_id,
error = %e,
"Failed to schedule module reload, preserving last known good version"
);
module_reload_error = Some(e.to_string());
}
}
}
#[allow(clippy::disallowed_methods)]
let response = if reloaded {
invalidate_component_cache(&state.response_cache, &component_id).await;
let route_cache_patterns: Vec<String> = vec![
file_path.replace("src/app/", "/").replace("/page.tsx", ""),
file_path.replace("src/app/", "/").replace("/page.ts", ""),
]
.into_iter()
.filter(|p| p.len() > 1)
.collect();
for pattern in route_cache_patterns {
let all_keys = state.response_cache.get_all_keys();
for key in all_keys {
if key.starts_with(&pattern) {
state.response_cache.invalidate(&key).await;
}
}
}
serde_json::json!({
"success": true,
"file_path": file_path,
"component_id": component_id,
"reloaded": true,
"error": null
})
} else if reload_error_details.is_some() || module_reload_error.is_some() {
serde_json::json!({
"success": true,
"file_path": file_path,
"component_id": component_id,
"reloaded": false,
"preserved_last_good": true,
"error": {
"dist_reload": reload_error_details,
"module_reload": module_reload_error,
"suggestion": "Component reload encountered errors. Last known good version is still available. Check console for details or try a manual page refresh."
}
})
} else {
serde_json::json!({
"success": true,
"file_path": file_path,
"component_id": component_id,
"reloaded": false,
"error": null
})
};
Ok(Json(response))
}
async fn handle_invalidate(
state: ServerState,
component_id: String,
_file_path: Option<String>,
) -> Result<Json<Value>, StatusCode> {
let result = {
let renderer = state.renderer.lock().await;
{
let mut registry = renderer.component_registry.lock();
registry.mark_module_stale(&component_id);
}
renderer.clear_component_cache(&component_id);
if let Err(e) = renderer.runtime.clear_module_loader_caches(&component_id).await {
error!("Failed to clear module loader caches for {}: {}", component_id, e);
}
let clear_script = format!(
r#"
(function() {{
let clearedCount = 0;
const componentId = "{}";
if (typeof globalThis[componentId] !== 'undefined') {{
delete globalThis[componentId];
clearedCount++;
}}
if (globalThis['~rsc'].modules && globalThis['~rsc'].modules[componentId]) {{
delete globalThis['~rsc'].modules[componentId];
clearedCount++;
}}
if (globalThis['~rsc'].functions && globalThis['~rsc'].functions[componentId]) {{
delete globalThis['~rsc'].functions[componentId];
clearedCount++;
}}
if (globalThis['~rsc'].componentFunctions && globalThis['~rsc'].componentFunctions.has(componentId)) {{
globalThis['~rsc'].componentFunctions.delete(componentId);
clearedCount++;
}}
if (globalThis['~rsc'].serverFunctions && globalThis['~rsc'].serverFunctions.has(componentId)) {{
globalThis['~rsc'].serverFunctions.delete(componentId);
clearedCount++;
}}
if (globalThis['~rsc'].componentData && globalThis['~rsc'].componentData.has(componentId)) {{
globalThis['~rsc'].componentData.delete(componentId);
clearedCount++;
}}
if (globalThis['~rsc'].componentNamespaces && globalThis['~rsc'].componentNamespaces.has(componentId)) {{
globalThis['~rsc'].componentNamespaces.delete(componentId);
clearedCount++;
}}
return {{
success: true,
clearedCount: clearedCount,
componentId: componentId
}};
}})()
"#,
component_id
);
renderer
.runtime
.execute_script(
format!("hmr_clear_cache_{}.js", component_id.cow_replace('/', "_")),
clear_script,
)
.await
};
match result {
Ok(clear_result) =>
{
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": true,
"componentId": component_id,
"cleared": clear_result
})))
}
Err(e) => {
error!("Failed to invalidate component cache for {}: {}", component_id, e);
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": e.to_string()
})))
}
}
}
async fn handle_reload(
state: ServerState,
component_id: String,
file_path: String,
) -> Result<Json<Value>, StatusCode> {
let config = match Config::get() {
Some(config) => config,
None => {
error!("Failed to get global configuration for HMR reload");
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": "Configuration not available"
})));
}
};
if file_path.contains("://") {
error!("Invalid file path: contains URL scheme");
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": "Invalid file path: URL schemes not allowed"
})));
}
let client = reqwest::Client::new();
let vite_base_url = format!("http://{}", config.vite_address());
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let file_path =
if file_path.starts_with('/') { file_path.clone() } else { format!("/{}", file_path) };
let vite_url = format!("{}{}?t={}", vite_base_url, file_path, timestamp);
let transpiled_code = match client.get(&vite_url).send().await {
Ok(response) => {
if !response.status().is_success() {
error!("Vite returned error status: {}", response.status());
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": format!("Vite returned status: {}", response.status())
})));
}
match response.text().await {
Ok(code) => code,
Err(e) => {
error!("Failed to read response from Vite: {}", e);
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": format!("Failed to read response: {}", e)
})));
}
}
}
Err(e) => {
error!("Failed to fetch from Vite dev server: {}", e);
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": format!("Failed to fetch from Vite: {}", e)
})));
}
};
let result =
{ state.renderer.lock().await.register_component(&component_id, &transpiled_code).await };
match result {
Ok(()) =>
{
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": true,
"componentId": component_id,
"codeSize": transpiled_code.len()
})))
}
Err(e) => {
error!("Failed to reload component {}: {}", component_id, e);
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": false,
"componentId": component_id,
"error": e.to_string()
})))
}
}
}
async fn handle_invalidate_api_route(
state: ServerState,
file_path: String,
) -> Result<Json<Value>, StatusCode> {
let api_handler = match &state.api_route_handler {
Some(handler) => handler,
None => {
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"filePath": file_path,
"error": "API route handler not available"
})));
}
};
api_handler.invalidate_handler(&file_path);
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": true,
"filePath": file_path,
"message": "API route handler cache invalidated"
})))
}
async fn handle_reload_component(
state: ServerState,
component_id: String,
bundle_path: String,
) -> Result<Json<Value>, StatusCode> {
use crate::server::utils::path_validation::validate_safe_path;
let project_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let bundle_full_path = match validate_safe_path(&project_root, &bundle_path) {
Ok(path) => path,
Err(e) => {
error!(
bundle_path = %bundle_path,
error = %e,
"Bundle path validation failed"
);
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"message": format!("Invalid bundle path: {}", e)
})));
}
};
let mut bundle_code = String::new();
let mut last_error = None;
for attempt in 0..3 {
match tokio::fs::read_to_string(&bundle_full_path).await {
Ok(code) => {
bundle_code = code;
last_error = None;
break;
}
Err(e) => {
last_error = Some(e);
if attempt < 2 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
}
}
}
if let Some(e) = last_error {
error!("Failed to read bundle file {}: {}", bundle_full_path.display(), e);
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"message": format!("Failed to read bundle: {}", e)
})));
}
if let Err(e) = {
let renderer = state.renderer.lock().await;
renderer.runtime.invalidate_component(&component_id).await
} {
error!("Failed to invalidate component {}: {}", component_id, e);
}
let load_result = {
let renderer = state.renderer.lock().await;
renderer.runtime.load_component_code(&component_id, &bundle_code).await
};
match load_result {
Ok(()) => {
{
let renderer = state.renderer.lock().await;
let mut registry = renderer.component_registry.lock();
registry.remove_component(&component_id);
let dependencies =
crate::rsc::utils::dependency_utils::extract_dependencies(&bundle_code);
match registry.register_component(
&component_id,
&bundle_code,
bundle_code.clone(),
dependencies.into_iter().collect(),
) {
Ok(()) => {
registry.mark_component_loaded(&component_id);
registry.mark_component_initially_loaded(&component_id);
}
Err(e) => {
error!("Failed to register component {}: {}", component_id, e);
registry.remove_component(&component_id);
#[allow(clippy::disallowed_methods)]
return Ok(Json(serde_json::json!({
"success": false,
"message": format!("Failed to register component: {}", e)
})));
}
}
}
invalidate_component_cache(&state.response_cache, &component_id).await;
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": true,
"message": format!("Component {} reloaded successfully", component_id)
})))
}
Err(e) => {
error!("Failed to reload component {}: {}", component_id, e);
#[allow(clippy::disallowed_methods)]
Ok(Json(serde_json::json!({
"success": false,
"message": format!("Failed to reload component: {}", e)
})))
}
}
}