use crate::modules::{ModuleGraph, compile_module_graph};
use noxid_compiler_core::Compilation;
use noxid_graph::EdgeKind;
use noxid_source::json_escape;
use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Component, Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime};
pub struct AppOptions {
pub entry: Option<String>,
pub title: Option<String>,
pub out_dir: PathBuf,
pub development: bool,
}
pub struct AppBuild {
pub entry: String,
pub components: BTreeSet<String>,
pub assets: Vec<String>,
}
pub fn build_app(
input: &Path,
compilation: &Compilation,
module_graph: Option<&ModuleGraph>,
options: &AppOptions,
) -> Result<AppBuild, String> {
if compilation.has_errors() {
return Err(format!(
"{} diagnostic(s); no application emitted",
compilation.diagnostics.len()
));
}
let stem = input
.file_stem()
.and_then(|value| value.to_str())
.ok_or("input has no valid file stem")?;
let entry = select_entry(compilation, stem, options.entry.as_deref())?;
let merged_graph = module_graph.map(ModuleGraph::merged_graph);
let components = component_closure(merged_graph.as_ref().unwrap_or(&compilation.graph), &entry);
let compilations: Vec<&Compilation> = module_graph
.map(|graph| {
graph
.modules()
.map(|(_, module)| &module.compilation)
.collect()
})
.unwrap_or_else(|| vec![compilation]);
let asset_dir = options.out_dir.join("assets");
fs::create_dir_all(&asset_dir)
.map_err(|error| format!("cannot create {}: {error}", asset_dir.display()))?;
let mut assets = Vec::new();
let mut runtime_imports = BTreeSet::new();
for selected in &components {
let owner = compilations
.iter()
.find(|compilation| {
compilation
.program
.components
.iter()
.any(|component| &component.name == selected)
})
.ok_or_else(|| format!("component `{selected}` has no compiled module"))?;
let generated = owner
.generated
.as_ref()
.ok_or_else(|| format!("component `{selected}` did not generate JavaScript"))?;
let (javascript, imports) = if generated.modules.is_empty() {
(&generated.javascript, &generated.runtime_imports)
} else {
let module = generated
.modules
.iter()
.find(|module| &module.component == selected)
.ok_or_else(|| format!("component `{selected}` has no generated chunk"))?;
(&module.javascript, &module.runtime_imports)
};
let owner_stem = module_graph
.and_then(|graph| graph.module_for_component(selected))
.map(|(path, _)| path)
.unwrap_or(input)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(selected);
let rewrite = |contents: &str| {
["validators", "resources", "streams", "agents"]
.into_iter()
.fold(contents.to_string(), |output, suffix| {
output.replace(
&format!("./{owner_stem}.{suffix}.js"),
&format!("./{selected}.{suffix}.js"),
)
})
};
let name = format!("{selected}.js");
write(&asset_dir.join(&name), &rewrite(javascript))?;
runtime_imports.extend(imports.iter().cloned());
assets.push(format!("assets/{name}"));
let component = owner
.program
.components
.iter()
.find(|component| &component.name == selected)
.expect("selected component belongs to compilation");
let uses_resources = !component.resources.is_empty();
let uses_streams = !component.streams.is_empty();
let uses_agents = !component.agents.is_empty();
if uses_resources {
runtime_imports.insert("createQueryClient".into());
runtime_imports.insert("createResourceDefinition".into());
}
if uses_streams {
runtime_imports.insert("createStreamDefinition".into());
}
if uses_agents {
runtime_imports.insert("createAgentDefinition".into());
}
for (suffix, contents, required) in [
(
"validators",
owner.generated_validators.as_deref(),
uses_resources || uses_streams || uses_agents,
),
(
"resources",
owner.generated_resources.as_deref(),
uses_resources,
),
("streams", owner.generated_streams.as_deref(), uses_streams),
("agents", owner.generated_agents.as_deref(), uses_agents),
] {
if !required {
continue;
}
if let Some(contents) = contents {
let name = format!("{selected}.{suffix}.js");
write(&asset_dir.join(&name), &rewrite(contents))?;
assets.push(format!("assets/{name}"));
}
}
}
let entry_module = format!("{entry}.js");
let selected = compilations
.iter()
.flat_map(|compilation| compilation.program.components.iter())
.filter(|component| components.contains(&component.name))
.collect::<Vec<_>>();
let has_resources = selected
.iter()
.any(|component| !component.resources.is_empty());
let has_streams = selected
.iter()
.any(|component| !component.streams.is_empty());
let has_agents = selected
.iter()
.any(|component| !component.agents.is_empty());
if has_resources {
runtime_imports.insert("createQueryClient".into());
runtime_imports.insert("createResourceDefinition".into());
}
if has_streams {
runtime_imports.insert("createStreamDefinition".into());
}
if has_agents {
runtime_imports.insert("createAgentDefinition".into());
}
write(
&asset_dir.join("noxid-runtime.js"),
&compilation.runtime_javascript_for(&runtime_imports),
)?;
assets.push("assets/noxid-runtime.js".into());
let css_name = format!("{stem}.css");
let css = compilations
.iter()
.map(|compilation| compilation.css_for_components(&components))
.collect::<Vec<_>>()
.join("\n");
write(&asset_dir.join(&css_name), &css)?;
assets.push(format!("assets/{css_name}"));
let boot = format!(
"import {{ mount{} }} from \"./assets/{}\";\n\nconst root = document.querySelector(\"#app\");\nif (!root) throw new Error(\"NOXID_APP_ROOT_MISSING\");\nconst instance = mount{}(root);\nglobalThis.__NOXID_APP__ = Object.freeze({{ entry: \"component:{}\", instance }});\n",
entry, entry_module, entry, entry
);
write(&options.out_dir.join("app.js"), &boot)?;
assets.push("app.js".into());
let title = options.title.as_deref().unwrap_or(&entry);
write(
&options.out_dir.join("index.html"),
&html_shell(title, &format!("./assets/{css_name}"), options.development),
)?;
assets.push("index.html".into());
assets.sort();
write(
&options.out_dir.join("app.manifest.json"),
&app_manifest(input, &entry, &components, &assets),
)?;
write(
&options.out_dir.join("app.meta.json"),
&compilation.metadata_json(),
)?;
write(
&options.out_dir.join("app.bundle.json"),
&app_bundle(
&entry,
&components,
&runtime_imports,
has_resources,
has_streams,
has_agents,
),
)?;
Ok(AppBuild {
entry,
components,
assets,
})
}
pub fn serve(input: PathBuf, mut options: AppOptions, port: u16) -> Result<(), String> {
options.development = true;
let watch_input = input.clone();
let build_input = input.clone();
let out_dir = options.out_dir.clone();
serve_rebuilding(
&input,
&out_dir,
port,
"/",
move || source_stamp(&watch_input),
move || rebuild(&build_input, &options),
)
}
pub fn serve_rebuilding<S, F, B>(
label: &Path,
out_dir: &Path,
port: u16,
base_path: &str,
mut source_stamp: F,
mut rebuild: B,
) -> Result<(), String>
where
S: Eq,
F: FnMut() -> Result<S, String>,
B: FnMut() -> Result<(), String>,
{
let mut revision = 1_u64;
let mut stamp = source_stamp()?;
rebuild()?;
let listener = TcpListener::bind(("127.0.0.1", port))
.map_err(|error| format!("cannot bind http://127.0.0.1:{port}: {error}"))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("cannot configure development server: {error}"))?;
println!(
"noxid dev serving {} at http://127.0.0.1:{port}{}",
label.display(),
if base_path == "/" {
"/".to_string()
} else {
format!("{base_path}/")
}
);
loop {
let current = source_stamp()?;
if current != stamp {
stamp = current;
match rebuild() {
Ok(()) => {
revision = revision.saturating_add(1);
println!("rebuilt {} (revision {revision})", label.display());
}
Err(error) => {
eprintln!("noxid: rebuild failed; serving last known good output\n{error}")
}
}
}
match listener.accept() {
Ok((mut stream, _)) => {
stream
.set_nonblocking(false)
.map_err(|error| format!("cannot configure development request: {error}"))?;
if let Err(error) = respond(&mut stream, out_dir, revision, base_path) {
eprintln!("noxid: development request failed: {error}");
}
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(40));
}
Err(error) => return Err(format!("development server failed: {error}")),
}
}
}
fn rebuild(input: &Path, options: &AppOptions) -> Result<(), String> {
let root = input.parent().unwrap_or_else(|| Path::new("."));
let components = root.join("components");
let graph = compile_module_graph(
input,
root,
components.exists().then_some(components.as_path()),
)?;
build_app(input, &graph.root().compilation, Some(&graph), options).map(|_| ())
}
fn select_entry(
compilation: &Compilation,
stem: &str,
requested: Option<&str>,
) -> Result<String, String> {
let names = compilation
.program
.components
.iter()
.map(|component| component.name.clone())
.collect::<Vec<_>>();
if names.is_empty() {
return Err("application requires at least one component".into());
}
if let Some(requested) = requested {
return names
.iter()
.any(|name| name == requested)
.then(|| requested.to_string())
.ok_or_else(|| {
format!(
"unknown entry component `{requested}`; available: {}",
names.join(", ")
)
});
}
if names.len() == 1 {
return Ok(names[0].clone());
}
if names.iter().any(|name| name == stem) {
return Ok(stem.to_string());
}
Err(format!(
"multiple components require --entry <name>; available: {}",
names.join(", ")
))
}
fn component_closure(graph: &noxid_graph::ApplicationGraph, entry: &str) -> BTreeSet<String> {
let mut selected = BTreeSet::from([entry.to_string()]);
let mut pending = vec![format!("component:{entry}")];
while let Some(current) = pending.pop() {
let owned = graph
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Owns && edge.from.as_str() == current)
.map(|edge| edge.to.clone())
.collect::<BTreeSet<_>>();
for edge in &graph.edges {
if edge.kind != EdgeKind::Mounts || !owned.contains(&edge.from) {
continue;
}
let Some(name) = edge.to.as_str().strip_prefix("component:") else {
continue;
};
if selected.insert(name.to_string()) {
pending.push(edge.to.to_string());
}
}
}
selected
}
fn html_shell(title: &str, css: &str, development: bool) -> String {
let reload = if development {
r#" <script type="module">
let revision = null;
async function poll() {
try {
const response = await fetch("/__noxid/revision", { cache: "no-store" });
const next = await response.text();
if (revision !== null && revision !== next) location.reload();
revision = next;
} catch {}
setTimeout(poll, 400);
}
poll();
</script>
"#
} else {
""
};
format!(
"<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>{}</title>\n <link rel=\"stylesheet\" href=\"{}\">\n </head>\n <body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"./app.js\"></script>\n{reload} </body>\n</html>\n",
html_escape(title),
html_escape(css),
)
}
fn app_manifest(
input: &Path,
entry: &str,
components: &BTreeSet<String>,
assets: &[String],
) -> String {
format!(
"{{\n \"schemaVersion\": 1,\n \"source\": \"{}\",\n \"entry\": \"component:{}\",\n \"components\": [{}],\n \"assets\": [{}]\n}}\n",
json_escape(&input.to_string_lossy()),
json_escape(entry),
components
.iter()
.map(|name| format!("\"component:{}\"", json_escape(name)))
.collect::<Vec<_>>()
.join(", "),
assets
.iter()
.map(|name| format!("\"{}\"", json_escape(name)))
.collect::<Vec<_>>()
.join(", "),
)
}
fn app_bundle(
entry: &str,
components: &BTreeSet<String>,
imports: &BTreeSet<String>,
resources: bool,
streams: bool,
agents: bool,
) -> String {
format!(
"{{\n \"schemaVersion\": 1,\n \"entry\": \"component:{}\",\n \"componentChunks\": [{}],\n \"runtimeImports\": [{}],\n \"hasResourceModule\": {},\n \"hasStreamModule\": {},\n \"hasAgentModule\": {}\n}}\n",
json_escape(entry),
components
.iter()
.map(|name| format!("\"assets/{name}.js\""))
.collect::<Vec<_>>()
.join(", "),
imports
.iter()
.map(|name| format!("\"{}\"", json_escape(name)))
.collect::<Vec<_>>()
.join(", "),
resources,
streams,
agents,
)
}
fn write(path: &Path, contents: &str) -> Result<(), String> {
fs::write(path, contents).map_err(|error| format!("cannot write {}: {error}", path.display()))
}
fn source_stamp(path: &Path) -> Result<(SystemTime, u64), String> {
let metadata = fs::metadata(path)
.map_err(|error| format!("cannot inspect {}: {error}", path.display()))?;
Ok((
metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
metadata.len(),
))
}
fn respond(
stream: &mut TcpStream,
root: &Path,
revision: u64,
base_path: &str,
) -> Result<(), String> {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|error| error.to_string())?;
let mut buffer = [0_u8; 8192];
let count = stream
.read(&mut buffer)
.map_err(|error| error.to_string())?;
let request = String::from_utf8_lossy(&buffer[..count]);
let first = request.lines().next().unwrap_or_default();
let mut parts = first.split_whitespace();
let method = parts.next().unwrap_or_default();
let target = parts.next().unwrap_or("/");
if method != "GET" && method != "HEAD" {
return send(
stream,
405,
"text/plain; charset=utf-8",
b"Method Not Allowed",
method == "HEAD",
);
}
let request_path = target.split('?').next().unwrap_or("/");
let revision_path = if base_path == "/" {
"/__noxid/revision".to_string()
} else {
format!("{base_path}/__noxid/revision")
};
if request_path == revision_path {
return send(
stream,
200,
"text/plain; charset=utf-8",
revision.to_string().as_bytes(),
method == "HEAD",
);
}
let Some(application_path) = strip_base_path(request_path, base_path) else {
return send(
stream,
404,
"text/plain; charset=utf-8",
b"Not Found",
method == "HEAD",
);
};
let relative = safe_relative_path(application_path)?;
let mut path = root.join(&relative);
if path.is_dir() {
path = path.join("index.html");
}
if !path.exists()
&& !application_path
.rsplit('/')
.next()
.unwrap_or_default()
.contains('.')
{
path = root.join("index.html");
}
match fs::read(&path) {
Ok(body) => send(stream, 200, mime(&path), &body, method == "HEAD"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => send(
stream,
404,
"text/plain; charset=utf-8",
b"Not Found",
method == "HEAD",
),
Err(error) => Err(format!("cannot read {}: {error}", path.display())),
}
}
fn strip_base_path<'a>(request_path: &'a str, base_path: &str) -> Option<&'a str> {
if base_path == "/" {
return Some(request_path);
}
if request_path == base_path {
return Some("/");
}
request_path
.strip_prefix(base_path)
.filter(|remainder| remainder.starts_with('/'))
}
fn safe_relative_path(target: &str) -> Result<PathBuf, String> {
if target.contains('%') || target.contains('\\') {
return Err("invalid request path".into());
}
let path = Path::new(target.trim_start_matches('/'));
if path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
&& !path.as_os_str().is_empty()
{
return Err("invalid request path".into());
}
Ok(path.to_path_buf())
}
fn send(
stream: &mut TcpStream,
status: u16,
content_type: &str,
body: &[u8],
head: bool,
) -> Result<(), String> {
let reason = match status {
200 => "OK",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let header = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(header.as_bytes())
.and_then(|_| if head { Ok(()) } else { stream.write_all(body) })
.map_err(|error| error.to_string())
}
fn mime(path: &Path) -> &'static str {
match path.extension().and_then(|value| value.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("txt") => "text/plain; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
_ => "application/octet-stream",
}
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use noxid_compiler_core::compile;
use noxid_source::{SourceFile, SourceId};
use std::process;
#[test]
fn request_paths_cannot_escape_the_app_root() {
assert!(safe_relative_path("/assets/app.js").is_ok());
assert!(safe_relative_path("/../secret").is_err());
assert!(safe_relative_path("/%2e%2e/secret").is_err());
assert!(safe_relative_path("/..\\secret").is_err());
}
#[test]
fn deployment_base_is_a_strict_path_boundary() {
assert_eq!(strip_base_path("/console", "/console"), Some("/"));
assert_eq!(
strip_base_path("/console/accounts/7", "/console"),
Some("/accounts/7")
);
assert_eq!(strip_base_path("/consolex", "/console"), None);
}
#[test]
fn app_build_emits_only_the_entry_component_closure() {
let source = SourceFile::new(
SourceId(0),
"Bundle.nox",
r#"component Child {
view { <p class="child">Child</p> }
style { .child { color: blue; } }
}
component App {
view { <main><Child /></main> }
}
component Unrelated {
state { count: Int = 0 }
actions { increment() { count = count + 1 } }
view { <button +click={increment}>Unrelated</button> }
style { button { color: hotpink; } }
}"#,
);
let compilation = compile(&source);
assert!(!compilation.has_errors(), "{:?}", compilation.diagnostics);
let out_dir = std::env::temp_dir().join(format!(
"noxid-app-build-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
let build = build_app(
Path::new("Bundle.nox"),
&compilation,
None,
&AppOptions {
entry: Some("App".into()),
title: None,
out_dir: out_dir.clone(),
development: false,
},
)
.expect("app build");
assert_eq!(
build.components,
BTreeSet::from(["App".into(), "Child".into()])
);
assert!(out_dir.join("assets/App.js").exists());
assert!(out_dir.join("assets/Child.js").exists());
assert!(!out_dir.join("assets/Unrelated.js").exists());
let bundle = fs::read_to_string(out_dir.join("app.bundle.json")).expect("bundle");
assert!(!bundle.contains("runAction"));
let css = fs::read_to_string(out_dir.join("assets/Bundle.css")).expect("css");
assert!(css.contains("color: blue"));
assert!(!css.contains("hotpink"));
fs::remove_dir_all(out_dir).expect("remove test output");
}
}