完成 Editor Agent Mock Agent P1 收尾

接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面
新增 Mock Agent、静态构建 runner 与独立预览网关
补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅
修复 sandbox 预览资源跨域加载并补充并发保护
接入本地 dev 预览端口漂移与服务身份初始化
更新 P1 技术方案、验收清单和 Hermes 共享记忆
This commit is contained in:
2026-06-16 17:31:25 +08:00
parent 80a382b034
commit 4b09ce3096
404 changed files with 14886 additions and 2497 deletions

View File

@@ -56,6 +56,7 @@ shared-kernel = { workspace = true }
shared-logging = { workspace = true }
socket2 = { workspace = true }
spacetime-client = { workspace = true }
web-project-runner = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal", "process"] }
tokio-stream = { workspace = true }
futures-util = { workspace = true }

View File

@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::profile::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::editor_project::router(state.clone()))
.merge(modules::web_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
.merge(modules::external_generation::router(state.clone()))
.merge(modules::play_flow::router(state.clone()))

View File

@@ -138,6 +138,13 @@ pub struct AppConfig {
pub spacetime_pool_size: u32,
pub spacetime_procedure_timeout: Duration,
pub spacetime_health_check_timeout: Duration,
pub web_project_runner_bin: Option<PathBuf>,
pub web_project_artifact_root: PathBuf,
pub web_project_preview_host: String,
pub web_project_preview_port: u16,
pub web_project_preview_public_base_url: String,
pub web_project_preview_frame_ancestors: String,
pub web_project_preview_build_max_concurrent_tasks: usize,
pub llm_provider: LlmProvider,
pub llm_base_url: String,
pub llm_api_key: Option<String>,
@@ -368,6 +375,14 @@ impl Default for AppConfig {
spacetime_health_check_timeout: Duration::from_secs(
DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS,
),
web_project_runner_bin: None,
web_project_artifact_root: PathBuf::from("server-rs/.data/web-project-artifacts"),
web_project_preview_host: "127.0.0.1".to_string(),
web_project_preview_port: 3104,
web_project_preview_public_base_url: "http://127.0.0.1:3104".to_string(),
web_project_preview_frame_ancestors: "http://127.0.0.1:3000 http://localhost:3000"
.to_string(),
web_project_preview_build_max_concurrent_tasks: 2,
llm_provider: LlmProvider::Ark,
llm_base_url: String::new(),
llm_api_key: None,
@@ -895,6 +910,43 @@ impl AppConfig {
config.spacetime_health_check_timeout =
Duration::from_secs(spacetime_health_check_timeout_seconds);
}
config.web_project_runner_bin =
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_RUNNER_BIN"]).map(PathBuf::from);
if let Some(artifact_root) =
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_ARTIFACT_ROOT"])
{
config.web_project_artifact_root = PathBuf::from(artifact_root);
}
if let Some(preview_host) =
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_HOST"])
{
config.web_project_preview_host = preview_host;
}
if let Ok(preview_port) = env::var("GENARRATIVE_WEB_PROJECT_PREVIEW_PORT")
&& let Ok(parsed_port) = preview_port.parse::<u16>()
{
config.web_project_preview_port = parsed_port;
}
if let Some(preview_base_url) =
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL"])
{
config.web_project_preview_public_base_url = preview_base_url;
} else {
config.web_project_preview_public_base_url = format!(
"http://{}:{}",
config.web_project_preview_host, config.web_project_preview_port
);
}
if let Some(frame_ancestors) =
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS"])
{
config.web_project_preview_frame_ancestors = frame_ancestors;
}
if let Some(max_concurrent_tasks) =
read_first_usize_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS"])
{
config.web_project_preview_build_max_concurrent_tasks = max_concurrent_tasks;
}
if let Some(llm_provider) =
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
@@ -1699,6 +1751,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS");
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
@@ -1717,6 +1770,10 @@ mod tests {
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
std::env::set_var(
"GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS",
"3",
);
std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000");
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
std::env::set_var(
@@ -1744,6 +1801,7 @@ mod tests {
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
assert_eq!(config.detail_max_concurrent_requests, Some(32));
assert_eq!(config.admin_max_concurrent_requests, Some(16));
assert_eq!(config.web_project_preview_build_max_concurrent_tasks, 3);
assert_eq!(
config.shutdown_outbox_flush_timeout,
std::time::Duration::from_millis(3_000)
@@ -1779,6 +1837,7 @@ mod tests {
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
std::env::remove_var("GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS");
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");

View File

@@ -9,10 +9,10 @@ use serde_json::{Value, json};
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput,
EditorAssetLibraryRecord, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord,
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord,
EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord,
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
};
@@ -1023,12 +1023,11 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
"message": message,
}))
}
SpacetimeClientError::Runtime(message) => {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST)
.with_details(json!({
"provider": "editor-project",
"message": message,
}))
}
})),
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": other.to_string(),

