接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面 新增 Mock Agent、静态构建 runner 与独立预览网关 补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅 修复 sandbox 预览资源跨域加载并补充并发保护 接入本地 dev 预览端口漂移与服务身份初始化 更新 P1 技术方案、验收清单和 Hermes 共享记忆
1092 lines
38 KiB
Rust
1092 lines
38 KiB
Rust
use std::{
|
||
collections::BTreeMap,
|
||
convert::Infallible,
|
||
path::PathBuf,
|
||
process::Stdio,
|
||
time::{SystemTime, UNIX_EPOCH},
|
||
};
|
||
|
||
use async_stream::stream;
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, State},
|
||
http::StatusCode,
|
||
response::{
|
||
IntoResponse, Response,
|
||
sse::{Event, Sse},
|
||
},
|
||
};
|
||
use serde::Deserialize;
|
||
use serde_json::{Value, json};
|
||
use shared_contracts::web_project::{
|
||
MockAgentTurnRequest, MockAgentTurnResponse, WebProjectFile, WebProjectPatch,
|
||
WebProjectPatchOperation, WebProjectPreviewBuild, WebProjectPreviewBuildEvent,
|
||
WebProjectPreviewBuildResponse, WebProjectPreviewBuildStatus, WebProjectResponse,
|
||
WebProjectSnapshot, WebProjectSnapshotResponse,
|
||
};
|
||
use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string};
|
||
use spacetime_client::{
|
||
SpacetimeClientError, WebProjectCreateRecordInput, WebProjectFileRecord,
|
||
WebProjectGetRecordInput, WebProjectPreviewBuildCreateRecordInput,
|
||
WebProjectPreviewBuildGetRecordInput, WebProjectPreviewBuildRecord, WebProjectRecord,
|
||
WebProjectSnapshotGetRecordInput, WebProjectSnapshotRecord, WebProjectSnapshotSaveRecordInput,
|
||
};
|
||
use tokio::{
|
||
io::{AsyncReadExt, AsyncWriteExt},
|
||
process::Command,
|
||
time::Duration,
|
||
};
|
||
use web_project_runner::{
|
||
WebProjectBuildInput, WebProjectBuildOutput, WebProjectBuildStatus, WebProjectRunnerFile,
|
||
};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
auth::AuthenticatedAccessToken,
|
||
http_error::AppError,
|
||
request_context::RequestContext,
|
||
state::{AppState, WebProjectPreviewBuildSlot, WebProjectPreviewBuildSlotError},
|
||
web_project_mock_agent::build_mock_agent_patch,
|
||
};
|
||
|
||
const WEB_PROJECT_ID_PREFIX: &str = "web-project-";
|
||
const WEB_PROJECT_SNAPSHOT_ID_PREFIX: &str = "web-snapshot-";
|
||
const WEB_PROJECT_BUILD_ID_PREFIX: &str = "web-build-";
|
||
const WEB_PROJECT_DEFAULT_TITLE: &str = "未命名 Web 工程";
|
||
const WEB_PROJECT_MAX_PATH_DEPTH: usize = 8;
|
||
const WEB_PROJECT_MAX_FILE_COUNT: usize = 80;
|
||
const WEB_PROJECT_MAX_FILE_BYTES: usize = 128 * 1024;
|
||
const WEB_PROJECT_MAX_SNAPSHOT_BYTES: usize = 512 * 1024;
|
||
const WEB_PROJECT_RUNNER_TIMEOUT: Duration = Duration::from_secs(300);
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct WebProjectCreateRequest {
|
||
title: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct WebProjectFilesPatchRequest {
|
||
patch: WebProjectPatch,
|
||
base_snapshot_id: String,
|
||
summary: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct WebProjectPreviewBuildCreateRequest {
|
||
snapshot_id: Option<String>,
|
||
}
|
||
|
||
pub async fn create_web_project(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<WebProjectCreateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let project_id = build_prefixed_uuid_id(WEB_PROJECT_ID_PREFIX);
|
||
let snapshot_id = build_prefixed_uuid_id(WEB_PROJECT_SNAPSHOT_ID_PREFIX);
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let title = payload
|
||
.title
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or_else(|| WEB_PROJECT_DEFAULT_TITLE.to_string());
|
||
let initial_files = initial_template_files();
|
||
validate_snapshot_files(&initial_files)?;
|
||
let mutation = state
|
||
.spacetime_client()
|
||
.create_web_project(WebProjectCreateRecordInput {
|
||
project_id,
|
||
snapshot_id,
|
||
owner_user_id,
|
||
title,
|
||
initial_files: initial_files
|
||
.into_iter()
|
||
.map(web_project_file_to_record)
|
||
.collect(),
|
||
now_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
WebProjectResponse {
|
||
project: project_from_record(mutation.project),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_web_project(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let project = state
|
||
.spacetime_client()
|
||
.get_web_project(WebProjectGetRecordInput {
|
||
project_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
WebProjectResponse {
|
||
project: project_from_record(project),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn get_web_project_snapshot(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let mutation = state
|
||
.spacetime_client()
|
||
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
|
||
project_id,
|
||
snapshot_id: None,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
WebProjectSnapshotResponse {
|
||
snapshot: snapshot_from_record(mutation.snapshot),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn patch_web_project_files(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<WebProjectFilesPatchRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let base_snapshot = state
|
||
.spacetime_client()
|
||
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
|
||
project_id: project_id.clone(),
|
||
snapshot_id: Some(payload.base_snapshot_id.clone()),
|
||
owner_user_id: owner_user_id.clone(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?
|
||
.snapshot;
|
||
let base_snapshot_payload = snapshot_from_record(base_snapshot.clone());
|
||
let files = apply_web_project_patch(&base_snapshot_payload.files, &payload.patch)?;
|
||
let mutation = state
|
||
.spacetime_client()
|
||
.save_web_project_snapshot(WebProjectSnapshotSaveRecordInput {
|
||
snapshot_id: build_prefixed_uuid_id(WEB_PROJECT_SNAPSHOT_ID_PREFIX),
|
||
project_id,
|
||
owner_user_id,
|
||
parent_snapshot_id: Some(base_snapshot.snapshot_id),
|
||
files: files.into_iter().map(web_project_file_to_record).collect(),
|
||
patch_summary: payload
|
||
.summary
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or_else(|| "手动更新 Web 工程文件".to_string()),
|
||
created_by: "user".to_string(),
|
||
now_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
WebProjectSnapshotResponse {
|
||
snapshot: snapshot_from_record(mutation.snapshot),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn create_mock_agent_turn(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<MockAgentTurnRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let base_snapshot = state
|
||
.spacetime_client()
|
||
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
|
||
project_id: project_id.clone(),
|
||
snapshot_id: Some(payload.base_snapshot_id.clone()),
|
||
owner_user_id: owner_user_id.clone(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?
|
||
.snapshot;
|
||
let (patch, summary) = build_mock_agent_patch(&payload.prompt);
|
||
let base_snapshot_payload = snapshot_from_record(base_snapshot.clone());
|
||
let files = apply_web_project_patch(&base_snapshot_payload.files, &patch)?;
|
||
let mutation = state
|
||
.spacetime_client()
|
||
.save_web_project_snapshot(WebProjectSnapshotSaveRecordInput {
|
||
snapshot_id: build_prefixed_uuid_id(WEB_PROJECT_SNAPSHOT_ID_PREFIX),
|
||
project_id,
|
||
owner_user_id,
|
||
parent_snapshot_id: Some(base_snapshot.snapshot_id),
|
||
files: files.into_iter().map(web_project_file_to_record).collect(),
|
||
patch_summary: summary.clone(),
|
||
created_by: "mock-agent".to_string(),
|
||
now_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
MockAgentTurnResponse {
|
||
snapshot: snapshot_from_record(mutation.snapshot),
|
||
patch,
|
||
summary,
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn create_web_project_preview_build(
|
||
State(state): State<AppState>,
|
||
Path(project_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
Json(payload): Json<WebProjectPreviewBuildCreateRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||
let snapshot_id = match payload.snapshot_id {
|
||
Some(snapshot_id) if !snapshot_id.trim().is_empty() => snapshot_id,
|
||
_ => {
|
||
state
|
||
.spacetime_client()
|
||
.get_web_project(WebProjectGetRecordInput {
|
||
project_id: project_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?
|
||
.active_snapshot_id
|
||
}
|
||
};
|
||
let build_slot = state
|
||
.try_acquire_web_project_preview_build_slot(&project_id)
|
||
.map_err(|error| map_preview_build_slot_error(error, project_id.as_str()))?;
|
||
let mutation = state
|
||
.spacetime_client()
|
||
.create_web_project_preview_build(WebProjectPreviewBuildCreateRecordInput {
|
||
job_id: build_prefixed_uuid_id(WEB_PROJECT_BUILD_ID_PREFIX),
|
||
project_id,
|
||
snapshot_id,
|
||
owner_user_id,
|
||
now_micros: current_utc_micros(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
let mut build = build_from_record(mutation.build);
|
||
build.logs.push("构建任务已进入队列".to_string());
|
||
publish_build_event(&state, &build, Some("构建任务已进入队列"));
|
||
spawn_preview_build_task(
|
||
state.clone(),
|
||
authenticated.claims().user_id().to_string(),
|
||
build.clone(),
|
||
build_slot,
|
||
);
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
WebProjectPreviewBuildResponse { build },
|
||
))
|
||
}
|
||
|
||
pub async fn get_web_project_preview_build(
|
||
State(state): State<AppState>,
|
||
Path(job_id): Path<String>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let mutation = state
|
||
.spacetime_client()
|
||
.get_web_project_preview_build(WebProjectPreviewBuildGetRecordInput {
|
||
job_id,
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?;
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
WebProjectPreviewBuildResponse {
|
||
build: build_from_record(mutation.build),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn stream_web_project_preview_build_events(
|
||
State(state): State<AppState>,
|
||
Path(job_id): Path<String>,
|
||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||
) -> Result<Response, AppError> {
|
||
let initial_build = state
|
||
.spacetime_client()
|
||
.get_web_project_preview_build(WebProjectPreviewBuildGetRecordInput {
|
||
job_id: job_id.clone(),
|
||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||
})
|
||
.await
|
||
.map_err(map_web_project_client_error)?
|
||
.build;
|
||
let initial_build = build_from_record(initial_build);
|
||
let initial_is_terminal = matches!(
|
||
initial_build.status,
|
||
WebProjectPreviewBuildStatus::Succeeded
|
||
| WebProjectPreviewBuildStatus::Failed
|
||
| WebProjectPreviewBuildStatus::Cancelled
|
||
| WebProjectPreviewBuildStatus::Expired
|
||
| WebProjectPreviewBuildStatus::Stale
|
||
);
|
||
let initial_payload = serde_json::to_string(&WebProjectPreviewBuildEvent {
|
||
job_id: initial_build.job_id.clone(),
|
||
status: initial_build.status.clone(),
|
||
message: None,
|
||
build: Some(initial_build),
|
||
})
|
||
.ok();
|
||
|
||
let mut receiver = state.web_project_build_updates().subscribe();
|
||
let stream = stream! {
|
||
if let Some(payload) = initial_payload {
|
||
yield Ok::<Event, Infallible>(Event::default().event("message").data(payload));
|
||
}
|
||
if initial_is_terminal {
|
||
return;
|
||
}
|
||
while let Ok(payload) = receiver.recv().await {
|
||
let matches = serde_json::from_str::<WebProjectPreviewBuildEvent>(&payload)
|
||
.ok()
|
||
.is_some_and(|event| event.job_id == job_id);
|
||
if matches {
|
||
yield Ok::<Event, Infallible>(Event::default().event("message").data(payload));
|
||
}
|
||
}
|
||
};
|
||
|
||
Ok(Sse::new(stream).into_response())
|
||
}
|
||
|
||
pub fn apply_web_project_patch(
|
||
base_files: &[WebProjectFile],
|
||
patch: &WebProjectPatch,
|
||
) -> Result<Vec<WebProjectFile>, AppError> {
|
||
validate_web_project_patch(patch)?;
|
||
let mut files = base_files
|
||
.iter()
|
||
.map(|file| (file.path.clone(), file.clone()))
|
||
.collect::<BTreeMap<_, _>>();
|
||
|
||
for operation in &patch.operations {
|
||
match operation {
|
||
WebProjectPatchOperation::CreateFile { path, content } => {
|
||
if files.contains_key(path) {
|
||
return Err(web_project_bad_request("文件已存在", Some(path)));
|
||
}
|
||
files.insert(path.clone(), build_text_file(path, content)?);
|
||
}
|
||
WebProjectPatchOperation::UpdateFile { path, content } => {
|
||
if !files.contains_key(path) {
|
||
return Err(web_project_bad_request("文件不存在", Some(path)));
|
||
}
|
||
files.insert(path.clone(), build_text_file(path, content)?);
|
||
}
|
||
WebProjectPatchOperation::DeleteFile { path } => {
|
||
validate_editable_project_path(path)?;
|
||
files.remove(path);
|
||
}
|
||
WebProjectPatchOperation::RenameFile { from_path, to_path } => {
|
||
validate_editable_project_path(from_path)?;
|
||
validate_editable_project_path(to_path)?;
|
||
if from_path != to_path && files.contains_key(to_path) {
|
||
return Err(web_project_bad_request("目标文件已存在", Some(to_path)));
|
||
}
|
||
let mut file = files
|
||
.remove(from_path)
|
||
.ok_or_else(|| web_project_bad_request("源文件不存在", Some(from_path)))?;
|
||
file.path = to_path.clone();
|
||
files.insert(to_path.clone(), file);
|
||
}
|
||
WebProjectPatchOperation::PackageManifestRequest { .. } => {}
|
||
}
|
||
}
|
||
let next = files.into_values().collect::<Vec<_>>();
|
||
validate_snapshot_files(&next)?;
|
||
Ok(next)
|
||
}
|
||
|
||
pub fn validate_web_project_patch(patch: &WebProjectPatch) -> Result<(), AppError> {
|
||
if patch.operations.is_empty() {
|
||
return Err(web_project_bad_request("patch 不能为空", None));
|
||
}
|
||
for operation in &patch.operations {
|
||
match operation {
|
||
WebProjectPatchOperation::CreateFile { path, content }
|
||
| WebProjectPatchOperation::UpdateFile { path, content } => {
|
||
validate_editable_project_path(path)?;
|
||
validate_text_content(path, content)?;
|
||
}
|
||
WebProjectPatchOperation::DeleteFile { path } => {
|
||
validate_editable_project_path(path)?;
|
||
}
|
||
WebProjectPatchOperation::RenameFile { from_path, to_path } => {
|
||
validate_editable_project_path(from_path)?;
|
||
validate_editable_project_path(to_path)?;
|
||
}
|
||
WebProjectPatchOperation::PackageManifestRequest { content } => {
|
||
validate_package_manifest_request(content)?;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn initial_template_files() -> Vec<WebProjectFile> {
|
||
vec![
|
||
build_text_file(
|
||
"src/App.tsx",
|
||
r#"import './App.css';
|
||
|
||
export default function App() {
|
||
return (
|
||
<main className="app-shell">
|
||
<section className="hero-panel">
|
||
<p className="eyebrow">Mock Agent</p>
|
||
<h1>Web 工程预览</h1>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
"#,
|
||
)
|
||
.expect("template app should be valid"),
|
||
build_text_file(
|
||
"src/App.css",
|
||
r#"body { margin: 0; font-family: system-ui, sans-serif; }
|
||
.app-shell { min-height: 100vh; display: grid; place-items: center; }
|
||
.hero-panel { border: 1px solid #e5e7eb; border-radius: 8px; padding: 24px; }
|
||
.eyebrow { color: #2563eb; font-weight: 700; }
|
||
"#,
|
||
)
|
||
.expect("template css should be valid"),
|
||
]
|
||
}
|
||
|
||
fn build_text_file(path: &str, content: &str) -> Result<WebProjectFile, AppError> {
|
||
validate_editable_project_path(path)?;
|
||
validate_text_content(path, content)?;
|
||
Ok(WebProjectFile {
|
||
path: path.to_string(),
|
||
content: content.to_string(),
|
||
media_type: media_type_for_path(path).to_string(),
|
||
encoding: "utf-8".to_string(),
|
||
size_bytes: u32::try_from(content.as_bytes().len()).unwrap_or(u32::MAX),
|
||
})
|
||
}
|
||
|
||
fn validate_snapshot_files(files: &[WebProjectFile]) -> Result<(), AppError> {
|
||
if files.is_empty() {
|
||
return Err(web_project_bad_request("snapshot 不能为空", None));
|
||
}
|
||
if files.len() > WEB_PROJECT_MAX_FILE_COUNT {
|
||
return Err(web_project_bad_request("snapshot 文件数量过多", None));
|
||
}
|
||
let mut total = 0usize;
|
||
for file in files {
|
||
validate_editable_project_path(&file.path)?;
|
||
validate_text_content(&file.path, &file.content)?;
|
||
total = total.saturating_add(file.content.as_bytes().len());
|
||
}
|
||
if total > WEB_PROJECT_MAX_SNAPSHOT_BYTES {
|
||
return Err(web_project_bad_request("snapshot 总大小过大", None));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_project_path(path: &str) -> Result<(), AppError> {
|
||
let trimmed = path.trim();
|
||
if trimmed.is_empty() {
|
||
return Err(web_project_bad_request("文件路径不能为空", None));
|
||
}
|
||
if path != trimmed {
|
||
return Err(web_project_bad_request(
|
||
"文件路径不能包含首尾空白",
|
||
Some(path),
|
||
));
|
||
}
|
||
if trimmed.starts_with('/') || trimmed.starts_with('\\') || trimmed.contains(':') {
|
||
return Err(web_project_bad_request(
|
||
"文件路径必须是相对路径",
|
||
Some(trimmed),
|
||
));
|
||
}
|
||
let parts = trimmed
|
||
.split(['/', '\\'])
|
||
.filter(|part| !part.is_empty())
|
||
.collect::<Vec<_>>();
|
||
if parts.len() > WEB_PROJECT_MAX_PATH_DEPTH {
|
||
return Err(web_project_bad_request("文件路径层级过深", Some(trimmed)));
|
||
}
|
||
for part in parts {
|
||
if part == "." || part == ".." {
|
||
return Err(web_project_bad_request(
|
||
"文件路径不能包含相对跳转",
|
||
Some(trimmed),
|
||
));
|
||
}
|
||
let lower = part.to_ascii_lowercase();
|
||
if matches!(
|
||
lower.as_str(),
|
||
".env" | ".npmrc" | ".git" | ".ssh" | "package-lock.json" | "npm-shrinkwrap.json"
|
||
) {
|
||
return Err(web_project_bad_request(
|
||
"文件路径包含敏感文件名",
|
||
Some(trimmed),
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_editable_project_path(path: &str) -> Result<(), AppError> {
|
||
validate_project_path(path)?;
|
||
let normalized = path.replace('\\', "/");
|
||
let lower = normalized.to_ascii_lowercase();
|
||
if lower == "package.json" {
|
||
return Ok(());
|
||
}
|
||
if matches!(
|
||
lower.as_str(),
|
||
"index.html" | "src/main.tsx" | "tsconfig.json" | "vite.config.ts"
|
||
) {
|
||
return Err(web_project_bad_request(
|
||
"P1 不允许覆盖固定模板控制文件",
|
||
Some(path),
|
||
));
|
||
}
|
||
if matches!(lower.as_str(), "src/app.tsx" | "src/app.css")
|
||
|| ((lower.starts_with("src/components/")
|
||
|| lower.starts_with("src/assets/")
|
||
|| lower.starts_with("public/"))
|
||
&& has_editable_project_extension(lower.as_str()))
|
||
{
|
||
return Ok(());
|
||
}
|
||
Err(web_project_bad_request(
|
||
"P1 仅允许编辑 src/App.tsx、src/App.css、src/components、src/assets 与 public 下的静态文件",
|
||
Some(path),
|
||
))
|
||
}
|
||
|
||
fn validate_text_content(path: &str, content: &str) -> Result<(), AppError> {
|
||
if content.as_bytes().len() > WEB_PROJECT_MAX_FILE_BYTES {
|
||
return Err(web_project_bad_request("文件内容过大", Some(path)));
|
||
}
|
||
if content.contains('\0') {
|
||
return Err(web_project_bad_request(
|
||
"文件内容不能包含二进制空字节",
|
||
Some(path),
|
||
));
|
||
}
|
||
if path == "package.json" {
|
||
validate_package_manifest_request(content)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn has_editable_project_extension(path: &str) -> bool {
|
||
matches!(
|
||
std::path::Path::new(path)
|
||
.extension()
|
||
.and_then(std::ffi::OsStr::to_str)
|
||
.unwrap_or(""),
|
||
"ts" | "tsx" | "js" | "jsx" | "css" | "json" | "svg" | "txt" | "md"
|
||
)
|
||
}
|
||
|
||
fn validate_package_manifest_request(content: &str) -> Result<(), AppError> {
|
||
let value = serde_json::from_str::<serde_json::Value>(content)
|
||
.map_err(|_| web_project_bad_request("package manifest 不是合法 JSON", None))?;
|
||
if value.get("dependencies").is_some()
|
||
|| value.get("devDependencies").is_some()
|
||
|| value.get("scripts").is_some()
|
||
{
|
||
return Err(web_project_bad_request(
|
||
"P1 不允许新增依赖或自定义 scripts",
|
||
None,
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn media_type_for_path(path: &str) -> &'static str {
|
||
if path.ends_with(".tsx") || path.ends_with(".ts") {
|
||
"text/typescript"
|
||
} else if path.ends_with(".css") {
|
||
"text/css"
|
||
} else if path.ends_with(".html") {
|
||
"text/html"
|
||
} else if path.ends_with(".json") {
|
||
"application/json"
|
||
} else {
|
||
"text/plain"
|
||
}
|
||
}
|
||
|
||
fn project_from_record(record: WebProjectRecord) -> shared_contracts::web_project::WebProject {
|
||
shared_contracts::web_project::WebProject {
|
||
project_id: record.project_id,
|
||
owner_user_id: record.owner_user_id,
|
||
title: record.title,
|
||
template_key: record.template_key,
|
||
active_snapshot_id: record.active_snapshot_id,
|
||
active_preview_build_id: record.active_preview_build_id,
|
||
created_at: record.created_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn snapshot_from_record(record: WebProjectSnapshotRecord) -> WebProjectSnapshot {
|
||
WebProjectSnapshot {
|
||
snapshot_id: record.snapshot_id,
|
||
project_id: record.project_id,
|
||
owner_user_id: record.owner_user_id,
|
||
parent_snapshot_id: record.parent_snapshot_id,
|
||
template_key: record.template_key,
|
||
files: record
|
||
.files
|
||
.into_iter()
|
||
.map(web_project_file_from_record)
|
||
.collect(),
|
||
patch_summary: record.patch_summary,
|
||
created_by: record.created_by,
|
||
created_at: record.created_at,
|
||
}
|
||
}
|
||
|
||
fn build_from_record(record: WebProjectPreviewBuildRecord) -> WebProjectPreviewBuild {
|
||
WebProjectPreviewBuild {
|
||
job_id: record.job_id,
|
||
project_id: record.project_id,
|
||
snapshot_id: record.snapshot_id,
|
||
owner_user_id: record.owner_user_id,
|
||
status: build_status_from_record(record.status.as_str()),
|
||
logs: record.logs,
|
||
artifact_id: record.artifact_id,
|
||
preview_token_id: record.preview_token_id,
|
||
preview_url: record.preview_url,
|
||
error_summary: record.error_summary,
|
||
created_at: record.created_at,
|
||
started_at: record.started_at,
|
||
finished_at: record.finished_at,
|
||
updated_at: record.updated_at,
|
||
}
|
||
}
|
||
|
||
fn web_project_file_to_record(file: WebProjectFile) -> WebProjectFileRecord {
|
||
WebProjectFileRecord {
|
||
path: file.path,
|
||
content: file.content,
|
||
media_type: file.media_type,
|
||
encoding: file.encoding,
|
||
size_bytes: file.size_bytes,
|
||
}
|
||
}
|
||
|
||
fn web_project_file_from_record(record: WebProjectFileRecord) -> WebProjectFile {
|
||
WebProjectFile {
|
||
path: record.path,
|
||
content: record.content,
|
||
media_type: record.media_type,
|
||
encoding: record.encoding,
|
||
size_bytes: record.size_bytes,
|
||
}
|
||
}
|
||
|
||
fn build_status_from_record(status: &str) -> WebProjectPreviewBuildStatus {
|
||
match status {
|
||
"running" => WebProjectPreviewBuildStatus::Running,
|
||
"succeeded" => WebProjectPreviewBuildStatus::Succeeded,
|
||
"failed" => WebProjectPreviewBuildStatus::Failed,
|
||
"cancelled" => WebProjectPreviewBuildStatus::Cancelled,
|
||
"expired" => WebProjectPreviewBuildStatus::Expired,
|
||
"stale" => WebProjectPreviewBuildStatus::Stale,
|
||
_ => WebProjectPreviewBuildStatus::Queued,
|
||
}
|
||
}
|
||
|
||
fn spawn_preview_build_task(
|
||
state: AppState,
|
||
owner_user_id: String,
|
||
build: WebProjectPreviewBuild,
|
||
build_slot: WebProjectPreviewBuildSlot,
|
||
) {
|
||
tokio::spawn(async move {
|
||
let _build_slot = build_slot;
|
||
if let Err(error) =
|
||
run_preview_build_task(state.clone(), owner_user_id.clone(), build.clone()).await
|
||
{
|
||
let fallback = mark_preview_build_failed(&state, &owner_user_id, build, error).await;
|
||
publish_build_event(&state, &fallback, fallback.error_summary.as_deref());
|
||
}
|
||
});
|
||
}
|
||
|
||
async fn run_preview_build_task(
|
||
state: AppState,
|
||
owner_user_id: String,
|
||
build: WebProjectPreviewBuild,
|
||
) -> Result<(), String> {
|
||
let started_at_micros = current_utc_micros();
|
||
let running = state
|
||
.spacetime_client()
|
||
.update_web_project_preview_build(
|
||
spacetime_client::WebProjectPreviewBuildUpdateRecordInput {
|
||
job_id: build.job_id.clone(),
|
||
owner_user_id: owner_user_id.clone(),
|
||
status: "running".to_string(),
|
||
logs: vec!["构建任务开始执行".to_string()],
|
||
artifact_id: None,
|
||
preview_token_id: None,
|
||
preview_url: None,
|
||
error_summary: None,
|
||
started_at_micros: Some(started_at_micros),
|
||
finished_at_micros: None,
|
||
updated_at_micros: started_at_micros,
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| error.to_string())?;
|
||
let running_build = build_from_record(running.build);
|
||
publish_build_event(&state, &running_build, Some("构建任务开始执行"));
|
||
|
||
let snapshot = state
|
||
.spacetime_client()
|
||
.get_web_project_snapshot(WebProjectSnapshotGetRecordInput {
|
||
project_id: build.project_id.clone(),
|
||
snapshot_id: Some(build.snapshot_id.clone()),
|
||
owner_user_id: owner_user_id.clone(),
|
||
})
|
||
.await
|
||
.map_err(|error| error.to_string())?
|
||
.snapshot;
|
||
let runner_output = invoke_runner_process(&state, &build, snapshot).await?;
|
||
let finished_at_micros = current_utc_micros();
|
||
let succeeded = runner_output.status == WebProjectBuildStatus::Succeeded;
|
||
let preview_token_id = succeeded.then(|| build_preview_token_id(finished_at_micros));
|
||
let preview_url = preview_token_id
|
||
.as_ref()
|
||
.map(|token| build_preview_url(&state.config.web_project_preview_public_base_url, token));
|
||
let mut logs = build.logs.clone();
|
||
logs.push("构建任务开始执行".to_string());
|
||
logs.extend(runner_output.logs);
|
||
let updated = state
|
||
.spacetime_client()
|
||
.update_web_project_preview_build(
|
||
spacetime_client::WebProjectPreviewBuildUpdateRecordInput {
|
||
job_id: build.job_id.clone(),
|
||
owner_user_id,
|
||
status: if succeeded { "succeeded" } else { "failed" }.to_string(),
|
||
logs,
|
||
artifact_id: runner_output.artifact_id,
|
||
preview_token_id,
|
||
preview_url,
|
||
error_summary: runner_output.error_summary,
|
||
started_at_micros: Some(started_at_micros),
|
||
finished_at_micros: Some(finished_at_micros),
|
||
updated_at_micros: finished_at_micros,
|
||
},
|
||
)
|
||
.await
|
||
.map_err(|error| error.to_string())?;
|
||
let updated_build = build_from_record(updated.build);
|
||
publish_build_event(
|
||
&state,
|
||
&updated_build,
|
||
updated_build
|
||
.error_summary
|
||
.as_deref()
|
||
.or(Some(if succeeded {
|
||
"构建完成"
|
||
} else {
|
||
"构建失败"
|
||
})),
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
async fn mark_preview_build_failed(
|
||
state: &AppState,
|
||
owner_user_id: &str,
|
||
build: WebProjectPreviewBuild,
|
||
error: String,
|
||
) -> WebProjectPreviewBuild {
|
||
let now = current_utc_micros();
|
||
let mut logs = build.logs.clone();
|
||
logs.push("构建任务失败".to_string());
|
||
logs.push(format!("runner: {error}"));
|
||
match state
|
||
.spacetime_client()
|
||
.update_web_project_preview_build(
|
||
spacetime_client::WebProjectPreviewBuildUpdateRecordInput {
|
||
job_id: build.job_id.clone(),
|
||
owner_user_id: owner_user_id.to_string(),
|
||
status: "failed".to_string(),
|
||
logs,
|
||
artifact_id: None,
|
||
preview_token_id: None,
|
||
preview_url: None,
|
||
error_summary: Some(error.clone()),
|
||
started_at_micros: None,
|
||
finished_at_micros: Some(now),
|
||
updated_at_micros: now,
|
||
},
|
||
)
|
||
.await
|
||
{
|
||
Ok(updated) => build_from_record(updated.build),
|
||
Err(update_error) => {
|
||
let mut logs = build.logs.clone();
|
||
logs.push(format!("runner: {error}"));
|
||
logs.push(format!("runner: 构建失败状态写回失败:{update_error}"));
|
||
WebProjectPreviewBuild {
|
||
status: WebProjectPreviewBuildStatus::Failed,
|
||
logs,
|
||
error_summary: Some(error),
|
||
finished_at: Some(shared_kernel::format_timestamp_micros(now)),
|
||
updated_at: shared_kernel::format_timestamp_micros(now),
|
||
..build
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn invoke_runner_process(
|
||
state: &AppState,
|
||
build: &WebProjectPreviewBuild,
|
||
snapshot: WebProjectSnapshotRecord,
|
||
) -> Result<WebProjectBuildOutput, String> {
|
||
let runner_bin = resolve_runner_binary(state)?;
|
||
let input = WebProjectBuildInput {
|
||
job_id: build.job_id.clone(),
|
||
project_id: build.project_id.clone(),
|
||
snapshot_id: build.snapshot_id.clone(),
|
||
files: snapshot
|
||
.files
|
||
.into_iter()
|
||
.map(|file| WebProjectRunnerFile {
|
||
path: file.path,
|
||
content: file.content,
|
||
})
|
||
.collect(),
|
||
artifact_root: state.config.web_project_artifact_root.clone(),
|
||
};
|
||
let input_json = serde_json::to_vec(&input).map_err(|error| error.to_string())?;
|
||
let mut command = Command::new(&runner_bin);
|
||
command
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.kill_on_drop(true);
|
||
let mut child = command
|
||
.spawn()
|
||
.map_err(|error| format!("启动 web-project-runner 失败:{error}"))?;
|
||
if let Some(mut stdin) = child.stdin.take() {
|
||
stdin
|
||
.write_all(&input_json)
|
||
.await
|
||
.map_err(|error| format!("写入 runner 输入失败:{error}"))?;
|
||
}
|
||
let mut stdout = child
|
||
.stdout
|
||
.take()
|
||
.ok_or_else(|| "web-project-runner stdout 未打开".to_string())?;
|
||
let mut stderr = child
|
||
.stderr
|
||
.take()
|
||
.ok_or_else(|| "web-project-runner stderr 未打开".to_string())?;
|
||
let stdout_task = tokio::spawn(async move {
|
||
let mut bytes = Vec::new();
|
||
stdout
|
||
.read_to_end(&mut bytes)
|
||
.await
|
||
.map(|_| bytes)
|
||
.map_err(|error| error.to_string())
|
||
});
|
||
let stderr_task = tokio::spawn(async move {
|
||
let mut bytes = Vec::new();
|
||
stderr
|
||
.read_to_end(&mut bytes)
|
||
.await
|
||
.map(|_| bytes)
|
||
.map_err(|error| error.to_string())
|
||
});
|
||
let status = match tokio::time::timeout(WEB_PROJECT_RUNNER_TIMEOUT, child.wait()).await {
|
||
Ok(result) => result.map_err(|error| format!("等待 runner 退出失败:{error}"))?,
|
||
Err(_) => {
|
||
let _ = child.kill().await;
|
||
let _ = child.wait().await;
|
||
let _ = stdout_task.await;
|
||
let _ = stderr_task.await;
|
||
return Err("web-project-runner 执行超时".to_string());
|
||
}
|
||
};
|
||
let stdout = stdout_task
|
||
.await
|
||
.map_err(|error| format!("读取 runner stdout 任务失败:{error}"))?
|
||
.map_err(|error| format!("读取 runner stdout 失败:{error}"))?;
|
||
let stderr = stderr_task
|
||
.await
|
||
.map_err(|error| format!("读取 runner stderr 任务失败:{error}"))?
|
||
.map_err(|error| format!("读取 runner stderr 失败:{error}"))?;
|
||
if !status.success() {
|
||
let stderr = String::from_utf8_lossy(&stderr);
|
||
return Ok(WebProjectBuildOutput {
|
||
job_id: build.job_id.clone(),
|
||
project_id: build.project_id.clone(),
|
||
snapshot_id: build.snapshot_id.clone(),
|
||
status: WebProjectBuildStatus::Failed,
|
||
artifact_id: None,
|
||
artifact_path: None,
|
||
error_summary: Some(format!("web-project-runner 退出失败:{}", stderr.trim())),
|
||
logs: vec![stderr.to_string()],
|
||
});
|
||
}
|
||
serde_json::from_slice::<WebProjectBuildOutput>(&stdout)
|
||
.map_err(|error| format!("runner 输出不是合法 JSON:{error}"))
|
||
}
|
||
|
||
fn resolve_runner_binary(state: &AppState) -> Result<PathBuf, String> {
|
||
if let Some(path) = state.config.web_project_runner_bin.as_ref() {
|
||
return Ok(path.clone());
|
||
}
|
||
let current_exe = std::env::current_exe().map_err(|error| error.to_string())?;
|
||
let exe_name = if cfg!(windows) {
|
||
"web-project-runner.exe"
|
||
} else {
|
||
"web-project-runner"
|
||
};
|
||
Ok(current_exe
|
||
.parent()
|
||
.map(|parent| parent.join(exe_name))
|
||
.unwrap_or_else(|| PathBuf::from(exe_name)))
|
||
}
|
||
|
||
fn build_preview_token_id(issued_at_micros: i64) -> String {
|
||
format!("wpt_{}_{}", issued_at_micros, new_uuid_simple_string())
|
||
}
|
||
|
||
fn build_preview_url(base_url: &str, preview_token_id: &str) -> String {
|
||
format!("{}/p/{preview_token_id}/", base_url.trim_end_matches('/'))
|
||
}
|
||
|
||
fn publish_build_event(state: &AppState, build: &WebProjectPreviewBuild, message: Option<&str>) {
|
||
let event = WebProjectPreviewBuildEvent {
|
||
job_id: build.job_id.clone(),
|
||
status: build.status.clone(),
|
||
message: message.map(str::to_string),
|
||
build: Some(build.clone()),
|
||
};
|
||
if let Ok(payload) = serde_json::to_string(&event) {
|
||
let _ = state.web_project_build_updates().send(payload);
|
||
}
|
||
}
|
||
|
||
fn web_project_bad_request(message: &str, path: Option<&str>) -> AppError {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||
"provider": "web-project",
|
||
"path": path,
|
||
"message": message,
|
||
}))
|
||
}
|
||
|
||
fn map_preview_build_slot_error(
|
||
error: WebProjectPreviewBuildSlotError,
|
||
project_id: &str,
|
||
) -> AppError {
|
||
let message = match error {
|
||
WebProjectPreviewBuildSlotError::GlobalLimit => "预览构建队列繁忙,请稍后重试",
|
||
WebProjectPreviewBuildSlotError::ProjectAlreadyRunning => "当前工程已有预览构建正在执行",
|
||
};
|
||
AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_details(json!({
|
||
"provider": "web-project",
|
||
"projectId": project_id,
|
||
"message": message,
|
||
}))
|
||
}
|
||
|
||
fn map_web_project_client_error(error: SpacetimeClientError) -> AppError {
|
||
let status = match &error {
|
||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||
SpacetimeClientError::Procedure(message)
|
||
if message.contains("不存在") || message.contains("不属于当前项目") =>
|
||
{
|
||
StatusCode::NOT_FOUND
|
||
}
|
||
SpacetimeClientError::Procedure(message) if message.contains("无权") => {
|
||
StatusCode::FORBIDDEN
|
||
}
|
||
SpacetimeClientError::Procedure(message) if message.contains("已存在") => {
|
||
StatusCode::CONFLICT
|
||
}
|
||
SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||
_ => StatusCode::BAD_GATEWAY,
|
||
};
|
||
AppError::from_status(status).with_details(json!({
|
||
"provider": "web-project",
|
||
"message": error.to_string(),
|
||
}))
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use axum::http::StatusCode;
|
||
use shared_contracts::web_project::{WebProjectPatch, WebProjectPatchOperation};
|
||
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn rename_file_rejects_existing_target_path() {
|
||
let base_files = vec![
|
||
build_text_file(
|
||
"src/App.tsx",
|
||
"export default function App() { return null; }\n",
|
||
)
|
||
.expect("source file should be valid"),
|
||
build_text_file("src/App.css", "body { margin: 0; }\n")
|
||
.expect("target file should be valid"),
|
||
];
|
||
let patch = WebProjectPatch {
|
||
operations: vec![WebProjectPatchOperation::RenameFile {
|
||
from_path: "src/App.tsx".to_string(),
|
||
to_path: "src/App.css".to_string(),
|
||
}],
|
||
};
|
||
|
||
let error = apply_web_project_patch(&base_files, &patch)
|
||
.expect_err("rename should reject existing target");
|
||
|
||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||
assert!(error.body_text().contains("目标文件已存在"));
|
||
}
|
||
}
|