feat: bootstrap namespace-driven Tessera renderer
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{Arc, RwLock},
|
||||
thread,
|
||||
};
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use winit::event_loop::EventLoopProxy;
|
||||
|
||||
use crate::{
|
||||
app::AppEvent,
|
||||
compiler,
|
||||
document::{NamespaceState, RenderDocument},
|
||||
};
|
||||
|
||||
pub const API_ADDRESS: &str = "127.0.0.1:4737";
|
||||
const MAX_CODE_SIZE: u64 = 1_048_576;
|
||||
|
||||
pub fn start(
|
||||
namespace: Arc<RwLock<NamespaceState>>,
|
||||
proxy: EventLoopProxy<AppEvent>,
|
||||
) -> Result<()> {
|
||||
let server = Server::http(API_ADDRESS)
|
||||
.map_err(|error| anyhow::anyhow!(error))
|
||||
.with_context(|| format!("bind Tessera API to {API_ADDRESS}"))?;
|
||||
thread::Builder::new()
|
||||
.name("tessera-agent-api".to_owned())
|
||||
.spawn(move || {
|
||||
for request in server.incoming_requests() {
|
||||
handle(request, &namespace, &proxy);
|
||||
}
|
||||
})
|
||||
.context("start Tessera agent API thread")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle(
|
||||
mut request: Request,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
proxy: &EventLoopProxy<AppEvent>,
|
||||
) {
|
||||
let method = request.method().clone();
|
||||
let path = request
|
||||
.url()
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(request.url())
|
||||
.to_owned();
|
||||
|
||||
let response = if method == Method::Get && path == "/agent" {
|
||||
match namespace.read() {
|
||||
Ok(namespace) => markdown(StatusCode(200), agent_guide(&namespace)),
|
||||
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
|
||||
}
|
||||
} else if let Some(addressed_namespace) = namespace_from_path(&path).map(str::to_owned) {
|
||||
code_request(&mut request, method, &addressed_namespace, namespace, proxy)
|
||||
} else if method == Method::Options {
|
||||
text(StatusCode(204), "", "text/plain")
|
||||
} else {
|
||||
error_response(StatusCode(404), "route not found; start with GET /agent")
|
||||
};
|
||||
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
fn code_request(
|
||||
request: &mut Request,
|
||||
method: Method,
|
||||
addressed_namespace: &str,
|
||||
namespace: &Arc<RwLock<NamespaceState>>,
|
||||
proxy: &EventLoopProxy<AppEvent>,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let Ok(current) = namespace.read() else {
|
||||
return error_response(StatusCode(500), "namespace lock is poisoned");
|
||||
};
|
||||
if addressed_namespace != current.name {
|
||||
return error_response(
|
||||
StatusCode(404),
|
||||
format!(
|
||||
"namespace `{addressed_namespace}` is not active; current namespace is `{}`",
|
||||
current.name
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if method == Method::Get {
|
||||
let mut response = text(
|
||||
StatusCode(200),
|
||||
¤t.document.source,
|
||||
"text/plain; charset=utf-8",
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"X-Tessera-Revision",
|
||||
¤t.revision.to_string(),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
drop(current);
|
||||
|
||||
match method {
|
||||
Method::Put => match read_code(request) {
|
||||
Ok(code) => match compiler::compile(&code, addressed_namespace) {
|
||||
Ok(document) => {
|
||||
if let Err(error) = document.validate() {
|
||||
return compile_error(error.to_string());
|
||||
}
|
||||
match namespace.write() {
|
||||
Ok(mut current) => {
|
||||
current.revision += 1;
|
||||
current.document = document;
|
||||
let revision = current.revision;
|
||||
drop(current);
|
||||
let _ = proxy.send_event(AppEvent::DocumentUpdated);
|
||||
json(
|
||||
StatusCode(200),
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"namespace": addressed_namespace,
|
||||
"revision": revision,
|
||||
"diagnostics": []
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
|
||||
}
|
||||
}
|
||||
Err(error) => compile_error(error.to_string()),
|
||||
},
|
||||
Err(error) => error_response(StatusCode(400), error.to_string()),
|
||||
},
|
||||
Method::Delete => match namespace.write() {
|
||||
Ok(mut current) => {
|
||||
current.revision += 1;
|
||||
current.document = RenderDocument::default();
|
||||
let revision = current.revision;
|
||||
drop(current);
|
||||
let _ = proxy.send_event(AppEvent::DocumentUpdated);
|
||||
json(
|
||||
StatusCode(200),
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"namespace": addressed_namespace,
|
||||
"revision": revision
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(_) => error_response(StatusCode(500), "namespace lock is poisoned"),
|
||||
},
|
||||
Method::Options => text(StatusCode(204), "", "text/plain"),
|
||||
_ => error_response(StatusCode(405), "code supports GET, PUT, and DELETE"),
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix("/v1/namespaces/")?
|
||||
.strip_suffix("/code")
|
||||
.filter(|namespace| !namespace.is_empty() && !namespace.contains('/'))
|
||||
}
|
||||
|
||||
fn read_code(request: &mut Request) -> Result<String> {
|
||||
if request
|
||||
.body_length()
|
||||
.is_some_and(|length| length as u64 > MAX_CODE_SIZE)
|
||||
{
|
||||
anyhow::bail!("code exceeds the 1 MiB limit");
|
||||
}
|
||||
let mut code = String::new();
|
||||
request
|
||||
.as_reader()
|
||||
.take(MAX_CODE_SIZE + 1)
|
||||
.read_to_string(&mut code)
|
||||
.context("read code")?;
|
||||
if code.len() as u64 > MAX_CODE_SIZE {
|
||||
anyhow::bail!("code exceeds the 1 MiB limit");
|
||||
}
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn agent_guide(namespace: &NamespaceState) -> String {
|
||||
format!(
|
||||
r#"# Tessera Agent API
|
||||
|
||||
Current namespace: `{name}`
|
||||
Current revision: `{revision}`
|
||||
|
||||
Tessera stores code by namespace. It has no files. Submit code only; Tessera parses,
|
||||
checks, compiles, and renders it.
|
||||
|
||||
## Code CRUD
|
||||
|
||||
- Read: `GET http://{address}/v1/namespaces/{name}/code`
|
||||
- Create or update: `PUT http://{address}/v1/namespaces/{name}/code`
|
||||
- Delete: `DELETE http://{address}/v1/namespaces/{name}/code`
|
||||
|
||||
Send PUT bodies as `text/plain; charset=utf-8`.
|
||||
|
||||
## Minimal syntax
|
||||
|
||||
```tessera
|
||||
namespace {name}
|
||||
|
||||
use tessera.ui::{{Column, Row, Spacer, Text}}
|
||||
|
||||
export scene Main {{
|
||||
render {{
|
||||
Column(gap: 12, padding: 24) {{
|
||||
Text("Hello", role: .title)
|
||||
Text("Rendered by Tessera")
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
Supported by this syntax experiment: one exported scene, one render root,
|
||||
`Column`, `Row`, `Spacer`, `Text`, `gap`, `padding`, and `.title`.
|
||||
Compilation errors reject the update and preserve the last rendered revision.
|
||||
"#,
|
||||
name = namespace.name,
|
||||
revision = namespace.revision,
|
||||
address = API_ADDRESS
|
||||
)
|
||||
}
|
||||
|
||||
fn compile_error(message: String) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
json(
|
||||
StatusCode(422),
|
||||
serde_json::json!({
|
||||
"ok": false,
|
||||
"diagnostics": [{
|
||||
"severity": "error",
|
||||
"message": message
|
||||
}]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn error_response(
|
||||
status: StatusCode,
|
||||
message: impl Into<String>,
|
||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
json(status, serde_json::json!({ "error": message.into() }))
|
||||
}
|
||||
|
||||
fn json(status: StatusCode, value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
text(
|
||||
status,
|
||||
&value.to_string(),
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
fn markdown(status: StatusCode, value: String) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
text(status, &value, "text/markdown; charset=utf-8")
|
||||
}
|
||||
|
||||
fn text(status: StatusCode, value: &str, content_type: &str) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let mut response = Response::from_string(value).with_status_code(status);
|
||||
add_header(&mut response, "Content-Type", content_type);
|
||||
add_header(
|
||||
&mut response,
|
||||
"Access-Control-Allow-Origin",
|
||||
"http://127.0.0.1",
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, PUT, DELETE, OPTIONS",
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type",
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn add_header(response: &mut Response<std::io::Cursor<Vec<u8>>>, name: &str, value: &str) {
|
||||
if let Ok(header) = Header::from_bytes(name, value) {
|
||||
response.add_header(header);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_namespace_only_from_code_routes() {
|
||||
assert_eq!(
|
||||
namespace_from_path("/v1/namespaces/app.main/code"),
|
||||
Some("app.main")
|
||||
);
|
||||
assert_eq!(namespace_from_path("/v1/namespaces/app.main"), None);
|
||||
assert_eq!(namespace_from_path("/v1/namespaces/a/b/code"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_contains_only_code_crud_routes() {
|
||||
let guide = agent_guide(&NamespaceState::default());
|
||||
assert!(guide.contains("Code CRUD"));
|
||||
assert!(guide.contains("PUT http://127.0.0.1:4737"));
|
||||
assert!(!guide.contains("display_list"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user