View File

@@ -744,6 +744,7 @@ mod tests {
started_at: Some("2026-06-03T00:00:00Z".to_string()),
completed_at: None,
updated_at: "2026-06-03T00:00:00Z".to_string(),
updated_at_micros: 1_749_165_600_000_000,
lease_token: lease_token.map(ToOwned::to_owned),
}
}

View File

@@ -94,6 +94,9 @@ mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
mod wallet_refund_outbox;
mod web_project;
mod web_project_mock_agent;
mod web_project_preview_gateway;
mod wechat;
mod wooden_fish;
mod work_author;
@@ -219,6 +222,8 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
{
Ok(state) => {
spawn_app_state_background_workers(&state);
let preview_shutdown = shutdown_signal_for_preview(state.clone());
spawn_web_project_preview_gateway(&state, preview_shutdown).await?;
let tracking_outbox = state.tracking_outbox();
let wallet_refund_outbox = state.wallet_refund_outbox();
let worker_state = process_role
@@ -275,6 +280,40 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
result
}
async fn spawn_web_project_preview_gateway(
state: &AppState,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> Result<(), io::Error> {
let bind_address = format!(
"{}:{}",
state.config.web_project_preview_host, state.config.web_project_preview_port
)
.parse::<SocketAddr>()
.map_err(|error| io::Error::other(format!("Web Project preview 监听地址非法:{error}")))?;
let listener = build_tcp_listener(bind_address, state.config.listen_backlog)?;
let router = web_project_preview_gateway::router(
state.clone(),
state.config.web_project_artifact_root.clone(),
state.config.web_project_preview_frame_ancestors.clone(),
);
tokio::spawn(async move {
info!(%bind_address, "Web Project preview gateway 已启动");
if let Err(error) = axum::serve(listener, router)
.with_graceful_shutdown(shutdown)
.await
{
error!(error = %error, "Web Project preview gateway 已退出");
}
});
Ok(())
}
async fn shutdown_signal_for_preview(state: AppState) {
let signal = wait_for_shutdown_signal().await;
state.mark_not_ready();
info!(signal, "Web Project preview gateway 收到退出信号");
}
async fn shutdown_signal(context: ShutdownContext) {
let signal = wait_for_shutdown_signal().await;
if let Some(state) = context.app_state.as_ref() {

View File

@@ -20,4 +20,5 @@ pub mod puzzle_clear;
pub mod square_hole;
pub mod story;
pub mod visual_novel;
pub mod web_project;
pub mod wooden_fish;

View File

@@ -8,10 +8,10 @@ use crate::{
editor_project::{
create_editor_asset, create_editor_asset_folder, create_editor_project,
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
delete_editor_project, edit_editor_image, generate_editor_image,
get_editor_asset_library, get_editor_project, list_editor_projects,
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
update_editor_asset, update_editor_asset_folder,
delete_editor_project, edit_editor_image, generate_editor_image, get_editor_asset_library,
get_editor_project, list_editor_projects, load_recent_editor_project,
rename_editor_project, save_editor_project_layout, update_editor_asset,
update_editor_asset_folder,
},
state::AppState,
};

View File

@@ -0,0 +1,72 @@
use axum::{
Router, middleware,
routing::{get, patch, post},
};
use crate::{
auth::require_bearer_auth,
state::AppState,
web_project::{
create_mock_agent_turn, create_web_project, create_web_project_preview_build,
get_web_project, get_web_project_preview_build, get_web_project_snapshot,
patch_web_project_files, stream_web_project_preview_build_events,
},
};
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/web-project/projects",
post(create_web_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/web-project/projects/{project_id}",
get(get_web_project).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/web-project/projects/{project_id}/snapshot",
get(get_web_project_snapshot).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/web-project/projects/{project_id}/files",
patch(patch_web_project_files).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/web-project/projects/{project_id}/mock-agent-turns",
post(create_mock_agent_turn).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/web-project/projects/{project_id}/preview-builds",
post(create_web_project_preview_build).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/web-project/preview-builds/{job_id}",
get(get_web_project_preview_build).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/web-project/preview-builds/{job_id}/events",
get(stream_web_project_preview_build_events)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}

View File

@@ -1,5 +1,5 @@
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
error::Error,
fmt,
sync::{
@@ -35,7 +35,7 @@ use spacetime_client::{
SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot,
};
use time::OffsetDateTime;
use tokio::sync::{Semaphore, broadcast};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast};
use tracing::{info, warn};
use crate::config::AppConfig;
@@ -272,6 +272,9 @@ pub struct AppStateInner {
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
profile_recharge_order_updates: broadcast::Sender<String>,
web_project_build_updates: broadcast::Sender<String>,
web_project_preview_build_limiter: Arc<Semaphore>,
web_project_preview_active_projects: Arc<Mutex<HashSet<String>>>,
#[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
@@ -283,6 +286,28 @@ struct CreativeAgentSessionRuntimeRecord {
snapshot: CreativeAgentSessionSnapshot,
}
#[derive(Debug)]
pub enum WebProjectPreviewBuildSlotError {
GlobalLimit,
ProjectAlreadyRunning,
}
#[derive(Debug)]
pub struct WebProjectPreviewBuildSlot {
project_key: String,
active_projects: Arc<Mutex<HashSet<String>>>,
_permit: OwnedSemaphorePermit,
}
impl Drop for WebProjectPreviewBuildSlot {
fn drop(&mut self) {
self.active_projects
.lock()
.expect("web project preview active project set should lock")
.remove(&self.project_key);
}
}
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
#[derive(Clone, Debug)]
pub struct AdminRuntime {
@@ -414,6 +439,10 @@ impl AppState {
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
let (profile_recharge_order_updates, _) = broadcast::channel(128);
let (web_project_build_updates, _) = broadcast::channel(256);
let web_project_preview_build_limiter = Arc::new(Semaphore::new(
config.web_project_preview_build_max_concurrent_tasks.max(1),
));
Ok(Self(Arc::new(AppStateInner {
config,
@@ -451,6 +480,9 @@ impl AppState {
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
profile_recharge_order_updates,
web_project_build_updates,
web_project_preview_build_limiter,
web_project_preview_active_projects: Arc::new(Mutex::new(HashSet::new())),
#[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
})))
@@ -919,6 +951,35 @@ impl AppState {
&self.spacetime_client
}
pub fn web_project_build_updates(&self) -> broadcast::Sender<String> {
self.web_project_build_updates.clone()
}
pub fn try_acquire_web_project_preview_build_slot(
&self,
project_id: &str,
) -> Result<WebProjectPreviewBuildSlot, WebProjectPreviewBuildSlotError> {
let project_key = project_id.to_string();
let mut active_projects = self
.web_project_preview_active_projects
.lock()
.expect("web project preview active project set should lock");
if active_projects.contains(&project_key) {
return Err(WebProjectPreviewBuildSlotError::ProjectAlreadyRunning);
}
let permit = self
.web_project_preview_build_limiter
.clone()
.try_acquire_owned()
.map_err(|_| WebProjectPreviewBuildSlotError::GlobalLimit)?;
active_projects.insert(project_key.clone());
Ok(WebProjectPreviewBuildSlot {
project_key,
active_projects: self.web_project_preview_active_projects.clone(),
_permit: permit,
})
}
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
&self.puzzle_gallery_cache
}
@@ -1625,6 +1686,33 @@ mod tests {
assert_eq!(created.stages.len(), 4);
}
#[test]
fn web_project_preview_build_slot_enforces_global_and_project_limits() {
let mut config = AppConfig::default();
config.web_project_preview_build_max_concurrent_tasks = 1;
let state = AppState::new(config).expect("state should build");
let slot = state
.try_acquire_web_project_preview_build_slot("web-project-1")
.expect("first build should acquire slot");
assert!(matches!(
state.try_acquire_web_project_preview_build_slot("web-project-1"),
Err(WebProjectPreviewBuildSlotError::ProjectAlreadyRunning)
));
assert!(matches!(
state.try_acquire_web_project_preview_build_slot("web-project-2"),
Err(WebProjectPreviewBuildSlotError::GlobalLimit)
));
drop(slot);
assert!(
state
.try_acquire_web_project_preview_build_slot("web-project-2")
.is_ok()
);
}
#[test]
fn app_state_skips_llm_client_when_api_key_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,248 @@
use shared_contracts::web_project::{WebProjectPatch, WebProjectPatchOperation};
pub fn build_mock_agent_patch(prompt: &str) -> (WebProjectPatch, String) {
let normalized = prompt.trim();
let theme = if normalized.contains("绿色") {
WebProjectTheme::Green
} else if normalized.contains("粉色") {
WebProjectTheme::Pink
} else {
WebProjectTheme::Blue
};
if normalized.contains("破坏构建") {
return (
WebProjectPatch {
operations: vec![WebProjectPatchOperation::UpdateFile {
path: "src/App.tsx".to_string(),
content: broken_app_source(),
}],
},
"生成一个构建语法错误用于验证失败保留上一版预览".to_string(),
);
}
if normalized.contains("卡片") || normalized.contains("列表") {
return (
WebProjectPatch {
operations: vec![
WebProjectPatchOperation::UpdateFile {
path: "src/App.tsx".to_string(),
content: card_list_app_source(),
},
WebProjectPatchOperation::UpdateFile {
path: "src/App.css".to_string(),
content: themed_css(theme),
},
],
},
"更新为卡片列表页面".to_string(),
);
}
if normalized.contains("计数") || normalized.contains("按钮") {
return (
WebProjectPatch {
operations: vec![
WebProjectPatchOperation::UpdateFile {
path: "src/App.tsx".to_string(),
content: counter_app_source(),
},
WebProjectPatchOperation::UpdateFile {
path: "src/App.css".to_string(),
content: themed_css(theme),
},
],
},
"更新为计数按钮页面".to_string(),
);
}
(
WebProjectPatch {
operations: vec![
WebProjectPatchOperation::UpdateFile {
path: "src/App.tsx".to_string(),
content: default_app_source(normalized),
},
WebProjectPatchOperation::UpdateFile {
path: "src/App.css".to_string(),
content: themed_css(theme),
},
],
},
"更新页面标题和主题色".to_string(),
)
}
enum WebProjectTheme {
Blue,
Green,
Pink,
}
fn counter_app_source() -> String {
r#"import { useState } from 'react';
import './App.css';
export default function App() {
const [count, setCount] = useState(0);
return (
<main className="app-shell">
<section className="hero-panel">
<p className="eyebrow">Mock Agent</p>
<h1>蓝色计数按钮</h1>
<button className="primary-action" onClick={() => setCount((value) => value + 1)}>
已点击 {count} 次
</button>
</section>
</main>
);
}
"#
.to_string()
}
fn card_list_app_source() -> String {
r#"import './App.css';
const cards = ['构思', '搭建', '预览'];
export default function App() {
return (
<main className="app-shell">
<section className="hero-panel">
<p className="eyebrow">Mock Agent</p>
<h1>项目卡片列表</h1>
<div className="card-grid">
{cards.map((item) => (
<article className="feature-card" key={item}>{item}</article>
))}
</div>
</section>
</main>
);
}
"#
.to_string()
}
fn broken_app_source() -> String {
r#"import './App.css';
export default function App() {
return <main className="app-shell">破坏构建</main
}
"#
.to_string()
}
fn default_app_source(prompt: &str) -> String {
let title = prompt
.chars()
.take(30)
.collect::<String>()
.replace(['<', '>', '{', '}'], "");
format!(
r#"import './App.css';
export default function App() {{
return (
<main className="app-shell">
<section className="hero-panel">
<p className="eyebrow">Mock Agent</p>
<h1>{}</h1>
<p className="summary">页面已根据本轮指令更新。</p>
</section>
</main>
);
}}
"#,
if title.trim().is_empty() {
"Web 工程预览"
} else {
title.trim()
}
)
}
fn themed_css(theme: WebProjectTheme) -> String {
let (accent, surface) = match theme {
WebProjectTheme::Blue => ("#2563eb", "#eff6ff"),
WebProjectTheme::Green => ("#059669", "#ecfdf5"),
WebProjectTheme::Pink => ("#db2777", "#fdf2f8"),
};
format!(
r#":root {{
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #111827;
background: {surface};
}}
body {{
margin: 0;
}}
.app-shell {{
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: linear-gradient(135deg, {surface}, #ffffff);
}}
.hero-panel {{
width: min(520px, 100%);
border: 1px solid rgba(17, 24, 39, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
padding: 28px;
box-shadow: 0 16px 40px rgba(17, 24, 39, 0.12);
}}
.eyebrow {{
margin: 0 0 8px;
color: {accent};
font-size: 13px;
font-weight: 700;
}}
h1 {{
margin: 0 0 18px;
font-size: 32px;
line-height: 1.15;
}}
.summary {{
margin: 0;
color: #4b5563;
}}
.primary-action {{
border: 0;
border-radius: 8px;
background: {accent};
color: white;
padding: 12px 18px;
font: inherit;
font-weight: 700;
cursor: pointer;
}}
.card-grid {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}}
.feature-card {{
border-radius: 8px;
background: {surface};
border: 1px solid rgba(17, 24, 39, 0.08);
padding: 16px;
font-weight: 700;
}}
"#
)
}

View File

@@ -0,0 +1,283 @@
use std::{
path::{Component, Path, PathBuf},
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use axum::{
Router,
body::Body,
extract::{Path as AxumPath, State},
http::{
HeaderValue, StatusCode,
header::{
ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE,
},
},
response::{IntoResponse, Response},
routing::get,
};
use tokio::fs;
use crate::state::AppState;
const PREVIEW_TOKEN_MAX_AGE_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
#[derive(Clone, Debug)]
struct PreviewGatewayState {
app_state: AppState,
artifact_root: Arc<PathBuf>,
frame_ancestors: Arc<String>,
}
pub fn router(app_state: AppState, artifact_root: PathBuf, frame_ancestors: String) -> Router {
Router::new()
.route("/p/{preview_token_id}", get(serve_preview_index))
.route("/p/{preview_token_id}/", get(serve_preview_index))
.route(
"/p/{preview_token_id}/{*asset_path}",
get(serve_preview_asset),
)
.with_state(PreviewGatewayState {
app_state,
artifact_root: Arc::new(artifact_root),
frame_ancestors: Arc::new(normalize_frame_ancestors(&frame_ancestors)),
})
}
async fn serve_preview_index(
State(state): State<PreviewGatewayState>,
AxumPath(preview_token_id): AxumPath<String>,
) -> Response {
serve_preview_file(state, preview_token_id, "index.html".to_string()).await
}
async fn serve_preview_asset(
State(state): State<PreviewGatewayState>,
AxumPath((preview_token_id, asset_path)): AxumPath<(String, String)>,
) -> Response {
let asset_path = if asset_path.trim().is_empty() {
"index.html".to_string()
} else {
asset_path
};
serve_preview_file(state, preview_token_id, asset_path).await
}
async fn serve_preview_file(
state: PreviewGatewayState,
preview_token_id: String,
asset_path: String,
) -> Response {
if !preview_token_is_fresh(&preview_token_id) {
return gateway_error(StatusCode::GONE, "预览令牌已失效");
}
let build = match state
.app_state
.spacetime_client()
.get_web_project_preview_build_by_token(
spacetime_client::WebProjectPreviewBuildTokenGetRecordInput {
preview_token_id: preview_token_id.clone(),
},
)
.await
{
Ok(record) => record.build,
Err(_) => return gateway_error(StatusCode::GONE, "预览令牌已失效"),
};
let build = if build.preview_token_id.as_deref() == Some(preview_token_id.as_str())
&& build.status == "succeeded"
{
build
} else {
return gateway_error(StatusCode::GONE, "预览令牌已失效");
};
let Some(artifact_id) = build.artifact_id else {
return gateway_error(StatusCode::GONE, "预览产物不存在");
};
let artifact_root = match fs::canonicalize(state.artifact_root.as_ref()).await {
Ok(path) => path,
Err(_) => return gateway_error(StatusCode::GONE, "预览产物不存在"),
};
let artifact_dir = match fs::canonicalize(artifact_root.join(artifact_id)).await {
Ok(path) if path.starts_with(&artifact_root) => path,
_ => return gateway_error(StatusCode::GONE, "预览产物不存在"),
};
let requested_file = match resolve_artifact_path(&artifact_dir, &asset_path) {
Ok(path) => path,
Err(response) => return response,
};
let file_path = if fs::metadata(&requested_file)
.await
.map(|metadata| metadata.is_file())
.unwrap_or(false)
{
requested_file
} else {
artifact_dir.join("index.html")
};
let file_path = match fs::canonicalize(file_path).await {
Ok(path) if path.starts_with(&artifact_dir) && path.starts_with(&artifact_root) => path,
_ => return gateway_error(StatusCode::FORBIDDEN, "预览路径不合法"),
};
let Some(mime_type) = mime_type_for_path(&file_path) else {
return gateway_error(StatusCode::UNSUPPORTED_MEDIA_TYPE, "预览文件类型不支持");
};
match fs::read(&file_path).await {
Ok(bytes) => preview_response(bytes, mime_type, state.frame_ancestors.as_str()),
Err(_) => gateway_error(StatusCode::NOT_FOUND, "预览文件不存在"),
}
}
fn preview_token_is_fresh(preview_token_id: &str) -> bool {
let Some(issued_at_micros) = parse_preview_token_issued_at(preview_token_id) else {
return false;
};
let now = current_utc_micros();
issued_at_micros <= now && now.saturating_sub(issued_at_micros) <= PREVIEW_TOKEN_MAX_AGE_MICROS
}
fn parse_preview_token_issued_at(preview_token_id: &str) -> Option<i64> {
let mut parts = preview_token_id.split('_');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some("wpt"), Some(issued_at), Some(secret), None) if secret.len() >= 32 => {
issued_at.parse::<i64>().ok()
}
_ => None,
}
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn resolve_artifact_path(artifact_dir: &Path, asset_path: &str) -> Result<PathBuf, Response> {
let mut relative = PathBuf::new();
for component in Path::new(asset_path).components() {
match component {
Component::Normal(segment) => relative.push(segment),
Component::CurDir => {}
_ => return Err(gateway_error(StatusCode::FORBIDDEN, "预览路径不合法")),
}
}
Ok(artifact_dir.join(relative))
}
fn preview_response(bytes: Vec<u8>, mime_type: &'static str, frame_ancestors: &str) -> Response {
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = StatusCode::OK;
let headers = response.headers_mut();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(mime_type));
let csp = format!(
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; worker-src 'none'; frame-ancestors {}; object-src 'none'; base-uri 'none'",
normalize_frame_ancestors(frame_ancestors)
);
if let Ok(value) = HeaderValue::from_str(&csp) {
headers.insert(CONTENT_SECURITY_POLICY, value);
}
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static("no-store, max-age=0, must-revalidate"),
);
headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
response
}
fn normalize_frame_ancestors(input: &str) -> String {
let values = input
.split_whitespace()
.filter(|value| {
*value == "'self'"
|| (value.starts_with("http://") || value.starts_with("https://"))
&& !value.contains(';')
&& !value.contains(',')
})
.collect::<Vec<_>>();
if values.is_empty() {
"'self'".to_string()
} else {
values.join(" ")
}
}
fn gateway_error(status: StatusCode, message: &'static str) -> Response {
(status, message).into_response()
}
fn mime_type_for_path(path: &Path) -> Option<&'static str> {
match path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"html" => Some("text/html; charset=utf-8"),
"js" | "mjs" => Some("text/javascript; charset=utf-8"),
"css" => Some("text/css; charset=utf-8"),
"json" => Some("application/json; charset=utf-8"),
"svg" => Some("image/svg+xml"),
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"webp" => Some("image/webp"),
"ico" => Some("image/x-icon"),
"woff" => Some("font/woff"),
"woff2" => Some("font/woff2"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_csp_allows_configured_editor_origin() {
let response = preview_response(
b"<html></html>".to_vec(),
"text/html; charset=utf-8",
"http://127.0.0.1:3000",
);
let csp = response
.headers()
.get(CONTENT_SECURITY_POLICY)
.expect("preview response should include csp")
.to_str()
.expect("csp should be ascii");
assert!(csp.contains("frame-ancestors http://127.0.0.1:3000"));
assert!(!csp.contains("frame-ancestors 'none'"));
assert!(csp.contains("connect-src 'none'"));
}
#[test]
fn preview_response_allows_sandboxed_module_assets() {
let response = preview_response(
b"export default 1;".to_vec(),
"text/javascript; charset=utf-8",
"http://127.0.0.1:3000",
);
assert_eq!(
response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&HeaderValue::from_static("*"))
);
let csp = response
.headers()
.get(CONTENT_SECURITY_POLICY)
.expect("preview response should include csp")
.to_str()
.expect("csp should be ascii");
assert!(csp.contains("connect-src 'none'"));
}
#[test]
fn frame_ancestors_falls_back_to_self_when_env_is_invalid() {
assert_eq!(
normalize_frame_ancestors("bad; https://ok.example"),
"https://ok.example"
);
assert_eq!(normalize_frame_ancestors("bad;"), "'self'");
}
}