use crate::modules::{ModuleGraph, compile_module_graph_with_options};
use noxid_codegen_server_js::{ServerTracingExport, ServerTracingMode};
use noxid_compiler_core::{devtools_javascript, runtime_javascript_for_imports};
use noxid_devtools_protocol::{
DevtoolsProject, DevtoolsRoute, DevtoolsRouteCache, DevtoolsRouteMetadata,
DevtoolsRouteParameter, DevtoolsRouteQuery,
};
use noxid_graph::{ApplicationGraph, EdgeKind};
use noxid_hmr_ir::ComponentSignature;
use noxid_ir::{
ComponentRenderMode, EndpointHandler, EndpointKind, EndpointMethod, EndpointRouteContract,
ExternalExecutionTarget, PropMode, RouteCacheMode, RouteRenderMode, SemanticId,
SemanticProgram, SemanticViewNode,
};
use noxid_router_ir::{
MiddlewareDefinition, RouteCachePolicy, RouteDefinition, RouteLoader, RouteLoaderArgument,
RouteMetadata, RouteParameter, RouteProgram, RouteQueryParameter, RouteRender, RouteTarget,
RouteTargetKind,
};
use noxid_source::{SourceFile, SourceId, json_escape};
use noxid_types::Type;
use std::collections::{BTreeMap, BTreeSet, hash_map::DefaultHasher};
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::UNIX_EPOCH;
use std::time::{Instant, SystemTime};
const ROUTER_RUNTIME: &str = include_str!("router_runtime.js");
const CLIENT_ROUTER_RUNTIME: &str = include_str!("router_client_runtime.js");
const HMR_CLIENT: &str = include_str!("hmr_client.js");
const DEVTOOLS_PANEL: &str = include_str!("devtools_panel.js");
const PRERENDER_RUNNER_SOURCE: &str = include_str!("../../../tools/noxid-prerender.mjs");
static PRERENDER_RUNNER_COUNTER: AtomicU64 = AtomicU64::new(0);
struct EmbeddedPrerenderRunner {
directory: PathBuf,
entry: PathBuf,
}
impl EmbeddedPrerenderRunner {
fn prepare() -> Result<Self, String> {
for _ in 0..100 {
let suffix = PRERENDER_RUNNER_COUNTER.fetch_add(1, Ordering::Relaxed);
let directory = std::env::temp_dir().join(format!(
"noxid-prerender-runner-{}-{suffix}",
std::process::id()
));
match fs::create_dir(&directory) {
Ok(()) => {
let entry = directory.join("noxid-prerender.mjs");
if let Err(error) = fs::write(&entry, PRERENDER_RUNNER_SOURCE) {
let _ = fs::remove_dir_all(&directory);
return Err(format!(
"cannot prepare the embedded Noxid prerender runner: {error}"
));
}
return Ok(Self { directory, entry });
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"cannot create a temporary directory for the embedded Noxid prerender runner: {error}"
));
}
}
}
Err(
"cannot allocate a unique temporary directory for the embedded Noxid prerender runner"
.into(),
)
}
fn entry(&self) -> &Path {
&self.entry
}
}
impl Drop for EmbeddedPrerenderRunner {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.directory);
}
}
#[derive(Clone, Debug)]
pub struct ProjectBuildOptions {
pub out_dir: PathBuf,
pub title: Option<String>,
pub development: bool,
pub strict_npm: bool,
}
#[derive(Clone, Debug)]
pub struct ProjectBuild {
pub routes: usize,
pub endpoints: usize,
pub endpoint_paths: Vec<String>,
pub tasks: usize,
pub queues: usize,
pub queue_worker: bool,
pub live_resources: usize,
pub presences: usize,
pub api_docs: bool,
pub mcp: bool,
pub components: usize,
pub middleware: usize,
pub route_loaders: usize,
pub ssr_routes: usize,
pub server_shell_routes: usize,
pub prerender_routes: usize,
pub prerender_entries: usize,
pub isr_routes: usize,
pub swr_routes: usize,
pub assets: usize,
pub compiled_targets: usize,
pub reused_targets: usize,
pub server_actions: usize,
pub edge_actions: usize,
pub worker_actions: usize,
pub external_browser_modules: usize,
pub native_esm_eligible: bool,
pub persistent_cache_hit: bool,
}
#[derive(Clone, Debug)]
struct ProjectConfig {
root: PathBuf,
manifest: PathBuf,
application_id: Option<String>,
title: String,
base_path: String,
routes_dir: PathBuf,
components_dir: PathBuf,
middleware_dir: PathBuf,
host: Option<PathBuf>,
server_dir: PathBuf,
server_host: Option<PathBuf>,
server_global_middleware: Vec<ServerMiddlewareSource>,
server_plugins: Vec<ServerPluginSource>,
server_route_middleware_dir: PathBuf,
worker_entry: Option<PathBuf>,
server_runtime: String,
server_storage_driver: ServerStorageDriver,
server_blob_dir: String,
server_pubsub_driver: ServerPubSubDriver,
server_pubsub_coalescing_ms: u64,
db_pool: u64,
shutdown_timeout_ms: u64,
server_secrets: Vec<String>,
/// The WO-30 models this project declares, by name. Agents live in route
/// files that cannot see `server/models/`, so `model:` resolves through
/// this list.
declared_models: Vec<String>,
/// The `server/agents/*.md` instruction assets, read once here so the
/// compiler embeds the prompt instead of the server reading it.
agent_instructions: Vec<noxid_compiler_core::AgentInstructionsAsset>,
/// The endpoint facts WO-31 tool-registry derivation needs. Filled by
/// `prepare_project` once endpoints are compiled; empty for doors that
/// never see them.
endpoint_tools: Vec<noxid_compiler_core::EndpointToolFact>,
server_tracing: ServerTracingMode,
server_tracing_export: ServerTracingExport,
server_tracing_service_name: String,
api_docs: bool,
mcp: bool,
queue_worker: bool,
queue_drain: bool,
queue_drain_budget_ms: u64,
vercel_max_duration: Option<u64>,
global_middleware: Vec<String>,
deploy_adapter: String,
prerender_entries: Vec<String>,
route_cache: BTreeMap<String, RouteCacheConfig>,
global_style: Option<PathBuf>,
static_assets_dir: Option<PathBuf>,
tailwind: Option<TailwindConfig>,
}
impl ProjectConfig {
/// The project facts semantic analysis cannot read from one source file.
/// Every project compile goes through here so a declaration's meaning is
/// the same whichever door reached it.
fn analysis_options(&self) -> noxid_compiler_core::AnalysisOptions {
noxid_compiler_core::AnalysisOptions {
server_secrets: self.server_secrets.clone(),
models: self.declared_models.clone(),
agent_instructions: self.agent_instructions.clone(),
endpoint_tools: self.endpoint_tools.clone(),
}
}
/// A stable rendering of every project fact analysis depends on. Target
/// fingerprints stamp it, so changing a declared model, an embedded
/// instructions asset, or an endpoint capability recompiles the pages
/// whose agents those facts describe instead of reusing a stale answer.
fn analysis_options_stamp(&self) -> String {
let options = self.analysis_options();
let mut stamp = String::new();
for secret in &options.server_secrets {
stamp.push_str(secret);
stamp.push('\u{1f}');
}
stamp.push('\u{1e}');
for model in &options.models {
stamp.push_str(model);
stamp.push('\u{1f}');
}
stamp.push('\u{1e}');
for asset in &options.agent_instructions {
stamp.push_str(&asset.path);
stamp.push('\u{1f}');
stamp.push_str(&asset.contents);
stamp.push('\u{1f}');
}
stamp.push('\u{1e}');
for endpoint in &options.endpoint_tools {
stamp.push_str(&endpoint.name);
stamp.push('\u{1f}');
stamp.push_str(&endpoint.version.to_string());
stamp.push('\u{1f}');
for capability in &endpoint.capabilities {
stamp.push_str(capability);
stamp.push('\u{1f}');
}
stamp.push_str(&endpoint.schema_hash);
stamp.push('\u{1e}');
}
stamp
}
fn compile_module_graph(&self, path: &Path) -> Result<ModuleGraph, String> {
compile_module_graph_with_options(
path,
&self.root,
Some(&self.components_dir),
&BTreeMap::new(),
&self.analysis_options(),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ServerStorageDriver {
Memory,
Fs,
Postgres,
Redis,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ServerPubSubDriver {
Memory,
Postgres,
Redis,
}
impl ServerPubSubDriver {
fn parse(value: &str, manifest: &Path, line: usize) -> Result<Self, String> {
match value {
"memory" => Ok(Self::Memory),
"postgres" => Ok(Self::Postgres),
"redis" => Ok(Self::Redis),
unknown => Err(format!(
"error[SERVER_LIVE_DRIVER_INVALID]: {}:{line}: [server] live_driver must be `memory`, `postgres`, or `redis`, not `{unknown}`",
manifest.display(),
)),
}
}
}
fn server_pubsub_runtime_options(
config: &ProjectConfig,
live_surface: bool,
) -> Option<noxid_codegen_server_js::PubSubRuntimeOptions> {
live_surface.then_some(noxid_codegen_server_js::PubSubRuntimeOptions {
driver: match config.server_pubsub_driver {
ServerPubSubDriver::Memory => noxid_codegen_server_js::PubSubDriver::Memory,
ServerPubSubDriver::Postgres => noxid_codegen_server_js::PubSubDriver::Postgres,
ServerPubSubDriver::Redis => noxid_codegen_server_js::PubSubDriver::Redis,
},
coalescing_ms: config.server_pubsub_coalescing_ms,
})
}
fn parse_server_tracing_mode(
value: &str,
manifest: &Path,
line: usize,
) -> Result<ServerTracingMode, String> {
match value {
"off" => Ok(ServerTracingMode::Off),
"requests" => Ok(ServerTracingMode::Requests),
"full" => Ok(ServerTracingMode::Full),
unknown => Err(format!(
"error[SERVER_TRACING_MODE_INVALID]: {}:{line}: [server] tracing must be `off`, `requests`, or `full`, not `{unknown}`",
manifest.display(),
)),
}
}
fn parse_server_tracing_export(
value: &str,
manifest: &Path,
line: usize,
) -> Result<ServerTracingExport, String> {
match value {
"stdout" => Ok(ServerTracingExport::Stdout),
"otlp" => Ok(ServerTracingExport::Otlp),
unknown => Err(format!(
"error[TRACING_EXPORT_UNKNOWN]: {}:{line}: [server] tracing_export must be `stdout` or `otlp`, not `{unknown}`; use `stdout` for one-line noxid.trace.v1 JSON or `otlp` for OTLP/HTTP JSON traces",
manifest.display(),
)),
}
}
impl ServerStorageDriver {
fn parse(value: &str, manifest: &Path, line: usize) -> Result<Self, String> {
match value {
"memory" => Ok(Self::Memory),
"fs" => Ok(Self::Fs),
"postgres" => Ok(Self::Postgres),
"redis" => Ok(Self::Redis),
unknown => Err(format!(
"error[SERVER_STORAGE_DRIVER_INVALID]: {}:{line}: [server] storage must be `memory`, `fs`, `postgres`, or `redis`, not `{unknown}`",
manifest.display(),
)),
}
}
}
#[derive(Clone, Debug)]
struct ServerMiddlewareSource {
/// The exact lexical filename controls global middleware ordering.
filename: String,
/// The exact lexical stem is the stable handler key used by generated registries.
stem: String,
source: PathBuf,
}
#[derive(Clone, Debug)]
struct ServerPluginSource {
/// The exact lexical filename controls startup order.
filename: String,
/// The exact lexical stem is the stable emitted module identity.
stem: String,
source: PathBuf,
}
type DiscoveredServerDirectory = (
Option<PathBuf>,
Vec<ServerMiddlewareSource>,
Vec<ServerPluginSource>,
);
#[derive(Clone, Debug)]
struct TailwindConfig {
input: PathBuf,
}
#[derive(Clone, Debug)]
struct RouteCacheConfig {
mode: RouteCacheMode,
revalidate_seconds: u64,
stale_seconds: u64,
vary: Vec<String>,
tags: Vec<String>,
}
#[derive(Clone)]
struct PreparedProject {
config: ProjectConfig,
routes: RouteProgram,
graph: ApplicationGraph,
compiled: BTreeMap<PathBuf, CompiledTarget>,
endpoints: BTreeMap<PathBuf, CompiledEndpoint>,
tasks: BTreeMap<PathBuf, CompiledTask>,
queues: BTreeMap<PathBuf, CompiledQueue>,
models: BTreeMap<PathBuf, CompiledModel>,
}
#[derive(Clone)]
struct CompiledModel {
program: SemanticProgram,
graph: ApplicationGraph,
models: noxid_model_ir::ModelProgram,
validation: noxid_validation_ir::ValidationProgram,
semantic_json: String,
input_fingerprint: u64,
}
#[derive(Clone)]
struct CompiledEndpoint {
program: SemanticProgram,
graph: ApplicationGraph,
execution: noxid_execution_ir::ExecutionProgram,
validation: noxid_validation_ir::ValidationProgram,
semantic_json: String,
input_fingerprint: u64,
}
#[derive(Clone)]
struct CompiledTask {
program: SemanticProgram,
graph: ApplicationGraph,
execution: noxid_execution_ir::ExecutionProgram,
semantic_json: String,
input_fingerprint: u64,
}
#[derive(Clone)]
struct CompiledQueue {
program: SemanticProgram,
graph: ApplicationGraph,
execution: noxid_execution_ir::ExecutionProgram,
validation: noxid_validation_ir::ValidationProgram,
semantic_json: String,
input_fingerprint: u64,
}
#[derive(Clone)]
struct CompiledTarget {
target: RouteTarget,
component: noxid_ir::ComponentDefinition,
program: noxid_ir::SemanticProgram,
diagnostics: Vec<noxid_source::Diagnostic>,
stream_definitions: Vec<noxid_ir::StreamDefinition>,
semantic_json: String,
accessibility_json: String,
design_json: String,
diagnostics_json: String,
devtools_json: String,
graph: ApplicationGraph,
execution: noxid_execution_ir::ExecutionProgram,
validation: noxid_validation_ir::ValidationProgram,
javascript: String,
css: String,
runtime_imports: BTreeSet<String>,
npm_sources: Vec<String>,
validators: Option<String>,
resources: Option<String>,
streams: Option<String>,
agents: Option<String>,
dependencies: Vec<CompiledComponentChunk>,
component_imports: usize,
auto_component_imports: usize,
source_files: Vec<PathBuf>,
input_fingerprint: u64,
external_browser_modules: BTreeSet<String>,
local_browser_modules: BTreeMap<PathBuf, String>,
}
#[derive(Clone)]
struct CompiledComponentChunk {
component: noxid_ir::ComponentDefinition,
component_name: String,
javascript: String,
css: String,
runtime_imports: BTreeSet<String>,
validators: Option<String>,
resources: Option<String>,
streams: Option<String>,
agents: Option<String>,
}
#[derive(Default)]
pub struct ProjectSession {
compiled: BTreeMap<PathBuf, CompiledTarget>,
}
#[derive(Default)]
struct PreparationStats {
compiled_targets: usize,
reused_targets: usize,
}
#[derive(Clone, Debug)]
enum RouteSegment {
Static(String),
Parameter(String),
CatchAll(String),
}
pub fn is_project_input(input: &Path) -> bool {
input.is_dir() || input.file_name().and_then(|name| name.to_str()) == Some("Noxid.toml")
}
pub fn source_import_context(input: &Path) -> Result<Option<(PathBuf, PathBuf)>, String> {
let canonical = fs::canonicalize(input)
.map_err(|error| format!("cannot resolve {}: {error}", input.display()))?;
let start = if canonical.is_dir() {
canonical.as_path()
} else {
canonical
.parent()
.ok_or_else(|| format!("{} has no source directory", canonical.display()))?
};
for ancestor in start.ancestors() {
let manifest = ancestor.join("Noxid.toml");
if !manifest.is_file() {
continue;
}
let config = load_config(&manifest)?;
return Ok(Some((config.root, config.components_dir)));
}
Ok(None)
}
pub fn default_out_dir(input: &Path, development: bool) -> PathBuf {
let root = if input.is_dir() {
input
} else {
input.parent().unwrap_or_else(|| Path::new("."))
};
if development {
root.join("target/noxid-dev")
} else {
root.join("dist")
}
}
pub fn routes_json(input: &Path) -> Result<String, String> {
Ok(prepare_project(input)?.routes.to_json())
}
pub fn graph_json(input: &Path) -> Result<String, String> {
Ok(prepare_project(input)?.graph.to_json())
}
pub fn query_graph(input: &Path) -> Result<ApplicationGraph, String> {
Ok(prepare_project(input)?.graph)
}
pub fn query_programs(input: &Path) -> Result<Vec<noxid_ir::SemanticProgram>, String> {
Ok(prepare_project(input)?
.compiled
.values()
.map(|target| target.program.clone())
.collect())
}
pub fn query_diagnostics(input: &Path) -> Result<Vec<noxid_source::Diagnostic>, String> {
Ok(prepare_project(input)?
.compiled
.values()
.flat_map(|target| target.diagnostics.clone())
.collect())
}
pub fn impact_json(input: &Path, symbol: &str) -> Result<String, String> {
let id = SemanticId::parse(symbol).ok_or_else(|| format!("invalid semantic ID `{symbol}`"))?;
prepare_project(input)?
.graph
.impact(&id)
.map(|impact| impact.to_json())
.ok_or_else(|| format!("unknown semantic symbol `{symbol}`"))
}
pub fn semantic_edit_context(
input: &Path,
symbol: &SemanticId,
) -> Result<(PathBuf, ApplicationGraph, noxid_ir::ComponentDefinition), String> {
let prepared = prepare_project(input)?;
if !prepared.graph.nodes.contains_key(symbol) {
return Err(format!("unknown semantic symbol `{symbol}`"));
}
let mut component_id = symbol.clone();
while prepared
.graph
.nodes
.get(&component_id)
.is_some_and(|candidate| candidate.kind != noxid_graph::NodeKind::Component)
{
component_id = prepared
.graph
.edges
.iter()
.find(|edge| edge.kind == EdgeKind::Owns && edge.to == component_id)
.map(|edge| edge.from.clone())
.ok_or_else(|| format!("`{symbol}` is not owned by a component"))?;
}
let component_name = prepared
.graph
.nodes
.get(&component_id)
.filter(|owner| owner.kind == noxid_graph::NodeKind::Component)
.map(|owner| owner.name.as_str())
.ok_or_else(|| format!("cannot determine the component that owns `{symbol}`"))?;
let mut sources = Vec::new();
walk_files(&prepared.config.root, &mut |path| {
if path.extension().and_then(|extension| extension.to_str()) == Some("nox") {
sources.push(path.to_path_buf());
}
})?;
sources.sort();
let source = sources
.into_iter()
.find(|path| {
fs::read_to_string(path)
.ok()
.map(|text| {
let source = SourceFile::new(SourceId(0), path, text);
noxid_parser::parse(&source)
.ast
.components
.iter()
.any(|component| component.name.text == component_name)
})
.unwrap_or(false)
})
.ok_or_else(|| format!("cannot locate source for component `{component_name}`"))?;
let module_graph = prepared.config.compile_module_graph(&source)?;
let definition = module_graph
.modules()
.flat_map(|(_, module)| module.compilation.program.components.iter())
.find(|component| component.name == component_name)
.cloned()
.ok_or_else(|| {
format!("cannot compile component `{component_name}` for semantic editing")
})?;
Ok((source, module_graph.merged_graph(), definition))
}
pub fn semantic_project_context(
input: &Path,
) -> Result<(Vec<SourceFile>, ApplicationGraph), String> {
let prepared = prepare_project(input)?;
let mut paths = Vec::new();
walk_files(&prepared.config.root, &mut |path| {
if path.extension().and_then(|extension| extension.to_str()) == Some("nox") {
paths.push(path.to_path_buf());
}
})?;
paths.sort();
let mut sources = Vec::with_capacity(paths.len());
for (index, path) in paths.into_iter().enumerate() {
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
sources.push(SourceFile::new(SourceId(index as u32), path, text));
}
Ok((sources, prepared.graph))
}
pub fn validate_semantic_edit(input: &Path) -> Result<(), String> {
let prepared = prepare_project(input)?;
let invalid = prepared
.compiled
.values()
.filter(|target| target.diagnostics_json.contains("\"severity\":\"error\""))
.map(|target| target.target.source.clone())
.collect::<Vec<_>>();
if invalid.is_empty() {
Ok(())
} else {
Err(format!(
"semantic edit produced compiler errors in {}",
invalid.join(", ")
))
}
}
pub fn product_json(input: &Path) -> Result<String, String> {
let prepared = prepare_project(input)?;
Ok(format!(
"{{\"schemaVersion\":1,\"components\":[{}]}}",
prepared
.compiled
.values()
.map(|target| target.component.product_json())
.collect::<Vec<_>>()
.join(",")
))
}
pub fn mcp_resource_json(input: &Path, resource: &str) -> Result<String, String> {
let prepared = prepare_project(input)?;
match resource {
"project" => {
let product = format!(
"{{\"schemaVersion\":1,\"components\":[{}]}}",
prepared
.compiled
.values()
.map(|target| target.component.product_json())
.collect::<Vec<_>>()
.join(",")
);
Ok(format!(
"{{\"schemaVersion\":1,\"routes\":{},\"graph\":{},\"product\":{product}}}",
prepared.routes.to_json(),
prepared.graph.to_json(),
))
}
"graph" => Ok(prepared.graph.to_json()),
"routes" => Ok(prepared.routes.to_json()),
"semantic" | "accessibility" | "design-system" | "diagnostics" | "devtools" => {
let files = prepared
.compiled
.values()
.map(|target| {
let value = match resource {
"semantic" => &target.semantic_json,
"accessibility" => &target.accessibility_json,
"design-system" => &target.design_json,
"diagnostics" => &target.diagnostics_json,
_ => &target.devtools_json,
};
format!(
"{{\"source\":\"{}\",\"data\":{}}}",
json_escape(&target.target.source),
value
)
})
.collect::<Vec<_>>()
.join(",");
Ok(format!("{{\"schemaVersion\":1,\"files\":[{files}]}}"))
}
_ => Err(format!("unknown Noxid MCP resource `{resource}`")),
}
}
pub fn mcp_graph_query_json(
input: &Path,
operation: &str,
symbol: Option<&str>,
) -> Result<String, String> {
let prepared = prepare_project(input)?;
if let Some(kind) = operation.strip_prefix("list_") {
let kind = match kind {
"components" => "component",
"resources" => "resource",
"agents" => "agent",
"state_machines" => "state-machine",
"requirements" => "requirement",
"scenarios" => "scenario",
other => other,
};
let nodes = prepared
.graph
.nodes
.values()
.filter(|node| node.kind.as_str() == kind)
.map(|node| {
format!(
"{{\"id\":\"{}\",\"kind\":\"{}\",\"name\":\"{}\"}}",
node.id,
node.kind.as_str(),
json_escape(&node.name)
)
})
.collect::<Vec<_>>()
.join(",");
return Ok(format!("{{\"schemaVersion\":1,\"symbols\":[{nodes}]}}"));
}
let symbol = symbol.ok_or_else(|| format!("{operation} requires a semantic ID"))?;
let id = SemanticId::parse(symbol).ok_or_else(|| format!("invalid semantic ID `{symbol}`"))?;
let node = prepared
.graph
.nodes
.get(&id)
.ok_or_else(|| format!("unknown semantic symbol `{symbol}`"))?;
if operation == "calculate_change_impact" {
return prepared
.graph
.impact(&id)
.map(|impact| impact.to_json())
.ok_or_else(|| format!("unknown semantic symbol `{symbol}`"));
}
let edges = match operation {
"find_dependencies" => prepared.graph.dependencies_of(&id),
"find_dependents" | "find_references" => prepared.graph.dependents_of(&id),
"inspect_symbol" => prepared
.graph
.edges
.iter()
.filter(|edge| edge.from == id || edge.to == id)
.collect(),
_ => return Err(format!("unknown graph operation `{operation}`")),
};
let relations = edges
.into_iter()
.map(|edge| {
format!(
"{{\"from\":\"{}\",\"kind\":\"{}\",\"to\":\"{}\"}}",
edge.from,
edge.kind.as_str(),
edge.to
)
})
.collect::<Vec<_>>()
.join(",");
Ok(format!(
"{{\"schemaVersion\":1,\"symbol\":{{\"id\":\"{}\",\"kind\":\"{}\",\"name\":\"{}\",\"span\":{{\"start\":{},\"end\":{}}}}},\"relationships\":[{relations}]}}",
node.id,
node.kind.as_str(),
json_escape(&node.name),
node.span.start,
node.span.end
))
}
pub fn base_path(input: &Path) -> Result<String, String> {
Ok(load_config(input)?.base_path)
}
pub fn deploy_adapter(input: &Path) -> Result<String, String> {
Ok(load_config(input)?.deploy_adapter)
}
pub fn server_runtime(input: &Path) -> Result<String, String> {
Ok(load_config(input)?.server_runtime)
}
pub fn server_tracing_export(input: &Path) -> Result<ServerTracingExport, String> {
Ok(load_config(input)?.server_tracing_export)
}
pub fn shutdown_timeout_ms(input: &Path) -> Result<u64, String> {
Ok(load_config(input)?.shutdown_timeout_ms)
}
pub fn execution_counts(input: &Path) -> Result<(usize, usize, usize, usize), String> {
let prepared = prepare_project(input)?;
let execution = execution_program(&prepared);
Ok((
execution
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "server")
.count(),
execution
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "edge")
.count(),
execution
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "worker")
.count(),
execution.endpoints.len(),
))
}
pub fn task_schedules(input: &Path) -> Result<Vec<(String, String)>, String> {
let prepared = prepare_project(input)?;
let mut schedules = execution_program(&prepared)
.tasks
.into_iter()
.map(|task| (task.name, task.schedule))
.collect::<Vec<_>>();
schedules.sort();
Ok(schedules)
}
pub fn vercel_max_duration(input: &Path) -> Result<u64, String> {
let prepared = prepare_project(input)?;
if let Some(configured) = prepared.config.vercel_max_duration {
return Ok(configured);
}
let longest_ms = execution_program(&prepared)
.endpoints
.iter()
.map(|endpoint| endpoint.timeout_ms)
.max();
Ok(longest_ms
.map(|milliseconds| milliseconds.div_ceil(1_000).clamp(1, 300))
.unwrap_or(30))
}
pub fn queue_drain_settings(input: &Path) -> Result<(bool, u64), String> {
let config = load_config(input)?;
Ok((config.queue_drain, config.queue_drain_budget_ms))
}
pub fn queue_names(input: &Path) -> Result<Vec<String>, String> {
let prepared = prepare_project(input)?;
let mut names = execution_program(&prepared)
.queues
.into_iter()
.map(|queue| queue.name)
.collect::<Vec<_>>();
names.sort();
Ok(names)
}
fn validate_queue_database_url(queue_count: usize) -> Result<(), String> {
let Some(database_url) = std::env::var_os("DATABASE_URL") else {
return Ok(());
};
let database_url = database_url.to_string_lossy();
if queue_count == 0 || database_url.starts_with("postgres://") {
return Ok(());
}
Err(
"error[QUEUE_REQUIRES_SHARED_DATABASE]: durable queues require a shared PostgreSQL database; set DATABASE_URL to a postgres:// URL, or remove the queue declarations before using MySQL or SQLite for application data"
.into(),
)
}
pub fn build_project(input: &Path, options: &ProjectBuildOptions) -> Result<ProjectBuild, String> {
// A scaffolded project carries its own vetted plugin files and the exact
// package pins those files were reviewed against. An edited adapter or a
// lockfile that disagrees refuses the build before anything is emitted, and
// before the persistent cache can answer — a cached artifact must never
// stand in for a review that no longer holds.
if is_project_input(input) {
crate::npm_admission::validate_vendored_plugins(&load_config(input)?.root)?;
}
if std::env::var_os("DATABASE_URL")
.is_some_and(|url| !url.to_string_lossy().starts_with("postgres://"))
{
validate_queue_database_url(queue_names(input)?.len())?;
}
// Strict npm admission always re-validates; the persistent cache must
// not mask a deleted or tampered vetting record.
if !options.development
&& !options.strict_npm
&& let Some(build) = restore_persistent_build(input, options)?
{
return Ok(build);
}
let (prepared, stats) = prepare_project_with_cache(input, None)?;
let mut npm_sources = prepared
.compiled
.values()
.flat_map(|target| target.npm_sources.iter().cloned())
.collect::<Vec<_>>();
for target in prepared.compiled.values() {
npm_sources.extend(noxid_compiler_core::runtime_external_packages_for_imports(
&target.runtime_imports,
));
for dependency in &target.dependencies {
npm_sources.extend(noxid_compiler_core::runtime_external_packages_for_imports(
&dependency.runtime_imports,
));
}
}
for warning in crate::npm_admission::validate_npm_imports(
&prepared.config.root,
npm_sources,
options.strict_npm,
)? {
eprintln!("{warning}");
}
let build = emit_project(&prepared, options, &stats)?;
if !options.development {
store_persistent_build(input, options, &build)?;
}
Ok(build)
}
/// The endpoints and engine agents of a scenario build, taken from the same
/// prepared project the artifact was emitted from. Agents come with the
/// project's derived tool registry, which a single-file compilation of the
/// route that declares one cannot see.
pub(crate) struct ScenarioServerSurface {
pub(crate) endpoints: Vec<noxid_ir::EndpointDefinition>,
pub(crate) agents: Vec<noxid_ir::AgentDefinition>,
}
pub(crate) fn build_endpoint_scenario_artifact(
input: &Path,
out_dir: &Path,
) -> Result<ScenarioServerSurface, String> {
let (prepared, stats) = prepare_project_with_cache(input, None)?;
emit_project(
&prepared,
&ProjectBuildOptions {
out_dir: out_dir.to_path_buf(),
title: None,
development: true,
strict_npm: false,
},
&stats,
)?;
let mut endpoints = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.endpoints.iter().cloned())
.collect::<Vec<_>>();
endpoints.sort_by(|left, right| left.id.cmp(&right.id));
let mut agents = prepared
.compiled
.values()
.flat_map(|compiled| compiled.program.agents.iter().cloned())
.collect::<Vec<_>>();
agents.sort_by(|left, right| left.id.cmp(&right.id));
agents.dedup_by(|left, right| left.id == right.id);
Ok(ScenarioServerSurface { endpoints, agents })
}
pub(crate) struct ApiContractUpdate {
path: PathBuf,
contents: String,
}
pub(crate) fn prepare_api_contract_gate(input: &Path) -> Result<Option<ApiContractUpdate>, String> {
if !is_project_input(input) {
return Ok(None);
}
let manifest = if input.is_dir() {
input.join("Noxid.toml")
} else {
input.to_path_buf()
};
if !manifest.is_file() {
// Scenario tests also accept loose source directories. They are not
// routed projects and therefore have no API contract to publish.
return Ok(None);
}
let prepared = prepare_project(input)?;
let contents = api_contract_document(&prepared)?;
let path = prepared.config.root.join("api-contract.json");
if path.is_file() {
let committed = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let before = noxid_openapi::parse_contract(&committed)?;
let after = noxid_openapi::parse_contract(&contents)?;
let diff = noxid_openapi::diff_contracts(&before, &after);
if !diff.breaking.is_empty() {
let differences = diff
.breaking
.iter()
.map(|difference| format!("\n - {difference}"))
.collect::<String>();
return Err(format!(
"error[API_CONTRACT_BREAKING_CHANGE]: the current endpoint contract breaks api-contract.json:{differences}\nIncrement `version: <n>` on each changed endpoint to accept its breaking differences; additive changes update the committed contract automatically."
));
}
if !diff.changed {
return Ok(None);
}
}
Ok(Some(ApiContractUpdate { path, contents }))
}
pub(crate) fn apply_api_contract_update(update: ApiContractUpdate) -> Result<(), String> {
let temporary = update.path.with_extension("json.nox-contract-tmp");
fs::write(&temporary, update.contents)
.map_err(|error| format!("cannot write {}: {error}", temporary.display()))?;
if let Err(error) = fs::rename(&temporary, &update.path) {
let _ = fs::remove_file(&temporary);
return Err(format!(
"cannot publish {} atomically: {error}",
update.path.display()
));
}
Ok(())
}
impl ProjectSession {
pub fn build(
&mut self,
input: &Path,
options: &ProjectBuildOptions,
) -> Result<ProjectBuild, String> {
let (prepared, stats) = prepare_project_with_cache(input, Some(&self.compiled))?;
let build = emit_project(&prepared, options, &stats)?;
self.compiled = prepared.compiled.clone();
Ok(build)
}
}
pub fn serve_project(
input: PathBuf,
options: ProjectBuildOptions,
port: u16,
) -> Result<(), String> {
crate::farm::serve_project(input, options, port)
}
pub fn project_revision(input: &Path) -> Result<u64, String> {
project_stamp(&load_config(input)?)
}
pub fn benchmark_json(input: &Path) -> Result<String, String> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
let out_dir =
std::env::temp_dir().join(format!("noxid-benchmark-{}-{nonce}", std::process::id()));
let options = ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: true,
strict_npm: false,
};
let mut session = ProjectSession::default();
let mut samples = Vec::new();
let mut first_compiled = 0;
let mut reused = 0;
for index in 0..8 {
let started = Instant::now();
let build = session.build(input, &options)?;
samples.push(started.elapsed().as_secs_f64() * 1_000.0);
if index == 0 {
first_compiled = build.compiled_targets;
} else {
reused += build.reused_targets;
}
}
samples.sort_by(f64::total_cmp);
let output_bytes = directory_bytes(&out_dir)?;
fs::remove_dir_all(&out_dir).map_err(|error| {
format!(
"cannot clean benchmark output {}: {error}",
out_dir.display()
)
})?;
let median = samples[samples.len() / 2];
let p95 = samples[((samples.len() as f64 * 0.95).ceil() as usize).saturating_sub(1)];
Ok(format!(
"{{\"schemaVersion\":1,\"kind\":\"project\",\"samples\":{},\"coldCompiledTargets\":{},\"warmReusedTargets\":{},\"medianMs\":{median:.3},\"p95Ms\":{p95:.3},\"outputBytes\":{output_bytes}}}",
samples.len(),
first_compiled,
reused,
))
}
fn directory_bytes(path: &Path) -> Result<u64, String> {
let mut bytes = 0;
for entry in fs::read_dir(path)
.map_err(|error| format!("cannot read benchmark output {}: {error}", path.display()))?
{
let entry = entry.map_err(|error| error.to_string())?;
let metadata = entry.metadata().map_err(|error| error.to_string())?;
if metadata.is_dir() {
bytes += directory_bytes(&entry.path())?;
} else {
bytes += metadata.len();
}
}
Ok(bytes)
}
fn prepare_project(input: &Path) -> Result<PreparedProject, String> {
prepare_project_with_cache(input, None).map(|(prepared, _)| prepared)
}
fn prepare_project_with_cache(
input: &Path,
cache: Option<&BTreeMap<PathBuf, CompiledTarget>>,
) -> Result<(PreparedProject, PreparationStats), String> {
let mut stats = PreparationStats::default();
let prepared = prepare_project_inner(input, cache, &mut stats)?;
Ok((prepared, stats))
}
fn prepare_project_inner(
input: &Path,
cache: Option<&BTreeMap<PathBuf, CompiledTarget>>,
stats: &mut PreparationStats,
) -> Result<PreparedProject, String> {
// WO-51: this is where the CLI walks a whole project's sources. An
// interrupted `repair --safe` is settled here too, not only at the command
// boundary, so a long-lived reader — the dev server, the MCP server — never
// loads a tree that is part repaired and part original.
crate::repair_transaction::recover_before_read(input)?;
let mut config = load_config(input)?;
let middleware = discover_middleware(&config)?;
let middleware_names = middleware
.iter()
.map(|definition| definition.name.clone())
.collect::<BTreeSet<_>>();
for name in &config.global_middleware {
if !middleware_names.contains(name) {
return Err(format!(
"global middleware `{name}` has no module at {}",
config.middleware_dir.display()
));
}
}
let endpoints = compile_project_endpoints(&config, &middleware_names)?;
// Agents live in route files, which cannot see `server/api/**`. Endpoints
// are compiled first, so their derived tool facts are available before any
// page — and therefore any agent — is analyzed.
config.endpoint_tools = endpoint_tool_facts(&endpoints);
let config = config;
let tasks = compile_project_tasks(&config)?;
let queues = compile_project_queues(&config)?;
let models = compile_project_models(&config)?;
if config.queue_worker && queues.is_empty() {
return Err(
"error[QUEUE_WORKER_REQUIRES_QUEUE]: [server] queue_worker = true requires at least one direct server/queues/<name>.nox declaration; remove the flag or declare the worker's queue"
.into(),
);
}
if config.queue_drain && queues.is_empty() {
return Err(
"error[QUEUE_DRAIN_REQUIRES_QUEUE]: [server] queue_drain = true requires at least one direct server/queues/<name>.nox declaration; remove the flag or declare a durable queue"
.into(),
);
}
let page_files = if config.routes_dir.exists() {
discover_named_files(&config.routes_dir, "+page.nox")?
} else {
Vec::new()
};
if page_files.is_empty()
&& endpoints.is_empty()
&& tasks.is_empty()
&& queues.is_empty()
&& models.is_empty()
{
return Err(format!(
"project has no +page.nox files under {}, no typed endpoints under server/api or server/routes, no scheduled tasks under server/tasks, no durable queues under server/queues, and no model declarations under server/models",
config.routes_dir.display()
));
}
let mut compiled = BTreeMap::new();
let mut routes = Vec::new();
let mut component_names = BTreeMap::<String, PathBuf>::new();
for page_file in page_files {
let page_dir = page_file
.parent()
.ok_or("route page has no parent directory")?;
let segments = route_segments(&config.routes_dir, page_dir)?;
let pattern = route_pattern(&segments);
if route_segments_claim_reserved_api(&segments) {
return Err(format!(
"error[API_PREFIX_RESERVED]: page route `{pattern}` can claim the reserved `/api` endpoint prefix; give the route a non-API static first segment or declare a typed file under server/api/"
));
}
if segments.first().is_some_and(
|segment| matches!(segment, RouteSegment::Static(value) if value == "_noxid"),
) {
return Err(format!(
"error[SYSTEM_PREFIX_RESERVED]: page route `{pattern}` claims the compiler-owned `/_noxid` namespace; move the page under a product route prefix"
));
}
if let Some(reserved) = segments.first().and_then(|segment| match segment {
RouteSegment::Static(value)
if crate::deployment::is_reserved_deployment_path_segment(value) =>
{
Some(value)
}
_ => None,
}) {
return Err(format!(
"error[ROUTE_PATH_RESERVED]: page route `{pattern}` starts with reserved top-level path `/{reserved}`; {} are compiler-owned deployment paths. Move the page below a product prefix such as `/docs/{reserved}`",
crate::deployment::reserved_deployment_path_description(),
));
}
let layout_files = layout_files(&config.routes_dir, page_dir)?;
let mut layouts = Vec::new();
for layout_file in layout_files {
if !compiled.contains_key(&layout_file) {
let layout_dir = layout_file
.parent()
.ok_or("layout has no parent directory")?;
let layout_pattern =
route_pattern(&route_segments(&config.routes_dir, layout_dir)?);
let target = compile_target_cached(
&config,
&layout_file,
RouteTargetKind::Layout,
Some(layout_pattern),
&middleware_names,
cache,
stats,
)?;
register_component_name(&mut component_names, &target.target, &layout_file)?;
compiled.insert(layout_file.clone(), target);
}
layouts.push(compiled[&layout_file].target.clone());
}
if !compiled.contains_key(&page_file) {
let target = compile_target_cached(
&config,
&page_file,
RouteTargetKind::Page,
None,
&middleware_names,
cache,
stats,
)?;
register_component_name(&mut component_names, &target.target, &page_file)?;
compiled.insert(page_file.clone(), target);
}
let mut page = compiled[&page_file].target.clone();
let page_component = find_component(&compiled, &page.component_name)?;
let metadata = page_component
.route_metadata
.as_ref()
.map(|metadata| RouteMetadata {
id: metadata.id.clone(),
title: metadata.title.clone(),
description: metadata.description.clone(),
});
let render = RouteRender {
id: SemanticId::route_render(&pattern),
mode: page_component
.route_render
.as_ref()
.map(|render| render.mode)
.unwrap_or(noxid_ir::RouteRenderMode::Client),
};
let route_cache_policy = config
.route_cache
.get(&pattern)
.map(|cache| RouteCachePolicy {
id: SemanticId::route_cache(&pattern),
mode: cache.mode,
revalidate_seconds: cache.revalidate_seconds,
stale_seconds: cache.stale_seconds,
vary: cache.vary.clone(),
tags: cache.tags.clone(),
});
let loading = compile_route_boundary(
&config,
page_dir,
"+loading.nox",
RouteTargetKind::Loading,
&middleware_names,
&mut component_names,
&mut compiled,
cache,
stats,
)?;
let error = compile_route_boundary(
&config,
page_dir,
"+error.nox",
RouteTargetKind::Error,
&middleware_names,
&mut component_names,
&mut compiled,
cache,
stats,
)?;
let parameters = route_parameters(&pattern, &segments, &page, &compiled, &layouts)?;
let query = route_query_parameters(&pattern, &page, &compiled, ¶meters)?;
schedule_route_loaders(&pattern, &mut layouts, &mut page, ¶meters, &query)?;
let middleware_chain = middleware_chain(
&config.global_middleware,
layouts.iter().chain(std::iter::once(&page)),
&compiled,
);
routes.push(RouteDefinition {
id: SemanticId::route(&pattern),
pattern,
metadata,
render,
cache: route_cache_policy,
parameters,
query,
layouts,
page,
loading,
error,
middleware: middleware_chain,
});
}
routes.sort_by_key(|route| route_sort_key(&route.pattern));
let mut seen_patterns = BTreeSet::new();
for route in &routes {
if !seen_patterns.insert(route.pattern.clone()) {
return Err(format!("duplicate route pattern `{}`", route.pattern));
}
}
validate_page_endpoint_route_ownership(&routes, &endpoints)?;
for pattern in config.route_cache.keys() {
if !routes.iter().any(|route| &route.pattern == pattern) {
return Err(format!(
"error[ROUTE_CACHE_PATTERN_UNKNOWN]: render cache rule `{pattern}` does not match a discovered route pattern"
));
}
}
validate_stream_endpoint_client_bindings(&endpoints, &compiled)?;
if let Some((path, task)) = compiled.iter().find_map(|(path, target)| {
target
.program
.tasks
.first()
.map(|task| (path, task.name.as_str()))
}) {
return Err(format!(
"error[TASK_LAYOUT_INVALID]: task `{task}` is declared in {}; scheduled tasks must each be the sole task declaration in a direct `server/tasks/<name>.nox` file",
path.display()
));
}
if let Some((path, queue)) = compiled.iter().find_map(|(path, target)| {
target
.program
.queues
.first()
.map(|queue| (path, queue.name.as_str()))
}) {
return Err(format!(
"error[QUEUE_LAYOUT_INVALID]: queue `{queue}` is declared in {}; durable queues must each be the sole queue declaration in a direct `server/queues/<name>.nox` file",
path.display()
));
}
let route_program = RouteProgram {
base_path: config.base_path.clone(),
middleware,
global_middleware: config
.global_middleware
.iter()
.map(|name| SemanticId::middleware(name))
.collect(),
routes,
};
let mut graph = ApplicationGraph::default();
for target in compiled.values() {
merge_graph(&mut graph, &target.graph);
}
route_program.add_to_graph(&mut graph);
add_execution_route_graph(&compiled, &route_program, &mut graph);
for endpoint in endpoints.values() {
merge_graph(&mut graph, &endpoint.graph);
}
for task in tasks.values() {
merge_graph(&mut graph, &task.graph);
}
for queue in queues.values() {
merge_graph(&mut graph, &queue.graph);
}
for model in models.values() {
merge_graph(&mut graph, &model.graph);
}
Ok(PreparedProject {
config,
routes: route_program,
graph,
compiled,
endpoints,
tasks,
queues,
models,
})
}
fn validate_page_endpoint_route_ownership(
pages: &[RouteDefinition],
endpoints: &BTreeMap<PathBuf, CompiledEndpoint>,
) -> Result<(), String> {
for (endpoint_source, compiled) in endpoints {
let endpoint = &compiled.program.endpoints[0];
let route = endpoint
.route
.as_ref()
.ok_or_else(|| format!("endpoint `{}` has no project route", endpoint.name))?;
for page in pages {
if page_endpoint_patterns_overlap(&page.pattern, &route.path) {
return Err(format!(
"error[ROUTE_PATH_CONFLICT]: page route `{}` in {} and typed endpoint `{}` in {} can both claim {}; every request path must have one owner. Move the endpoint under `server/api/`, or give the page and endpoint distinct static path segments",
page.pattern,
page.page.source,
endpoint.name,
endpoint_source.display(),
route.path,
));
}
}
}
Ok(())
}
fn page_endpoint_patterns_overlap(page: &str, endpoint: &str) -> bool {
let page = page
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let endpoint = endpoint
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let mut index = 0;
while index < page.len() {
let page_segment = page[index];
if page_segment.starts_with("{*") && page_segment.ends_with('}') {
return index < endpoint.len();
}
let Some(endpoint_segment) = endpoint.get(index) else {
return false;
};
let page_is_parameter = page_segment.starts_with('{') && page_segment.ends_with('}');
let endpoint_is_parameter = dynamic_endpoint_parameter(endpoint_segment).is_some();
if !page_is_parameter && !endpoint_is_parameter && page_segment != *endpoint_segment {
return false;
}
index += 1;
}
index == endpoint.len()
}
fn validate_stream_endpoint_client_bindings(
endpoints: &BTreeMap<PathBuf, CompiledEndpoint>,
targets: &BTreeMap<PathBuf, CompiledTarget>,
) -> Result<(), String> {
let mut stream_definitions = BTreeMap::<String, Vec<&noxid_ir::StreamDefinition>>::new();
for target in targets.values() {
for stream in &target.stream_definitions {
stream_definitions
.entry(stream.name.clone())
.or_default()
.push(stream);
}
}
for compiled in endpoints.values() {
for endpoint in compiled
.program
.endpoints
.iter()
.filter(|endpoint| endpoint.kind == EndpointKind::Stream)
{
let Some(bindings) = stream_definitions.get(&endpoint.name) else {
return Err(format!(
"error[STREAM_ENDPOINT_CLIENT_STREAM_REQUIRED]: stream endpoint `{}` requires an ordinary client `stream {}(...) {{ event Ready({}) }}` declaration with the same name so components can consume it through `streams {{ ... }}` and `#stream`",
endpoint.name, endpoint.name, endpoint.result.ty
));
};
for stream in bindings {
let external = stream
.variants
.iter()
.filter(|variant| variant.external)
.collect::<Vec<_>>();
let ready = external.first().copied();
if external.len() != 1
|| ready.is_none_or(|variant| {
variant.name != "Ready"
|| variant.payload_type.as_ref() != Some(&endpoint.result.ty)
|| variant.payload_type_id != endpoint.result.type_id
})
{
return Err(format!(
"error[STREAM_ENDPOINT_CLIENT_EVENTS_MISMATCH]: client stream `{}` must declare exactly one authored event `Ready({})` matching stream endpoint `{}`; `Completed` and `Failed` remain compiler-owned lifecycle events",
stream.name, endpoint.result.ty, endpoint.name
));
}
if stream
.ssr
.as_ref()
.is_some_and(|contract| contract.resume_type.is_some())
{
return Err(format!(
"error[STREAM_ENDPOINT_SSR_RESUME_UNSUPPORTED]: endpoint-bound client stream `{}` cannot declare an SSR `resume` type because synchronous snapshot providers cannot mint compiler-retained SSE event IDs; keep the typed snapshot, remove `resume`, and let the first live connection start fresh",
stream.name
));
}
let expected = endpoint
.params
.iter()
.chain(&endpoint.query)
.map(|field| (field.name.as_str(), &field.ty, field.type_id.as_ref()))
.collect::<Vec<_>>();
let actual = stream
.parameters
.iter()
.map(|parameter| {
(
parameter.name.as_str(),
¶meter.ty,
parameter.type_id.as_ref(),
)
})
.collect::<Vec<_>>();
if expected != actual || !endpoint.body.is_empty() {
let expected_text = endpoint
.params
.iter()
.chain(&endpoint.query)
.map(|field| format!("{}: {}", field.name, field.ty))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"error[STREAM_ENDPOINT_CLIENT_PARAMETERS_MISMATCH]: client stream `{}` parameters must exactly match endpoint `{}` params followed by query fields (`{}`), and stream endpoints cannot declare body fields; update the ordinary stream declaration so URL construction stays typed and deterministic",
stream.name, endpoint.name, expected_text
));
}
}
}
}
Ok(())
}
fn compile_project_tasks(
config: &ProjectConfig,
) -> Result<BTreeMap<PathBuf, CompiledTask>, String> {
let directory = config.server_dir.join("tasks");
if !directory.exists() {
return Ok(BTreeMap::new());
}
let mut paths = fs::read_dir(&directory)
.map_err(|error| {
format!(
"cannot read task directory {}: {error}",
directory.display()
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("cannot read task entry in {}: {error}", directory.display()))?;
paths.sort_by_key(|entry| entry.file_name());
let mut task_names = BTreeMap::<String, PathBuf>::new();
let mut compiled = BTreeMap::new();
for entry in paths {
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| format!("cannot inspect task path {}: {error}", path.display()))?;
if file_type.is_dir() {
return Err(format!(
"error[TASK_LAYOUT_INVALID]: scheduled tasks must be direct `server/tasks/<name>.nox` files; nested directory {} is not a task declaration",
path.display()
));
}
if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("nox")
{
continue;
}
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("task filename is not UTF-8: {}", path.display()))?;
if !is_identifier(stem) {
return Err(format!(
"error[TASK_FILENAME_INVALID]: {} must use an identifier filename `<name>.nox` under server/tasks",
path.display()
));
}
let module_graph = config.compile_module_graph(&path)?;
reject_model_declarations(&module_graph, &path, "task")?;
let root = &module_graph.root().compilation;
if let Some(queue) = root.program.queues.first() {
return Err(format!(
"error[QUEUE_LAYOUT_INVALID]: queue `{}` is declared in task file {}; durable queues belong in direct `server/queues/<name>.nox` files",
queue.name,
path.display()
));
}
if root.program.tasks.len() != 1 {
return Err(format!(
"error[TASK_FILE_DECLARATION_INVALID]: {} must own exactly one `task <Name> {{ ... }}` declaration, found {}; keep shared types and functions in imported task-free .nox modules",
path.display(),
root.program.tasks.len()
));
}
let task = &root.program.tasks[0];
if let Some(previous) = task_names.insert(task.name.clone(), path.clone()) {
return Err(format!(
"error[DUPLICATE_PROJECT_TASK]: task `{}` is declared in both {} and {}; task host keys are exactly `task:<Name>`, so rename one declaration",
task.name,
previous.display(),
path.display()
));
}
let mut imported_tasks = module_graph
.modules()
.filter(|(_, module)| !std::ptr::eq(*module, module_graph.root()))
.flat_map(|(_, module)| {
module
.compilation
.program
.tasks
.iter()
.map(move |task| (module.source.path().to_path_buf(), task.name.clone()))
})
.collect::<Vec<_>>();
imported_tasks.sort();
if !imported_tasks.is_empty() {
let declarations = imported_tasks
.iter()
.map(|(source, name)| format!("`{name}` in {}", source.display()))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"error[TASK_IMPORT_UNROUTED]: {} imports scheduled task declaration(s) {declarations}; every task must be the sole declaration in its own direct server/tasks/<name>.nox file",
path.display()
));
}
let mut source_files = module_graph
.modules()
.map(|(_, module)| module.source.path().to_path_buf())
.collect::<Vec<_>>();
source_files.sort();
source_files.dedup();
let input_fingerprint = target_input_fingerprint(config, &source_files)?;
let program = root.program.clone();
compiled.insert(
path,
CompiledTask {
semantic_json: program.to_json(),
execution: noxid_execution_ir::lower(&program),
graph: module_graph.merged_graph(),
program,
input_fingerprint,
},
);
}
Ok(compiled)
}
fn compile_project_queues(
config: &ProjectConfig,
) -> Result<BTreeMap<PathBuf, CompiledQueue>, String> {
let directory = config.server_dir.join("queues");
if !directory.exists() {
return Ok(BTreeMap::new());
}
let mut paths = fs::read_dir(&directory)
.map_err(|error| {
format!(
"cannot read queue directory {}: {error}",
directory.display()
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
format!(
"cannot read queue entry in {}: {error}",
directory.display()
)
})?;
paths.sort_by_key(|entry| entry.file_name());
let mut queue_names = BTreeMap::<String, PathBuf>::new();
let mut compiled = BTreeMap::new();
for entry in paths {
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| format!("cannot inspect queue path {}: {error}", path.display()))?;
if file_type.is_dir() {
return Err(format!(
"error[QUEUE_LAYOUT_INVALID]: durable queues must be direct `server/queues/<name>.nox` files; nested directory {} is not a queue declaration",
path.display()
));
}
if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("nox")
{
continue;
}
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("queue filename is not UTF-8: {}", path.display()))?;
if !is_identifier(stem) {
return Err(format!(
"error[QUEUE_FILENAME_INVALID]: {} must use an identifier filename `<name>.nox` under server/queues",
path.display()
));
}
let module_graph = config.compile_module_graph(&path)?;
reject_model_declarations(&module_graph, &path, "queue")?;
let root = &module_graph.root().compilation;
if let Some(endpoint) = root.program.endpoints.first() {
return Err(format!(
"error[ENDPOINT_LAYOUT_INVALID]: endpoint `{}` is declared in queue file {}; typed endpoints belong under server/api or server/routes",
endpoint.name,
path.display()
));
}
if let Some(task) = root.program.tasks.first() {
return Err(format!(
"error[TASK_LAYOUT_INVALID]: task `{}` is declared in queue file {}; scheduled tasks belong in direct `server/tasks/<name>.nox` files",
task.name,
path.display()
));
}
if root.program.queues.len() != 1 {
return Err(format!(
"error[QUEUE_FILE_DECLARATION_INVALID]: {} must own exactly one `queue <Name> {{ ... }}` declaration, found {}; keep shared types and functions in imported queue-free .nox modules",
path.display(),
root.program.queues.len()
));
}
let queue = &root.program.queues[0];
if queue.name != stem {
return Err(format!(
"error[QUEUE_FILENAME_MISMATCH]: {} declares queue `{}`, but the direct filename must be `{}.nox`",
path.display(),
queue.name,
queue.name
));
}
if let Some(previous) = queue_names.insert(queue.name.clone(), path.clone()) {
return Err(format!(
"error[DUPLICATE_PROJECT_QUEUE]: queue `{}` is declared in both {} and {}; queue host keys are exactly `queue:<Name>`, so rename one declaration",
queue.name,
previous.display(),
path.display()
));
}
let mut imported_queues = module_graph
.modules()
.filter(|(_, module)| !std::ptr::eq(*module, module_graph.root()))
.flat_map(|(_, module)| {
module
.compilation
.program
.queues
.iter()
.map(move |queue| (module.source.path().to_path_buf(), queue.name.clone()))
})
.collect::<Vec<_>>();
imported_queues.sort();
if !imported_queues.is_empty() {
let declarations = imported_queues
.iter()
.map(|(source, name)| format!("`{name}` in {}", source.display()))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"error[QUEUE_IMPORT_UNROUTED]: {} imports queue declaration(s) {declarations}; every queue must be the sole declaration in its own direct server/queues/<name>.nox file",
path.display()
));
}
let mut source_files = module_graph
.modules()
.map(|(_, module)| module.source.path().to_path_buf())
.collect::<Vec<_>>();
source_files.sort();
source_files.dedup();
let input_fingerprint = target_input_fingerprint(config, &source_files)?;
let program = root.program.clone();
let lowered_validation = noxid_validation_ir::lower(&program);
if !lowered_validation.diagnostics.is_empty() {
return Err(format!(
"error[QUEUE_VALIDATION_LOWERING_FAILED]: queue `{}` could not produce its declared payload validators",
queue.name
));
}
compiled.insert(
path,
CompiledQueue {
semantic_json: program.to_json(),
execution: noxid_execution_ir::lower(&program),
validation: lowered_validation.program,
graph: module_graph.merged_graph(),
program,
input_fingerprint,
},
);
}
Ok(compiled)
}
/// A `model` declaration outside `server/models/` has no owner on disk, so
/// nothing would emit its client or record it in the security manifest. Refuse
/// it at the layout boundary rather than silently dropping it.
fn reject_model_declarations(
graph: &ModuleGraph,
path: &Path,
context: &str,
) -> Result<(), String> {
let mut declared = graph
.modules()
.flat_map(|(_, module)| {
module
.compilation
.program
.models
.iter()
.map(move |model| (module.source.path().to_path_buf(), model.name.clone()))
})
.collect::<Vec<_>>();
declared.sort();
let Some((source, name)) = declared.first() else {
return Ok(());
};
Err(format!(
"error[MODEL_LAYOUT_INVALID]: model `{name}` is declared in {} (reached from {context} file {}); model declarations belong in direct `server/models/<name>.nox` files",
source.display(),
path.display()
))
}
/// WO-30 model declarations are discovered exactly like scheduled tasks and
/// durable queues: one declaration per direct `server/models/<name>.nox` file,
/// so `model:<Name>` has one owner on disk.
fn compile_project_models(
config: &ProjectConfig,
) -> Result<BTreeMap<PathBuf, CompiledModel>, String> {
let directory = config.server_dir.join("models");
if !directory.exists() {
return Ok(BTreeMap::new());
}
let mut paths = fs::read_dir(&directory)
.map_err(|error| {
format!(
"cannot read model directory {}: {error}",
directory.display()
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
format!(
"cannot read model entry in {}: {error}",
directory.display()
)
})?;
paths.sort_by_key(|entry| entry.file_name());
let mut model_names = BTreeMap::<String, PathBuf>::new();
let mut compiled = BTreeMap::new();
for entry in paths {
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| format!("cannot inspect model path {}: {error}", path.display()))?;
if file_type.is_dir() {
return Err(format!(
"error[MODEL_LAYOUT_INVALID]: model declarations must be direct `server/models/<name>.nox` files; nested directory {} is not a model declaration",
path.display()
));
}
if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("nox")
{
continue;
}
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("model filename is not UTF-8: {}", path.display()))?;
if !is_identifier(stem) {
return Err(format!(
"error[MODEL_FILENAME_INVALID]: {} must use an identifier filename `<name>.nox` under server/models",
path.display()
));
}
let module_graph = config.compile_module_graph(&path)?;
let root = &module_graph.root().compilation;
if let Some(endpoint) = root.program.endpoints.first() {
return Err(format!(
"error[ENDPOINT_LAYOUT_INVALID]: endpoint `{}` is declared in model file {}; typed endpoints belong under server/api or server/routes",
endpoint.name,
path.display()
));
}
if let Some(task) = root.program.tasks.first() {
return Err(format!(
"error[TASK_LAYOUT_INVALID]: task `{}` is declared in model file {}; scheduled tasks belong in direct `server/tasks/<name>.nox` files",
task.name,
path.display()
));
}
if let Some(queue) = root.program.queues.first() {
return Err(format!(
"error[QUEUE_LAYOUT_INVALID]: queue `{}` is declared in model file {}; durable queues belong in direct `server/queues/<name>.nox` files",
queue.name,
path.display()
));
}
if root.program.models.len() != 1 {
return Err(format!(
"error[MODEL_FILE_DECLARATION_INVALID]: {} must own exactly one `model <Name> {{ ... }}` declaration, found {}; keep shared types in imported model-free .nox modules",
path.display(),
root.program.models.len()
));
}
let model = &root.program.models[0];
if let Some(previous) = model_names.insert(model.name.clone(), path.clone()) {
return Err(format!(
"error[DUPLICATE_PROJECT_MODEL]: model `{}` is declared in both {} and {}; model identities are exactly `model:<Name>`, so rename one declaration",
model.name,
previous.display(),
path.display()
));
}
let mut imported_models = module_graph
.modules()
.filter(|(_, module)| !std::ptr::eq(*module, module_graph.root()))
.flat_map(|(_, module)| {
module
.compilation
.program
.models
.iter()
.map(move |model| (module.source.path().to_path_buf(), model.name.clone()))
})
.collect::<Vec<_>>();
imported_models.sort();
if !imported_models.is_empty() {
let declarations = imported_models
.iter()
.map(|(source, name)| format!("`{name}` in {}", source.display()))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"error[MODEL_IMPORT_UNROUTED]: {} imports model declaration(s) {declarations}; every model must be the sole declaration in its own direct server/models/<name>.nox file",
path.display()
));
}
let mut source_files = module_graph
.modules()
.map(|(_, module)| module.source.path().to_path_buf())
.collect::<Vec<_>>();
source_files.sort();
source_files.dedup();
let input_fingerprint = target_input_fingerprint(config, &source_files)?;
let program = root.program.clone();
let lowered_validation = noxid_validation_ir::lower(&program);
if let Some(diagnostic) = lowered_validation.diagnostics.first() {
return Err(format!(
"error[{}]: {} ({})",
diagnostic.code,
diagnostic.message,
path.display()
));
}
compiled.insert(
path,
CompiledModel {
semantic_json: program.to_json(),
models: noxid_model_ir::lower(&program),
validation: lowered_validation.program,
graph: module_graph.merged_graph(),
program,
input_fingerprint,
},
);
}
Ok(compiled)
}
fn compile_project_endpoints(
config: &ProjectConfig,
middleware_names: &BTreeSet<String>,
) -> Result<BTreeMap<PathBuf, CompiledEndpoint>, String> {
let mut discovered = Vec::new();
for (directory, api) in [
(config.server_dir.join("api"), true),
(config.server_dir.join("routes"), false),
] {
if !directory.exists() {
continue;
}
discover_endpoint_files(&directory, &directory, api, &mut discovered)?;
}
discovered.sort_by(|left, right| left.0.cmp(&right.0));
let mut endpoint_names = BTreeMap::<String, PathBuf>::new();
let mut route_owners = BTreeMap::<(EndpointMethod, String), PathBuf>::new();
let mut compiled = BTreeMap::new();
for (path, route, in_api_directory) in discovered {
let module_graph = config.compile_module_graph(&path)?;
reject_unrouted_endpoint_declarations(&module_graph, Some(&path))?;
reject_model_declarations(&module_graph, &path, "endpoint")?;
let root = &module_graph.root().compilation;
if let Some(queue) = root.program.queues.first() {
return Err(format!(
"error[QUEUE_LAYOUT_INVALID]: queue `{}` is declared in endpoint file {}; durable queues belong in direct `server/queues/<name>.nox` files",
queue.name,
path.display()
));
}
if let Some(task) = root.program.tasks.first() {
return Err(format!(
"error[TASK_LAYOUT_INVALID]: task `{}` is declared in endpoint file {}; scheduled tasks belong in direct `server/tasks/<name>.nox` files",
task.name,
path.display()
));
}
if root.program.endpoints.len() != 1 {
return Err(format!(
"error[ENDPOINT_FILE_DECLARATION_INVALID]: {} must own exactly one `endpoint <Name> {{ ... }}` declaration, found {}; keep shared types and functions in imported .nox modules",
path.display(),
root.program.endpoints.len()
));
}
let mut program = root.program.clone();
let endpoint = &mut program.endpoints[0];
endpoint.attach_route(route.clone());
if route.method == EndpointMethod::Get {
if endpoint.invalidation_declared {
return Err(format!(
"error[ENDPOINT_READ_INVALIDATION_UNSUPPORTED]: endpoint `{}` is routed as GET and cannot declare `invalidates`; remove the clause because reads never publish live invalidations",
endpoint.name
));
}
endpoint.invalidation = noxid_ir::ActionInvalidation {
mode: noxid_ir::ActionInvalidationMode::None,
resources: vec![],
};
} else if endpoint.invalidation.mode == noxid_ir::ActionInvalidationMode::Derived
&& endpoint.invalidation.resources.len() > 1
{
return Err(format!(
"error[AMBIGUOUS_ENDPOINT_RESOURCE_INVALIDATION]: endpoint `{}` is routed as {} in a module with multiple live resources; write `invalidates [Name]` or `invalidates none`",
endpoint.name,
route.method.as_str().to_uppercase()
));
}
if endpoint.kind == EndpointKind::Stream && !in_api_directory {
return Err(format!(
"error[STREAM_ENDPOINT_API_LAYOUT_REQUIRED]: stream endpoint `{}` is declared at {}, but SSE endpoints belong under `server/api/**`; move this file into server/api and keep its `.get.nox` suffix",
endpoint.name,
path.display()
));
}
if endpoint.kind == EndpointKind::Stream && route.method != EndpointMethod::Get {
return Err(format!(
"error[STREAM_ENDPOINT_REQUIRES_GET]: stream endpoint `{}` is routed as {}; SSE connections must use GET, so rename the file to `{}.get.nox`",
endpoint.name,
route.method.as_str().to_uppercase(),
path.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| stem.rsplit_once('.').map(|(name, _)| name))
.unwrap_or("stream")
));
}
if endpoint_route_claims_path(&route.path, "/_noxid/revalidate") {
return Err(format!(
"error[ENDPOINT_SYSTEM_ROUTE_RESERVED]: {} claims {} {}, but `/_noxid/revalidate` is reserved for compiler-owned cache invalidation; move this endpoint to another path and call the system route with the `cache.invalidate` capability",
path.display(),
route.method.as_str().to_uppercase(),
route.path,
));
}
if endpoint_route_claims_path(&route.path, "/_noxid/tasks/task") {
return Err(format!(
"error[ENDPOINT_SYSTEM_ROUTE_RESERVED]: {} claims {} {}, but `/_noxid/tasks/<name>` is reserved for compiler-owned capability-protected task triggers; move this endpoint to another path",
path.display(),
route.method.as_str().to_uppercase(),
route.path,
));
}
if let Some(previous) = endpoint_names.insert(endpoint.name.clone(), path.clone()) {
return Err(format!(
"error[DUPLICATE_PROJECT_ENDPOINT]: endpoint `{}` is declared in both {} and {}; endpoint host keys are exactly `endpoint:<Name>`, so rename one declaration",
endpoint.name,
previous.display(),
path.display()
));
}
let matcher_identity = endpoint_matcher_identity(&route.path);
if let Some(previous) = route_owners.insert((route.method, matcher_identity), path.clone())
{
return Err(format!(
"error[DUPLICATE_ENDPOINT_ROUTE]: {} and {} both claim {} {}; each method/path pair must have exactly one typed endpoint",
previous.display(),
path.display(),
route.method.as_str().to_uppercase(),
route.path
));
}
let declared_params = endpoint
.params
.iter()
.map(|field| field.name.clone())
.collect::<BTreeSet<_>>();
let dynamic_params = route
.dynamic_params
.iter()
.cloned()
.collect::<BTreeSet<_>>();
if declared_params != dynamic_params {
let missing = dynamic_params
.difference(&declared_params)
.cloned()
.collect::<Vec<_>>();
let extra = declared_params
.difference(&dynamic_params)
.cloned()
.collect::<Vec<_>>();
return Err(format!(
"error[ENDPOINT_PARAMS_MISMATCH]: endpoint `{}` at {} must declare params exactly matching its dynamic path segments; missing [{}], extra [{}]",
endpoint.name,
path.display(),
missing.join(", "),
extra.join(", ")
));
}
if route.method == EndpointMethod::Get
&& let Some(upload) = endpoint.body.iter().find(|field| field.file.is_some())
{
// The HTTP method is filename-derived, so this is the first place
// that can see a `File` field landing on a read. Name the upload
// rule rather than the generic body rule: a reader who wrote a
// `File` field needs to know it belongs on a mutating method.
return Err(format!(
"error[FILE_UPLOAD_REQUIRES_BODY_ENDPOINT]: GET endpoint `{}` declares upload field `{}`; a `File` field is a multipart request body, and a GET has none — rename the file to `.post.nox` (or `.put.nox`/`.patch.nox`) so the declared cap and magic-byte allow-list are enforced on a request that carries bytes",
endpoint.name, upload.name
));
}
if route.method == EndpointMethod::Get && !endpoint.body.is_empty() {
return Err(format!(
"error[ENDPOINT_GET_BODY_FORBIDDEN]: GET endpoint `{}` declares a body; move request inputs to `query {{ ... }}` or use a mutating method",
endpoint.name
));
}
if endpoint.cache.is_some() && route.method != EndpointMethod::Get {
return Err(format!(
"error[ENDPOINT_CACHE_REQUIRES_GET]: endpoint `{}` declares a cache policy on {}; endpoint response caching is only valid for GET endpoints, so remove `cache:` or move the read to a `.get.nox` endpoint",
endpoint.name,
route.method.as_str().to_uppercase()
));
}
if endpoint.cache.is_some()
&& (!endpoint.capabilities.is_empty()
|| !endpoint.middleware.is_empty()
|| !config.server_global_middleware.is_empty())
{
return Err(format!(
"error[ENDPOINT_CACHE_PERSONALIZATION_UNSAFE]: endpoint `{}` declares public shared caching together with capabilities or request middleware, whose personalized bodies and headers have no compiler-declared cache variation or private mode; remove `cache:` or remove the personalized boundary until a compiler-visible variation/private cache contract exists",
endpoint.name,
));
}
if endpoint.cache.is_some()
&& matches!(&endpoint.handler, EndpointHandler::Host { .. })
&& !cached_host_endpoint_is_context_free(config, endpoint.id.as_str())?
{
return Err(format!(
"error[ENDPOINT_CACHE_HOST_HANDLER_UNANALYZABLE]: endpoint `{}` declares public shared caching with an opaque host implementation; host handlers that accept the request context can read request or environment personalization that has no cache variation/private contract. Move the handler into the endpoint declaration, remove `cache:`, or keep a directly registered inline host arrow with no context parameter",
endpoint.name,
));
}
if endpoint.idempotent && !route.method.is_mutating() {
return Err(format!(
"error[ENDPOINT_IDEMPOTENT_METHOD]: endpoint `{}` declares `idempotent` on GET; idempotency keys are only valid on post, put, patch, or delete endpoints",
endpoint.name
));
}
for middleware in &endpoint.middleware {
if !middleware_names.contains(&middleware.name) {
return Err(format!(
"error[ENDPOINT_MIDDLEWARE_MISSING]: endpoint `{}` requires middleware `{}`, but no declaration exists at {}; add the browser declaration and optionally a server/route-middleware variant",
endpoint.name,
middleware.name,
config
.middleware_dir
.join(format!("{}.js", middleware.name))
.display()
));
}
}
let execution = noxid_execution_ir::lower(&program);
let lowered_validation = noxid_validation_ir::lower(&program);
if !lowered_validation.diagnostics.is_empty() {
return Err(format!(
"error[ENDPOINT_VALIDATION_LOWERING_FAILED]: enriched endpoint `{}` could not produce its declared boundary validators",
program.endpoints[0].name
));
}
let mut source_files = module_graph
.modules()
.map(|(_, module)| module.source.path().to_path_buf())
.collect::<Vec<_>>();
source_files.sort();
source_files.dedup();
let input_fingerprint = target_input_fingerprint(config, &source_files)?;
let mut graph = module_graph.merged_graph();
for resource in &program.endpoints[0].invalidation.resources {
graph.add_edge(
program.endpoints[0].id.clone(),
EdgeKind::Invalidates,
resource.clone(),
);
}
compiled.insert(
path,
CompiledEndpoint {
semantic_json: program.to_json(),
program,
graph,
execution,
validation: lowered_validation.program,
input_fingerprint,
},
);
}
Ok(compiled)
}
fn endpoint_matcher_identity(path: &str) -> String {
path.split('/')
.map(|segment| {
if dynamic_endpoint_parameter(segment).is_some() {
"[]"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/")
}
fn endpoint_route_claims_path(route: &str, literal: &str) -> bool {
let route_segments = route.split('/').collect::<Vec<_>>();
let literal_segments = literal.split('/').collect::<Vec<_>>();
route_segments.len() == literal_segments.len()
&& route_segments
.iter()
.zip(literal_segments)
.all(|(route, literal)| {
route == &literal || dynamic_endpoint_parameter(route).is_some()
})
}
fn javascript_identifier(value: &str) -> bool {
let mut characters = value.chars();
characters.next().is_some_and(|character| {
character == '_' || character == '$' || character.is_ascii_alphabetic()
}) && characters
.all(|character| character == '_' || character == '$' || character.is_ascii_alphanumeric())
}
fn inline_arrow_input_bindings(parameters: &str) -> Option<BTreeSet<String>> {
let parameters = parameters.trim();
if parameters.is_empty() {
return Some(BTreeSet::new());
}
if let Some(fields) = parameters
.strip_prefix('{')
.and_then(|fields| fields.strip_suffix('}'))
{
let mut bindings = BTreeSet::new();
for field in fields.split(',') {
let field = field.trim();
if !javascript_identifier(field) || !bindings.insert(field.to_string()) {
return None;
}
}
return Some(bindings);
}
javascript_identifier(parameters).then(|| BTreeSet::from([parameters.to_string()]))
}
fn javascript_expression_has_property_suffix(expression: &str, end: usize) -> bool {
expression[end..]
.trim_start()
.chars()
.next()
.is_none_or(|character| matches!(character, ',' | '}' | ')' | ';'))
}
fn cached_host_arrow_body_is_input_only(expression: &str, bindings: &BTreeSet<String>) -> bool {
let expression = expression.trim_start();
if expression.starts_with(['\'', '"']) {
return quoted_module_specifier(expression, 0).is_some_and(|literal| {
javascript_expression_has_property_suffix(expression, literal.end + 1)
});
}
if !expression.starts_with('`') {
return false;
}
let bytes = expression.as_bytes();
let mut index = 1;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
b'`' => return javascript_expression_has_property_suffix(expression, index + 1),
b'$' if bytes.get(index + 1) == Some(&b'{') => {
let Some(relative_end) = expression[index + 2..].find('}') else {
return false;
};
let end = index + 2 + relative_end;
let binding = expression[index + 2..end].trim();
if !javascript_identifier(binding) || !bindings.contains(binding) {
return false;
}
index = end + 1;
}
_ => index += 1,
}
}
false
}
/// Earlier host-backed endpoints predate compiler-owned endpoint bodies. Keep
/// a deliberately tiny input-only inline-arrow form available for caching:
/// literal strings and templates interpolating only destructured input fields.
/// Anything indirect, executable, closure-backed, or accepting the privileged
/// request context remains opaque and fails closed.
fn cached_host_endpoint_is_context_free(
config: &ProjectConfig,
endpoint_key: &str,
) -> Result<bool, String> {
let Some(host) = config.server_host.as_ref() else {
return Ok(false);
};
let source = fs::read_to_string(host)
.map_err(|error| format!("cannot read {}: {error}", host.display()))?;
for key in scan_javascript_string_literals(&source) {
if key.value == endpoint_key {
let tail = source[key.end + 1..].trim_start();
let Some(tail) = tail.strip_prefix(':') else {
continue;
};
let mut expression = tail.trim_start();
if let Some(rest) = expression.strip_prefix("async")
&& rest
.chars()
.next()
.is_some_and(|character| character.is_whitespace() || character == '(')
{
expression = rest.trim_start();
}
let Some(parameters) = expression.strip_prefix('(') else {
continue;
};
let bytes = parameters.as_bytes();
let mut braces = 0usize;
let mut brackets = 0usize;
let mut parentheses = 0usize;
let mut quote = None;
let mut escaped = false;
let mut top_level_commas = 0usize;
let mut closing = None;
for (index, byte) in bytes.iter().copied().enumerate() {
if let Some(active_quote) = quote {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == active_quote {
quote = None;
}
continue;
}
match byte {
b'\'' | b'"' => quote = Some(byte),
b'{' => braces += 1,
b'}' => braces = braces.saturating_sub(1),
b'[' => brackets += 1,
b']' => brackets = brackets.saturating_sub(1),
b'(' => parentheses += 1,
b')' if braces == 0 && brackets == 0 && parentheses == 0 => {
closing = Some(index);
break;
}
b')' => parentheses = parentheses.saturating_sub(1),
b',' if braces == 0 && brackets == 0 && parentheses == 0 => {
top_level_commas += 1;
}
_ => {}
}
}
let Some(closing) = closing else {
continue;
};
if !parameters[closing + 1..].trim_start().starts_with("=>") {
continue;
}
let Some(bindings) = inline_arrow_input_bindings(¶meters[..closing]) else {
continue;
};
let body = parameters[closing + 1..]
.trim_start()
.strip_prefix("=>")
.expect("arrow checked above");
if top_level_commas == 0 && cached_host_arrow_body_is_input_only(body, &bindings) {
return Ok(true);
}
}
}
Ok(false)
}
fn reject_unrouted_endpoint_declarations(
module_graph: &ModuleGraph,
routed_owner: Option<&Path>,
) -> Result<(), String> {
let routed_owner = routed_owner.and_then(|path| fs::canonicalize(path).ok());
let mut violations = Vec::new();
for (module_path, module) in module_graph.modules() {
if routed_owner.as_ref() == Some(module_path) {
continue;
}
for endpoint in &module.compilation.program.endpoints {
violations.push((module_path.display().to_string(), endpoint.name.clone()));
}
}
violations.sort();
if violations.is_empty() {
return Ok(());
}
let declarations = violations
.iter()
.map(|(path, endpoint)| format!("`{endpoint}` in {path}"))
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"error[ENDPOINT_IMPORT_UNROUTED]: imported modules declare unrouted endpoint(s): {declarations}; every endpoint must be the sole declaration in its own file under `server/api/**/<name>.<method>.nox` or `server/routes/**/<name>.<method>.nox`, and shared imported modules may contain only endpoint-free types and functions"
))
}
fn discover_endpoint_files(
root: &Path,
directory: &Path,
api: bool,
output: &mut Vec<(PathBuf, EndpointRouteContract, bool)>,
) -> Result<(), String> {
let mut entries = fs::read_dir(directory)
.map_err(|error| {
format!(
"cannot read endpoint directory {}: {error}",
directory.display()
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| {
format!(
"cannot read endpoint entry in {}: {error}",
directory.display()
)
})?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let file_type = entry
.file_type()
.map_err(|error| format!("cannot inspect endpoint path {}: {error}", path.display()))?;
if file_type.is_dir() {
validate_endpoint_segment(&entry.file_name().to_string_lossy(), &path)?;
discover_endpoint_files(root, &path, api, output)?;
continue;
}
if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("nox")
{
continue;
}
let filename = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
let stem = filename.strip_suffix(".nox").unwrap_or(filename);
let Some((name, method)) = stem.rsplit_once('.') else {
return Err(endpoint_filename_error(&path));
};
let Some(method) = EndpointMethod::parse(method) else {
return Err(endpoint_filename_error(&path));
};
validate_endpoint_segment(name, &path)?;
let relative_parent = path
.parent()
.and_then(|parent| parent.strip_prefix(root).ok())
.unwrap_or(Path::new(""));
let mut segments = relative_parent
.components()
.filter_map(|component| match component {
Component::Normal(value) => Some(value.to_string_lossy().to_string()),
_ => None,
})
.collect::<Vec<_>>();
segments.push(name.to_string());
let mut dynamic_params = Vec::new();
for segment in &segments {
if let Some(parameter) = dynamic_endpoint_parameter(segment) {
if dynamic_params.iter().any(|existing| existing == parameter) {
return Err(format!(
"error[ENDPOINT_DYNAMIC_PARAM_DUPLICATE]: {} repeats dynamic parameter `[{parameter}]`; every path parameter name must be unique",
path.display()
));
}
dynamic_params.push(parameter.to_string());
}
}
if api {
segments.insert(0, "api".into());
}
output.push((
path,
EndpointRouteContract {
method,
path: format!("/{}", segments.join("/")),
dynamic_params,
},
api,
));
}
Ok(())
}
fn validate_endpoint_segment(segment: &str, path: &Path) -> Result<(), String> {
if let Some(parameter) = dynamic_endpoint_parameter(segment) {
if valid_endpoint_identifier(parameter) {
return Ok(());
}
} else if !segment.is_empty()
&& segment != "."
&& segment != ".."
&& !segment.contains('[')
&& !segment.contains(']')
&& segment.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
{
return Ok(());
}
Err(format!(
"error[ENDPOINT_PATH_SEGMENT_INVALID]: `{segment}` in {} is not a legal endpoint path segment; `.` and `..` are reserved by URL normalization, so use URL-safe static text or a single `[param]` identifier",
path.display()
))
}
fn dynamic_endpoint_parameter(segment: &str) -> Option<&str> {
segment.strip_prefix('[')?.strip_suffix(']')
}
fn valid_endpoint_identifier(value: &str) -> bool {
let mut chars = value.chars();
chars
.next()
.is_some_and(|character| character.is_ascii_alphabetic() || character == '_')
&& chars.all(|character| character.is_ascii_alphanumeric() || character == '_')
}
fn endpoint_filename_error(path: &Path) -> String {
format!(
"error[ENDPOINT_FILENAME_INVALID]: {} must use `<name>.<get|post|put|patch|delete>.nox`; endpoint methods are explicit and catch-all files are not supported",
path.display()
)
}
// The arguments are the explicit cache key and compilation context. Keeping them separate makes
// it harder to accidentally omit one when route-boundary reuse is evaluated.
#[allow(clippy::too_many_arguments)]
fn compile_route_boundary(
config: &ProjectConfig,
page_dir: &Path,
filename: &str,
kind: RouteTargetKind,
middleware_names: &BTreeSet<String>,
component_names: &mut BTreeMap<String, PathBuf>,
compiled: &mut BTreeMap<PathBuf, CompiledTarget>,
cache: Option<&BTreeMap<PathBuf, CompiledTarget>>,
stats: &mut PreparationStats,
) -> Result<Option<RouteTarget>, String> {
let Some(path) = nearest_named_file(&config.routes_dir, page_dir, filename)? else {
return Ok(None);
};
if !compiled.contains_key(&path) {
let directory = path
.parent()
.ok_or("route boundary has no parent directory")?;
let scope_pattern = route_pattern(&route_segments(&config.routes_dir, directory)?);
let target = compile_target_cached(
config,
&path,
kind,
Some(scope_pattern),
middleware_names,
cache,
stats,
)?;
register_component_name(component_names, &target.target, &path)?;
compiled.insert(path.clone(), target);
}
Ok(Some(compiled[&path].target.clone()))
}
fn register_component_name(
names: &mut BTreeMap<String, PathBuf>,
target: &RouteTarget,
source: &Path,
) -> Result<(), String> {
if let Some(previous) = names.insert(target.component_name.clone(), source.to_path_buf()) {
return Err(format!(
"route component `{}` is declared in both {} and {}; project route component names must be unique",
target.component_name,
previous.display(),
source.display(),
));
}
Ok(())
}
fn compile_target_cached(
config: &ProjectConfig,
path: &Path,
kind: RouteTargetKind,
scope_pattern: Option<String>,
middleware_names: &BTreeSet<String>,
cache: Option<&BTreeMap<PathBuf, CompiledTarget>>,
stats: &mut PreparationStats,
) -> Result<CompiledTarget, String> {
if let Some(existing) = cache.and_then(|cache| cache.get(path)) {
let fresh = existing.target.kind == kind
&& existing.target.scope_pattern == scope_pattern
&& target_input_fingerprint(config, &existing.source_files)
.is_ok_and(|fingerprint| fingerprint == existing.input_fingerprint);
if fresh {
for usage in &existing.component.middleware {
if !middleware_names.contains(&usage.name) {
return Err(format!(
"component `{}` references middleware `{}` but {} does not exist",
existing.component.name,
usage.name,
config
.middleware_dir
.join(format!("{}.js", usage.name))
.display(),
));
}
}
stats.reused_targets += 1;
return Ok(existing.clone());
}
}
let target = compile_target(config, path, kind, scope_pattern, middleware_names)?;
stats.compiled_targets += 1;
Ok(target)
}
fn target_input_fingerprint(
config: &ProjectConfig,
source_files: &[PathBuf],
) -> Result<u64, String> {
let mut hasher = DefaultHasher::new();
stamp_content(&config.manifest, &mut hasher)?;
config.analysis_options_stamp().hash(&mut hasher);
for source in source_files {
stamp_content(source, &mut hasher)?;
}
if config.components_dir.exists() {
walk_files(&config.components_dir, &mut |path| {
if path.extension().and_then(|value| value.to_str()) == Some("nox") {
path.hash(&mut hasher);
}
})?;
}
let package_root = nearest_package_root(&config.root);
for name in [
"package.json",
"pnpm-lock.yaml",
"package-lock.json",
"yarn.lock",
] {
let path = package_root.join(name);
if path.exists() {
stamp_content(&path, &mut hasher)?;
}
}
Ok(hasher.finish())
}
fn nearest_package_root(root: &Path) -> PathBuf {
fs::canonicalize(root)
.ok()
.and_then(|root| {
root.ancestors()
.find(|candidate| candidate.join("package.json").is_file())
.map(Path::to_path_buf)
})
.unwrap_or_else(|| root.to_path_buf())
}
fn package_json_name(root: &Path) -> Option<String> {
let document = fs::read_to_string(nearest_package_root(root).join("package.json")).ok()?;
let marker = "\"name\"";
let start = document.find(marker)? + marker.len();
let after_colon = document[start..]
.find(':')
.map(|offset| start + offset + 1)?;
let value = document[after_colon..].trim_start().strip_prefix('"')?;
let end = value.find('"')?;
let name = &value[..end];
(!name.is_empty() && !name.contains('\\')).then(|| name.to_string())
}
fn compile_target(
config: &ProjectConfig,
path: &Path,
kind: RouteTargetKind,
scope_pattern: Option<String>,
middleware_names: &BTreeSet<String>,
) -> Result<CompiledTarget, String> {
let module_graph = config.compile_module_graph(path)?;
reject_model_declarations(&module_graph, path, "route")?;
reject_unrouted_endpoint_declarations(&module_graph, None)?;
let canonical_root = config.root.canonicalize().map_err(|error| {
format!(
"cannot resolve project root {}: {error}",
config.root.display()
)
})?;
let mut source_files = module_graph
.modules()
.map(|(_, module)| module.source.path().to_path_buf())
.collect::<Vec<_>>();
for (_, module) in module_graph.modules() {
for external in &module.compilation.program.external_modules {
let external_path = PathBuf::from(&external.runtime_source);
if external_path.is_absolute() && external_path.starts_with(&canonical_root) {
source_files.push(external_path.clone());
let contract = PathBuf::from(format!("{}.nox-contract", external_path.display()));
if contract.is_file() {
source_files.push(contract);
}
}
}
}
source_files.sort();
source_files.dedup();
let npm_sources = module_graph
.modules()
.flat_map(|(_, module)| module.compilation.program.external_modules.iter())
.map(|external| external.source.clone())
.collect::<Vec<_>>();
let input_fingerprint = target_input_fingerprint(config, &source_files)?;
let external_browser_modules = module_graph
.modules()
.flat_map(|(_, module)| &module.compilation.program.external_modules)
.filter(|module| {
matches!(
module.execution,
ExternalExecutionTarget::Universal | ExternalExecutionTarget::Client
)
})
.map(|module| module.id.to_string())
.collect::<BTreeSet<_>>();
let mut local_browser_modules = BTreeMap::new();
for (_, module) in module_graph.modules() {
for external in &module.compilation.program.external_modules {
let source = PathBuf::from(&external.runtime_source);
if !source.is_absolute() || !source.starts_with(&canonical_root) {
continue;
}
let relative = source.strip_prefix(&canonical_root).map_err(|error| {
format!("cannot make {} project-relative: {error}", source.display())
})?;
let relative = relative.to_str().ok_or_else(|| {
format!(
"error[PROJECT_ASSET_PATH_NOT_UTF8]: local browser module path `{}` is not valid UTF-8",
relative.display()
)
})?;
let asset = format!("assets/external/{}", relative.replace('\\', "/"));
local_browser_modules.insert(source, asset);
}
}
let output = &module_graph.root().compilation;
let mut execution = output.execution.clone();
let mut validation = output.validation.clone();
if output.program.components.len() != 1 {
return Err(format!(
"{} must declare exactly one route component, found {}",
path.display(),
output.program.components.len()
));
}
let component = &output.program.components[0];
if kind != RouteTargetKind::Page && component.route_metadata.is_some() {
return Err(format!(
"route metadata is only allowed in +page.nox components; `{}` in {} is a {} target",
component.name,
path.display(),
kind.as_str(),
));
}
if kind != RouteTargetKind::Page && !component.route_query.is_empty() {
return Err(format!(
"query schemas are only allowed in +page.nox components; `{}` in {} is a {} target",
component.name,
path.display(),
kind.as_str(),
));
}
if kind == RouteTargetKind::Layout {
let outlets = count_outlets(&component.view);
if outlets != 1 {
return Err(format!(
"layout {} must render exactly one <outlet></outlet>, found {outlets}",
path.display()
));
}
}
validate_boundary_contract(path, component, kind)?;
for usage in &component.middleware {
if !middleware_names.contains(&usage.name) {
return Err(format!(
"component `{}` references middleware `{}` but {} does not exist",
component.name,
usage.name,
config
.middleware_dir
.join(format!("{}.js", usage.name))
.display(),
));
}
}
let generated = output
.generated
.as_ref()
.ok_or_else(|| format!("{} did not generate a component module", path.display()))?;
let component_name = component.name.clone();
let old_stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("route");
let rewrite = |contents: &str| {
let rewritten = contents
.replace(
&format!("./{old_stem}.validators.js"),
&format!("./{component_name}.validators.js"),
)
.replace(
&format!("./{old_stem}.resources.js"),
&format!("./{component_name}.resources.js"),
)
.replace(
&format!("./{old_stem}.streams.js"),
&format!("./{component_name}.streams.js"),
)
.replace(
&format!("./{old_stem}.agents.js"),
&format!("./{component_name}.agents.js"),
);
local_browser_modules
.iter()
.fold(rewritten, |output, (source, asset)| {
output.replace(
&source.to_string_lossy().replace('\\', "/"),
&format!("./{}", asset.trim_start_matches("assets/")),
)
})
};
let relative = path
.strip_prefix(&config.root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let target_id = match kind {
RouteTargetKind::Page => component.id.clone(),
RouteTargetKind::Layout => SemanticId::layout(
scope_pattern
.as_deref()
.ok_or("layout is missing its route scope")?,
&component_name,
),
RouteTargetKind::Loading => SemanticId::route_loading(
scope_pattern
.as_deref()
.ok_or("loading boundary is missing its route scope")?,
&component_name,
),
RouteTargetKind::Error => SemanticId::route_error(
scope_pattern
.as_deref()
.ok_or("error boundary is missing its route scope")?,
&component_name,
),
};
let mut dependencies = Vec::new();
let reachable = module_graph.reachable_components(&component_name);
let reachable_definitions = module_graph
.modules()
.flat_map(|(_, module)| module.compilation.program.components.iter())
.filter(|candidate| reachable.contains(&candidate.name))
.collect::<Vec<_>>();
let mut browser_components = reachable_definitions
.iter()
.filter(|candidate| candidate.render.mode != ComponentRenderMode::Server)
.copied()
.collect::<Vec<_>>();
browser_components
.sort_by_key(|candidate| (candidate.name != component.name, candidate.name.as_str()));
for browser_component in browser_components {
let browser_reachable = module_graph.reachable_components(&browser_component.name);
if let Some(server_component) = reachable_definitions.iter().find(|candidate| {
candidate.render.mode == ComponentRenderMode::Server
&& browser_reachable.contains(&candidate.name)
}) {
return Err(format!(
"error[CLIENT_COMPONENT_REACHES_SERVER_COMPONENT]: client-capable component `{}` reachable from `{}` in {} cannot invoke server-only component `{}`; move the server component behind an SSR boundary or make it universal",
browser_component.name,
component.name,
path.display(),
server_component.name,
));
}
}
for (_, module) in module_graph.modules() {
if std::ptr::eq(module, module_graph.root()) {
continue;
}
if !module
.compilation
.program
.components
.iter()
.any(|dependency| reachable.contains(&dependency.name))
{
// Type/function-only modules are compile-time dependencies. Their
// selected definitions are cloned into the consuming module, so
// they intentionally have no independently mountable JS chunk.
continue;
}
let generated = module.compilation.generated.as_ref().ok_or_else(|| {
format!(
"{} did not generate a component chunk",
module.source.path().display()
)
})?;
for dependency in &module.compilation.program.components {
if !reachable.contains(&dependency.name) {
continue;
}
for usage in &dependency.middleware {
if !middleware_names.contains(&usage.name) {
return Err(format!(
"component `{}` references middleware `{}` but {} does not exist",
dependency.name,
usage.name,
config
.middleware_dir
.join(format!("{}.js", usage.name))
.display(),
));
}
}
merge_execution_program(&mut execution, &module.compilation.execution);
merge_validation_program(&mut validation, &module.compilation.validation);
let dependency_stem = module
.source
.path()
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(&dependency.name);
let rewrite_dependency = |contents: &str| {
let rewritten = ["validators", "resources", "streams", "agents"]
.into_iter()
.fold(contents.to_string(), |output, suffix| {
output.replace(
&format!("./{dependency_stem}.{suffix}.js"),
&format!("./{}.{suffix}.js", dependency.name),
)
});
local_browser_modules
.iter()
.fold(rewritten, |output, (source, asset)| {
output.replace(
&source.to_string_lossy().replace('\\', "/"),
&format!("./{}", asset.trim_start_matches("assets/")),
)
})
};
let (javascript, runtime_imports) = if generated.modules.is_empty() {
(
rewrite_dependency(&generated.javascript),
generated.runtime_imports.clone(),
)
} else {
let chunk = generated
.modules
.iter()
.find(|chunk| chunk.component == dependency.name)
.ok_or_else(|| {
format!("component `{}` has no generated chunk", dependency.name)
})?;
(
rewrite_dependency(&chunk.javascript),
chunk.runtime_imports.clone(),
)
};
let uses_resources = !dependency.resources.is_empty();
let uses_streams = !dependency.streams.is_empty();
let uses_agents = !dependency.agents.is_empty();
let mut runtime_imports = runtime_imports;
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());
}
let mut selected = BTreeSet::new();
selected.insert(dependency.name.clone());
let resources = uses_resources
.then(|| {
module
.compilation
.generated_resources
.as_deref()
.map(rewrite_dependency)
})
.flatten();
let streams = uses_streams
.then(|| {
module
.compilation
.generated_streams
.as_deref()
.map(rewrite_dependency)
})
.flatten();
let agents = uses_agents
.then(|| {
module
.compilation
.generated_agents
.as_deref()
.map(rewrite_dependency)
})
.flatten();
let validator_import = format!("./{}.validators.js", dependency.name);
let validators = std::iter::once(javascript.as_str())
.chain(resources.as_deref())
.chain(streams.as_deref())
.chain(agents.as_deref())
.any(|module| module.contains(&validator_import))
.then(|| {
module
.compilation
.generated_validators
.as_deref()
.map(rewrite_dependency)
})
.flatten();
dependencies.push(CompiledComponentChunk {
component: dependency.clone(),
component_name: dependency.name.clone(),
javascript,
css: module.compilation.css_for_components(&selected),
runtime_imports,
validators,
resources,
streams,
agents,
});
}
}
let css = std::iter::once(generated.css.as_str())
.chain(
dependencies
.iter()
.map(|dependency| dependency.css.as_str()),
)
.collect::<Vec<_>>()
.join("\n");
let javascript = rewrite(&generated.javascript);
let resources = output.generated_resources.as_deref().map(rewrite);
let streams = output.generated_streams.as_deref().map(rewrite);
let agents = output.generated_agents.as_deref().map(rewrite);
let validator_import = format!("./{component_name}.validators.js");
let validators = std::iter::once(javascript.as_str())
.chain(resources.as_deref())
.chain(streams.as_deref())
.chain(agents.as_deref())
.any(|module| module.contains(&validator_import))
.then(|| output.generated_validators.as_deref().map(rewrite))
.flatten();
let diagnostics = output
.diagnostics
.iter()
.map(|diagnostic| {
if diagnostic.path.is_some() {
diagnostic.clone()
} else {
diagnostic.clone().with_path(relative.clone())
}
})
.collect::<Vec<_>>();
let diagnostics_json = format!(
"{{\"diagnostics\":[{}]}}",
diagnostics
.iter()
.map(noxid_source::Diagnostic::to_json)
.collect::<Vec<_>>()
.join(",")
);
Ok(CompiledTarget {
program: output.program.clone(),
npm_sources,
diagnostics,
semantic_json: output.program.to_json(),
accessibility_json: output.accessibility.to_json(),
design_json: output.design.to_json(),
diagnostics_json,
devtools_json: output.devtools.to_json(),
target: RouteTarget {
id: target_id,
component: component.id.clone(),
component_name: component_name.clone(),
source: relative,
module: format!("assets/{component_name}.js"),
css: format!("assets/{component_name}.css"),
middleware: component
.middleware
.iter()
.map(|usage| usage.middleware.clone())
.collect(),
capabilities: component
.capabilities
.iter()
.map(|capability| capability.id.clone())
.collect(),
loaders: component
.loaders
.iter()
.map(|loader| RouteLoader {
id: loader.id.clone(),
name: loader.name.clone(),
prop: loader.prop.clone(),
action: loader.action.clone(),
action_name: loader.action_name.clone(),
execution: loader.execution,
result_type: loader.result_type.clone(),
result_type_id: loader.result_type_id.clone(),
stage: 0,
arguments: loader
.arguments
.iter()
.map(|argument| RouteLoaderArgument {
id: argument.id.clone(),
parameter: argument.parameter.clone(),
name: argument.name.clone(),
source: argument.source.clone(),
source_name: argument.source_name.clone(),
ty: argument.ty.clone(),
})
.collect(),
})
.collect(),
render_mode: component.render.mode,
hydration: component.render.hydration,
kind,
scope_pattern,
},
stream_definitions: output.program.streams.clone(),
component: component.clone(),
graph: module_graph.merged_graph(),
execution,
validation,
javascript,
css,
runtime_imports: generated.runtime_imports.clone(),
validators,
resources,
streams,
agents,
dependencies,
component_imports: output.program.imports.len(),
auto_component_imports: output
.program
.imports
.iter()
.filter(|import| import.automatic)
.count(),
source_files,
input_fingerprint,
external_browser_modules,
local_browser_modules,
})
}
fn validate_boundary_contract(
path: &Path,
component: &noxid_ir::ComponentDefinition,
kind: RouteTargetKind,
) -> Result<(), String> {
if !matches!(kind, RouteTargetKind::Loading | RouteTargetKind::Error) {
return Ok(());
}
if !component.middleware.is_empty()
|| !component.capabilities.is_empty()
|| !component.loaders.is_empty()
{
return Err(format!(
"route boundary component `{}` in {} cannot declare middleware, capabilities, or loaders",
component.name,
path.display()
));
}
match kind {
RouteTargetKind::Loading if !component.props.is_empty() => Err(format!(
"loading boundary component `{}` in {} cannot declare props",
component.name,
path.display()
)),
RouteTargetKind::Error => {
let valid = component.props.len() == 2
&& ["code", "message"].iter().all(|name| {
component.props.iter().any(|prop| {
prop.name == *name
&& prop.ty == Type::String
&& prop.mode != PropMode::Binding
})
});
if valid {
Ok(())
} else {
Err(format!(
"error boundary component `{}` in {} must declare exactly `code: String` and `message: String` props",
component.name,
path.display()
))
}
}
_ => Ok(()),
}
}
fn route_parameters(
pattern: &str,
segments: &[RouteSegment],
page: &RouteTarget,
compiled: &BTreeMap<PathBuf, CompiledTarget>,
layouts: &[RouteTarget],
) -> Result<Vec<RouteParameter>, String> {
let page_component = find_component(compiled, &page.component_name)?;
let parameter_names = segments
.iter()
.filter_map(|segment| match segment {
RouteSegment::Parameter(name) | RouteSegment::CatchAll(name) => Some(name.clone()),
RouteSegment::Static(_) => None,
})
.collect::<Vec<_>>();
let mut parameters = Vec::new();
for name in ¶meter_names {
let catch_all = segments
.iter()
.any(|segment| matches!(segment, RouteSegment::CatchAll(value) if value == name));
if page_component
.route_query
.iter()
.any(|query| &query.name == name)
{
return Err(format!(
"route `{pattern}` input `{name}` cannot be both a path parameter and a query field"
));
}
let prop = page_component
.props
.iter()
.find(|prop| &prop.name == name)
.ok_or_else(|| {
format!(
"route `{pattern}` parameter `{name}` requires a matching prop on component `{}`",
page.component_name
)
})?;
let valid_type = if catch_all {
matches!(&prop.ty, Type::Array(inner) if inner.as_ref() == &Type::String)
} else {
matches!(prop.ty, Type::String | Type::Int | Type::Boolean)
};
if prop.mode == PropMode::Binding || !valid_type {
return Err(format!(
"route `{pattern}` {} parameter `{name}` must use {}, found {}",
if catch_all { "catch-all" } else { "dynamic" },
if catch_all {
"Array<String>"
} else {
"String, Int, or Boolean"
},
prop.ty,
));
}
for layout in layouts {
let layout_component = find_component(compiled, &layout.component_name)?;
if let Some(layout_prop) = layout_component
.props
.iter()
.find(|item| item.name == *name)
.filter(|layout_prop| layout_prop.ty != prop.ty)
{
return Err(format!(
"layout `{}` expects route parameter `{name}` as {}, but page `{}` declares {}",
layout.component_name, layout_prop.ty, page.component_name, prop.ty
));
}
}
parameters.push(RouteParameter {
id: SemanticId::route_parameter(pattern, name),
name: name.clone(),
ty: prop.ty.clone(),
catch_all,
});
}
for prop in &page_component.props {
if !parameter_names.contains(&prop.name)
&& !page_component
.route_query
.iter()
.any(|query| query.prop == prop.id)
&& !page_component
.loaders
.iter()
.any(|loader| loader.prop == prop.id)
{
return Err(format!(
"route page component `{}` has prop `{}` that is not supplied by route `{pattern}`",
page.component_name, prop.name
));
}
}
Ok(parameters)
}
fn schedule_route_loaders(
pattern: &str,
layouts: &mut [RouteTarget],
page: &mut RouteTarget,
parameters: &[RouteParameter],
query: &[RouteQueryParameter],
) -> Result<(), String> {
let mut available = parameters
.iter()
.map(|parameter| (parameter.name.clone(), (parameter.ty.clone(), None)))
.chain(query.iter().map(|field| {
let ty = if field.required {
field.ty.clone()
} else {
Type::Optional(Box::new(field.ty.clone()))
};
(field.name.clone(), (ty, None))
}))
.collect::<BTreeMap<_, _>>();
for target in layouts.iter_mut().chain(std::iter::once(page)) {
for loader in &mut target.loaders {
let mut stage = 0;
for argument in &loader.arguments {
let Some((source_type, producer_stage)) = available.get(&argument.source_name)
else {
return Err(format!(
"route `{pattern}` loader `{}` reads `{}`, but that prop is not available before the loader runs",
loader.name, argument.source_name
));
};
if !argument.ty.is_assignable_from(source_type) {
return Err(format!(
"route `{pattern}` loader `{}` argument `{}` expects {}, but source `{}` supplies {}",
loader.name, argument.name, argument.ty, argument.source_name, source_type,
));
}
if let Some(producer_stage) = producer_stage {
stage = stage.max(producer_stage + 1);
}
}
loader.stage = stage;
if available
.insert(
loader.name.clone(),
(loader.result_type.clone(), Some(stage)),
)
.is_some()
{
return Err(format!(
"route `{pattern}` loader `{}` would overwrite an existing route prop",
loader.name
));
}
}
}
Ok(())
}
fn route_query_parameters(
pattern: &str,
page: &RouteTarget,
compiled: &BTreeMap<PathBuf, CompiledTarget>,
parameters: &[RouteParameter],
) -> Result<Vec<RouteQueryParameter>, String> {
let component = find_component(compiled, &page.component_name)?;
let mut names = BTreeSet::new();
let mut query = Vec::new();
for field in &component.route_query {
if !names.insert(field.name.clone()) {
return Err(format!(
"route `{pattern}` declares duplicate query field `{}`",
field.name
));
}
if parameters
.iter()
.any(|parameter| parameter.name == field.name)
{
return Err(format!(
"route `{pattern}` input `{}` cannot be both a path parameter and a query field",
field.name
));
}
query.push(RouteQueryParameter {
id: field.id.clone(),
name: field.name.clone(),
ty: field.ty.clone(),
required: field.required,
});
}
Ok(query)
}
fn find_component<'a>(
compiled: &'a BTreeMap<PathBuf, CompiledTarget>,
name: &str,
) -> Result<&'a noxid_ir::ComponentDefinition, String> {
compiled
.values()
.find(|target| target.target.component_name == name)
.map(|target| &target.component)
.ok_or_else(|| format!("missing compiled route component `{name}`"))
}
fn middleware_chain<'a>(
global: &[String],
targets: impl Iterator<Item = &'a RouteTarget>,
compiled: &BTreeMap<PathBuf, CompiledTarget>,
) -> Vec<SemanticId> {
let mut seen = BTreeSet::new();
let mut chain = Vec::new();
for id in global.iter().map(|name| SemanticId::middleware(name)) {
if seen.insert(id.clone()) {
chain.push(id);
}
}
for target in targets {
for id in &target.middleware {
if seen.insert(id.clone()) {
chain.push(id.clone());
}
}
if let Some(compiled) = compiled
.values()
.find(|compiled| compiled.target.component == target.component)
{
for dependency in &compiled.dependencies {
for usage in &dependency.component.middleware {
if seen.insert(usage.middleware.clone()) {
chain.push(usage.middleware.clone());
}
}
}
}
}
chain
}
fn add_execution_route_graph(
compiled: &BTreeMap<PathBuf, CompiledTarget>,
routes: &RouteProgram,
graph: &mut ApplicationGraph,
) {
for route in &routes.routes {
for target in route.layouts.iter().chain(std::iter::once(&route.page)) {
let Some(compiled) = compiled
.values()
.find(|compiled| compiled.target.component == target.component)
else {
continue;
};
for boundary in &compiled.execution.boundaries {
graph.add_edge(route.id.clone(), EdgeKind::Invokes, boundary.id.clone());
for middleware in &route.middleware {
graph.add_edge(
boundary.id.clone(),
EdgeKind::ProtectedBy,
middleware.clone(),
);
}
}
}
}
}
fn count_outlets(nodes: &[SemanticViewNode]) -> usize {
nodes
.iter()
.map(|node| match node {
SemanticViewNode::Element {
tag,
attributes: _,
attachments: _,
children,
prefetch: _,
span: _,
} => usize::from(tag == "outlet") + count_outlets(children),
SemanticViewNode::Conditional { children, .. }
| SemanticViewNode::For { children, .. } => count_outlets(children),
SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
cases.iter().map(|case| count_outlets(&case.children)).sum()
}
SemanticViewNode::ComponentInvocation { children, .. } => count_outlets(children),
SemanticViewNode::Slot { .. }
| SemanticViewNode::Text { .. }
| SemanticViewNode::Binding { .. } => 0,
})
.sum()
}
fn emit_global_style(config: &ProjectConfig, out_dir: &Path) -> Result<Option<String>, String> {
let Some(input) = &config.global_style else {
return Ok(None);
};
if !input.is_file() {
return Err(format!(
"error[GLOBAL_STYLE_INPUT_MISSING]: [styles] global stylesheet does not exist: {}",
input.display()
));
}
let contents = fs::read_to_string(input).map_err(|error| {
format!(
"error[GLOBAL_STYLE_READ_FAILED]: cannot read global stylesheet {}: {error}",
input.display()
)
})?;
write(&out_dir.join("assets/global.css"), &contents)?;
Ok(Some("assets/global.css".into()))
}
fn emit_tailwind(
config: &ProjectConfig,
out_dir: &Path,
development: bool,
) -> Result<Option<String>, String> {
let Some(tailwind) = &config.tailwind else {
return Ok(None);
};
if !tailwind.input.is_file() {
return Err(format!(
"error[TAILWIND_INPUT_MISSING]: [tailwind] input does not exist: {}",
tailwind.input.display()
));
}
let package_root = nearest_package_root(&config.root);
let local_binary = package_root.join("node_modules/.bin/tailwindcss");
#[cfg(windows)]
let local_binary = if local_binary.is_file() {
local_binary
} else {
package_root.join("node_modules/.bin/tailwindcss.cmd")
};
let binary = if local_binary.is_file() {
local_binary
} else {
PathBuf::from("tailwindcss")
};
let output_path = out_dir.join("assets/tailwind.css");
let mut command = Command::new(&binary);
command
.arg("-i")
.arg(&tailwind.input)
.arg("-o")
.arg(&output_path)
.arg("--cwd")
.arg(&config.root)
.current_dir(&config.root);
if !development {
command.arg("--minify");
}
let output = command.output().map_err(|error| {
format!(
"error[TAILWIND_CLI_MISSING]: cannot start `{}`: {error}; install `tailwindcss` and `@tailwindcss/cli` in the project or place the standalone `tailwindcss` executable on PATH",
binary.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() { stderr } else { stdout };
let detail = if detail.is_empty() {
String::new()
} else {
format!("\n{detail}")
};
return Err(format!(
"error[TAILWIND_BUILD_FAILED]: Tailwind exited with {}{}",
output.status, detail
));
}
if !output_path.is_file() {
return Err(format!(
"error[TAILWIND_OUTPUT_MISSING]: Tailwind succeeded without creating {}",
output_path.display()
));
}
Ok(Some("assets/tailwind.css".into()))
}
fn collect_static_assets(config: &ProjectConfig) -> Result<BTreeMap<String, PathBuf>, String> {
fn visit(
root: &Path,
directory: &Path,
assets: &mut BTreeMap<String, PathBuf>,
) -> Result<(), String> {
let mut entries = fs::read_dir(directory)
.map_err(|error| {
format!(
"cannot read static asset directory {}: {error}",
directory.display()
)
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("cannot enumerate static assets: {error}"))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let metadata = fs::symlink_metadata(&path).map_err(|error| {
format!("cannot inspect static asset {}: {error}", path.display())
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"error[STATIC_ASSET_SYMLINK_UNSUPPORTED]: static asset `{}` is a symlink; copy the file into the declared directory so the build input is explicit",
path.display()
));
}
if metadata.is_dir() {
visit(root, &path, assets)?;
} else if metadata.is_file() {
let relative = path.strip_prefix(root).map_err(|error| {
format!(
"cannot make static asset {} relative: {error}",
path.display()
)
})?;
let relative = relative.to_str().ok_or_else(|| {
format!(
"error[PROJECT_ASSET_PATH_NOT_UTF8]: static asset path `{}` is not valid UTF-8",
relative.display()
)
})?;
let destination = format!("assets/{}", relative.replace('\\', "/"));
if let Some(previous) = assets.insert(destination.clone(), path.clone()) {
return Err(format!(
"error[PROJECT_ASSET_COLLISION]: `{}` and `{}` both emit `{destination}`",
previous.display(),
path.display()
));
}
}
}
Ok(())
}
let mut assets = BTreeMap::new();
if let Some(root) = &config.static_assets_dir {
let metadata = fs::symlink_metadata(root).map_err(|error| {
format!(
"cannot inspect static asset directory {}: {error}",
root.display()
)
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"error[STATIC_ASSET_SYMLINK_UNSUPPORTED]: static asset directory `{}` is a symlink; declare a real project directory",
root.display()
));
}
visit(root, root, &mut assets)?;
}
Ok(assets)
}
/// Copy the project's declared static assets (`[assets] directory`) into an
/// output directory. `noxid build` does this as part of emission; the
/// deployment adapters call it after bundling so a deployed site ships the
/// same files a build does. Returns the number of assets copied.
pub(crate) fn copy_static_assets_into(input: &Path, out_dir: &Path) -> Result<usize, String> {
let config = load_config(input)?;
let assets = collect_static_assets(&config)?;
// The bundler has already written its output; a project asset that
// lands on one of those paths would overwrite compiler output silently,
// which is the collision `noxid build` refuses through its generated-file
// manifest.
for asset in assets.keys() {
if out_dir.join(asset).exists() {
return Err(format!(
"error[PROJECT_ASSET_COLLISION]: project asset `{asset}` conflicts with compiler-generated output; rename the source asset"
));
}
}
copy_project_assets(out_dir, &assets)?;
Ok(assets.len())
}
fn copy_project_assets(out_dir: &Path, assets: &BTreeMap<String, PathBuf>) -> Result<(), String> {
for (destination, source) in assets {
let destination = out_dir.join(destination);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
fs::copy(source, &destination).map_err(|error| {
format!(
"cannot copy project asset {} to {}: {error}",
source.display(),
destination.display()
)
})?;
}
Ok(())
}
fn emit_project(
prepared: &PreparedProject,
options: &ProjectBuildOptions,
stats: &PreparationStats,
) -> Result<ProjectBuild, String> {
let mut project_assets = collect_static_assets(&prepared.config)?;
for target in prepared.compiled.values() {
for (source, destination) in &target.local_browser_modules {
if let Some(previous) = project_assets.insert(destination.clone(), source.clone())
&& previous != *source
{
return Err(format!(
"error[PROJECT_ASSET_COLLISION]: `{}` and `{}` both emit `{destination}`; give the static assets distinct project-relative paths",
previous.display(),
source.display()
));
}
}
}
let browser_authorization_subjects = browser_authorization_subjects(prepared);
if !browser_authorization_subjects.is_empty() && prepared.config.host.is_none() {
return Err(format!(
"error[CLIENT_AUTHORIZER_HOST_REQUIRED]: capability-protected browser components [{}] require `[app] host = \"src/host.js\"` with a synchronous `authorizeComponent(capability, subject, route)` policy",
browser_authorization_subjects.join(", ")
));
}
let requires_browser_authorizer = !browser_authorization_subjects.is_empty();
// Complete server planning before touching the requested output. Server
// source graph failures (including flattened module collisions across
// independently discovered entries) must not publish a partial client
// tree or mutate the last successful build.
let render_program = noxid_render_ir::lower(&prepared.routes);
let execution = execution_program(prepared);
let validation = validation_program(prepared);
let server_middleware = server_middleware_names(&execution, &prepared.routes);
let server_actions = execution
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "server")
.count();
let edge_actions = execution
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "edge")
.count();
let worker_actions = execution
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "worker")
.count();
let endpoint_count = execution.endpoints.len();
let openapi = openapi_document(prepared)?;
let api_contract = api_contract_document(prepared)?;
let stream_endpoints = prepared
.endpoints
.values()
.flat_map(|compiled| &compiled.program.endpoints)
.filter(|endpoint| endpoint.kind == EndpointKind::Stream)
.collect::<Vec<_>>();
let task_count = execution.tasks.len();
let queue_count = execution.queues.len();
validate_queue_database_url(queue_count)?;
let model_program = project_model_program(prepared);
let model_count = model_program.models.len();
let live_resource_count = execution.live_resources.len();
let presence_count = execution.presences.len();
let application_namespace = if live_resource_count > 0 || presence_count > 0 {
let application_id = prepared.config.application_id.as_deref().ok_or_else(|| {
"error[APP_ID_REQUIRED_FOR_LIVE]: live resources and presence require a stable application security-domain identity; add `[app] id = \"example_product\"` to Noxid.toml so replicas interoperate and projects sharing Postgres or Redis cannot see each other's events or membership".to_string()
})?;
Some(application_id.to_string())
} else {
None
};
let ssr_routes = render_program.ssr_routes();
let server_shell_routes = render_program.server_shell_routes();
let render_routes = render_program.server_rendered_routes();
let dynamic_render_routes = ssr_routes + server_shell_routes;
let emits_server_handler =
server_actions + edge_actions + endpoint_count + task_count + queue_count + model_count > 0
|| live_resource_count > 0
|| presence_count > 0
|| render_routes > 0
|| !prepared.config.server_plugins.is_empty()
|| prepared.config.api_docs
|| prepared.config.mcp;
let publishes_server_handler =
server_actions + edge_actions + endpoint_count + task_count + queue_count + model_count > 0
|| live_resource_count > 0
|| presence_count > 0
|| dynamic_render_routes > 0
|| !prepared.config.server_plugins.is_empty()
|| prepared.config.api_docs
|| prepared.config.mcp;
let requires_server_host = dynamic_render_routes > 0
|| execution.boundaries.iter().any(|boundary| {
matches!(boundary.target.as_str(), "server" | "edge")
&& (boundary.body.is_none() || !boundary.capabilities.is_empty())
})
|| execution
.endpoints
.iter()
.any(|endpoint| endpoint.host_key.is_some() || !endpoint.capabilities.is_empty())
|| execution
.live_resources
.iter()
.any(|resource| !resource.capabilities.is_empty())
|| execution
.presences
.iter()
.any(|presence| !presence.capabilities.is_empty())
|| task_count > 0
|| execution
.queues
.iter()
.any(|queue| queue.host_key.is_some());
let requires_endpoint_storage = execution
.endpoints
.iter()
.any(|endpoint| endpoint.limit.is_some() || endpoint.idempotent)
|| !prepared.config.server_plugins.is_empty()
|| queue_count > 0
|| presence_count > 0
// WO-31: an engine agent persists every run in WO-19 storage, so the
// `noxid:server` storage module travels with the build that has one.
|| prepared
.compiled
.values()
.flat_map(|compiled| compiled.program.agents.iter())
.any(|agent| agent.engine.is_some());
let requires_uploads = execution
.endpoints
.iter()
.flat_map(|endpoint| &endpoint.inputs)
.any(|input| input.file.is_some());
let server_modules = prepare_server_source_modules(
prepared,
&server_middleware,
emits_server_handler,
requires_server_host,
requires_endpoint_storage || requires_uploads,
requires_uploads,
)?;
validate_compiler_generated_server_imports(&server_modules)?;
let data_policies = project_data_policies(&prepared.config.root, &server_modules)?;
// ADR 0137 rule 4: a scoped column that a Noxid declaration names must be
// typed `PrincipalId`, checked before the first output-directory
// mutation so a wrongly typed scope predicate never reaches a build.
let scoped_column_types = crate::data_security::check_scoped_column_types(
&data_policies,
&scoped_column_sites(prepared),
)?;
// SSR diagnostics must fail before the first output-directory mutation.
// In particular, client-owned presence cannot leave a superficially
// complete partial build behind when renderer planning rejects it.
let renderer = if render_routes > 0 {
let mut components = BTreeMap::new();
let mut stream_definitions = BTreeMap::new();
let mut ssr_functions = BTreeMap::new();
for target in prepared.compiled.values() {
components.insert(target.component.name.clone(), target.component.clone());
for function in &target.program.functions {
ssr_functions.insert(function.id.clone(), function.clone());
}
for stream in &target.stream_definitions {
stream_definitions.insert(stream.id.clone(), stream.clone());
}
for dependency in &target.dependencies {
components.insert(
dependency.component.name.clone(),
dependency.component.clone(),
);
}
}
let stream_definitions = stream_definitions.into_values().collect::<Vec<_>>();
let ssr_functions = ssr_functions.into_values().collect::<Vec<_>>();
Some(noxid_codegen_ssr_js::generate(
&prepared.routes,
&components,
&stream_definitions,
&ssr_functions,
)?)
} else {
None
};
let assets_dir = options.out_dir.join("assets");
let middleware_out = assets_dir.join("middleware");
fs::create_dir_all(&middleware_out)
.map_err(|error| format!("cannot create {}: {error}", middleware_out.display()))?;
let global_style_asset = emit_global_style(&prepared.config, &options.out_dir)?;
let tailwind_asset = emit_tailwind(&prepared.config, &options.out_dir, options.development)?;
let global_styles = global_style_asset
.iter()
.chain(tailwind_asset.iter())
.cloned()
.collect::<Vec<_>>();
let mut runtime_imports = BTreeSet::new();
let mut emitted_components = BTreeSet::new();
for target in prepared.compiled.values() {
let emit_browser_module = target.target.render_mode != ComponentRenderMode::Server;
if emit_browser_module {
runtime_imports.extend(target.runtime_imports.iter().cloned());
write(
&assets_dir.join(format!("{}.js", target.target.component_name)),
&development_javascript(&target.javascript, &target.component, options.development),
)?;
emitted_components.insert(target.target.component_name.clone());
}
for dependency in &target.dependencies {
if dependency.component.render.mode == ComponentRenderMode::Server {
continue;
}
runtime_imports.extend(dependency.runtime_imports.iter().cloned());
if !emitted_components.insert(dependency.component_name.clone()) {
continue;
}
write(
&assets_dir.join(format!("{}.js", dependency.component_name)),
&development_javascript(
&dependency.javascript,
&dependency.component,
options.development,
),
)?;
for (suffix, contents) in [
("validators", dependency.validators.as_deref()),
("resources", dependency.resources.as_deref()),
("streams", dependency.streams.as_deref()),
("agents", dependency.agents.as_deref()),
] {
if let Some(contents) = contents {
write(
&assets_dir.join(format!("{}.{}.js", dependency.component_name, suffix)),
contents,
)?;
}
}
}
write(
&assets_dir.join(format!("{}.css", target.target.component_name)),
&target.css,
)?;
for (suffix, contents) in [
("validators", target.validators.as_deref()),
("resources", target.resources.as_deref()),
("streams", target.streams.as_deref()),
("agents", target.agents.as_deref()),
] {
if let Some(contents) = contents {
write(
&assets_dir.join(format!("{}.{}.js", target.target.component_name, suffix)),
contents,
)?;
}
}
}
if !stream_endpoints.is_empty() {
runtime_imports.insert("createEndpointStreamConnector".into());
}
if live_resource_count > 0 || presence_count > 0 {
runtime_imports.insert("createLiveResourceController".into());
runtime_imports.insert("installLiveResourceController".into());
}
if emits_server_handler && prepared.config.server_tracing_export == ServerTracingExport::Otlp {
runtime_imports.insert("createOtlpTraceExporter".into());
}
let island_modules = server_island_modules(prepared);
let supports_router_hydration = render_program.server_rendered_routes() > 0;
let client_core_router = !options.development
&& !supports_router_hydration
&& island_modules.is_empty()
&& prepared.routes.middleware.is_empty()
&& prepared.config.host.is_none()
&& prepared.routes.routes.iter().all(|route| {
route
.layouts
.iter()
.chain(std::iter::once(&route.page))
.all(|target| target.loaders.is_empty())
})
&& execution.boundaries.is_empty();
if !island_modules.is_empty() {
runtime_imports.insert("findMarker".into());
runtime_imports.insert("hydrateComponent".into());
}
if options.development {
// The DevTools panel imports the devtools feature from the runtime;
// production runtimes prune it.
for name in [
"configureDevtools",
"devtoolsSnapshot",
"devtoolsSetState",
"devtoolsReplayTransaction",
"devtoolsInspectDom",
] {
runtime_imports.insert(name.into());
}
}
write(
&assets_dir.join("noxid-runtime.js"),
&runtime_javascript_for_imports(&runtime_imports),
)?;
for middleware in &prepared.routes.middleware {
let source = prepared
.config
.middleware_dir
.join(format!("{}.js", middleware.name));
let contents = fs::read_to_string(&source)
.map_err(|error| format!("cannot read {}: {error}", source.display()))?;
write(
&middleware_out.join(format!("{}.js", middleware.name)),
&contents,
)?;
}
let router_source = if client_core_router {
CLIENT_ROUTER_RUNTIME.to_string()
} else if supports_router_hydration {
ROUTER_RUNTIME.to_string()
} else {
ROUTER_RUNTIME
.replace(
"const ROUTER_SUPPORTS_HYDRATION = true;",
"const ROUTER_SUPPORTS_HYDRATION = false;",
)
.replace(
"export function scheduleHydration",
"function scheduleHydration",
)
};
let router_runtime = if client_core_router {
router_source
} else if !supports_router_hydration {
format!("const emitRuntimeEvent = () => {{}};\n{router_source}")
} else if island_modules.is_empty() {
format!(
"import {{ emitRuntimeEvent }} from \"./noxid-runtime.js\";\n{}",
router_source
)
} else {
format!(
"import {{ emitRuntimeEvent, findMarker, hydrateComponent }} from \"./noxid-runtime.js\";\n{}",
router_source
)
};
write(&assets_dir.join("noxid-router.js"), &router_runtime)?;
if options.development {
write(&assets_dir.join("noxid-hmr-client.js"), HMR_CLIENT)?;
write(
&assets_dir.join("noxid-diagnostics.js"),
"export const diagnostics = Object.freeze([]);\n",
)?;
}
write(
&options.out_dir.join("app.hmr.json"),
&hmr_manifest(prepared),
)?;
let host_module = if let Some(host) = &prepared.config.host {
let contents = fs::read_to_string(host)
.map_err(|error| format!("cannot read {}: {error}", host.display()))?;
write(&assets_dir.join("host.js"), &contents)?;
Some("assets/host.js")
} else {
None
};
write(
&options.out_dir.join("app.js"),
&app_javascript(
&prepared.routes,
AppJavaScriptContext {
stream_endpoints: &stream_endpoints,
live_resources: &execution.live_resources,
presences: &execution.presences,
host_module,
default_title: options.title.as_deref().unwrap_or(&prepared.config.title),
development: options.development,
island_modules: &island_modules,
client_core_router,
requires_browser_authorizer,
},
),
)?;
write(
&options.out_dir.join("index.html"),
&project_html(
options.title.as_deref().unwrap_or(&prepared.config.title),
&prepared.config.base_path,
&global_styles,
None,
),
)?;
write(
&options.out_dir.join("app.routes.json"),
&prepared.routes.to_json(),
)?;
write(
&options.out_dir.join("app.render.json"),
&render_program.to_json(),
)?;
write(
&options.out_dir.join("app.graph.json"),
&prepared.graph.to_json(),
)?;
write(
&options.out_dir.join("app.semantic-units.json"),
&semantic_units_json(prepared),
)?;
write(&options.out_dir.join("api.openapi.json"), &openapi)?;
write(&options.out_dir.join("api-contract.json"), &api_contract)?;
if worker_actions > 0 {
let requires_host = execution.boundaries.iter().any(|boundary| {
boundary.target.as_str() == "worker"
&& (boundary.body.is_none() || !boundary.capabilities.is_empty())
});
if requires_host && prepared.config.worker_entry.is_none() {
return Err("error[WORKER_ENTRY_REQUIRED]: bodyless or capability-protected worker actions require `[worker] entry = \"src/worker.js\"` in Noxid.toml".into());
}
let host = if let Some(worker_entry) = prepared.config.worker_entry.as_ref() {
fs::read_to_string(worker_entry)
.map_err(|error| format!("cannot read {}: {error}", worker_entry.display()))?
} else {
"export const actions = Object.freeze({});\n".into()
};
write(&assets_dir.join("noxid-worker-host.js"), &host)?;
write(
&assets_dir.join("noxid-worker-validators.js"),
&noxid_codegen_validation_js::generate(&validation),
)?;
write(
&assets_dir.join("noxid-worker.js"),
&worker_javascript(&execution)?,
)?;
}
let route_loaders = prepared
.compiled
.values()
.map(|target| target.component.loaders.len())
.sum::<usize>();
let prerender_routes = render_program.prerender_routes();
let prerender_entries =
resolved_prerender_entries(&prepared.routes, &prepared.config.prerender_entries)?.len();
let isr_routes = prepared
.routes
.routes
.iter()
.filter(|route| {
route
.cache
.as_ref()
.is_some_and(|cache| cache.mode == RouteCacheMode::Isr)
})
.count();
let swr_routes = prepared
.routes
.routes
.iter()
.filter(|route| {
route
.cache
.as_ref()
.is_some_and(|cache| cache.mode == RouteCacheMode::Swr)
})
.count();
let external_browser_modules = prepared
.compiled
.values()
.flat_map(|target| target.external_browser_modules.iter().cloned())
.collect::<BTreeSet<_>>();
let native_esm_eligible = external_browser_modules.is_empty()
&& prepared.routes.middleware.is_empty()
&& prepared.config.host.is_none()
&& route_loaders == 0
&& render_routes == 0
&& server_actions == 0
&& edge_actions == 0
&& endpoint_count == 0
&& task_count == 0
&& queue_count == 0
&& live_resource_count == 0
&& presence_count == 0
&& prepared.config.server_plugins.is_empty()
&& !prepared.config.api_docs
&& !prepared.config.mcp
&& worker_actions == 0;
validate_prerender_routes(prepared)?;
validate_route_cache(prepared)?;
let mut server_source_outputs = Vec::new();
let principal_authority_import =
select_principal_authority_import(&server_modules, &prepared.config.root)?;
if emits_server_handler {
let server_dir = options.out_dir.join("server");
fs::create_dir_all(&server_dir)
.map_err(|error| format!("cannot create {}: {error}", server_dir.display()))?;
for module in &server_modules {
debug_assert!(
!module.typescript,
"server planning must transpile modules before output mutation"
);
let output = server_dir.join(&module.emitted);
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
write(&output, &module.source)?;
server_source_outputs.push(format!("server/{}", module.emitted));
}
let validators = noxid_codegen_validation_js::generate(&validation);
write(&server_dir.join("validators.js"), &validators)?;
write(
&server_dir.join("middleware.js"),
&server_middleware_javascript(
&server_middleware,
&prepared.config.server_global_middleware,
),
)?;
if !prepared.config.server_plugins.is_empty() {
write(
&server_dir.join("plugins.js"),
&server_plugins_javascript(&prepared.config.server_plugins),
)?;
}
let generated = noxid_codegen_server_js::generate_with_runtime_options(
&execution,
"./host.js",
"./validators.js",
"./middleware.js",
(!prepared.config.server_plugins.is_empty()).then_some("./plugins.js"),
&prepared.config.base_path,
&prepared.config.server_secrets,
noxid_codegen_server_js::AgentSurfaceOptions {
openapi_json: (prepared.config.api_docs || prepared.config.mcp)
.then_some(openapi.as_str()),
serve_openapi: prepared.config.api_docs,
mcp: prepared.config.mcp,
principal_authority_import: principal_authority_import.as_deref(),
},
prepared.config.server_tracing,
noxid_codegen_server_js::ServerRuntimeOptions {
db_pool: prepared.config.db_pool,
pubsub: server_pubsub_runtime_options(
&prepared.config,
!execution.live_resources.is_empty() || !execution.presences.is_empty(),
),
development_trace_capture: options.development,
application_namespace,
models: noxid_codegen_server_js::ModelRuntimeOptions {
models: model_program.models.clone(),
// Every declared type's strict schema travels with the
// models section: `generateObject` names its type at call
// time, so the compiler cannot know which one in advance.
type_schemas: noxid_model_ir::type_schemas(&validation),
},
tracing_export: prepared.config.server_tracing_export,
tracing_service_name: prepared.config.server_tracing_service_name.clone(),
otlp_endpoint_secret: prepared
.config
.server_secrets
.iter()
.any(|name| name == "OTEL_EXPORTER_OTLP_ENDPOINT"),
otlp_headers_secret: prepared
.config
.server_secrets
.iter()
.any(|name| name == "OTEL_EXPORTER_OTLP_HEADERS"),
// WO-31: every declared agent in the build, lowered once. The
// emitter keeps only the ones with an engine, so a project
// with client-only agent sessions emits no `agents` section.
agents: noxid_codegen_server_js::AgentRuntimeOptions {
agents: prepared
.compiled
.values()
.flat_map(|compiled| compiled.program.agents.iter())
.map(|agent| (agent.name.clone(), agent))
.collect::<BTreeMap<_, _>>()
.into_values()
.map(noxid_agent_ir::lower_definition)
.collect(),
},
},
)?;
if render_routes > 0 {
write(&server_dir.join("actions.js"), &generated.handler)?;
let renderer = renderer
.as_ref()
.expect("server-rendered routes preflight one renderer");
write(&server_dir.join("renderer.js"), &renderer.javascript)?;
write(
&server_dir.join("handler.js"),
&server_dispatcher_javascript(
&prepared.config.base_path,
&prepared.config.server_secrets,
prepared.config.server_tracing_export,
),
)?;
} else {
write(&server_dir.join("handler.js"), &generated.handler)?;
}
write(
&server_dir.join("execution.manifest.json"),
&format!("{}\n", generated.manifest),
)?;
write(
&server_dir.join("security.manifest.json"),
&format!(
"{}\n",
endpoint_security_manifest(prepared, &data_policies, &scoped_column_types)
),
)?;
write(
&server_dir.join("tasks.manifest.json"),
&format!("{}\n", task_security_manifest(prepared)),
)?;
write(
&server_dir.join("queues.manifest.json"),
&format!("{}\n", queue_manifest(prepared)),
)?;
write(
&server_dir.join("models.manifest.json"),
&format!("{}\n", model_program.to_json()),
)?;
}
if server_shell_routes > 0
&& let Some(shell) = render_initial_server_shell(&options.out_dir, &prepared.routes)?
{
write(
&options.out_dir.join("index.html"),
&project_html(
options.title.as_deref().unwrap_or(&prepared.config.title),
&prepared.config.base_path,
&global_styles,
Some(&shell),
),
)?;
}
let mut devtools = noxid_devtools_protocol::lower(
&prepared.graph,
if options.development {
prepared.config.root.display().to_string()
} else {
".".into()
},
);
devtools.project = Some(devtools_project(
prepared,
options.title.as_deref().unwrap_or(&prepared.config.title),
));
for target in prepared.compiled.values() {
let component = &target.component.name;
for symbol in &mut devtools.symbols {
if symbol.semantic_id.contains(&format!(":{component}")) {
symbol.source = target.target.source.clone();
}
}
}
if !options.development {
for symbol in &mut devtools.symbols {
symbol.source =
project_relative_devtools_source(&prepared.config.root, Path::new(&symbol.source))?;
}
}
let devtools_metadata = devtools.to_json();
write(
&options.out_dir.join("app.devtools.json"),
&format!("{devtools_metadata}\n"),
)?;
if options.development {
write(&assets_dir.join("noxid-devtools.js"), devtools_javascript())?;
write(
&assets_dir.join("noxid-devtools-metadata.js"),
&format!("export const metadata = Object.freeze({devtools_metadata});\n"),
)?;
write(&assets_dir.join("noxid-devtools-panel.js"), DEVTOOLS_PANEL)?;
}
write(
&options.out_dir.join("app.prerender.json"),
&prerender_manifest(&prepared.routes, &prepared.config.prerender_entries)?,
)?;
let global_styles_json = format!(
"[{}]",
global_styles
.iter()
.map(|asset| format!("\"{}\"", json_escape(asset)))
.collect::<Vec<_>>()
.join(",")
);
write(
&options.out_dir.join("app.manifest.json"),
&format!(
"{{\n \"schemaVersion\": 20,\n \"name\": \"NoxidApplication\",\n \"basePath\": \"{}\",\n \"globalStyles\": {},\n \"routes\": {},\n \"routesWithMetadata\": {},\n \"routesWithQuery\": {},\n \"routesWithCatchAll\": {},\n \"routeLoaders\": {},\n \"renderIr\": \"app.render.json\",\n \"ssrRoutes\": {},\n \"serverShellRoutes\": {},\n \"prerenderRoutes\": {},\n \"prerenderEntries\": {},\n \"isrRoutes\": {},\n \"swrRoutes\": {},\n \"prerenderManifest\": {},\n \"serverRenderer\": {},\n \"components\": {},\n \"endpoints\": {},\n \"queues\": {},\n \"componentImports\": {},\n \"autoComponentImports\": {},\n \"middleware\": {},\n \"routeIsolated\": true,\n \"routerProfile\": \"{}\",\n \"semanticHmr\": true,\n \"hmrRuntime\": {},\n \"devtoolsMetadata\": \"app.devtools.json\",\n \"devtoolsRuntime\": {},\n \"serverRuntime\": \"{}\",\n \"serverTracing\": \"{}\",\n \"serverTracingExport\": \"{}\",\n \"queueWorker\": {},\n \"serverActions\": {},\n \"edgeActions\": {},\n \"workerActions\": {},\n \"externalBrowserModules\": {},\n \"nativeEsmEligible\": {},\n \"fetchHandler\": {}\n}}\n",
json_escape(&prepared.config.base_path),
global_styles_json,
prepared.routes.routes.len(),
prepared
.routes
.routes
.iter()
.filter(|route| route.metadata.is_some())
.count(),
prepared
.routes
.routes
.iter()
.filter(|route| !route.query.is_empty())
.count(),
prepared
.routes
.routes
.iter()
.filter(|route| route.parameters.iter().any(|parameter| parameter.catch_all))
.count(),
route_loaders,
ssr_routes,
server_shell_routes,
prerender_routes,
prerender_entries,
isr_routes,
swr_routes,
if prerender_routes > 0 {
"\"app.prerender.json\""
} else {
"null"
},
if dynamic_render_routes > 0 {
"\"server/renderer.js\""
} else {
"null"
},
emitted_components.len(),
endpoint_count,
queue_count,
prepared
.compiled
.values()
.map(|target| target.component_imports)
.sum::<usize>(),
prepared
.compiled
.values()
.map(|target| target.auto_component_imports)
.sum::<usize>(),
prepared.routes.middleware.len(),
if client_core_router {
"client-core"
} else {
"universal"
},
if options.development {
"\"assets/noxid-hmr-client.js\""
} else {
"null"
},
if options.development {
"\"assets/noxid-devtools-panel.js\""
} else {
"null"
},
json_escape(&prepared.config.server_runtime),
prepared.config.server_tracing.as_str(),
prepared.config.server_tracing_export.as_str(),
prepared.config.queue_worker,
server_actions,
edge_actions,
worker_actions,
external_browser_modules.len(),
native_esm_eligible,
if publishes_server_handler {
"\"server/handler.js\""
} else {
"null"
},
),
)?;
let mut generated_files = vec![
"app.js".to_string(),
"app.hmr.json".to_string(),
"index.html".to_string(),
"app.routes.json".to_string(),
"app.render.json".to_string(),
"app.graph.json".to_string(),
"app.semantic-units.json".to_string(),
"api.openapi.json".to_string(),
"api-contract.json".to_string(),
"app.devtools.json".to_string(),
"app.manifest.json".to_string(),
"app.prerender.json".to_string(),
"assets/noxid-runtime.js".to_string(),
"assets/noxid-router.js".to_string(),
];
generated_files.extend(global_styles.iter().cloned());
if prepared.config.host.is_some() {
generated_files.push("assets/host.js".into());
}
if worker_actions > 0 {
generated_files.extend([
"assets/noxid-worker.js".into(),
"assets/noxid-worker-host.js".into(),
"assets/noxid-worker-validators.js".into(),
]);
}
if emits_server_handler {
generated_files.extend([
"server/host.js".into(),
"server/handler.js".into(),
"server/validators.js".into(),
"server/middleware.js".into(),
"server/execution.manifest.json".into(),
"server/security.manifest.json".into(),
"server/tasks.manifest.json".into(),
"server/queues.manifest.json".into(),
"server/models.manifest.json".into(),
]);
if !prepared.config.server_plugins.is_empty() {
generated_files.push("server/plugins.js".into());
}
if render_routes > 0 {
generated_files.push("server/actions.js".into());
generated_files.push("server/renderer.js".into());
}
generated_files.extend(server_source_outputs);
}
if options.development {
generated_files.extend([
"assets/noxid-hmr-client.js".into(),
"assets/noxid-diagnostics.js".into(),
"assets/noxid-devtools.js".into(),
"assets/noxid-devtools-metadata.js".into(),
"assets/noxid-devtools-panel.js".into(),
]);
}
for target in prepared.compiled.values() {
if target.target.render_mode != ComponentRenderMode::Server {
generated_files.push(format!("assets/{}.js", target.target.component_name));
}
generated_files.push(format!("assets/{}.css", target.target.component_name));
for dependency in &target.dependencies {
if dependency.component.render.mode == ComponentRenderMode::Server {
continue;
}
generated_files.push(format!("assets/{}.js", dependency.component_name));
for (suffix, contents) in [
("validators", dependency.validators.as_ref()),
("resources", dependency.resources.as_ref()),
("streams", dependency.streams.as_ref()),
("agents", dependency.agents.as_ref()),
] {
if contents.is_some() {
generated_files.push(format!(
"assets/{}.{}.js",
dependency.component_name, suffix
));
}
}
}
for (suffix, contents) in [
("validators", target.validators.as_ref()),
("resources", target.resources.as_ref()),
("streams", target.streams.as_ref()),
("agents", target.agents.as_ref()),
] {
if contents.is_some() {
generated_files.push(format!(
"assets/{}.{}.js",
target.target.component_name, suffix
));
}
}
}
for middleware in &prepared.routes.middleware {
generated_files.push(format!("assets/middleware/{}.js", middleware.name));
}
for asset in project_assets.keys() {
if generated_files.iter().any(|generated| generated == asset) {
return Err(format!(
"error[PROJECT_ASSET_COLLISION]: project asset `{asset}` conflicts with compiler-generated output; rename the source asset"
));
}
generated_files.push(asset.clone());
}
generated_files.sort();
generated_files.dedup();
clean_stale_generated(&options.out_dir, &generated_files)?;
copy_project_assets(&options.out_dir, &project_assets)?;
write(
&options.out_dir.join(".noxid-generated-files"),
&format!("{}\n", generated_files.join("\n")),
)?;
let mut endpoint_paths = execution
.endpoints
.iter()
.filter_map(|endpoint| endpoint.path.clone())
.collect::<Vec<_>>();
endpoint_paths.sort();
endpoint_paths.dedup();
Ok(ProjectBuild {
routes: prepared.routes.routes.len(),
endpoints: endpoint_count,
endpoint_paths,
tasks: task_count,
queues: queue_count,
queue_worker: prepared.config.queue_worker,
live_resources: live_resource_count,
presences: presence_count,
api_docs: prepared.config.api_docs,
mcp: prepared.config.mcp,
components: emitted_components.len(),
middleware: prepared.routes.middleware.len()
+ prepared.config.server_global_middleware.len(),
route_loaders,
ssr_routes,
server_shell_routes,
prerender_routes,
prerender_entries,
isr_routes,
swr_routes,
assets: generated_files.len(),
compiled_targets: stats.compiled_targets,
reused_targets: stats.reused_targets,
server_actions,
edge_actions,
worker_actions,
external_browser_modules: external_browser_modules.len(),
native_esm_eligible,
persistent_cache_hit: false,
})
}
fn project_relative_devtools_source(root: &Path, source: &Path) -> Result<String, String> {
let relative = if source.is_absolute() {
source.strip_prefix(root).map_err(|_| {
format!(
"error[DEVTOOLS_SOURCE_OUTSIDE_PROJECT]: production DevTools metadata source `{}` is outside project root `{}`; keep compiler inputs inside the project before building",
source.display(),
root.display(),
)
})?
} else {
source
};
let mut parts = Vec::new();
for component in relative.components() {
match component {
Component::Normal(value) => parts.push(value.to_string_lossy().into_owned()),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(format!(
"error[DEVTOOLS_SOURCE_OUTSIDE_PROJECT]: production DevTools metadata source `{}` does not resolve within project root `{}`; keep compiler inputs inside the project before building",
source.display(),
root.display(),
));
}
}
}
Ok(if parts.is_empty() {
".".into()
} else {
parts.join("/")
})
}
/// One lowered model program for the whole project. Discovery already refuses
/// duplicate `model:<Name>` identities, so the dedup here is a belt on the
/// braces rather than a merge policy.
fn project_model_program(prepared: &PreparedProject) -> noxid_model_ir::ModelProgram {
let mut models = prepared
.models
.values()
.flat_map(|compiled| compiled.models.models.iter().cloned())
.collect::<Vec<_>>();
models.sort_by(|left, right| left.id.cmp(&right.id));
models.dedup_by(|left, right| left.id == right.id);
noxid_model_ir::ModelProgram { models }
}
fn semantic_units_json(prepared: &PreparedProject) -> String {
let mut units = prepared
.compiled
.iter()
.map(|(path, target)| {
format!(
"{{\"source\":\"{}\",\"component\":\"{}\",\"semanticId\":\"{}\",\"inputFingerprint\":\"{:016x}\",\"semantic\":{}}}",
json_escape(&path.display().to_string()),
json_escape(&target.target.component_name),
target.target.component,
target.input_fingerprint,
target.semantic_json
)
})
.collect::<Vec<_>>();
units.extend(prepared.endpoints.iter().flat_map(|(path, target)| {
target.program.endpoints.iter().map(move |endpoint| {
format!(
"{{\"source\":\"{}\",\"endpoint\":\"{}\",\"semanticId\":\"{}\",\"inputFingerprint\":\"{:016x}\",\"semantic\":{}}}",
json_escape(&path.display().to_string()),
json_escape(&endpoint.name),
endpoint.id,
target.input_fingerprint,
target.semantic_json
)
})
}));
units.extend(prepared.tasks.iter().flat_map(|(path, target)| {
target.program.tasks.iter().map(move |task| {
format!(
"{{\"source\":\"{}\",\"task\":\"{}\",\"semanticId\":\"{}\",\"inputFingerprint\":\"{:016x}\",\"semantic\":{}}}",
json_escape(&path.display().to_string()),
json_escape(&task.name),
task.id,
target.input_fingerprint,
target.semantic_json
)
})
}));
units.extend(prepared.queues.iter().flat_map(|(path, target)| {
target.program.queues.iter().map(move |queue| {
format!(
"{{\"source\":\"{}\",\"queue\":\"{}\",\"semanticId\":\"{}\",\"inputFingerprint\":\"{:016x}\",\"semantic\":{}}}",
json_escape(&path.display().to_string()),
json_escape(&queue.name),
queue.id,
target.input_fingerprint,
target.semantic_json
)
})
}));
units.extend(prepared.models.iter().flat_map(|(path, target)| {
target.program.models.iter().map(move |model| {
format!(
"{{\"source\":\"{}\",\"model\":\"{}\",\"semanticId\":\"{}\",\"inputFingerprint\":\"{:016x}\",\"semantic\":{}}}",
json_escape(&path.display().to_string()),
json_escape(&model.name),
model.id,
target.input_fingerprint,
target.semantic_json
)
})
}));
units.sort();
let units = units.join(",");
format!(
"{{\"schemaVersion\":1,\"compilerVersion\":\"{}\",\"units\":[{units}]}}\n",
env!("CARGO_PKG_VERSION")
)
}
fn openapi_document(prepared: &PreparedProject) -> Result<String, String> {
let endpoints = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.endpoints.iter().cloned())
.collect::<Vec<_>>();
let types = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.types.iter().cloned())
.collect::<Vec<_>>();
let distinct_types = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.distinct_types.iter().cloned())
.collect::<Vec<_>>();
noxid_openapi::generate_with_distincts(
&prepared.config.title,
&endpoints,
&types,
&distinct_types,
)
}
fn api_contract_document(prepared: &PreparedProject) -> Result<String, String> {
let endpoints = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.endpoints.iter().cloned())
.collect::<Vec<_>>();
let types = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.types.iter().cloned())
.collect::<Vec<_>>();
let distinct_types = prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.distinct_types.iter().cloned())
.collect::<Vec<_>>();
noxid_openapi::contract_document_with_distincts(&endpoints, &types, &distinct_types)
}
fn endpoint_security_manifest(
prepared: &PreparedProject,
data_policies: &[crate::data_security::TablePolicy],
scoped_column_types: &[String],
) -> String {
noxid_ir::EndpointSecurityManifest::from_endpoints(
prepared
.endpoints
.values()
.flat_map(|compiled| compiled.program.endpoints.iter()),
)
.with_surfaces(noxid_ir::AgentSurfaceOptIns {
api_docs: prepared.config.api_docs,
mcp: prepared.config.mcp,
})
.with_models(
prepared
.models
.values()
.flat_map(|compiled| compiled.program.models.iter()),
)
.with_agents(
prepared
.compiled
.values()
.flat_map(|compiled| compiled.program.agents.iter()),
)
.with_data_policies(data_policies.iter().enumerate().map(|(index, policy)| {
let principal_column = match &policy.kind {
crate::data_security::TablePolicyKind::Scoped { principal_column } => {
Some(principal_column.clone())
}
crate::data_security::TablePolicyKind::Unscoped => None,
};
noxid_ir::DataSecurityPolicy {
table: policy.table.clone(),
principal_column,
// The Noxid type of that column where a declaration names it,
// and `None` where nothing in this project's `.nox` sources
// mentions it (ADR 0137 rule 4).
principal_type: scoped_column_types
.get(index)
.filter(|typed| !typed.is_empty())
.cloned(),
}
}))
.to_json()
}
fn task_security_manifest(prepared: &PreparedProject) -> String {
let mut entries = prepared
.tasks
.values()
.flat_map(|compiled| {
noxid_ir::TaskSecurityManifest::from_program(&compiled.program)
.tasks
.into_iter()
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| left.id.cmp(&right.id));
noxid_ir::TaskSecurityManifest { tasks: entries }.to_json()
}
fn queue_manifest(prepared: &PreparedProject) -> String {
let mut queues = prepared
.queues
.values()
.flat_map(|compiled| compiled.program.queues.iter())
.collect::<Vec<_>>();
queues.sort_by(|left, right| left.id.cmp(&right.id));
let entries = queues
.iter()
.map(|queue| {
let payload = queue
.payload
.iter()
.map(|field| {
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"type\":\"{}\",\"typeId\":{}}}",
field.id,
json_escape(&field.name),
json_escape(&field.ty.to_string()),
field
.type_id
.as_ref()
.map(|id| format!("\"{id}\""))
.unwrap_or_else(|| "null".into())
)
})
.collect::<Vec<_>>()
.join(",");
let host_key = match &queue.handler {
noxid_ir::QueueHandler::Host { key } => format!("\"{key}\""),
noxid_ir::QueueHandler::CompilerOwned { .. } => "null".into(),
};
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"payload\":[{}],\"retry\":{},\"backoffMs\":{},\"hostKey\":{}}}",
queue.id,
json_escape(&queue.name),
payload,
queue.retry,
queue.backoff_ms,
host_key,
)
})
.collect::<Vec<_>>()
.join(",");
format!("{{\"schemaVersion\":1,\"queues\":[{entries}]}}")
}
fn worker_javascript(program: &noxid_execution_ir::ExecutionProgram) -> Result<String, String> {
let compiled_actions = program
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "worker")
.filter_map(|boundary| {
boundary.body.as_ref().map(|body| {
Ok(format!(
" \"{}\": async (args) => ({})",
boundary.action,
noxid_codegen_server_js::compiler_body_javascript(body)?,
))
})
})
.collect::<Result<Vec<_>, String>>()?
.join(",\n");
let schemas = program
.boundaries
.iter()
.filter(|boundary| boundary.target.as_str() == "worker")
.map(|boundary| {
let parameters = boundary
.parameters
.iter()
.map(|parameter| {
let type_id = parameter
.type_id
.as_ref()
.map(|id| format!("\"{id}\""))
.unwrap_or_else(|| "null".into());
format!(
"Object.freeze({{ name: \"{}\", type: \"{}\", typeId: {type_id} }})",
json_escape(¶meter.name),
json_escape(¶meter.ty),
)
})
.collect::<Vec<_>>()
.join(", ");
let result_type_id = boundary
.result
.type_id
.as_ref()
.map(|id| format!("\"{id}\""))
.unwrap_or_else(|| "null".into());
let capabilities = boundary
.capabilities
.iter()
.map(|value| format!("\"{}\"", json_escape(value)))
.collect::<Vec<_>>()
.join(", ");
format!(
" \"{}\": Object.freeze({{ id: \"{}\", parameters: Object.freeze([{parameters}]), result: Object.freeze({{ type: \"{}\", typeId: {result_type_id} }}), capabilities: Object.freeze([{capabilities}]) }})",
boundary.action,
boundary.action,
json_escape(&boundary.result.ty),
)
})
.collect::<Vec<_>>()
.join(",\n");
Ok(format!(
r#"import * as hostModule from "./noxid-worker-host.js";
import {{ typeValidators }} from "./noxid-worker-validators.js";
const hostActions = hostModule.actions ?? hostModule.default ?? Object.create(null);
const compiledActions = Object.freeze({{
{compiled_actions}
}});
const authorize = hostModule.authorize;
const schemas = Object.freeze({{
{schemas}
}});
const running = new Map();
function splitGeneric(type, prefix) {{
return type.startsWith(`${{prefix}}<`) && type.endsWith(">") ? type.slice(prefix.length + 1, -1) : null;
}}
function validate(type, typeId, value, path) {{
if (type === "String" && typeof value === "string") return value;
if (type === "Boolean" && typeof value === "boolean") return value;
if (type === "Int" && Number.isSafeInteger(value)) return value;
if ((type === "Number" || type === "Float") && typeof value === "number" && Number.isFinite(value)) return value;
if (type === "Date" && typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
const optional = splitGeneric(type, "Optional");
if (optional !== null && value == null) return value;
if (optional !== null) return validate(optional, typeId, value, path);
const array = splitGeneric(type, "Array");
if (array !== null && Array.isArray(value)) return Object.freeze(value.map((item, index) => validate(array, typeId, item, `${{path}}[${{index}}]`)));
const validator = typeId == null ? null : typeValidators[typeId];
if (typeof validator === "function") return validator(value);
throw Object.assign(new TypeError(`${{path}} must be ${{type}}`), {{ code: "WORKER_BOUNDARY_TYPE" }});
}}
function respond(id, ok, value) {{ self.postMessage(ok ? {{ id, ok, value }} : {{ id, ok, error: value }}); }}
self.addEventListener("message", async (event) => {{
const message = event.data;
if (message?.type === "cancel") {{ running.get(message.id)?.abort(new DOMException("Worker action cancelled", "AbortError")); return; }}
if (message?.type !== "execute" || typeof message.id !== "number") return;
const schema = schemas[message.action];
if (!schema) {{ respond(message.id, false, {{ code: "WORKER_ACTION_UNKNOWN", message: "Unknown worker action" }}); return; }}
const implementation = compiledActions[schema.id] ?? hostActions[schema.id];
if (typeof implementation !== "function") {{ respond(message.id, false, {{ code: "WORKER_IMPLEMENTATION_MISSING", message: "Worker action implementation is missing" }}); return; }}
const controller = new AbortController();
running.set(message.id, controller);
try {{
const incoming = Array.isArray(message.arguments) ? message.arguments : [];
const values = Object.create(null);
for (const parameter of schema.parameters) {{
const argument = incoming.find((candidate) => candidate?.name === parameter.name);
if (!argument) throw Object.assign(new TypeError(`Missing worker argument ${{parameter.name}}`), {{ code: "WORKER_ARGUMENT_MISSING" }});
values[parameter.name] = validate(parameter.type, parameter.typeId, argument.value, `arguments.${{parameter.name}}`);
}}
if (incoming.some((argument) => !schema.parameters.some((parameter) => parameter.name === argument?.name))) throw Object.assign(new TypeError("Unknown worker argument"), {{ code: "WORKER_ARGUMENT_UNKNOWN" }});
for (const capability of schema.capabilities) {{
if (typeof authorize !== "function" || await authorize(Object.freeze({{ capability, semanticId: schema.id, route: message.route }})) !== true) throw Object.assign(new Error("Worker capability denied"), {{ code: "WORKER_CAPABILITY_DENIED" }});
}}
const value = await implementation(Object.freeze(values), Object.freeze({{ signal: controller.signal, semanticId: schema.id, route: message.route }}));
respond(message.id, true, validate(schema.result.type, schema.result.typeId, value, "result"));
}} catch (cause) {{
respond(message.id, false, {{ code: typeof cause?.code === "string" ? cause.code : "WORKER_EXECUTION_FAILED", message: typeof cause?.message === "string" ? cause.message : "Worker action failed" }});
}} finally {{ running.delete(message.id); }}
}});
"#
))
}
const PERSISTENT_BUILD_CACHE_SCHEMA_VERSION: u32 = 11;
fn persistent_build_cache_dir(
input: &Path,
options: &ProjectBuildOptions,
) -> Result<PathBuf, String> {
let config = load_config(input)?;
let mut hasher = DefaultHasher::new();
PERSISTENT_BUILD_CACHE_SCHEMA_VERSION.hash(&mut hasher);
env!("CARGO_PKG_VERSION").hash(&mut hasher);
if let Ok(executable) = std::env::current_exe() {
stamp_path(&executable, &mut hasher)?;
}
project_stamp(&config)?.hash(&mut hasher);
options.title.hash(&mut hasher);
Ok(config
.root
.join("target/noxid-cache/project-v2")
.join(format!("{:016x}", hasher.finish())))
}
fn restore_persistent_build(
input: &Path,
options: &ProjectBuildOptions,
) -> Result<Option<ProjectBuild>, String> {
let cache = persistent_build_cache_dir(input, options)?;
let metadata = cache.join("build.meta");
let ledger = cache.join("artifacts/.noxid-generated-files");
if !metadata.is_file() || !ledger.is_file() {
return Ok(None);
}
let values = fs::read_to_string(&metadata)
.map_err(|error| {
format!(
"cannot read persistent compiler cache {}: {error}",
metadata.display()
)
})?
.lines()
.filter_map(|line| line.split_once('='))
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect::<BTreeMap<_, _>>();
let expected_schema = PERSISTENT_BUILD_CACHE_SCHEMA_VERSION.to_string();
if values.get("schema") != Some(&expected_schema) {
return Ok(None);
}
let parse = |name: &str| -> Result<usize, String> {
values
.get(name)
.ok_or_else(|| format!("persistent compiler cache is missing `{name}`"))?
.parse::<usize>()
.map_err(|_| format!("persistent compiler cache field `{name}` is invalid"))
};
let parse_bool = |name: &str| -> Result<bool, String> {
values
.get(name)
.ok_or_else(|| format!("persistent compiler cache is missing `{name}`"))?
.parse::<bool>()
.map_err(|_| format!("persistent compiler cache field `{name}` is invalid"))
};
let current = fs::read_to_string(&ledger)
.map_err(|error| format!("cannot read persistent compiler cache ledger: {error}"))?
.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
clean_stale_generated(&options.out_dir, ¤t)?;
copy_generated_artifacts(&cache.join("artifacts"), &options.out_dir, ¤t)?;
let endpoint_paths = (0..parse("endpoint_paths")?)
.map(|index| {
values
.get(&format!("endpoint_path_{index}"))
.cloned()
.ok_or_else(|| {
format!("persistent compiler cache is missing `endpoint_path_{index}`")
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Some(ProjectBuild {
routes: parse("routes")?,
endpoints: parse("endpoints")?,
endpoint_paths,
tasks: parse("tasks")?,
queues: parse("queues")?,
queue_worker: parse_bool("queue_worker")?,
live_resources: parse("live_resources")?,
presences: parse("presences")?,
api_docs: parse_bool("api_docs")?,
mcp: parse_bool("mcp")?,
components: parse("components")?,
middleware: parse("middleware")?,
route_loaders: parse("route_loaders")?,
ssr_routes: parse("ssr_routes")?,
server_shell_routes: parse("server_shell_routes")?,
prerender_routes: parse("prerender_routes")?,
prerender_entries: parse("prerender_entries")?,
isr_routes: parse("isr_routes")?,
swr_routes: parse("swr_routes")?,
assets: parse("assets")?,
compiled_targets: 0,
reused_targets: parse("targets")?,
server_actions: parse("server_actions")?,
edge_actions: parse("edge_actions")?,
worker_actions: parse("worker_actions")?,
external_browser_modules: parse("external_browser_modules")?,
native_esm_eligible: parse_bool("native_esm_eligible")?,
persistent_cache_hit: true,
}))
}
fn store_persistent_build(
input: &Path,
options: &ProjectBuildOptions,
build: &ProjectBuild,
) -> Result<(), String> {
let cache = persistent_build_cache_dir(input, options)?;
let artifacts = cache.join("artifacts");
fs::create_dir_all(&artifacts).map_err(|error| {
format!(
"cannot create persistent compiler cache {}: {error}",
artifacts.display()
)
})?;
let ledger = options.out_dir.join(".noxid-generated-files");
let current = fs::read_to_string(&ledger)
.map_err(|error| {
format!(
"cannot read generated artifact ledger {}: {error}",
ledger.display()
)
})?
.lines()
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
copy_generated_artifacts(&options.out_dir, &artifacts, ¤t)?;
let endpoint_paths = build
.endpoint_paths
.iter()
.enumerate()
.map(|(index, path)| format!("endpoint_path_{index}={path}\n"))
.collect::<String>();
write(
&cache.join("build.meta"),
&format!(
"schema={}\nroutes={}\nendpoints={}\nendpoint_paths={}\n{}tasks={}\nqueues={}\nqueue_worker={}\nlive_resources={}\npresences={}\napi_docs={}\nmcp={}\ncomponents={}\nmiddleware={}\nroute_loaders={}\nssr_routes={}\nserver_shell_routes={}\nprerender_routes={}\nprerender_entries={}\nisr_routes={}\nswr_routes={}\nassets={}\ntargets={}\nserver_actions={}\nedge_actions={}\nworker_actions={}\nexternal_browser_modules={}\nnative_esm_eligible={}\n",
PERSISTENT_BUILD_CACHE_SCHEMA_VERSION,
build.routes,
build.endpoints,
build.endpoint_paths.len(),
endpoint_paths,
build.tasks,
build.queues,
build.queue_worker,
build.live_resources,
build.presences,
build.api_docs,
build.mcp,
build.components,
build.middleware,
build.route_loaders,
build.ssr_routes,
build.server_shell_routes,
build.prerender_routes,
build.prerender_entries,
build.isr_routes,
build.swr_routes,
build.assets,
build.compiled_targets + build.reused_targets,
build.server_actions,
build.edge_actions,
build.worker_actions,
build.external_browser_modules,
build.native_esm_eligible,
),
)
}
fn copy_generated_artifacts(
source: &Path,
destination: &Path,
files: &[String],
) -> Result<(), String> {
for relative in files
.iter()
.map(String::as_str)
.chain(std::iter::once(".noxid-generated-files"))
{
let path = Path::new(relative);
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_) | Component::CurDir))
{
return Err(format!(
"refusing unsafe persistent-cache artifact `{relative}`"
));
}
let from = source.join(path);
let to = destination.join(path);
if let Some(parent) = to.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
fs::copy(&from, &to).map_err(|error| {
format!(
"cannot copy persistent compiler artifact {} to {}: {error}",
from.display(),
to.display()
)
})?;
}
Ok(())
}
pub fn prerender_output(out_dir: &Path, build: &ProjectBuild) -> Result<(), String> {
if build.prerender_routes == 0 {
return Ok(());
}
let handler = out_dir.join("server/handler.js");
let manifest = out_dir.join("app.prerender.json");
if !handler.is_file() || !manifest.is_file() {
return Err("error[PRERENDER_ARTIFACT_MISSING]: build-time renderer or prerender manifest is missing".into());
}
let runner = EmbeddedPrerenderRunner::prepare()?;
let status = Command::new("node")
.arg(runner.entry())
.arg(&handler)
.arg(out_dir)
.arg(&manifest)
.status()
.map_err(|error| format!("cannot start Noxid prerender host: {error}"))?;
if !status.success() {
return Err(format!(
"error[PRERENDER_FAILED]: build-time renderer exited with {status}"
));
}
if build.ssr_routes == 0
&& build.server_shell_routes == 0
&& build.server_actions == 0
&& build.endpoints == 0
&& build.edge_actions == 0
{
let server = out_dir.join("server");
if server.exists() {
fs::remove_dir_all(&server).map_err(|error| {
format!(
"cannot remove build-only renderer {}: {error}",
server.display()
)
})?;
}
}
Ok(())
}
#[derive(Clone, Debug)]
struct PrerenderEntry {
route_id: SemanticId,
pattern: String,
path: String,
}
fn prerender_manifest(routes: &RouteProgram, configured: &[String]) -> Result<String, String> {
let resolved = resolved_prerender_entries(routes, configured)?;
let entries = resolved
.iter()
.enumerate()
.map(|(index, entry)| {
let pathname = entry.path.split('?').next().unwrap_or(&entry.path);
let relative = pathname.trim_matches('/');
let output = if relative.is_empty() {
"index.html".to_string()
} else {
format!("{relative}/index.html")
};
let (pathname, query) = entry.path.split_once('?').unwrap_or((&entry.path, ""));
let route_path = if pathname == "/" { "/".to_string() } else { format!("{}/", pathname.trim_end_matches('/')) };
let url = if routes.base_path == "/" {
route_path
} else if route_path == "/" {
format!("{}/", routes.base_path.trim_end_matches('/'))
} else {
format!("{}{}", routes.base_path.trim_end_matches('/'), route_path)
};
let url = if query.is_empty() { url } else { format!("{url}?{query}") };
format!(
"{{\"id\":\"{}#entry:{}\",\"route\":\"{}\",\"pattern\":\"{}\",\"url\":\"{}\",\"output\":\"{}\"}}",
entry.route_id,
index + 1,
entry.route_id,
json_escape(&entry.pattern),
json_escape(&url),
json_escape(&output),
)
})
.collect::<Vec<_>>()
.join(",");
let endpoint = if routes.base_path == "/" {
"/_noxid/ssr".to_string()
} else {
format!("{}/_noxid/ssr", routes.base_path.trim_end_matches('/'))
};
Ok(format!(
"{{\n \"schemaVersion\": 2,\n \"endpoint\": \"{}\",\n \"routes\": [{entries}]\n}}\n",
json_escape(&endpoint),
))
}
fn resolved_prerender_entries(
routes: &RouteProgram,
configured: &[String],
) -> Result<Vec<PrerenderEntry>, String> {
let prerender_routes = routes
.routes
.iter()
.filter(|route| route.render.mode == RouteRenderMode::Prerender)
.collect::<Vec<_>>();
let mut entries = Vec::new();
let mut matched_configured = BTreeSet::new();
for route in prerender_routes {
let explicit = configured
.iter()
.enumerate()
.filter(|(_, entry)| route_matches_path(&route.pattern, entry))
.collect::<Vec<_>>();
if route.parameters.is_empty() {
entries.push(PrerenderEntry {
route_id: route.id.clone(),
pattern: route.pattern.clone(),
path: route.pattern.clone(),
});
} else if explicit.is_empty() {
return Err(format!(
"error[PRERENDER_DYNAMIC_ROUTE_REQUIRES_ENTRIES]: `{}` requires one or more concrete URLs in `[render] prerender`",
route.pattern
));
}
for (index, entry) in explicit {
matched_configured.insert(index);
for field in route.query.iter().filter(|field| field.required) {
let query = entry.split_once('?').map(|(_, query)| query).unwrap_or("");
if !query
.split('&')
.filter_map(|pair| pair.split_once('='))
.any(|(name, _)| name == field.name)
{
return Err(format!(
"error[PRERENDER_REQUIRED_QUERY]: `{entry}` must provide query field `{}` for `{}`",
field.name, route.pattern
));
}
}
entries.push(PrerenderEntry {
route_id: route.id.clone(),
pattern: route.pattern.clone(),
path: entry.clone(),
});
}
}
for (index, entry) in configured.iter().enumerate() {
if !matched_configured.contains(&index) {
return Err(format!(
"error[PRERENDER_ENTRY_UNMATCHED]: `{entry}` does not match a dynamic `render: prerender` route"
));
}
}
let mut outputs = BTreeSet::new();
for entry in &entries {
let pathname = entry
.path
.split('?')
.next()
.unwrap_or(&entry.path)
.trim_end_matches('/');
if !outputs.insert(pathname.to_string()) {
return Err(format!(
"error[PRERENDER_OUTPUT_CONFLICT]: more than one prerender entry writes `{pathname}`"
));
}
}
Ok(entries)
}
fn route_matches_path(pattern: &str, value: &str) -> bool {
let actual = value
.split('?')
.next()
.unwrap_or(value)
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let expected = pattern
.trim_matches('/')
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let catch_all = expected
.last()
.is_some_and(|segment| segment.starts_with("{*") && segment.ends_with('}'));
if (!catch_all && actual.len() != expected.len())
|| (catch_all && actual.len() + 1 < expected.len())
{
return false;
}
expected.iter().enumerate().all(|(index, segment)| {
if segment.starts_with("{*") && segment.ends_with('}') {
return true;
}
segment.starts_with('{') && segment.ends_with('}')
|| actual.get(index).is_some_and(|actual| actual == segment)
})
}
fn validate_prerender_routes(prepared: &PreparedProject) -> Result<(), String> {
for route in prepared
.routes
.routes
.iter()
.filter(|route| route.render.mode == RouteRenderMode::Prerender)
{
if !route.middleware.is_empty() {
return Err(format!(
"error[PRERENDER_MIDDLEWARE_UNSUPPORTED]: `{}` has request middleware; prerender routes must be request-independent",
route.pattern
));
}
for target in route.layouts.iter().chain(std::iter::once(&route.page)) {
let compiled = prepared
.compiled
.values()
.find(|compiled| compiled.target.component == target.component)
.ok_or_else(|| {
format!(
"error[PRERENDER_COMPONENT_MISSING]: `{}`",
target.component_name
)
})?;
let components = std::iter::once(&compiled.component).chain(
compiled
.dependencies
.iter()
.map(|dependency| &dependency.component),
);
for component in
components.filter(|component| component.render.mode != ComponentRenderMode::Client)
{
if component_has_external_render_call(component) {
return Err(format!(
"error[PRERENDER_EXTERNAL_EXPRESSION]: `{}` calls external JavaScript while rendering; declare static compiler-understood input instead",
component.name
));
}
if !component.resources.is_empty()
|| !component.streams.is_empty()
|| !component.agents.is_empty()
{
return Err(format!(
"error[PRERENDER_LIVE_ACQUISITION]: `{}` has a live resource, stream, or agent acquisition; use static props until build-safe acquisitions are explicit",
component.name
));
}
}
}
}
resolved_prerender_entries(&prepared.routes, &prepared.config.prerender_entries)?;
Ok(())
}
fn validate_route_cache(prepared: &PreparedProject) -> Result<(), String> {
for route in prepared
.routes
.routes
.iter()
.filter(|route| route.cache.is_some())
{
if route.render.mode != RouteRenderMode::Ssr {
return Err(format!(
"error[ROUTE_CACHE_REQUIRES_SSR]: `{}` must use `render: ssr` before ISR or SWR can be enabled",
route.pattern
));
}
if !route.middleware.is_empty() {
return Err(format!(
"error[ROUTE_CACHE_MIDDLEWARE_UNSAFE]: `{}` uses middleware whose request dependencies are not compiler-declared; cached personalized routes fail closed",
route.pattern
));
}
let cache = route.cache.as_ref().expect("filtered cache route");
let missing_query_vary = route
.query
.iter()
.filter(|query| query.required)
.find(|query| {
!cache
.vary
.iter()
.any(|value| value == &format!("query:{}", query.name))
});
if let Some(query) = missing_query_vary {
return Err(format!(
"error[ROUTE_CACHE_QUERY_UNSAFE]: `{}` has required query `{}`; add `{}@query:{}` to render.vary",
route.pattern, query.name, route.pattern, query.name
));
}
}
Ok(())
}
fn component_has_external_render_call(component: &noxid_ir::ComponentDefinition) -> bool {
component
.states
.iter()
.any(|state| expr_has_call(&state.initializer))
|| component
.computed
.iter()
.any(|computed| expr_has_call(&computed.expression))
|| view_has_call(&component.view)
}
fn view_has_call(nodes: &[SemanticViewNode]) -> bool {
nodes.iter().any(|node| match node {
SemanticViewNode::Text { .. } => false,
SemanticViewNode::Binding { expression, .. } => expr_has_call(expression),
SemanticViewNode::Element {
attributes,
// Declared attachment literals never contain calls.
attachments: _,
children,
prefetch: _,
tag: _,
span: _,
} => {
attributes.iter().any(|attribute| match attribute {
noxid_ir::SemanticAttribute::Binding { expression, .. } => {
expr_has_call(expression)
}
noxid_ir::SemanticAttribute::Event { arguments, .. } => arguments
.as_ref()
.is_some_and(|arguments| arguments.iter().any(expr_has_call)),
noxid_ir::SemanticAttribute::Static { .. } => false,
noxid_ir::SemanticAttribute::TwoWayBinding { .. } => false,
}) || view_has_call(children)
}
SemanticViewNode::ComponentInvocation {
props, children, ..
} => props.iter().any(|prop| expr_has_call(&prop.expression)) || view_has_call(children),
SemanticViewNode::Slot { .. } => false,
SemanticViewNode::Conditional {
condition,
children,
..
} => expr_has_call(condition) || view_has_call(children),
SemanticViewNode::Match {
expression, cases, ..
}
| SemanticViewNode::Stream {
expression, cases, ..
} => expr_has_call(expression) || cases.iter().any(|case| view_has_call(&case.children)),
SemanticViewNode::For {
collection,
key,
children,
..
} => expr_has_call(collection) || expr_has_call(key) || view_has_call(children),
})
}
fn expr_has_call(expression: &noxid_ir::SemanticExpr) -> bool {
match &expression.kind {
noxid_ir::SemanticExprKind::Call { .. } => true,
noxid_ir::SemanticExprKind::Array(values) => values.iter().any(expr_has_call),
noxid_ir::SemanticExprKind::Struct { fields, .. } => {
fields.iter().any(|field| expr_has_call(&field.value))
}
noxid_ir::SemanticExprKind::FieldAccess { base, .. } => expr_has_call(base),
noxid_ir::SemanticExprKind::CollectionQuery { base, value, .. } => {
expr_has_call(base)
|| value.as_deref().is_some_and(expr_has_call)
}
noxid_ir::SemanticExprKind::Variant { payload, .. } => {
payload.as_deref().is_some_and(expr_has_call)
}
noxid_ir::SemanticExprKind::Binary { left, right, .. } => {
expr_has_call(left) || expr_has_call(right)
}
noxid_ir::SemanticExprKind::Unary { operand, .. } => expr_has_call(operand),
noxid_ir::SemanticExprKind::StringTemplate(parts) => parts.iter().any(|part| {
matches!(part, noxid_ir::SemanticTemplatePart::Expression(expression) if expr_has_call(expression))
}),
// User functions are file-local, not external calls.
noxid_ir::SemanticExprKind::FunctionCall { arguments, .. } => {
arguments.iter().any(expr_has_call)
}
noxid_ir::SemanticExprKind::Int(_)
| noxid_ir::SemanticExprKind::Float(_)
| noxid_ir::SemanticExprKind::String(_)
| noxid_ir::SemanticExprKind::Boolean(_)
| noxid_ir::SemanticExprKind::Reference(_) => false,
}
}
fn execution_program(prepared: &PreparedProject) -> noxid_execution_ir::ExecutionProgram {
let mut program = noxid_execution_ir::ExecutionProgram::default();
for target in prepared.compiled.values() {
merge_execution_program(&mut program, &target.execution);
}
for endpoint in prepared.endpoints.values() {
merge_execution_program(&mut program, &endpoint.execution);
}
for task in prepared.tasks.values() {
merge_execution_program(&mut program, &task.execution);
}
for queue in prepared.queues.values() {
merge_execution_program(&mut program, &queue.execution);
}
for route in &prepared.routes.routes {
let mut actions = BTreeSet::new();
let mut live_resources = BTreeSet::new();
let mut presences = BTreeSet::new();
for target in route.layouts.iter().chain(std::iter::once(&route.page)) {
if let Some(compiled) = prepared
.compiled
.values()
.find(|compiled| compiled.target.component == target.component)
{
actions.extend(
compiled
.execution
.boundaries
.iter()
.map(|boundary| boundary.action.clone()),
);
live_resources.extend(
compiled
.execution
.live_resources
.iter()
.map(|resource| resource.id.clone()),
);
presences.extend(
compiled
.execution
.presences
.iter()
.map(|presence| presence.id.clone()),
);
}
}
for boundary in program
.boundaries
.iter_mut()
.filter(|boundary| actions.contains(&boundary.action))
{
boundary
.route_scopes
.push(noxid_execution_ir::ExecutionRouteScope {
route: route.id.clone(),
pattern: route.pattern.clone(),
parameters: route
.parameters
.iter()
.map(|parameter| noxid_execution_ir::ExecutionRouteParameter {
name: parameter.name.clone(),
ty: parameter.ty.to_string(),
catch_all: parameter.catch_all,
})
.collect(),
middleware: route.middleware.clone(),
});
}
for resource in program
.live_resources
.iter_mut()
.filter(|resource| live_resources.contains(&resource.id))
{
resource
.route_scopes
.push(noxid_execution_ir::ExecutionRouteScope {
route: route.id.clone(),
pattern: route.pattern.clone(),
parameters: route
.parameters
.iter()
.map(|parameter| noxid_execution_ir::ExecutionRouteParameter {
name: parameter.name.clone(),
ty: parameter.ty.to_string(),
catch_all: parameter.catch_all,
})
.collect(),
middleware: route.middleware.clone(),
});
}
for presence in program
.presences
.iter_mut()
.filter(|presence| presences.contains(&presence.id))
{
presence
.route_scopes
.push(noxid_execution_ir::ExecutionRouteScope {
route: route.id.clone(),
pattern: route.pattern.clone(),
parameters: route
.parameters
.iter()
.map(|parameter| noxid_execution_ir::ExecutionRouteParameter {
name: parameter.name.clone(),
ty: parameter.ty.to_string(),
catch_all: parameter.catch_all,
})
.collect(),
middleware: route.middleware.clone(),
});
}
}
for boundary in &mut program.boundaries {
boundary
.route_scopes
.sort_by(|left, right| left.route.cmp(&right.route));
boundary
.route_scopes
.dedup_by(|left, right| left.route == right.route);
}
for resource in &mut program.live_resources {
resource
.route_scopes
.sort_by(|left, right| left.route.cmp(&right.route));
resource
.route_scopes
.dedup_by(|left, right| left.route == right.route);
}
for presence in &mut program.presences {
presence
.route_scopes
.sort_by(|left, right| left.route.cmp(&right.route));
presence
.route_scopes
.dedup_by(|left, right| left.route == right.route);
}
let live_resources = program
.live_resources
.iter()
.map(|resource| resource.id.clone())
.collect::<BTreeSet<_>>();
for boundary in &mut program.boundaries {
// WO-28 keeps its complete cache invalidation contract. Only the
// server publisher projection is narrowed to compiler-declared live
// resources, so ordinary resource invalidation semantics do not drift.
boundary
.invalidates
.retain(|resource| live_resources.contains(resource));
}
program
}
fn validation_program(prepared: &PreparedProject) -> noxid_validation_ir::ValidationProgram {
let mut program = noxid_validation_ir::ValidationProgram::default();
for target in prepared.compiled.values() {
merge_validation_program(&mut program, &target.validation);
}
for endpoint in prepared.endpoints.values() {
merge_validation_program(&mut program, &endpoint.validation);
}
for queue in prepared.queues.values() {
merge_validation_program(&mut program, &queue.validation);
}
for model in prepared.models.values() {
merge_validation_program(&mut program, &model.validation);
}
program
}
fn server_middleware_names(
execution: &noxid_execution_ir::ExecutionProgram,
routes: &RouteProgram,
) -> BTreeSet<String> {
let mut names = execution
.boundaries
.iter()
.filter(|boundary| matches!(boundary.target.as_str(), "server" | "edge"))
.flat_map(|boundary| &boundary.route_scopes)
.flat_map(|scope| &scope.middleware)
.filter_map(|id| id.as_str().strip_prefix("middleware:"))
.map(str::to_string)
.collect::<BTreeSet<_>>();
names.extend(
execution
.live_resources
.iter()
.flat_map(|resource| &resource.route_scopes)
.flat_map(|scope| &scope.middleware)
.filter_map(|id| id.as_str().strip_prefix("middleware:"))
.map(str::to_string),
);
names.extend(
execution
.presences
.iter()
.flat_map(|presence| &presence.route_scopes)
.flat_map(|scope| &scope.middleware)
.filter_map(|id| id.as_str().strip_prefix("middleware:"))
.map(str::to_string),
);
names.extend(
routes
.routes
.iter()
.filter(|route| route.render.mode != RouteRenderMode::Client)
.flat_map(|route| &route.middleware)
.filter_map(|id| id.as_str().strip_prefix("middleware:"))
.map(str::to_string),
);
names.extend(
execution
.endpoints
.iter()
.flat_map(|endpoint| endpoint.middleware.iter().cloned()),
);
names
}
fn prepare_server_source_modules(
prepared: &PreparedProject,
server_middleware: &BTreeSet<String>,
emits_server_handler: bool,
requires_server_host: bool,
requires_endpoint_storage: bool,
requires_uploads: bool,
) -> Result<Vec<ServerSourceModule>, String> {
if !emits_server_handler {
return Ok(Vec::new());
}
if prepared.config.server_host.is_none() && requires_server_host {
return Err("error[SERVER_HOST_REQUIRED]: bodyless/capability-protected server actions or endpoints, and SSR routes, require `server/host.ts` (or `server/host.js`)".to_string());
}
let mut server_entries = Vec::new();
if let Some(source) = prepared.config.server_host.as_ref() {
server_entries.push((source.clone(), "host.js".into()));
}
for middleware in &prepared.config.server_global_middleware {
server_entries.push((
middleware.source.clone(),
format!("global-middleware/{}.js", middleware.stem),
));
}
for plugin in &prepared.config.server_plugins {
server_entries.push((plugin.source.clone(), format!("plugins/{}.js", plugin.stem)));
}
for name in server_middleware {
// A server-only route variant may be TypeScript; its browser
// declaration remains the fallback when no server variant exists.
let source = [
prepared
.config
.server_route_middleware_dir
.join(format!("{name}.ts")),
prepared
.config
.server_route_middleware_dir
.join(format!("{name}.js")),
prepared.config.middleware_dir.join(format!("{name}.js")),
]
.into_iter()
.find(|candidate| candidate.is_file())
.ok_or_else(|| format!("server middleware `{name}` has no module"))?;
server_entries.push((source, format!("middleware/{name}.js")));
}
let mut server_modules = collect_server_source_graph(
&prepared.config.root,
&server_entries,
prepared.config.server_storage_driver,
prepared.config.db_pool,
requires_endpoint_storage,
requires_uploads.then_some(prepared.config.server_blob_dir.as_str()),
)?;
if prepared.config.server_host.is_none() {
server_modules.push(ServerSourceModule {
emitted: "host.js".into(),
source: "export const actions = Object.freeze({});\n".into(),
typescript: false,
source_path: None,
});
server_modules.sort_by(|left, right| left.emitted.cmp(&right.emitted));
}
for module in &mut server_modules {
if !module.typescript {
continue;
}
module.source =
transpile_typescript(&prepared.config.root, &module.emitted, &module.source)?;
module.typescript = false;
}
Ok(server_modules)
}
fn server_middleware_javascript(
names: &BTreeSet<String>,
global: &[ServerMiddlewareSource],
) -> String {
let route_imports = names
.iter()
.enumerate()
.map(|(index, name)| {
format!(
"import * as routeMiddleware{index} from \"./middleware/{}.js\";",
json_escape(name)
)
})
.collect::<Vec<_>>()
.join("\n");
let global_imports = global
.iter()
.enumerate()
.map(|(index, middleware)| {
format!(
"import * as globalMiddleware{index} from \"./global-middleware/{}.js\";",
json_escape(&middleware.stem)
)
})
.collect::<Vec<_>>()
.join("\n");
let route_entries = names
.iter()
.enumerate()
.map(|(index, name)| {
format!(
" \"{}\": routeMiddleware{index}.server ?? routeMiddleware{index}.default ?? routeMiddleware{index}.handle",
json_escape(name)
)
})
.collect::<Vec<_>>()
.join(",\n");
let global_entries = global
.iter()
.enumerate()
.map(|(index, middleware)| {
format!(
" \"{}\": globalMiddleware{index}.server ?? globalMiddleware{index}.default ?? globalMiddleware{index}.handle",
json_escape(&middleware.stem)
)
})
.collect::<Vec<_>>()
.join(",\n");
let global_order = global
.iter()
.map(|middleware| format!("\"{}\"", json_escape(&middleware.stem)))
.collect::<Vec<_>>()
.join(", ");
format!(
"{route_imports}\n{global_imports}\n\nexport const middleware = Object.freeze({{\n{route_entries}\n}});\nexport const globalMiddlewareHandlers = Object.freeze({{\n{global_entries}\n}});\nexport const globalMiddleware = Object.freeze([{global_order}]);\n"
)
}
fn server_plugins_javascript(plugins: &[ServerPluginSource]) -> String {
let imports = plugins
.iter()
.enumerate()
.map(|(index, plugin)| {
format!(
"import * as plugin{index} from \"./plugins/{}.js\";",
json_escape(&plugin.stem)
)
})
.collect::<Vec<_>>()
.join("\n");
let entries = plugins
.iter()
.enumerate()
.map(|(index, plugin)| {
format!(
" Object.freeze({{ name: \"{}\", start: plugin{index}.default }})",
json_escape(&plugin.filename)
)
})
.collect::<Vec<_>>()
.join(",\n");
format!(
r#"import * as host from "./host.js";
import {{ storage }} from "./noxid-server.js";
{imports}
const plugins = Object.freeze([
{entries}
]);
let startup;
export function startServerPlugins(environment = Object.create(null)) {{
if (startup !== undefined) return startup;
const context = Object.freeze({{ environment, storage, host }});
startup = (async () => {{
for (const plugin of plugins) {{
if (typeof plugin.start !== "function") {{
throw new TypeError(`server plugin ${{plugin.name}} must default-export a startup function`);
}}
await plugin.start(context);
}}
}})();
return startup;
}}
"#
)
}
fn server_dispatcher_javascript(
base_path: &str,
server_secrets: &[String],
tracing_export: ServerTracingExport,
) -> String {
let endpoint = if base_path == "/" {
"/_noxid/ssr".to_string()
} else {
format!("{base_path}/_noxid/ssr")
};
let lifecycle_exports = noxid_codegen_server_js::server_lifecycle_exports()
.filter(|name| {
*name != "fetch"
&& noxid_codegen_server_js::server_lifecycle_export_required(name, tracing_export)
})
.collect::<Vec<_>>()
.join(", ");
format!(
"import {{ fetch as actionFetch, fetchEndpoint, withNoxidRequestTrace, {lifecycle_exports}, __noxidConfiguredServerEnvironment }} from \"./actions.js\";\nimport {{ fetch as ssrFetch }} from \"./renderer.js\";\n\n{}\nexport {{ {lifecycle_exports} }};\nexport async function fetch(request, environment = Object.create(null), executionContext = Object.create(null)) {{\n environment = __noxidConfiguredServerEnvironment(environment);\n return withNoxidRequestTrace(request, async () => {{\n const endpointResponse = await fetchEndpoint(request, environment, executionContext);\n if (endpointResponse !== null) return endpointResponse;\n const pathname = new URL(request.url).pathname;\n return pathname === \"{}\"\n ? ssrFetch(request, environment, executionContext)\n : actionFetch(request, environment, executionContext);\n }});\n}}\n\nglobalThis.__NOXID_FETCH_HANDLER__ = fetch;\nexport default Object.freeze({{ fetch }});\n",
noxid_codegen_server_js::server_secrets_prelude_javascript(server_secrets),
json_escape(&endpoint),
)
}
fn merge_execution_program(
target: &mut noxid_execution_ir::ExecutionProgram,
source: &noxid_execution_ir::ExecutionProgram,
) {
target
.live_resources
.extend(source.live_resources.iter().cloned());
target
.live_resources
.sort_by(|left, right| left.id.cmp(&right.id));
target
.live_resources
.dedup_by(|left, right| left.id == right.id);
target.presences.extend(source.presences.iter().cloned());
target
.presences
.sort_by(|left, right| left.id.cmp(&right.id));
target.presences.dedup_by(|left, right| left.id == right.id);
target.boundaries.extend(source.boundaries.iter().cloned());
target
.boundaries
.sort_by(|left, right| left.id.cmp(&right.id));
target
.boundaries
.dedup_by(|left, right| left.id == right.id);
target.endpoints.extend(source.endpoints.iter().cloned());
target
.endpoints
.sort_by(|left, right| left.id.cmp(&right.id));
target.endpoints.dedup_by(|left, right| left.id == right.id);
target.tasks.extend(source.tasks.iter().cloned());
target.tasks.sort_by(|left, right| left.id.cmp(&right.id));
target.tasks.dedup_by(|left, right| left.id == right.id);
target.queues.extend(source.queues.iter().cloned());
target.queues.sort_by(|left, right| left.id.cmp(&right.id));
target.queues.dedup_by(|left, right| left.id == right.id);
}
fn merge_validation_program(
target: &mut noxid_validation_ir::ValidationProgram,
source: &noxid_validation_ir::ValidationProgram,
) {
target.validators.extend(source.validators.iter().cloned());
target
.validators
.sort_by(|left, right| left.id.cmp(&right.id));
target
.validators
.dedup_by(|left, right| left.id == right.id);
target.boundaries.extend(source.boundaries.iter().cloned());
target
.boundaries
.sort_by(|left, right| left.id.cmp(&right.id));
target
.boundaries
.dedup_by(|left, right| left.id == right.id);
target
.endpoint_boundaries
.extend(source.endpoint_boundaries.iter().cloned());
target
.endpoint_boundaries
.sort_by(|left, right| left.id.cmp(&right.id));
target
.endpoint_boundaries
.dedup_by(|left, right| left.id == right.id);
}
fn clean_stale_generated(out_dir: &Path, current: &[String]) -> Result<(), String> {
let ledger = out_dir.join(".noxid-generated-files");
let Ok(contents) = fs::read_to_string(&ledger) else {
return Ok(());
};
for relative in contents.lines().filter(|line| !line.is_empty()) {
if current.iter().any(|entry| entry == relative) {
continue;
}
let path = Path::new(relative);
if path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_) | Component::CurDir))
{
return Err(format!(
"refusing unsafe generated-file entry `{relative}` in {}",
ledger.display()
));
}
let target = out_dir.join(path);
match fs::remove_file(&target) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("cannot remove stale {}: {error}", target.display())),
}
}
Ok(())
}
pub fn write_development_diagnostics(out_dir: &Path, error: &str) -> Result<(), String> {
let code = error
.split("error[")
.nth(1)
.and_then(|value| value.split(']').next())
.unwrap_or("NOXID_REBUILD_FAILED");
let module = format!(
"export const diagnostics = Object.freeze([Object.freeze({{ code: \"{}\", severity: \"error\", message: \"{}\" }})]);\n",
json_escape(code),
json_escape(error),
);
let path = out_dir.join("assets/noxid-diagnostics.js");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|write_error| format!("cannot create {}: {write_error}", parent.display()))?;
}
write(&path, &module)
}
fn development_javascript(
javascript: &str,
component: &noxid_ir::ComponentDefinition,
development: bool,
) -> String {
if !development {
let mut output = javascript.to_string();
for state in &component.states {
let instrumented = format!(
"globalThis.__NOXID_HMR__ ? globalThis.__NOXID_HMR__.initialState(\"{}\", $noxInitial_{}) : $noxInitial_{}",
json_escape(state.id.as_str()),
state.name,
state.name,
);
output = output.replace(&instrumented, &format!("$noxInitial_{}", state.name));
}
let state_handles = component
.states
.iter()
.map(|state| format!("\"{}\": {}", json_escape(state.id.as_str()), state.name))
.collect::<Vec<_>>()
.join(", ");
let registration = format!(
" globalThis.__NOXID_HMR__?.registerInstance({{ component: \"{}\", owner: $noxOwner, state: {{ {state_handles} }}, patchActions($noxNextModule) {{ if (typeof $noxNextModule.__noxidCreate{}Actions === \"function\") $noxActions = $noxNextModule.__noxidCreate{}Actions($noxActionScope); }} }});\n",
json_escape(&component.name),
component.name,
component.name,
);
return output.replace(®istration, "");
}
let signature = ComponentSignature::from_component(component);
format!(
"{javascript}\nexport const __noxidHmrSignature = Object.freeze({});\nglobalThis.__NOXID_HMR__?.registerModule(\"{}\", __noxidHmrSignature);\nif (import.meta.hot) import.meta.hot.accept((nextModule) => {{\n if (!nextModule) return import.meta.hot.invalidate(\"Noxid HMR module was unavailable\");\n globalThis.__NOXID_HMR__?.acceptComponent(\"{}\", nextModule, nextModule.__noxidHmrSignature);\n}});\n",
signature.to_json(),
json_escape(&component.name),
json_escape(&component.name),
)
}
fn hmr_manifest(prepared: &PreparedProject) -> String {
let mut signatures = BTreeMap::new();
for target in prepared.compiled.values() {
signatures.insert(
target.component.name.clone(),
ComponentSignature::from_component(&target.component),
);
for dependency in &target.dependencies {
signatures.insert(
dependency.component.name.clone(),
ComponentSignature::from_component(&dependency.component),
);
}
}
format!(
"{{\n \"schemaVersion\": 1,\n \"components\": [{}]\n}}\n",
signatures
.values()
.map(ComponentSignature::to_json)
.collect::<Vec<_>>()
.join(","),
)
}
fn server_island_modules(prepared: &PreparedProject) -> BTreeMap<String, String> {
let mut modules = BTreeMap::new();
for target in prepared.compiled.values() {
if target.target.render_mode != ComponentRenderMode::Server {
continue;
}
for dependency in &target.dependencies {
if dependency.component.render.mode != ComponentRenderMode::Server {
modules.insert(
dependency.component_name.clone(),
format!("assets/{}.js", dependency.component_name),
);
}
}
}
modules
}
fn browser_authorization_subjects(prepared: &PreparedProject) -> Vec<String> {
let mut subjects = BTreeSet::new();
for target in prepared.compiled.values() {
if target.component.render.mode != ComponentRenderMode::Server
&& !target.component.capabilities.is_empty()
{
subjects.insert(target.component.id.to_string());
}
for dependency in &target.dependencies {
if dependency.component.render.mode != ComponentRenderMode::Server
&& !dependency.component.capabilities.is_empty()
{
subjects.insert(dependency.component.id.to_string());
}
}
}
subjects.into_iter().collect()
}
struct AppJavaScriptContext<'a> {
stream_endpoints: &'a [&'a noxid_ir::EndpointDefinition],
live_resources: &'a [noxid_execution_ir::LiveResourceExecutionContract],
presences: &'a [noxid_execution_ir::PresenceExecutionContract],
host_module: Option<&'a str>,
default_title: &'a str,
development: bool,
island_modules: &'a BTreeMap<String, String>,
client_core_router: bool,
requires_browser_authorizer: bool,
}
fn app_javascript(routes: &RouteProgram, context: AppJavaScriptContext<'_>) -> String {
let AppJavaScriptContext {
stream_endpoints,
live_resources,
presences,
host_module,
default_title,
development,
island_modules,
client_core_router,
requires_browser_authorizer,
} = context;
let endpoint_streams = stream_endpoints
.iter()
.map(|endpoint| {
let route = endpoint
.route
.as_ref()
.expect("project stream endpoints have routes");
let params = endpoint
.params
.iter()
.map(|field| format!("\"{}\"", json_escape(&field.name)))
.collect::<Vec<_>>()
.join(", ");
let query = endpoint
.query
.iter()
.map(|field| {
format!(
"Object.freeze({{ name: \"{}\", type: \"{}\" }})",
json_escape(&field.name),
json_escape(&field.ty.to_string())
)
})
.collect::<Vec<_>>()
.join(", ");
format!(
" \"stream:{}\": createEndpointStreamConnector(Object.freeze({{ stream: \"stream:{}\", endpoint: \"{}\", path: \"{}\", basePath: \"{}\", params: Object.freeze([{}]), query: Object.freeze([{}]) }}))",
json_escape(&endpoint.name),
json_escape(&endpoint.name),
endpoint.id,
json_escape(&route.path),
json_escape(&routes.base_path),
params,
query,
)
})
.collect::<Vec<_>>()
.join(",\n");
let endpoint_stream_import = if stream_endpoints.is_empty() {
String::new()
} else {
format!(
"import {{ createEndpointStreamConnector }} from \"{}\";\n",
module_url("assets/noxid-runtime.js")
)
};
let endpoint_stream_registry =
format!("const endpointStreams = Object.freeze({{\n{endpoint_streams}\n}});\n");
let live_resource_setup = if live_resources.is_empty() && presences.is_empty() {
String::new()
} else {
let resource_ids = live_resources
.iter()
.map(|resource| format!("\"{}\"", json_escape(resource.id.as_str())))
.collect::<Vec<_>>()
.join(", ");
let presence_ids = presences
.iter()
.map(|presence| format!("\"{}\"", json_escape(presence.id.as_str())))
.collect::<Vec<_>>()
.join(", ");
format!(
"import {{ createLiveResourceController, installLiveResourceController }} from \"{}\";\nconst liveResourceController = createLiveResourceController(Object.freeze({{ basePath: \"{}\", resources: Object.freeze([{resource_ids}]), presences: Object.freeze([{presence_ids}]) }}));\ninstallLiveResourceController(liveResourceController);\n",
module_url("assets/noxid-runtime.js"),
json_escape(&routes.base_path),
)
};
if client_core_router {
let route_values = routes
.routes
.iter()
.map(|route| client_route_javascript(route, &routes.base_path))
.collect::<Vec<_>>()
.join(",\n ");
return format!(
"{live_resource_setup}{endpoint_stream_import}import {{ startRouter }} from \"{}\";\n\n{endpoint_stream_registry}const routes = [\n {route_values}\n];\nconst root = document.querySelector(\"#app\");\nif (!root) throw new Error(\"NOXID_APP_ROOT_MISSING\");\nconst defaultTitle = \"{}\";\nconst router = startRouter({{ root, routes, streams: endpointStreams, basePath: \"{}\", defaultTitle }});\nglobalThis.__NOXID_APP__ = Object.freeze({{ router, routes, basePath: \"{}\", defaultTitle }});\n",
module_url("assets/noxid-router.js"),
json_escape(default_title),
json_escape(&routes.base_path),
json_escape(&routes.base_path),
);
}
let host = host_module
.map(|module| {
format!(
"const host = (await import(\"{}\")).default ?? {{}};",
module_url(module)
)
})
.unwrap_or_else(|| "const host = {};".into());
let authorization_guard = if requires_browser_authorizer {
"\nconst authorizeComponent = typeof host.authorizeComponent === \"function\" ? host.authorizeComponent : host.runtimeOptions?.authorizeComponent;\nif (typeof authorizeComponent !== \"function\") throw Object.assign(new Error(\"CLIENT_COMPONENT_AUTHORIZER_MISSING: configured browser host must export a synchronous authorizeComponent policy\"), { code: \"CLIENT_COMPONENT_AUTHORIZER_MISSING\" });"
} else {
""
};
let middleware = routes
.middleware
.iter()
.map(|definition| {
format!(
"\"{}\": () => import(\"{}\")",
json_escape(&definition.name),
json_escape(&module_url(&definition.module))
)
})
.collect::<Vec<_>>()
.join(",\n ");
let islands = island_modules
.iter()
.map(|(component, module)| {
format!(
"\"{}\": () => import(\"{}\")",
json_escape(component),
json_escape(&module_url(module))
)
})
.collect::<Vec<_>>()
.join(",\n ");
let route_values = routes
.routes
.iter()
.map(|route| route_javascript(route, &routes.base_path))
.collect::<Vec<_>>()
.join(",\n ");
format!(
"{}{live_resource_setup}{endpoint_stream_import}import {{ startRouter }} from \"{}\";\n\n{host}{authorization_guard}\n{endpoint_stream_registry}const middleware = {{\n {middleware}\n}};\nconst islands = {{\n {islands}\n}};\nconst routes = [\n {route_values}\n];\nconst root = document.querySelector(\"#app\");\nif (!root) throw new Error(\"NOXID_APP_ROOT_MISSING\");\nconst defaultTitle = \"{}\";\nlet hydration = null;\nconst hydrationNode = document.querySelector(\"#__NOXID_SSR_PAYLOAD__\");\nif (hydrationNode) {{ try {{ hydration = JSON.parse(hydrationNode.textContent); }} catch {{ hydration = null; }} }}\nconst router = startRouter({{ root, routes, middleware, islands, host, streams: endpointStreams, basePath: \"{}\", defaultTitle, hydration }});\nglobalThis.__NOXID_APP__ = Object.freeze({{ router, routes, basePath: \"{}\", defaultTitle }});\n",
if development {
format!(
"import \"{}\";\nimport \"{}\";\n",
module_url("assets/noxid-hmr-client.js"),
module_url("assets/noxid-devtools-panel.js")
)
} else {
String::new()
},
module_url("assets/noxid-router.js"),
json_escape(default_title),
json_escape(&routes.base_path),
json_escape(&routes.base_path),
)
}
fn client_route_javascript(route: &RouteDefinition, base_path: &str) -> String {
let parameters = route
.parameters
.iter()
.map(|parameter| {
format!(
"{{ name: \"{}\", type: \"{}\", catchAll: {} }}",
json_escape(¶meter.name),
parameter.ty,
parameter.catch_all,
)
})
.collect::<Vec<_>>()
.join(", ");
let query = route
.query
.iter()
.map(|field| {
format!(
"{{ name: \"{}\", type: \"{}\", required: {} }}",
json_escape(&field.name),
field.ty,
field.required,
)
})
.collect::<Vec<_>>()
.join(", ");
let targets = route
.layouts
.iter()
.map(|target| client_target_javascript(target, base_path))
.chain(std::iter::once(client_target_javascript(
&route.page,
base_path,
)))
.collect::<Vec<_>>()
.join(", ");
format!(
"{{ id: \"{}\", pattern: \"{}\", metadata: {}, parameters: [{}], query: [{}], targets: [{}], loading: {}, error: {} }}",
route.id,
json_escape(&route.pattern),
route
.metadata
.as_ref()
.map(route_metadata_javascript)
.unwrap_or_else(|| "null".into()),
parameters,
query,
targets,
route
.loading
.as_ref()
.map(|target| client_target_javascript(target, base_path))
.unwrap_or_else(|| "null".into()),
route
.error
.as_ref()
.map(|target| client_target_javascript(target, base_path))
.unwrap_or_else(|| "null".into()),
)
}
fn client_target_javascript(target: &RouteTarget, base_path: &str) -> String {
format!(
"{{ component: \"{}\", layout: {}, css: \"{}\", load: () => import(\"{}\") }}",
json_escape(&target.component_name),
target.kind == RouteTargetKind::Layout,
json_escape(&asset_url(base_path, &target.css)),
json_escape(&module_url(&target.module)),
)
}
fn route_javascript(route: &RouteDefinition, base_path: &str) -> String {
let parameters = route
.parameters
.iter()
.map(|parameter| {
format!(
"{{ name: \"{}\", type: \"{}\", catchAll: {} }}",
json_escape(¶meter.name),
parameter.ty,
parameter.catch_all,
)
})
.collect::<Vec<_>>()
.join(", ");
let query = route
.query
.iter()
.map(|field| {
format!(
"{{ id: \"{}\", name: \"{}\", type: \"{}\", required: {} }}",
field.id,
json_escape(&field.name),
field.ty,
field.required,
)
})
.collect::<Vec<_>>()
.join(", ");
let targets = route
.layouts
.iter()
.map(|target| target_javascript(target, base_path))
.chain(std::iter::once(target_javascript(&route.page, base_path)))
.collect::<Vec<_>>()
.join(", ");
let middleware = route
.middleware
.iter()
.filter_map(|id| id.as_str().strip_prefix("middleware:"))
.map(|name| format!("\"{}\"", json_escape(name)))
.collect::<Vec<_>>()
.join(", ");
format!(
"{{ id: \"{}\", pattern: \"{}\", metadata: {}, render: {{ id: \"{}\", mode: \"{}\" }}, parameters: [{}], query: [{}], middleware: [{}], targets: [{}], loading: {}, error: {} }}",
route.id,
json_escape(&route.pattern),
route
.metadata
.as_ref()
.map(route_metadata_javascript)
.unwrap_or_else(|| "null".into()),
route.render.id,
route.render.mode.as_str(),
parameters,
query,
middleware,
targets,
optional_target_javascript(route.loading.as_ref(), base_path),
optional_target_javascript(route.error.as_ref(), base_path),
)
}
fn route_metadata_javascript(metadata: &RouteMetadata) -> String {
format!(
"{{ id: \"{}\", title: \"{}\", description: {} }}",
metadata.id,
json_escape(&metadata.title),
metadata
.description
.as_ref()
.map(|value| format!("\"{}\"", json_escape(value)))
.unwrap_or_else(|| "null".into()),
)
}
fn devtools_project(prepared: &PreparedProject, title: &str) -> DevtoolsProject {
DevtoolsProject {
title: title.into(),
base_path: prepared.routes.base_path.clone(),
routes: prepared
.routes
.routes
.iter()
.map(|route| DevtoolsRoute {
id: route.id.to_string(),
pattern: route.pattern.clone(),
page: route.page.component_name.clone(),
layouts: route
.layouts
.iter()
.map(|layout| layout.component_name.clone())
.collect(),
render_mode: route.render.mode.as_str().into(),
cache: route.cache.as_ref().map(|cache| DevtoolsRouteCache {
mode: cache.mode.as_str().into(),
revalidate_seconds: cache.revalidate_seconds,
stale_seconds: cache.stale_seconds,
vary: cache.vary.clone(),
tags: cache.tags.clone(),
}),
parameters: route
.parameters
.iter()
.map(|parameter| DevtoolsRouteParameter {
name: parameter.name.clone(),
ty: parameter.ty.to_string(),
catch_all: parameter.catch_all,
})
.collect(),
query: route
.query
.iter()
.map(|query| DevtoolsRouteQuery {
name: query.name.clone(),
ty: query.ty.to_string(),
required: query.required,
})
.collect(),
middleware: route.middleware.iter().map(ToString::to_string).collect(),
metadata: route
.metadata
.as_ref()
.map(|metadata| DevtoolsRouteMetadata {
id: metadata.id.to_string(),
title: metadata.title.clone(),
description: metadata.description.clone(),
}),
})
.collect(),
}
}
fn target_javascript(target: &RouteTarget, base_path: &str) -> String {
let loaders = target
.loaders
.iter()
.map(|loader| {
let arguments = loader
.arguments
.iter()
.map(|argument| {
format!(
"{{ id: \"{}\", parameter: \"{}\", name: \"{}\", source: \"{}\", sourceName: \"{}\", type: \"{}\" }}",
argument.id,
argument.parameter,
json_escape(&argument.name),
argument.source,
json_escape(&argument.source_name),
argument.ty,
)
})
.collect::<Vec<_>>()
.join(", ");
let result_type_id = loader
.result_type_id
.as_ref()
.map(|id| format!("\"{id}\""))
.unwrap_or_else(|| "null".into());
format!(
"{{ id: \"{}\", name: \"{}\", prop: \"{}\", action: \"{}\", actionName: \"{}\", execution: \"{}\", result: {{ type: \"{}\", typeId: {} }}, stage: {}, arguments: [{}] }}",
loader.id,
json_escape(&loader.name),
loader.prop,
loader.action,
json_escape(&loader.action_name),
loader.execution.as_str(),
loader.result_type,
result_type_id,
loader.stage,
arguments,
)
})
.collect::<Vec<_>>()
.join(", ");
let load = if target.render_mode == ComponentRenderMode::Server {
"null".into()
} else {
format!(
"() => import(\"{}\")",
json_escape(&module_url(&target.module))
)
};
format!(
"{{ component: \"{}\", kind: \"{}\", layout: {}, renderMode: \"{}\", hydration: \"{}\", css: \"{}\", loaders: [{}], load: {} }}",
json_escape(&target.component_name),
target.kind.as_str(),
target.kind == RouteTargetKind::Layout,
target.render_mode.as_str(),
target.hydration.as_str(),
json_escape(&asset_url(base_path, &target.css)),
loaders,
load,
)
}
fn optional_target_javascript(target: Option<&RouteTarget>, base_path: &str) -> String {
target
.map(|target| target_javascript(target, base_path))
.unwrap_or_else(|| "null".into())
}
fn asset_url(base_path: &str, relative: &str) -> String {
let relative = relative.trim_start_matches('/');
if base_path == "/" {
format!("/{relative}")
} else {
format!("{base_path}/{relative}")
}
}
fn module_url(relative: &str) -> String {
format!("./{}", relative.trim_start_matches('/'))
}
fn render_initial_server_shell(
out_dir: &Path,
routes: &RouteProgram,
) -> Result<Option<String>, String> {
let Some(_) = routes.routes.iter().find(|route| {
route.pattern == "/"
&& route.render.mode == RouteRenderMode::Client
&& route
.layouts
.iter()
.chain(std::iter::once(&route.page))
.any(|target| target.render_mode == ComponentRenderMode::Server)
}) else {
return Ok(None);
};
let url = if routes.base_path == "/" {
"http://noxid.local/".to_string()
} else {
format!(
"http://noxid.local{}/",
routes.base_path.trim_end_matches('/')
)
};
let script = r#"const { renderClientShell } = await import("./server/renderer.js");
const html = renderClientShell(process.argv[1]);
if (typeof html === "string") process.stdout.write(html);"#;
let output = Command::new("node")
.args(["--input-type=module", "-e", script, &url])
.current_dir(out_dir)
.output()
.map_err(|error| {
format!(
"error[SERVER_SHELL_RENDER_FAILED]: cannot start Node.js for the initial server shell: {error}"
)
})?;
if !output.status.success() {
return Err(format!(
"error[SERVER_SHELL_RENDER_FAILED]: generated root server shell failed:\n{}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let html = String::from_utf8(output.stdout).map_err(|_| {
"error[SERVER_SHELL_RENDER_FAILED]: generated root server shell was not UTF-8".to_string()
})?;
Ok((!html.is_empty()).then_some(html))
}
fn project_html(
title: &str,
base_path: &str,
global_styles: &[String],
initial_server_shell: Option<&str>,
) -> String {
let global_styles = global_styles
.iter()
.map(|style| {
let kind = if style.ends_with("tailwind.css") {
"tailwind"
} else {
"project"
};
format!(
" <link rel=\"stylesheet\" href=\"{}\" data-noxid-global-style=\"{}\">\n",
html_escape(&asset_url(base_path, style)),
kind
)
})
.collect::<String>();
let app_contents = initial_server_shell
.map(|shell| format!("<!--noxid-server-shell-start-->{shell}<!--noxid-server-shell-end-->"))
.unwrap_or_default();
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{} </head>\n <body>\n <div id=\"app\">{}</div>\n <script type=\"module\" src=\"{}\"></script>\n </body>\n</html>\n",
html_escape(title),
global_styles,
app_contents,
html_escape(&asset_url(base_path, "app.js")),
)
}
/// The enclosing project's analysis facts, for doors that compile sources
/// without preparing the whole project (scenario tests, editors). A loose
/// source directory has no `[server] secrets` and therefore no model
/// credentials — the same fail-closed answer a project with an empty
/// allowlist gets.
pub(crate) fn project_analysis_options(input: &Path) -> noxid_compiler_core::AnalysisOptions {
let manifest = if input.is_dir() {
input.join("Noxid.toml")
} else {
input.to_path_buf()
};
if !manifest.is_file() {
return noxid_compiler_core::AnalysisOptions::default();
}
let Ok(mut config) = load_config(&manifest) else {
return noxid_compiler_core::AnalysisOptions::default();
};
// A single-file door must derive the same tool registry the build does,
// or `noxid check` on a page would report an agent as tool-less purely
// because its endpoints live in another file. Endpoints that do not
// compile leave the facts empty; the build refuses them anyway.
if let Ok(middleware) = discover_middleware(&config) {
let names = middleware
.iter()
.map(|definition| definition.name.clone())
.collect::<BTreeSet<_>>();
if let Ok(endpoints) = compile_project_endpoints(&config, &names) {
config.endpoint_tools = endpoint_tool_facts(&endpoints);
}
}
config.analysis_options()
}
/// The WO-31 tool facts of every compiled project endpoint, in name order.
fn endpoint_tool_facts(
endpoints: &BTreeMap<PathBuf, CompiledEndpoint>,
) -> Vec<noxid_compiler_core::EndpointToolFact> {
// Each endpoint hashes against the types *its own* compilation unit
// declares, so a change inside a referenced type moves the registry hash
// the same way it moves the OpenAPI and MCP schema.
let mut facts = endpoints
.values()
.flat_map(|compiled| {
compiled.program.endpoints.iter().map(|endpoint| {
noxid_compiler_core::endpoint_tool_fact(
endpoint,
&compiled.program.types,
&compiled.program.distinct_types,
)
})
})
.collect::<Vec<_>>();
facts.sort_by(|left, right| left.name.cmp(&right.name));
facts.dedup_by(|left, right| left.name == right.name);
facts
}
fn load_config(input: &Path) -> Result<ProjectConfig, String> {
let manifest = if input.is_dir() {
input.join("Noxid.toml")
} else {
input.to_path_buf()
};
if manifest.file_name().and_then(|name| name.to_str()) != Some("Noxid.toml") {
return Err(
"Noxid projects must be a directory containing Noxid.toml or a Noxid.toml path".into(),
);
}
let root = manifest
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let text = fs::read_to_string(&manifest)
.map_err(|error| format!("cannot read {}: {error}", manifest.display()))?;
let mut title = "Noxid Application".to_string();
let mut declared_app_name = None;
let mut application_id = None;
let mut base_path = "/".to_string();
let mut routes = "src/routes".to_string();
let mut components = "src/components".to_string();
let mut middleware = "src/middleware".to_string();
let mut host = None;
let mut worker_entry = None;
let mut server_runtime = "node".to_string();
let mut server_storage_driver = ServerStorageDriver::Memory;
let mut server_blob_dir = ".noxid/blobs".to_string();
let mut server_pubsub_driver_override = None;
let mut server_pubsub_coalescing_ms = 250;
let mut db_pool = 10;
let mut shutdown_timeout_ms = 20_000;
let mut server_secrets: Vec<String> = Vec::new();
let mut server_tracing = ServerTracingMode::Requests;
let mut server_tracing_export = ServerTracingExport::Stdout;
let mut server_tracing_service_name = None;
let mut api_docs = false;
let mut mcp = false;
let mut queue_worker = false;
let mut queue_drain = false;
let mut queue_drain_budget_ms = 25_000;
let mut vercel_max_duration = None;
let mut global_middleware = Vec::new();
let mut deploy_adapter = "auto".to_string();
let mut prerender_entries = Vec::new();
let mut isr_rules = Vec::new();
let mut swr_rules = Vec::new();
let mut cache_vary_rules = Vec::new();
let mut cache_tag_rules = Vec::new();
let mut global_style = None;
let mut static_assets_dir = None;
let mut tailwind_enabled = false;
let mut tailwind_input = "src/tailwind.css".to_string();
let mut section = String::new();
for (index, raw) in text.lines().enumerate() {
let line = strip_toml_comment(raw).trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
section = line[1..line.len() - 1].trim().to_string();
if !matches!(
section.as_str(),
"app"
| "javascript"
| "deploy"
| "server"
| "worker"
| "render"
| "styles"
| "assets"
| "tailwind"
) {
return Err(format!(
"{}:{}: unknown section [{section}]",
manifest.display(),
index + 1
));
}
tailwind_enabled |= section == "tailwind";
continue;
}
if section == "styles" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
if key.trim() != "global" {
return Err(format!(
"{}:{}: unknown styles setting `{}`",
manifest.display(),
index + 1,
key.trim()
));
}
if global_style.is_some() {
return Err(format!(
"{}:{}: [styles] global may be declared only once",
manifest.display(),
index + 1
));
}
global_style = Some(parse_string(value, &manifest, index + 1)?);
continue;
}
if section == "assets" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
if key.trim() != "directory" {
return Err(format!(
"{}:{}: unknown assets setting `{}`",
manifest.display(),
index + 1,
key.trim()
));
}
if static_assets_dir.is_some() {
return Err(format!(
"{}:{}: [assets] directory may be declared only once",
manifest.display(),
index + 1
));
}
static_assets_dir = Some(parse_string(value, &manifest, index + 1)?);
continue;
}
if section == "tailwind" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
if key.trim() != "input" {
return Err(format!(
"{}:{}: unknown tailwind setting `{}`",
manifest.display(),
index + 1,
key.trim()
));
}
tailwind_input = parse_string(value, &manifest, index + 1)?;
continue;
}
if section == "javascript" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
if key.trim() != "pure" {
return Err(format!(
"{}:{}: unknown javascript setting `{}`",
manifest.display(),
index + 1,
key.trim()
));
}
parse_string_array(value, &manifest, index + 1)?;
continue;
}
if section == "deploy" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
if key.trim() != "adapter" {
return Err(format!(
"{}:{}: unknown deploy setting `{}`",
manifest.display(),
index + 1,
key.trim()
));
}
deploy_adapter = parse_string(value, &manifest, index + 1)?;
continue;
}
if section == "render" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
let values = parse_string_array(value, &manifest, index + 1)?;
match key.trim() {
"prerender" => prerender_entries = values,
"isr" => isr_rules = values,
"swr" => swr_rules = values,
"vary" => cache_vary_rules = values,
"tags" => cache_tag_rules = values,
unknown => {
return Err(format!(
"{}:{}: unknown render setting `{unknown}`",
manifest.display(),
index + 1,
));
}
}
continue;
}
if section == "server" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
match key.trim() {
"entry" => {
let _ = parse_string(value, &manifest, index + 1)?;
return Err(format!(
"error[SERVER_ENTRY_KEY_REMOVED]: {}:{}: `[server] entry` was removed; move the host module to `server/host.ts` (or `server/host.js`), which Noxid auto-detects",
manifest.display(),
index + 1,
));
}
"runtime" => {
server_runtime = parse_string(value, &manifest, index + 1)?;
if !matches!(server_runtime.as_str(), "node" | "deno") {
return Err(format!(
"{}:{}: server runtime must be `node` or `deno`",
manifest.display(),
index + 1,
));
}
}
"storage" => {
let driver = parse_string(value, &manifest, index + 1)?;
server_storage_driver =
ServerStorageDriver::parse(&driver, &manifest, index + 1)?;
}
"blob_dir" => {
server_blob_dir = parse_string(value, &manifest, index + 1)?;
let blob_path = Path::new(&server_blob_dir);
if server_blob_dir.is_empty()
|| server_blob_dir.contains('\\')
|| blob_path.is_absolute()
|| blob_path.components().any(|component| {
!matches!(component, Component::Normal(_) | Component::CurDir)
})
{
return Err(format!(
"error[SERVER_BLOB_DIR_INVALID]: {}:{}: [server] blob_dir must be a non-empty project-relative path without `..` or backslashes, such as `.noxid/blobs`",
manifest.display(),
index + 1,
));
}
}
"live_driver" => {
let driver = parse_string(value, &manifest, index + 1)?;
server_pubsub_driver_override =
Some(ServerPubSubDriver::parse(&driver, &manifest, index + 1)?);
}
"live_coalescing_ms" => {
server_pubsub_coalescing_ms = parse_bounded_server_integer(
value,
&manifest,
index + 1,
"live_coalescing_ms",
60_000,
"SERVER_LIVE_COALESCING_INVALID",
)?;
}
"db_pool" => {
db_pool = parse_bounded_server_integer(
value,
&manifest,
index + 1,
"db_pool",
9_007_199_254_740_991,
"DB_POOL_INVALID",
)?;
}
"shutdown_timeout_ms" => {
shutdown_timeout_ms = parse_bounded_server_integer(
value,
&manifest,
index + 1,
"shutdown_timeout_ms",
300_000,
"SHUTDOWN_TIMEOUT_INVALID",
)?;
}
"secrets" => {
server_secrets = parse_string_array(value, &manifest, index + 1)?;
for name in &server_secrets {
let valid = !name.is_empty()
&& name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& name
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
if !valid {
return Err(format!(
"{}:{}: server secret `{name}` must be an UPPER_SNAKE_CASE environment variable name",
manifest.display(),
index + 1,
));
}
}
}
"tracing" => {
let mode = parse_string(value, &manifest, index + 1)?;
server_tracing = parse_server_tracing_mode(&mode, &manifest, index + 1)?;
}
"tracing_export" => {
let exporter = parse_string(value, &manifest, index + 1)?;
server_tracing_export =
parse_server_tracing_export(&exporter, &manifest, index + 1)?;
}
"tracing_service_name" => {
let name = parse_string(value, &manifest, index + 1)?;
if name.is_empty() || name.chars().count() > 256 {
return Err(format!(
"error[TRACING_SERVICE_NAME_INVALID]: {}:{}: [server] tracing_service_name must contain 1 to 256 characters; write the stable service name your collector should group together, or remove the override to use the declared [app] title or package name",
manifest.display(),
index + 1,
));
}
server_tracing_service_name = Some(name);
}
"api_docs" => {
api_docs = parse_toml_bool(value, &manifest, index + 1, "api_docs")?;
}
"mcp" => {
mcp = parse_toml_bool(value, &manifest, index + 1, "mcp")?;
}
"queue_worker" => {
queue_worker = match value.trim() {
"true" => true,
"false" => false,
unknown => {
return Err(format!(
"error[QUEUE_WORKER_BOOLEAN_REQUIRED]: {}:{}: [server] queue_worker must be the boolean `true` or `false`, not `{unknown}`",
manifest.display(),
index + 1,
));
}
};
}
"queue_drain" => {
queue_drain = parse_toml_bool(value, &manifest, index + 1, "queue_drain")?;
}
"queue_drain_budget_ms" => {
queue_drain_budget_ms = parse_bounded_server_integer(
value,
&manifest,
index + 1,
"queue_drain_budget_ms",
300_000,
"QUEUE_DRAIN_BUDGET_INVALID",
)?;
}
"vercel_max_duration" => {
vercel_max_duration = Some(parse_bounded_server_integer(
value,
&manifest,
index + 1,
"vercel_max_duration",
300,
"VERCEL_MAX_DURATION_INVALID",
)?);
}
unknown => {
return Err(format!(
"{}:{}: unknown server setting `{unknown}`",
manifest.display(),
index + 1,
));
}
}
continue;
}
if section == "worker" {
let (key, value) = line.split_once('=').ok_or_else(|| {
format!("{}:{}: expected key = value", manifest.display(), index + 1)
})?;
if key.trim() != "entry" {
return Err(format!(
"{}:{}: unknown worker setting `{}`",
manifest.display(),
index + 1,
key.trim()
));
}
worker_entry = Some(parse_string(value, &manifest, index + 1)?);
continue;
}
if section != "app" {
return Err(format!(
"{}:{}: settings must be inside [app]",
manifest.display(),
index + 1
));
}
let (key, value) = line
.split_once('=')
.ok_or_else(|| format!("{}:{}: expected key = value", manifest.display(), index + 1))?;
match key.trim() {
"id" => {
let value = parse_string(value, &manifest, index + 1)?;
if !valid_application_id(&value) {
return Err(format!(
"error[APP_ID_INVALID]: {}:{}: [app] id must match `[a-z][a-z0-9_]{{0,31}}`; write a stable deployment identity such as `id = \"example_orders\"`",
manifest.display(),
index + 1,
));
}
application_id = Some(value);
}
"title" => {
let value = parse_string(value, &manifest, index + 1)?;
title.clone_from(&value);
declared_app_name = Some(value);
}
"base" => base_path = normalize_base_path(&parse_string(value, &manifest, index + 1)?)?,
"routes" => routes = parse_string(value, &manifest, index + 1)?,
"components" => components = parse_string(value, &manifest, index + 1)?,
"middleware" => middleware = parse_string(value, &manifest, index + 1)?,
"host" => host = Some(parse_string(value, &manifest, index + 1)?),
"global_middleware" => {
global_middleware = parse_string_array(value, &manifest, index + 1)?
}
unknown => {
return Err(format!(
"{}:{}: unknown app setting `{unknown}`",
manifest.display(),
index + 1
));
}
}
}
if server_storage_driver == ServerStorageDriver::Redis
&& !server_secrets.iter().any(|secret| secret == "REDIS_URL")
{
return Err(format!(
"error[SERVER_STORAGE_REDIS_SECRET_REQUIRED]: {}: [server] storage = \"redis\" requires REDIS_URL to be declared in [server] secrets; write `secrets = [\"REDIS_URL\"]` and provide a redis:// or rediss:// single-instance endpoint at runtime",
manifest.display(),
));
}
let server_pubsub_driver =
server_pubsub_driver_override.unwrap_or(match server_storage_driver {
ServerStorageDriver::Memory | ServerStorageDriver::Fs => ServerPubSubDriver::Memory,
ServerStorageDriver::Postgres => ServerPubSubDriver::Postgres,
ServerStorageDriver::Redis => ServerPubSubDriver::Redis,
});
if server_pubsub_driver == ServerPubSubDriver::Redis
&& !server_secrets.iter().any(|secret| secret == "REDIS_URL")
{
return Err(format!(
"error[SERVER_LIVE_REDIS_SECRET_REQUIRED]: {}: [server] live_driver = \"redis\" requires REDIS_URL in [server] secrets; declare `secrets = [\"REDIS_URL\"]` and provide the admitted WO-39 endpoint at runtime",
manifest.display(),
));
}
let routes_dir = safe_project_path(&root, &routes)?;
let components_dir = safe_project_path(&root, &components)?;
let middleware_dir = safe_project_path(&root, &middleware)?;
let host = host
.map(|path| safe_project_path(&root, &path))
.transpose()?;
let server_dir = root.join("server");
let server_route_middleware_dir = server_dir.join("route-middleware");
let (server_host, server_global_middleware, server_plugins) =
discover_server_directory(&root, &middleware_dir, &server_dir)?;
let worker_entry = worker_entry
.map(|path| safe_project_path(&root, &path))
.transpose()?;
let global_style = global_style
.map(|path| safe_project_path(&root, &path))
.transpose()?;
let static_assets_dir = static_assets_dir
.map(|path| safe_project_path(&root, &path))
.transpose()?;
if let Some(path) = &static_assets_dir
&& !path.is_dir()
{
return Err(format!(
"error[STATIC_ASSETS_DIRECTORY_MISSING]: [assets] directory does not exist: {}",
path.display()
));
}
if let Some(path) = &global_style {
if path.extension().and_then(|extension| extension.to_str()) != Some("css") {
return Err(format!(
"error[GLOBAL_STYLE_EXTENSION_INVALID]: [styles] global must reference a .css file: {}",
path.display()
));
}
if !path.is_file() {
return Err(format!(
"error[GLOBAL_STYLE_INPUT_MISSING]: [styles] global stylesheet does not exist: {}",
path.display()
));
}
}
let tailwind = tailwind_enabled
.then(|| safe_project_path(&root, &tailwind_input).map(|input| TailwindConfig { input }))
.transpose()?;
let prerender_entries = prerender_entries
.into_iter()
.map(|entry| normalize_prerender_entry(&entry))
.collect::<Result<Vec<_>, _>>()?;
let mut route_cache = BTreeMap::new();
for rule in isr_rules {
let (pattern, cache) = parse_route_cache_rule(&rule, RouteCacheMode::Isr)?;
if route_cache.insert(pattern.clone(), cache).is_some() {
return Err(format!(
"error[DUPLICATE_ROUTE_CACHE_RULE]: route `{pattern}` has more than one cache rule"
));
}
}
for rule in swr_rules {
let (pattern, cache) = parse_route_cache_rule(&rule, RouteCacheMode::Swr)?;
if route_cache.insert(pattern.clone(), cache).is_some() {
return Err(format!(
"error[DUPLICATE_ROUTE_CACHE_RULE]: route `{pattern}` has more than one cache rule"
));
}
}
apply_route_cache_metadata(&mut route_cache, cache_vary_rules, true)?;
apply_route_cache_metadata(&mut route_cache, cache_tag_rules, false)?;
if server_tracing_export == ServerTracingExport::Otlp
&& !server_secrets
.iter()
.any(|name| name == "OTEL_EXPORTER_OTLP_ENDPOINT")
{
return Err(format!(
"error[TRACING_EXPORT_ENDPOINT_REQUIRED]: {}: [server] tracing_export = `otlp` requires `OTEL_EXPORTER_OTLP_ENDPOINT` in [server] secrets; declare the endpoint there and provide it in the deployment environment, or use tracing_export = `stdout`",
manifest.display(),
));
}
let server_tracing_service_name = server_tracing_service_name
.or(declared_app_name)
.or_else(|| package_json_name(&root))
.unwrap_or_else(|| "noxid.application".into());
let declared_models = discover_declared_models(&server_dir)?;
let agent_instructions = discover_agent_instructions(&root, &server_dir)?;
Ok(ProjectConfig {
root,
manifest,
application_id,
title,
base_path,
routes_dir,
components_dir,
middleware_dir,
host,
server_dir,
server_host,
server_global_middleware,
server_plugins,
server_route_middleware_dir,
worker_entry,
server_runtime,
server_storage_driver,
server_blob_dir,
server_pubsub_driver,
server_pubsub_coalescing_ms,
db_pool,
shutdown_timeout_ms,
declared_models,
agent_instructions,
endpoint_tools: Vec::new(),
server_secrets,
server_tracing,
server_tracing_export,
server_tracing_service_name,
api_docs,
mcp,
queue_worker,
queue_drain,
queue_drain_budget_ms,
vercel_max_duration,
global_middleware,
deploy_adapter,
prerender_entries,
route_cache,
global_style,
static_assets_dir,
tailwind,
})
}
fn parse_bounded_server_integer(
value: &str,
manifest: &Path,
line: usize,
key: &str,
maximum: u64,
code: &str,
) -> Result<u64, String> {
value
.trim()
.parse::<u64>()
.ok()
.filter(|parsed| *parsed > 0 && *parsed <= maximum)
.ok_or_else(|| {
format!(
"error[{code}]: {}:{line}: [server] {key} must be a positive integer no greater than {maximum}, not `{}`",
manifest.display(),
value.trim(),
)
})
}
fn parse_toml_bool(value: &str, manifest: &Path, line: usize, key: &str) -> Result<bool, String> {
match value.trim() {
"true" => Ok(true),
"false" => Ok(false),
unknown => Err(format!(
"error[SERVER_AGENT_SURFACE_BOOLEAN_REQUIRED]: {}:{line}: [server] {key} must be the boolean `true` or `false`, not `{unknown}`",
manifest.display()
)),
}
}
fn normalize_prerender_entry(value: &str) -> Result<String, String> {
if !value.starts_with('/')
|| value.starts_with("//")
|| value.contains('#')
|| value.contains('\\')
|| value
.split('?')
.next()
.unwrap_or(value)
.split('/')
.any(|segment| matches!(segment, "." | ".."))
{
return Err(format!(
"error[PRERENDER_ENTRY_INVALID]: `{value}` must be a safe application-relative URL beginning with `/`"
));
}
Ok(value.to_string())
}
fn parse_route_cache_rule(
value: &str,
mode: RouteCacheMode,
) -> Result<(String, RouteCacheConfig), String> {
let parts = value.rsplit('@').collect::<Vec<_>>();
let (pattern, revalidate, stale) = match (mode, parts.as_slice()) {
(RouteCacheMode::Isr, [revalidate, pattern]) => (*pattern, *revalidate, "0"),
(RouteCacheMode::Swr, [stale, revalidate, pattern]) => (*pattern, *revalidate, *stale),
(RouteCacheMode::Isr, _) => {
return Err(format!(
"error[ISR_RULE_INVALID]: `{value}` must use `/route/pattern@revalidate-seconds`"
));
}
(RouteCacheMode::Swr, _) => {
return Err(format!(
"error[SWR_RULE_INVALID]: `{value}` must use `/route/pattern@revalidate-seconds@stale-seconds`"
));
}
};
if !pattern.starts_with('/')
|| pattern.starts_with("//")
|| pattern.contains('?')
|| pattern.contains('#')
{
return Err(format!(
"error[ROUTE_CACHE_PATTERN_INVALID]: `{pattern}` must be a route pattern beginning with `/`"
));
}
let revalidate_seconds = revalidate.parse::<u64>().ok().filter(|value| *value > 0).ok_or_else(|| {
format!("error[ROUTE_CACHE_DURATION_INVALID]: `{revalidate}` must be a positive number of seconds")
})?;
let stale_seconds = stale.parse::<u64>().map_err(|_| {
format!("error[ROUTE_CACHE_DURATION_INVALID]: `{stale}` must be a number of seconds")
})?;
if mode == RouteCacheMode::Swr && stale_seconds == 0 {
return Err(
"error[ROUTE_CACHE_DURATION_INVALID]: SWR stale seconds must be positive".into(),
);
}
Ok((
pattern.to_string(),
RouteCacheConfig {
mode,
revalidate_seconds,
stale_seconds,
vary: vec![],
tags: vec![],
},
))
}
fn apply_route_cache_metadata(
route_cache: &mut BTreeMap<String, RouteCacheConfig>,
rules: Vec<String>,
vary: bool,
) -> Result<(), String> {
for rule in rules {
let (pattern, values) = rule.rsplit_once('@').ok_or_else(|| {
format!(
"error[ROUTE_CACHE_METADATA_INVALID]: `{rule}` must use /route/pattern@value[,value]"
)
})?;
let cache = route_cache.get_mut(pattern).ok_or_else(|| {
format!(
"error[ROUTE_CACHE_METADATA_WITHOUT_POLICY]: route `{pattern}` needs an ISR or SWR rule before cache metadata"
)
})?;
let parsed = values
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
let valid = value.chars().all(|character| {
character.is_ascii_alphanumeric()
|| matches!(character, '-' | '_' | '.' | ':')
});
if !valid {
return Err(format!(
"error[ROUTE_CACHE_METADATA_INVALID]: `{value}` contains unsafe cache metadata characters"
));
}
Ok(value.to_ascii_lowercase())
})
.collect::<Result<Vec<_>, String>>()?;
if parsed.is_empty() {
return Err(
"error[ROUTE_CACHE_METADATA_INVALID]: cache metadata cannot be empty".into(),
);
}
let destination = if vary {
&mut cache.vary
} else {
&mut cache.tags
};
destination.extend(parsed);
destination.sort();
destination.dedup();
}
Ok(())
}
fn strip_toml_comment(value: &str) -> &str {
let mut quoted = false;
for (index, ch) in value.char_indices() {
if ch == '"' {
quoted = !quoted;
} else if ch == '#' && !quoted {
return &value[..index];
}
}
value
}
fn normalize_base_path(value: &str) -> Result<String, String> {
if !value.starts_with('/') || value.contains('?') || value.contains('#') || value.contains('\\')
{
return Err(format!(
"application base `{value}` must be an origin-relative path beginning with `/`"
));
}
let normalized = if value == "/" {
"/".to_string()
} else {
value.trim_end_matches('/').to_string()
};
if normalized.starts_with("//")
|| normalized
.split('/')
.any(|segment| matches!(segment, "." | ".."))
|| (normalized != "/"
&& normalized
.trim_start_matches('/')
.split('/')
.any(|segment| {
segment.is_empty()
|| !segment.chars().all(|ch| {
ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~')
})
}))
{
return Err(format!(
"application base `{value}` cannot contain an authority, `.` segment, or `..` segment"
));
}
Ok(normalized)
}
fn valid_application_id(value: &str) -> bool {
let bytes = value.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 32
&& bytes[0].is_ascii_lowercase()
&& bytes
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
}
fn safe_project_path(root: &Path, value: &str) -> Result<PathBuf, String> {
let path = Path::new(value);
if path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(format!(
"project path `{value}` must remain inside the project root"
));
}
Ok(root.join(path))
}
fn parse_string(value: &str, manifest: &Path, line: usize) -> Result<String, String> {
let value = value.trim();
value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.map(str::to_string)
.ok_or_else(|| format!("{}:{line}: expected a quoted string", manifest.display()))
}
fn parse_string_array(value: &str, manifest: &Path, line: usize) -> Result<Vec<String>, String> {
let value = value.trim();
let inner = value
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.ok_or_else(|| {
format!(
"{}:{line}: expected an array of quoted strings",
manifest.display()
)
})?;
if inner.trim().is_empty() {
return Ok(vec![]);
}
let mut values = Vec::new();
let mut start = 0;
let mut quoted = false;
let mut escaped = false;
for (index, character) in inner.char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
',' if !quoted => {
values.push(parse_string(&inner[start..index], manifest, line)?);
start = index + character.len_utf8();
}
_ => {}
}
}
if quoted || escaped {
return Err(format!(
"{}:{line}: unterminated quoted string",
manifest.display()
));
}
values.push(parse_string(&inner[start..], manifest, line)?);
Ok(values)
}
/// One module of the auto-discovered `server/` import graph, ready to emit
/// under `dist/server/`. Entry points retain their compiler-owned output
/// names, every transitively imported project file lands flattened under
/// `modules/`, and TypeScript sources are transpiled through the vetted
/// `typescript` package.
struct ServerSourceModule {
emitted: String,
source: String,
typescript: bool,
source_path: Option<PathBuf>,
}
#[cfg(test)]
#[derive(Clone, Debug, PartialEq, Eq)]
enum SqlSurfaceToken {
Word(String),
String(String),
Template(String),
Punct(char),
}
#[cfg(test)]
fn sql_surface_tokens(source: &str) -> Vec<SqlSurfaceToken> {
let bytes = source.as_bytes();
let mut tokens = Vec::new();
let mut index = 0;
while index < bytes.len() {
if bytes[index].is_ascii_whitespace() {
index += 1;
} else if bytes.get(index..index + 2) == Some(b"//") {
index += 2;
while bytes
.get(index)
.is_some_and(|byte| !matches!(byte, b'\n' | b'\r'))
{
index += 1;
}
} else if bytes.get(index..index + 2) == Some(b"/*") {
index += 2;
while index < bytes.len() && bytes.get(index..index + 2) != Some(b"*/") {
index += 1;
}
index = (index + 2).min(bytes.len());
} else if matches!(bytes[index], b'\'' | b'"' | b'`') {
let quote = bytes[index];
index += 1;
let mut value = String::new();
while index < bytes.len() {
if bytes[index] == b'\\' {
index += 1;
if index < bytes.len() {
value.push(bytes[index] as char);
}
} else if bytes[index] == quote {
index += 1;
break;
} else {
value.push(bytes[index] as char);
}
index += 1;
}
tokens.push(if quote == b'`' {
SqlSurfaceToken::Template(value)
} else {
SqlSurfaceToken::String(value)
});
} else if bytes[index] == b'_' || bytes[index] == b'$' || bytes[index].is_ascii_alphabetic()
{
let start = index;
index += 1;
while bytes
.get(index)
.is_some_and(|byte| *byte == b'_' || *byte == b'$' || byte.is_ascii_alphanumeric())
{
index += 1;
}
tokens.push(SqlSurfaceToken::Word(source[start..index].to_string()));
} else {
tokens.push(SqlSurfaceToken::Punct(bytes[index] as char));
index += 1;
}
}
tokens
}
#[cfg(test)]
const SQL_DRIVER_SPECIFIERS: &[&str] = &["postgres", "mysql2", "mysql2/promise", "node:sqlite"];
/// Compiler-emitted server modules may select only these exact platform
/// drivers dynamically. Keep this list exact: prefixes and substring matches
/// would turn the allowlist back into a leaky name heuristic.
const COMPILER_DYNAMIC_DRIVER_SPECIFIERS: &[&str] =
&["postgres", "mysql2", "mysql2/promise", "node:sqlite"];
const UNRESOLVED_DYNAMIC_MODULE_REASON: &str = "assembles a database driver name or otherwise uses a dynamic module load whose specifier is not a direct allowlisted string literal";
#[cfg(test)]
const NODE_VM_SPECIFIERS: &[&str] = &["node:vm", "vm"];
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UnsafeServerDynamicCode {
FunctionConstructor,
Eval,
NodeVm,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum DynamicModuleSpecifier {
Literal(String),
Unresolvable,
}
fn skip_javascript_trivia(source: &str, mut index: usize) -> usize {
let bytes = source.as_bytes();
loop {
while bytes.get(index).is_some_and(u8::is_ascii_whitespace) {
index += 1;
}
if bytes.get(index..index + 2) == Some(b"//") {
index = bytes[index + 2..]
.iter()
.position(|byte| matches!(byte, b'\n' | b'\r'))
.map_or(bytes.len(), |offset| index + 2 + offset + 1);
continue;
}
if bytes.get(index..index + 2) == Some(b"/*") {
index = source[index + 2..]
.find("*/")
.map_or(bytes.len(), |offset| index + 2 + offset + 2);
continue;
}
return index;
}
}
fn dynamic_string_literal(source: &str, start: usize) -> Option<(String, usize)> {
match source.as_bytes().get(start) {
Some(b'\'' | b'"') => {
let literal = quoted_module_specifier(&source[start..], start)?;
Some((literal.value, literal.end + 1))
}
Some(b'`') => {
let value = static_template_module_specifier(&source[start..])?;
let bytes = source.as_bytes();
let mut escaped = false;
let mut end = start + 1;
while end < bytes.len() {
if escaped {
escaped = false;
} else if bytes[end] == b'\\' {
escaped = true;
} else if bytes[end] == b'`' {
return Some((value, end + 1));
}
end += 1;
}
None
}
_ => None,
}
}
fn javascript_executable_bytes(source: &str) -> Vec<bool> {
#[derive(Clone, Copy)]
enum State {
Code { template_braces: Option<usize> },
SingleQuoted,
DoubleQuoted,
Template,
Regex { character_class: bool },
LineComment,
BlockComment,
}
let bytes = source.as_bytes();
let mut executable = vec![false; bytes.len()];
let mut states = vec![State::Code {
template_braces: None,
}];
let mut index = 0;
while index < bytes.len() {
match *states.last().expect("JavaScript lexer always has a state") {
State::Code { template_braces } => {
executable[index] = true;
match bytes[index] {
b'\'' => states.push(State::SingleQuoted),
b'"' => states.push(State::DoubleQuoted),
b'`' => states.push(State::Template),
b'/' if bytes.get(index + 1) == Some(&b'/') => {
executable[index] = false;
executable[index + 1] = false;
states.push(State::LineComment);
index += 1;
}
b'/' if bytes.get(index + 1) == Some(&b'*') => {
executable[index] = false;
executable[index + 1] = false;
states.push(State::BlockComment);
index += 1;
}
b'/' if starts_javascript_regex(bytes, index) => {
executable[index] = false;
states.push(State::Regex {
character_class: false,
});
}
b'{' if template_braces.is_some() => {
*states.last_mut().expect("code state exists") = State::Code {
template_braces: template_braces.map(|depth| depth + 1),
};
}
b'}' if template_braces == Some(0) => {
executable[index] = false;
states.pop();
}
b'}' if template_braces.is_some() => {
*states.last_mut().expect("code state exists") = State::Code {
template_braces: template_braces.map(|depth| depth - 1),
};
}
_ => {}
}
}
State::SingleQuoted => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
index += 1;
} else if bytes[index] == b'\'' {
states.pop();
}
}
State::DoubleQuoted => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
index += 1;
} else if bytes[index] == b'"' {
states.pop();
}
}
State::Template => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
index += 1;
} else if bytes[index] == b'`' {
states.pop();
} else if bytes[index] == b'$' && bytes.get(index + 1) == Some(&b'{') {
states.push(State::Code {
template_braces: Some(0),
});
index += 1;
}
}
State::Regex { character_class } => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
index += 1;
} else if bytes[index] == b'[' {
*states.last_mut().expect("regex state exists") = State::Regex {
character_class: true,
};
} else if bytes[index] == b']' {
*states.last_mut().expect("regex state exists") = State::Regex {
character_class: false,
};
} else if bytes[index] == b'/' && !character_class {
states.pop();
}
}
State::LineComment => {
if matches!(bytes[index], b'\n' | b'\r') {
states.pop();
}
}
State::BlockComment => {
if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') {
states.pop();
index += 1;
}
}
}
index += 1;
}
executable
}
/// Find executable `import(...)` and `require(...)` calls and admit only a
/// direct, statically decoded string/template literal as the first argument.
/// Aliases, concatenation, interpolation, member projections, and every other
/// indirect form are deliberately represented as unresolvable.
fn scan_dynamic_module_specifiers(source: &str) -> Vec<DynamicModuleSpecifier> {
let source_bytes = source.as_bytes();
let executable = javascript_executable_bytes(source);
let mut specifiers = Vec::new();
let mut index = 0;
while index < source_bytes.len() {
let keyword = ["import", "require"].into_iter().find(|keyword| {
let end = index + keyword.len();
executable
.get(index..end)
.is_some_and(|bytes| bytes.iter().all(|value| *value))
&& source_bytes.get(index..end) == Some(keyword.as_bytes())
&& index
.checked_sub(1)
.and_then(|previous| source_bytes.get(previous))
.is_none_or(|byte| !is_javascript_identifier_byte(*byte))
&& source_bytes
.get(end)
.is_none_or(|byte| !is_javascript_identifier_byte(*byte))
&& source_bytes[..index]
.iter()
.zip(&executable[..index])
.rev()
.find_map(|(byte, executable)| {
(*executable && !byte.is_ascii_whitespace()).then_some(*byte)
})
!= Some(b'.')
});
let Some(keyword) = keyword else {
index += 1;
continue;
};
let mut cursor = skip_javascript_trivia(source, index + keyword.len());
if source_bytes.get(cursor) != Some(&b'(') {
index += keyword.len();
continue;
}
cursor = skip_javascript_trivia(source, cursor + 1);
let specifier = dynamic_string_literal(source, cursor).and_then(|(value, end)| {
let end = skip_javascript_trivia(source, end);
matches!(source.as_bytes().get(end), Some(b')' | b',')).then_some(value)
});
specifiers.push(specifier.map_or(
DynamicModuleSpecifier::Unresolvable,
DynamicModuleSpecifier::Literal,
));
index += keyword.len();
}
specifiers
}
fn decode_javascript_identifier_escape(source: &str, start: usize) -> Option<(char, usize)> {
let tail = source.as_bytes().get(start..)?;
if !tail.starts_with(b"\\u") {
return None;
}
if tail.get(2) == Some(&b'{') {
let closing = tail[3..].iter().position(|byte| *byte == b'}')? + 3;
let digits = std::str::from_utf8(&tail[3..closing]).ok()?;
if digits.is_empty()
|| digits.len() > 6
|| !digits.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return None;
}
let value = u32::from_str_radix(digits, 16).ok()?;
Some((char::from_u32(value)?, start + closing + 1))
} else {
let digits = std::str::from_utf8(tail.get(2..6)?).ok()?;
if !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(digits, 16).ok()?;
Some((char::from_u32(value)?, start + 6))
}
}
fn javascript_identifier_character(character: char, first: bool) -> bool {
character == '_'
|| character == '$'
|| character.is_ascii_alphabetic()
|| (!first && character.is_ascii_digit())
}
/// Search executable JavaScript identifiers by their decoded binding name.
/// Refusing the dangerous root at the point it is referenced also refuses
/// every local/exported alias before that alias can launder the binding into a
/// call in another module. Comments, strings, templates, and regexes remain
/// inert, while escaped spellings such as `e\u{76}al` resolve to the same root.
fn has_executable_javascript_identifier(source: &str, expected: &str) -> bool {
let executable = javascript_executable_bytes(source);
let bytes = source.as_bytes();
let mut index = 0;
while index < bytes.len() {
if !executable[index]
|| !(bytes[index] == b'\\'
|| bytes[index] == b'_'
|| bytes[index] == b'$'
|| bytes[index].is_ascii_alphabetic())
{
index += 1;
continue;
}
let mut cursor = index;
let mut decoded = String::new();
let mut first = true;
while cursor < bytes.len() && executable[cursor] {
let (character, end) = if bytes[cursor] == b'\\' {
let Some(decoded_escape) = decode_javascript_identifier_escape(source, cursor)
else {
break;
};
decoded_escape
} else if bytes[cursor].is_ascii() {
(bytes[cursor] as char, cursor + 1)
} else {
break;
};
if !javascript_identifier_character(character, first) {
break;
}
decoded.push(character);
cursor = end;
first = false;
}
if decoded == expected {
return true;
}
index = cursor.max(index + 1);
}
false
}
#[cfg(test)]
fn unsafe_server_dynamic_code(source: &str) -> Option<UnsafeServerDynamicCode> {
let imports_node_vm = scan_import_specifiers(source)
.into_iter()
.any(|specifier| NODE_VM_SPECIFIERS.contains(&specifier.as_str()))
|| scan_dynamic_module_specifiers(source)
.into_iter()
.any(|specifier| {
matches!(specifier, DynamicModuleSpecifier::Literal(specifier) if NODE_VM_SPECIFIERS.contains(&specifier.as_str()))
});
if imports_node_vm {
return Some(UnsafeServerDynamicCode::NodeVm);
}
if has_executable_javascript_identifier(source, "Function") {
return Some(UnsafeServerDynamicCode::FunctionConstructor);
}
if has_executable_javascript_identifier(source, "eval") {
return Some(UnsafeServerDynamicCode::Eval);
}
None
}
/// Generated-output ratchet (WO-40 resolution): a compiler-emitted server
/// module may load only the exact platform driver allowlist, and only through
/// literal specifiers. Developer-authored `server/**` modules are a trusted
/// tier and are never scanned by any build.
fn compiler_emitted_unsafe_reason(source: &str) -> Option<&'static str> {
for specifier in scan_dynamic_module_specifiers(source) {
match specifier {
DynamicModuleSpecifier::Unresolvable => {
return Some(UNRESOLVED_DYNAMIC_MODULE_REASON);
}
DynamicModuleSpecifier::Literal(specifier) => {
if !COMPILER_DYNAMIC_DRIVER_SPECIFIERS.contains(&specifier.as_str()) {
return Some(
"uses a compiler-emitted dynamic module load outside the exact platform driver allowlist",
);
}
}
}
}
None
}
/// Reference scanner for developer-authored server source. It is compiled
/// only for tests and is applied to NO build: the WO-40 resolution
/// (`docs/work-orders/qa/wo-40-resolution.md`) made developer server code a
/// trusted tier because static scanning of it is unsound. It survives so the
/// ESM-binding resolution tests keep exercising `scan_dynamic_module_specifiers`.
#[cfg(test)]
fn reference_project_scanner_reason(source: &str) -> Option<&'static str> {
for specifier in scan_dynamic_module_specifiers(source) {
match specifier {
DynamicModuleSpecifier::Unresolvable => {
return Some(UNRESOLVED_DYNAMIC_MODULE_REASON);
}
DynamicModuleSpecifier::Literal(specifier) => {
if SQL_DRIVER_SPECIFIERS.contains(&specifier.as_str()) {
return Some("imports a database driver directly");
}
}
}
}
if scan_import_specifiers(source)
.iter()
.any(|specifier| SQL_DRIVER_SPECIFIERS.contains(&specifier.as_str()))
{
return Some("imports a database driver directly");
}
let tokens = sql_surface_tokens(source);
for (index, token) in tokens.iter().enumerate() {
if matches!(token, SqlSurfaceToken::Word(word) if word == "__installNoxidPrincipalAuthority")
{
return Some("references the compiler-owned principal authority installer");
}
if matches!(token, SqlSurfaceToken::Word(word) if word == "unsafe" || word == "raw")
&& tokens.get(index.wrapping_sub(1)) == Some(&SqlSurfaceToken::Punct('.'))
&& tokens.get(index + 1) == Some(&SqlSurfaceToken::Punct('('))
{
return Some("calls a raw or unsafe SQL escape hatch");
}
let value = match token {
SqlSurfaceToken::String(value) | SqlSurfaceToken::Template(value) => value,
_ => continue,
};
if tokens.get(index + 1) == Some(&SqlSurfaceToken::Punct('+'))
&& [
"SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "CREATE", "DROP",
]
.iter()
.any(|keyword| {
value
.to_ascii_uppercase()
.starts_with(&format!("{keyword} "))
})
{
return Some("assembles SQL text from strings");
}
}
None
}
#[cfg(test)]
fn unsafe_sql_reason(source: &str) -> Option<&'static str> {
reference_project_scanner_reason(source)
}
const PRINCIPAL_AUTHORITY_INSTALLER: &str = "__installNoxidPrincipalAuthority";
/// The runtime principal authority is bound only to the compiler-owned drizzle
/// adapter (`plugins/drizzle-orm/adapter.js`, resolved from the project root or
/// one of its ancestors). Any developer-authored server module whose executable
/// source mentions the installer identifier, under any spelling or export form,
/// refuses the build: selecting the installer by text search would let such a
/// module receive every `(context, principal)` pair.
fn select_principal_authority_import(
server_modules: &[ServerSourceModule],
project_root: &Path,
) -> Result<Option<String>, String> {
let canonical_root = fs::canonicalize(project_root)
.map_err(|error| format!("cannot resolve {}: {error}", project_root.display()))?;
let mut selected: Option<String> = None;
for module in server_modules {
let Some(path) = module.source_path.as_deref() else {
continue;
};
if !has_executable_javascript_identifier(&module.source, PRINCIPAL_AUTHORITY_INSTALLER) {
continue;
}
if !is_compiler_owned_drizzle_adapter(path, &canonical_root) {
return Err(format!(
"error[PRINCIPAL_AUTHORITY_INSTALLER_SHADOWED]: server module {} references `__installNoxidPrincipalAuthority`, but only the compiler-owned adapter at plugins/drizzle-orm/adapter.js (resolved from the project root or an ancestor directory) may install the runtime principal authority; remove that reference and import the adapter's public surface instead",
module.emitted
));
}
if let Some(previous) = &selected {
return Err(format!(
"error[PRINCIPAL_AUTHORITY_INSTALLER_DUPLICATED]: both {previous} and ./{} resolve to a drizzle adapter that installs the runtime principal authority; a build may import exactly one copy of plugins/drizzle-orm/adapter.js",
module.emitted
));
}
selected = Some(format!("./{}", module.emitted));
}
Ok(selected)
}
/// A path is the compiler-owned adapter only when it is
/// `<base>/plugins/drizzle-orm/adapter.js` and `<base>` is the canonical
/// project root or one of its ancestors. Paths are canonical (symlinks
/// resolved) before they reach here, so an alias cannot forge the base.
fn is_compiler_owned_drizzle_adapter(path: &Path, canonical_root: &Path) -> bool {
let Some(base) = path
.strip_prefix("/")
.ok()
.and_then(|_| path.parent())
.and_then(Path::parent)
.and_then(Path::parent)
else {
return false;
};
let suffix_matches = path
.strip_prefix(base)
.is_ok_and(|rest| rest == Path::new("plugins/drizzle-orm/adapter.js"));
suffix_matches && canonical_root.starts_with(base)
}
fn validate_compiler_generated_server_imports(
modules: &[ServerSourceModule],
) -> Result<(), String> {
for module in modules {
let compiler_emitted = module.source_path.is_none();
if !compiler_emitted {
// Developer-authored first-party server modules are a trusted tier.
// Static scanning is deliberately limited to compiler output, where
// the compiler controls the complete source and the check is sound.
continue;
}
if let Some(reason) = compiler_emitted_unsafe_reason(&module.source) {
return Err(format!(
"error[UNSAFE_SQL_SURFACE]: compiler-emitted server module {} {reason}; generated output must use only exact allowlisted literal driver imports",
module.emitted
));
}
}
Ok(())
}
/// Every place a Noxid declaration in this project names a field: endpoint
/// params, query, and body inputs, queue payload fields, and the fields of
/// every declared document type (which is where an endpoint result shape and
/// a `validatedRows` row shape both surface). These are exactly the seams
/// where a scoped column becomes compiler-visible; the developer's drizzle
/// schema is the trusted tier and is not scanned for column types.
fn scoped_column_sites(prepared: &PreparedProject) -> Vec<crate::data_security::ScopedColumnSite> {
let mut sites = Vec::new();
let programs = prepared
.endpoints
.values()
.map(|compiled| &compiled.program)
.chain(prepared.queues.values().map(|compiled| &compiled.program))
.chain(prepared.tasks.values().map(|compiled| &compiled.program))
.chain(prepared.compiled.values().map(|compiled| &compiled.program));
for program in programs {
for endpoint in &program.endpoints {
for field in endpoint
.params
.iter()
.chain(&endpoint.query)
.chain(&endpoint.body)
{
sites.push(crate::data_security::ScopedColumnSite {
owner: endpoint.id.to_string(),
where_: format!("endpoint `{}`", endpoint.name),
field: field.name.clone(),
ty: field.ty.to_string(),
});
}
}
for queue in &program.queues {
for field in &queue.payload {
sites.push(crate::data_security::ScopedColumnSite {
owner: queue.id.to_string(),
where_: format!("queue `{}` payload", queue.name),
field: field.name.clone(),
ty: field.ty.to_string(),
});
}
}
for definition in &program.types {
for field in &definition.fields {
sites.push(crate::data_security::ScopedColumnSite {
owner: definition.id.to_string(),
where_: format!("type `{}`", definition.name),
field: field.name.clone(),
ty: field.ty.to_string(),
});
}
}
}
sites.sort_by(|left, right| {
left.owner
.cmp(&right.owner)
.then_with(|| left.field.cmp(&right.field))
});
sites.dedup();
sites
}
fn project_data_policies(
root: &Path,
modules: &[ServerSourceModule],
) -> Result<Vec<crate::data_security::TablePolicy>, String> {
let schema = root.join("server/utils/schema.ts");
if !schema.is_file() {
return Ok(Vec::new());
}
let schema = fs::canonicalize(&schema)
.map_err(|error| format!("cannot resolve {}: {error}", schema.display()))?;
if !modules
.iter()
.any(|module| module.source_path.as_ref() == Some(&schema))
{
return Ok(Vec::new());
}
let source = fs::read_to_string(&schema)
.map_err(|error| format!("cannot read {}: {error}", schema.display()))?;
crate::data_security::discover_schema_policies(&schema, &source)
}
/// Mask comments and string contents while retaining source length, quote
/// delimiters, and line boundaries. Static declaration offsets can then be
/// found anywhere in the module without activating inert text.
fn mask_static_import_decoys(source: &str) -> String {
#[derive(Clone, Copy)]
enum State {
Code,
SingleQuoted,
DoubleQuoted,
Template,
Regex,
LineComment,
BlockComment,
}
let bytes = source.as_bytes();
let mut masked = bytes.to_vec();
let mut state = State::Code;
let mut regex_character_class = false;
let mut index = 0;
while index < bytes.len() {
match state {
State::Code => match bytes[index] {
b'\'' => state = State::SingleQuoted,
b'"' => state = State::DoubleQuoted,
b'`' => {
masked[index] = b' ';
state = State::Template;
}
b'/' if bytes.get(index + 1) == Some(&b'/') => {
masked[index] = b' ';
masked[index + 1] = b' ';
index += 1;
state = State::LineComment;
}
b'/' if bytes.get(index + 1) == Some(&b'*') => {
masked[index] = b' ';
masked[index + 1] = b' ';
index += 1;
state = State::BlockComment;
}
b'/' if starts_javascript_regex(bytes, index) => {
masked[index] = b' ';
regex_character_class = false;
state = State::Regex;
}
_ => {}
},
State::SingleQuoted => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
masked[index] = b' ';
index += 1;
if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
} else if bytes[index] == b'\'' {
state = State::Code;
} else if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
}
State::DoubleQuoted => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
masked[index] = b' ';
index += 1;
if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
} else if bytes[index] == b'"' {
state = State::Code;
} else if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
}
State::Template => {
if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
if bytes[index] == b'\\' && index + 1 < bytes.len() {
index += 1;
if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
} else if bytes[index] == b'`' {
state = State::Code;
}
}
State::Regex => {
if bytes[index] == b'\\' && index + 1 < bytes.len() {
masked[index] = b' ';
index += 1;
if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
} else if bytes[index] == b'[' {
masked[index] = b' ';
regex_character_class = true;
} else if bytes[index] == b']' {
masked[index] = b' ';
regex_character_class = false;
} else if bytes[index] == b'/' && !regex_character_class {
masked[index] = b' ';
state = State::Code;
} else if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
}
State::LineComment => {
if matches!(bytes[index], b'\n' | b'\r') {
state = State::Code;
} else {
masked[index] = b' ';
}
}
State::BlockComment => {
if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') {
masked[index] = b' ';
masked[index + 1] = b' ';
index += 1;
state = State::Code;
} else if !matches!(bytes[index], b'\n' | b'\r') {
masked[index] = b' ';
}
}
}
index += 1;
}
String::from_utf8(masked).expect("masking JavaScript ASCII syntax preserves UTF-8")
}
fn starts_javascript_regex(source: &[u8], slash: usize) -> bool {
let line_start = source[..slash]
.iter()
.rposition(|byte| matches!(byte, b'\n' | b'\r'))
.map_or(0, |newline| newline + 1);
if source[line_start..slash]
.iter()
.all(u8::is_ascii_whitespace)
{
return true;
}
let Some((_, previous)) = source[..slash]
.iter()
.copied()
.enumerate()
.rev()
.find(|(_, byte)| !byte.is_ascii_whitespace())
else {
return true;
};
matches!(
previous,
b'=' | b'('
| b'['
| b'{'
| b','
| b':'
| b';'
| b'!'
| b'?'
| b'&'
| b'|'
| b'+'
| b'-'
| b'*'
| b'%'
| b'^'
| b'~'
| b'<'
| b'>'
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct StaticImportSpecifier {
value: String,
start: usize,
end: usize,
}
/// Quoted JavaScript literals that occur in executable source, excluding
/// comments, regexes, and templates. The returned offsets cover the literal
/// contents, matching `quoted_module_specifier`.
fn scan_javascript_string_literals(source: &str) -> Vec<StaticImportSpecifier> {
let masked = mask_static_import_decoys(source);
let mut literals = Vec::new();
let mut index = 0;
while index < masked.len() {
if matches!(masked.as_bytes()[index], b'\'' | b'"')
&& let Some(literal) = quoted_module_specifier(&source[index..], index)
{
index = literal.end + 1;
literals.push(literal);
} else {
index += 1;
}
}
literals
}
fn decode_javascript_string_contents(contents: &str) -> Option<String> {
let mut decoded = String::new();
let mut characters = contents.chars();
while let Some(character) = characters.next() {
if character != '\\' {
decoded.push(character);
continue;
}
let escaped = characters.next()?;
match escaped {
'\'' | '"' | '\\' => decoded.push(escaped),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000c}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'v' => decoded.push('\u{000b}'),
'0' => decoded.push('\0'),
'\n' => {}
'\r' => {
if characters.as_str().starts_with('\n') {
characters.next();
}
}
'x' => {
let digits = characters.by_ref().take(2).collect::<String>();
if digits.len() != 2 {
return None;
}
decoded.push(char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?);
}
'u' => {
let value = if characters.as_str().starts_with('{') {
characters.next();
let mut digits = String::new();
let mut closed = false;
for digit in characters.by_ref() {
if digit == '}' {
closed = true;
break;
}
if digits.len() == 6 || !digit.is_ascii_hexdigit() {
return None;
}
digits.push(digit);
}
if !closed || digits.is_empty() {
return None;
}
u32::from_str_radix(&digits, 16).ok()?
} else {
let digits = characters.by_ref().take(4).collect::<String>();
if digits.len() != 4 {
return None;
}
u32::from_str_radix(&digits, 16).ok()?
};
decoded.push(char::from_u32(value)?);
}
// JavaScript permits identity escapes for non-escape characters.
other => decoded.push(other),
}
}
Some(decoded)
}
fn quoted_module_specifier(tail: &str, offset: usize) -> Option<StaticImportSpecifier> {
let quote = tail.as_bytes().first().copied()?;
if !matches!(quote, b'\'' | b'"') {
return None;
}
let mut escaped = false;
for (index, byte) in tail.as_bytes()[1..].iter().copied().enumerate() {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == quote {
let raw = &tail[1..index + 1];
return Some(StaticImportSpecifier {
value: decode_javascript_string_contents(raw).unwrap_or_else(|| raw.to_string()),
start: offset + 1,
end: offset + index + 1,
});
}
}
None
}
fn static_template_module_specifier(tail: &str) -> Option<String> {
if !tail.starts_with('`') {
return None;
}
let bytes = tail.as_bytes();
let mut escaped = false;
let mut index = 1;
while index < bytes.len() {
if escaped {
escaped = false;
} else if bytes[index] == b'\\' {
escaped = true;
} else if bytes[index] == b'`' {
return decode_javascript_string_contents(&tail[1..index]);
} else if bytes[index] == b'$' && bytes.get(index + 1) == Some(&b'{') {
return None;
}
index += 1;
}
None
}
fn specifier_after_from(clause: &str, offset: usize) -> Option<StaticImportSpecifier> {
let bytes = clause.as_bytes();
let mut quote = None;
let mut escaped = false;
let mut index = 0;
while index < bytes.len() {
if let Some(active_quote) = quote {
if escaped {
escaped = false;
} else if bytes[index] == b'\\' {
escaped = true;
} else if bytes[index] == active_quote {
quote = None;
}
index += 1;
continue;
}
if matches!(bytes[index], b'\'' | b'"') {
quote = Some(bytes[index]);
index += 1;
continue;
}
if bytes[index..].starts_with(b"from")
&& index > 0
&& bytes[index - 1].is_ascii_whitespace()
&& bytes.get(index + 4).is_some_and(u8::is_ascii_whitespace)
{
let tail = &clause[index + 4..];
let trimmed = tail.trim_start();
if let Some(specifier) =
quoted_module_specifier(trimmed, offset + index + 4 + tail.len() - trimmed.len())
{
return Some(specifier);
}
}
index += 1;
}
None
}
fn declaration_end(source: &str, start: usize) -> usize {
let bytes = source.as_bytes();
let mut quote = None;
let mut escaped = false;
let mut nesting = 0usize;
let mut line_start = start;
let mut index = start;
while index < bytes.len() {
if let Some(active_quote) = quote {
if escaped {
escaped = false;
} else if bytes[index] == b'\\' {
escaped = true;
} else if bytes[index] == active_quote {
quote = None;
}
index += 1;
continue;
}
match bytes[index] {
b'\'' | b'"' => quote = Some(bytes[index]),
b'{' | b'[' | b'(' => nesting += 1,
b'}' | b']' | b')' => nesting = nesting.saturating_sub(1),
b';' if nesting == 0 => return index + 1,
b'\n' | b'\r' if nesting == 0 => {
let line = source[line_start..index].trim_end();
let next = source[index + 1..].trim_start();
if !line.ends_with(',') && !line.ends_with("from") && !next.starts_with("from ") {
return index;
}
line_start = index + 1;
}
b'\n' | b'\r' => line_start = index + 1,
_ => {}
}
index += 1;
}
bytes.len()
}
fn keyword_rest<'a>(declaration: &'a str, keyword: &str) -> Option<&'a str> {
let rest = declaration.strip_prefix(keyword)?;
rest.chars()
.next()
.is_some_and(char::is_whitespace)
.then_some(rest)
}
fn is_javascript_identifier_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')
}
fn starts_top_level_declaration(source: &str, index: usize) -> bool {
let prefix = &source[..index];
let trimmed = prefix.trim_end_matches(char::is_whitespace);
if trimmed.is_empty() {
return true;
}
if prefix[trimmed.len()..].contains(['\n', '\r']) {
return true;
}
trimmed
.as_bytes()
.last()
.is_some_and(|byte| matches!(*byte, b';' | b'}'))
}
fn static_declaration_starts(source: &str) -> Vec<usize> {
let bytes = source.as_bytes();
let mut starts = Vec::new();
let mut braces = 0usize;
let mut brackets = 0usize;
let mut parentheses = 0usize;
let mut index = 0;
while index < bytes.len() {
if braces == 0 && brackets == 0 && parentheses == 0 {
for keyword in ["import", "export"] {
let keyword_bytes = keyword.as_bytes();
let end = index + keyword_bytes.len();
if bytes.get(index..end) == Some(keyword_bytes)
&& index
.checked_sub(1)
.and_then(|previous| bytes.get(previous))
.is_none_or(|byte| !is_javascript_identifier_byte(*byte))
&& bytes
.get(end)
.is_some_and(|byte| byte.is_ascii_whitespace())
&& starts_top_level_declaration(source, index)
{
starts.push(index);
break;
}
}
}
match bytes[index] {
b'{' => braces += 1,
b'}' => braces = braces.saturating_sub(1),
b'[' => brackets += 1,
b']' => brackets = brackets.saturating_sub(1),
b'(' => parentheses += 1,
b')' => parentheses = parentheses.saturating_sub(1),
_ => {}
}
index += 1;
}
starts
}
fn scan_static_import_specifiers(source: &str) -> Vec<StaticImportSpecifier> {
let masked = mask_static_import_decoys(source);
let mut specifiers = Vec::new();
for declaration_start in static_declaration_starts(&masked) {
let declaration_end = declaration_end(&masked, declaration_start);
let declaration = &source[declaration_start..declaration_end];
let specifier = if let Some(rest) = keyword_rest(declaration, "import") {
let rest = rest.trim_start();
let rest_offset = declaration_start + declaration.len() - rest.len();
if rest.starts_with(['"', '\'']) {
quoted_module_specifier(rest, rest_offset)
} else {
specifier_after_from(rest, rest_offset)
}
} else if let Some(rest) = keyword_rest(declaration, "export") {
let rest = rest.trim_start();
let reexport = rest.starts_with(['{', '*'])
|| rest
.strip_prefix("type")
.and_then(|rest| {
rest.chars()
.next()
.is_some_and(char::is_whitespace)
.then_some(rest)
})
.is_some_and(|rest| rest.trim_start().starts_with(['{', '*']));
reexport
.then(|| {
specifier_after_from(rest, declaration_start + declaration.len() - rest.len())
})
.flatten()
} else {
None
};
if let Some(specifier) = specifier {
specifiers.push(specifier);
}
}
specifiers
}
/// Static ESM specifiers in a server source: `import … from "x"`,
/// `export … from "x"`, and bare `import "x"`. Declarations may span lines,
/// while inert comments, templates, and ordinary string expressions cannot
/// activate the server import graph.
fn scan_import_specifiers(source: &str) -> Vec<String> {
scan_static_import_specifiers(source)
.into_iter()
.map(|specifier| specifier.value)
.collect()
}
/// Dynamic `import("x")` specifiers in executable source. These are not
/// admitted to the statically emitted server graph, but recognizing literal
/// specifiers lets compiler-owned virtual modules fail closed instead of
/// escaping into an unrunnable artifact.
fn scan_dynamic_import_specifiers(source: &str) -> Vec<String> {
let masked = mask_static_import_decoys(source);
let bytes = masked.as_bytes();
let mut specifiers = Vec::new();
let mut index = 0;
while index < bytes.len() {
let end = index + "import".len();
if bytes.get(index..end) != Some(b"import")
|| index
.checked_sub(1)
.and_then(|previous| bytes.get(previous))
.is_some_and(|byte| is_javascript_identifier_byte(*byte))
|| bytes
.get(end)
.is_some_and(|byte| is_javascript_identifier_byte(*byte))
{
index += 1;
continue;
}
let mut cursor = end;
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor += 1;
}
if bytes.get(cursor) != Some(&b'(') {
index = end;
continue;
}
cursor += 1;
let source_bytes = source.as_bytes();
loop {
while source_bytes
.get(cursor)
.is_some_and(u8::is_ascii_whitespace)
{
cursor += 1;
}
if source_bytes.get(cursor..cursor + 2) == Some(b"//") {
cursor = source_bytes[cursor + 2..]
.iter()
.position(|byte| matches!(byte, b'\n' | b'\r'))
.map_or(source_bytes.len(), |relative| cursor + 2 + relative + 1);
continue;
}
if source_bytes.get(cursor..cursor + 2) == Some(b"/*") {
let Some(relative) = source[cursor + 2..].find("*/") else {
index = source_bytes.len();
break;
};
cursor += 2 + relative + 2;
continue;
}
break;
}
if index == source_bytes.len() {
continue;
}
if matches!(bytes.get(cursor), Some(b'\'' | b'"'))
&& let Some(specifier) = quoted_module_specifier(&source[cursor..], cursor)
{
specifiers.push(specifier.value);
index = specifier.end + 1;
} else if source.as_bytes().get(cursor) == Some(&b'`')
&& let Some(specifier) = static_template_module_specifier(&source[cursor..])
{
specifiers.push(specifier);
index = cursor + 1;
} else {
index = cursor.saturating_add(1);
}
}
specifiers
}
fn replace_static_server_specifier(source: &str, specifier: &str, replacement: &str) -> String {
let mut rewritten = source.to_string();
for candidate in scan_static_import_specifiers(source).into_iter().rev() {
if candidate.value == specifier {
rewritten.replace_range(candidate.start..candidate.end, replacement);
}
}
rewritten
}
/// Resolve a relative server import against its importer. `./x.js` also
/// resolves to `./x.ts` (the TypeScript ESM convention keeps the `.js`
/// specifier while the file on disk is `.ts`). Non-relative specifiers
/// (npm packages, node builtins) return `None` and pass through untouched.
fn resolve_server_import(importer: &Path, specifier: &str) -> Result<Option<PathBuf>, String> {
if !specifier.starts_with("./") && !specifier.starts_with("../") {
return Ok(None);
}
let base = importer.parent().unwrap_or(Path::new("."));
let joined = base.join(specifier);
let mut candidates = vec![joined.clone()];
if specifier.ends_with(".js") {
candidates.push(joined.with_extension("ts"));
}
for candidate in candidates {
if candidate.is_file() {
return fs::canonicalize(&candidate)
.map(Some)
.map_err(|error| format!("cannot resolve {}: {error}", candidate.display()));
}
}
Err(format!(
"error[SERVER_IMPORT_UNRESOLVED]: {} imports `{specifier}`, which does not exist (server imports need explicit .js/.ts-backed paths)",
importer.display()
))
}
/// Flattened emitted name for a server module: the path relative to the
/// project root with separators folded to `__`; each `../` hop above the
/// root (e.g. repo-level `plugins/`) becomes an `up__` prefix.
fn flattened_server_module_name(root: &Path, path: &Path) -> String {
let mut prefix = String::new();
let mut base = root.to_path_buf();
loop {
if let Ok(relative) = path.strip_prefix(&base) {
let mut name = format!(
"{prefix}{}",
relative.display().to_string().replace(['/', '\\'], "__")
);
if let Some(stem) = name.strip_suffix(".ts") {
name = format!("{stem}.js");
}
return name;
}
match base.parent() {
Some(parent) => {
base = parent.to_path_buf();
prefix.push_str("up__");
}
None => {
return format!(
"external__{}",
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("module")
.replace(".ts", ".js")
);
}
}
}
}
fn relative_server_module_specifier(importer: &str, target: &str) -> String {
let importer_parts = importer.split('/').collect::<Vec<_>>();
let importer_directory = &importer_parts[..importer_parts.len().saturating_sub(1)];
let target_parts = target.split('/').collect::<Vec<_>>();
let shared = importer_directory
.iter()
.zip(&target_parts)
.take_while(|(left, right)| left == right)
.count();
let mut parts = vec![".."; importer_directory.len() - shared];
parts.extend_from_slice(&target_parts[shared..]);
let relative = parts.join("/");
if relative.starts_with("../") {
relative
} else {
format!("./{relative}")
}
}
fn register_server_module_output(
emitted_sources: &mut BTreeMap<String, PathBuf>,
emitted: &str,
source: &Path,
) -> Result<(), String> {
if let Some(previous) = emitted_sources.get(emitted)
&& previous != source
{
let (first, second) = if previous < source {
(previous.as_path(), source)
} else {
(source, previous.as_path())
};
return Err(format!(
"error[SERVER_MODULE_OUTPUT_COLLISION]: {} and {} both flatten to the emitted server module `server/{emitted}`; rename one source so its project-relative path does not collide when directory separators are encoded as `__`",
first.display(),
second.display(),
));
}
emitted_sources.insert(emitted.to_string(), source.to_path_buf());
Ok(())
}
fn collect_server_source_graph(
root: &Path,
entries: &[(PathBuf, String)],
storage_driver: ServerStorageDriver,
db_pool: u64,
requires_server_storage: bool,
blob_dir: Option<&str>,
) -> Result<Vec<ServerSourceModule>, String> {
let canonical_root = fs::canonicalize(root)
.map_err(|error| format!("cannot resolve {}: {error}", root.display()))?;
let canonical_server_dir = canonical_root.join("server");
let mut emitted_names: BTreeMap<PathBuf, String> = BTreeMap::new();
let mut emitted_sources: BTreeMap<String, PathBuf> = BTreeMap::new();
let mut queue = Vec::new();
for (entry, emitted) in entries {
let entry = fs::canonicalize(entry)
.map_err(|error| format!("cannot resolve {}: {error}", entry.display()))?;
if let Some(previous) = emitted_names.insert(entry.clone(), emitted.clone())
&& previous != *emitted
{
return Err(format!(
"error[SERVER_MODULE_ENTRY_CONFLICT]: {} is used as both `{previous}` and `{emitted}`",
entry.display(),
));
}
register_server_module_output(&mut emitted_sources, emitted, &entry)?;
queue.push(entry);
}
let mut modules = Vec::new();
let mut uses_noxid_server = requires_server_storage;
while let Some(path) = queue.pop() {
let mut source = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let importer_emitted = emitted_names[&path].clone();
if scan_dynamic_import_specifiers(&source)
.iter()
.any(|specifier| specifier == "noxid:server")
{
return Err(format!(
"error[NOXID_SERVER_DYNAMIC_IMPORT_UNSUPPORTED]: {} dynamically imports `noxid:server`, but compiler-owned server modules must be visible in the static server graph; replace `import(\"noxid:server\")` with a top-level static import",
path.display(),
));
}
for specifier in scan_import_specifiers(&source) {
if specifier == "noxid:server" {
if !path.starts_with(&canonical_server_dir) {
return Err(format!(
"error[NOXID_SERVER_IMPORT_CONTEXT_INVALID]: {} imports `noxid:server`, but the virtual server API is available only to modules under `server/` (host, utils, and middleware); move this server-only module under `server/utils/` or add a server-only middleware variant",
path.display(),
));
}
let replacement =
relative_server_module_specifier(&importer_emitted, "noxid-server.js");
source = replace_static_server_specifier(&source, "noxid:server", &replacement);
uses_noxid_server = true;
continue;
}
let Some(resolved) = resolve_server_import(&path, &specifier)? else {
continue;
};
if !emitted_names.contains_key(&resolved) {
let name = format!(
"modules/{}",
flattened_server_module_name(&canonical_root, &resolved)
);
register_server_module_output(&mut emitted_sources, &name, &resolved)?;
emitted_names.insert(resolved.clone(), name);
queue.push(resolved.clone());
}
let target = &emitted_names[&resolved];
let replacement = relative_server_module_specifier(&importer_emitted, target);
source = source
.replace(&format!("\"{specifier}\""), &format!("\"{replacement}\""))
.replace(&format!("'{specifier}'"), &format!("'{replacement}'"));
}
let typescript = path.extension().and_then(|value| value.to_str()) == Some("ts");
modules.push(ServerSourceModule {
emitted: importer_emitted,
source,
typescript,
source_path: Some(path),
});
}
if uses_noxid_server {
modules.push(ServerSourceModule {
emitted: "noxid-server.js".into(),
source: server_storage_runtime_javascript(storage_driver, db_pool, blob_dir),
typescript: false,
source_path: None,
});
}
modules.sort_by(|left, right| left.emitted.cmp(&right.emitted));
Ok(modules)
}
fn server_storage_runtime_javascript(
driver: ServerStorageDriver,
db_pool: u64,
blob_dir: Option<&str>,
) -> String {
let shared = r#"import { types as __utilTypes } from "node:util";
const __JSON_ERROR = "noxid:server storage values must be JSON data (null, booleans, finite numbers, strings, arrays, or plain objects)";
const __isProxy = __utilTypes.isProxy;
function __assertName(value, label, allowEmpty = false) {
if (typeof value !== "string" || (!allowEmpty && value.length === 0) || value.includes("\0")) {
throw new TypeError(`noxid:server storage ${label} must be ${allowEmpty ? "a" : "a non-empty"} string without NUL bytes`);
}
return value;
}
function __copyJson(value, stack = new Set()) {
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
throw new TypeError(__JSON_ERROR);
}
if (typeof value !== "object" || __isProxy(value) || stack.has(value)) throw new TypeError(__JSON_ERROR);
stack.add(value);
if (Array.isArray(value)) {
if (Object.getPrototypeOf(value) !== Array.prototype) throw new TypeError(__JSON_ERROR);
if (Object.getOwnPropertySymbols(value).length !== 0) throw new TypeError(__JSON_ERROR);
const descriptors = Object.getOwnPropertyDescriptors(value);
const length = descriptors.length?.value;
const copy = [];
Object.setPrototypeOf(copy, null);
for (let index = 0; index < length; index += 1) {
const descriptor = descriptors[index];
if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) throw new TypeError(__JSON_ERROR);
copy[index] = __copyJson(descriptor.value, stack);
}
if (Object.keys(descriptors).some((key) => key !== "length" && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= length))) {
throw new TypeError(__JSON_ERROR);
}
stack.delete(value);
return copy;
} else {
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) throw new TypeError(__JSON_ERROR);
if (Object.getOwnPropertySymbols(value).length !== 0) throw new TypeError(__JSON_ERROR);
const copy = Object.create(null);
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
if (!descriptor.enumerable || !("value" in descriptor)) throw new TypeError(__JSON_ERROR);
copy[key] = __copyJson(descriptor.value, stack);
}
stack.delete(value);
return copy;
}
}
function __serializeJson(value) {
return JSON.stringify(__copyJson(value));
}
function __cloneJson(value) {
return JSON.parse(__serializeJson(value));
}
function __expiresAt(options) {
if (options === undefined) return null;
if (options === null || typeof options !== "object" || __isProxy(options) || Array.isArray(options)) {
throw new TypeError("noxid:server storage set options must be an object with an optional ttl in seconds");
}
const prototype = Object.getPrototypeOf(options);
if (prototype !== Object.prototype && prototype !== null || Object.getOwnPropertySymbols(options).length !== 0) {
throw new TypeError("noxid:server storage set options must be a plain object");
}
const descriptors = Object.getOwnPropertyDescriptors(options);
const unknown = Object.keys(descriptors).filter((key) => key !== "ttl");
if (unknown.length !== 0) throw new TypeError(`noxid:server storage set option \`${unknown[0]}\` is unknown; use \`ttl\``);
const ttl = descriptors.ttl;
if (ttl === undefined) return null;
if (!ttl.enumerable || !("value" in ttl)) throw new TypeError("noxid:server storage ttl must be an ordinary data property");
if (ttl.value === undefined) return null;
if (typeof ttl.value !== "number" || !Number.isFinite(ttl.value) || ttl.value < 0) {
throw new TypeError("noxid:server storage ttl must be a finite, non-negative number of seconds");
}
const expiresAt = Date.now() + ttl.value * 1000;
if (!Number.isFinite(expiresAt)) throw new TypeError("noxid:server storage ttl exceeds the supported time range");
return expiresAt;
}
// `compareAndSet(key, expected, value, options)` is the one storage primitive
// that is a decision rather than a write: it replaces a record only while the
// record still carries the field values the caller last read. `expected` is a
// plain object of top-level field equalities, which every driver can express
// as one atomic operation.
function __assertExpected(expected) {
if (expected === null || typeof expected !== "object" || __isProxy(expected) || Array.isArray(expected)) {
throw new TypeError("noxid:server storage compareAndSet expects a plain object of field equalities");
}
const prototype = Object.getPrototypeOf(expected);
if (prototype !== Object.prototype && prototype !== null) throw new TypeError("noxid:server storage compareAndSet expects a plain object of field equalities");
for (const [key, value] of Object.entries(expected)) {
__assertName(key, "compareAndSet field name");
if (value !== null && typeof value !== "string" && typeof value !== "boolean" && !(typeof value === "number" && Number.isFinite(value))) {
throw new TypeError("noxid:server storage compareAndSet field equalities must be null, booleans, finite numbers, or strings");
}
}
return expected;
}
function __expectedMatch(current, expected) {
if (current === null || typeof current !== "object" || Array.isArray(current)) return false;
for (const [key, value] of Object.entries(expected)) {
if (!Object.hasOwn(current, key) || current[key] !== value) return false;
}
return true;
}
"#;
let implementation = match driver {
ServerStorageDriver::Memory => {
r#"
const __namespaces = new Map();
export const __noxidEndpointRateLimit = null;
export const __noxidEndpointIdempotencyPrepare = null;
export const __noxidEndpointIdempotencyComplete = null;
export const __noxidEndpointIdempotencyRelease = null;
export function storage(namespace) {
__assertName(namespace, "namespace");
let records = __namespaces.get(namespace);
if (!records) {
records = new Map();
__namespaces.set(namespace, records);
}
const read = (key) => {
const record = records.get(key);
if (!record) return null;
if (record.expiresAt !== null && record.expiresAt <= Date.now()) {
records.delete(key);
return null;
}
return __cloneJson(record.value);
};
return Object.freeze({
async get(key) {
__assertName(key, "key");
return read(key);
},
async set(key, value, options) {
__assertName(key, "key");
records.set(key, { value: __cloneJson(value), expiresAt: __expiresAt(options) });
},
async compareAndSet(key, expected, value, options) {
__assertName(key, "key");
__assertExpected(expected);
const copied = __cloneJson(value);
const expiresAt = __expiresAt(options);
// No `await` separates the read from the write, so one JavaScript
// process cannot interleave a second caller between them.
if (!__expectedMatch(read(key), expected)) return false;
records.set(key, { value: copied, expiresAt });
return true;
},
async delete(key) {
__assertName(key, "key");
return records.delete(key);
},
async list(prefix = "") {
__assertName(prefix, "list prefix", true);
const keys = [];
for (const [key, record] of records) {
if (record.expiresAt !== null && record.expiresAt <= Date.now()) {
records.delete(key);
} else if (key.startsWith(prefix)) {
keys.push(key);
}
}
return Object.freeze(keys.sort());
},
});
}
"#
}
ServerStorageDriver::Fs => {
r#"
import { constants } from "node:fs";
import { lstat, mkdir, open, readdir, realpath, rename, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
const __root = globalThis.process?.env?.NOXID_STORAGE_DIR || path.join(globalThis.process?.cwd?.() || ".", ".nox", "storage");
let __temporary = 0;
export const __noxidEndpointRateLimit = null;
export const __noxidEndpointIdempotencyPrepare = null;
export const __noxidEndpointIdempotencyComplete = null;
export const __noxidEndpointIdempotencyRelease = null;
function __encode(value) {
let encoded = "";
for (let index = 0; index < value.length;) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
encoded += encodeURIComponent(value.slice(index, index + 2));
index += 2;
continue;
}
}
if (unit >= 0xd800 && unit <= 0xdfff) {
encoded += `%u${unit.toString(16).toUpperCase().padStart(4, "0")}`;
} else {
encoded += encodeURIComponent(value[index]);
}
index += 1;
}
return encoded.replace(/\./g, "%2E");
}
function __decode(value) {
let decoded = "";
let segmentStart = 0;
for (let index = 0; index < value.length;) {
if (value[index] === "%" && value[index + 1] === "u" && /^[0-9A-F]{4}$/.test(value.slice(index + 2, index + 6))) {
decoded += decodeURIComponent(value.slice(segmentStart, index));
decoded += String.fromCharCode(Number.parseInt(value.slice(index + 2, index + 6), 16));
index += 6;
segmentStart = index;
} else {
index += 1;
}
}
return decoded + decodeURIComponent(value.slice(segmentStart));
}
async function __missing(error) {
if (error && error.code === "ENOENT") return true;
throw error;
}
export function storage(namespace) {
__assertName(namespace, "namespace");
const directory = path.join(__root, __encode(namespace));
const boundaryError = () => new Error(`noxid:server storage namespace \`${namespace}\` must resolve to an ordinary directory directly below NOXID_STORAGE_DIR; replace namespace symlinks with a directory inside the storage root`);
const recordBoundaryError = (key) => Object.assign(
new Error(`noxid:server storage record for namespace \`${namespace}\`, key \`${key}\` must be an ordinary file inside its namespace with exactly one filesystem link; remove linked or symbolic records and write the value through storage.set`),
{ code: "SERVER_STORAGE_RECORD_BOUNDARY" },
);
// Atomic replacement can detach an inode after lstat/open, making its saved
// metadata report zero links. It then has no outside alias; only nlink > 1
// proves the inode is still shared through a hard link.
const recordInodeIsShared = (metadata) => metadata.nlink > 1;
const recordFilenameError = (name) => new Error(`noxid:server storage record filename \`${name}\` in namespace \`${namespace}\` is not canonical; remove the tampered record and write it through storage.set`);
const resolveDirectory = async (create) => {
let canonicalRoot;
try {
if (create) await mkdir(__root, { recursive: true });
canonicalRoot = await realpath(__root);
} catch (error) {
if (!create && await __missing(error)) return null;
throw error;
}
if (create) {
try { await mkdir(directory); }
catch (error) { if (!error || error.code !== "EEXIST") throw error; }
}
let metadata;
try { metadata = await lstat(directory); }
catch (error) {
if (!create && await __missing(error)) return null;
throw error;
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw boundaryError();
const canonicalDirectory = await realpath(directory);
if (path.dirname(canonicalDirectory) !== canonicalRoot) throw boundaryError();
return canonicalDirectory;
};
const filename = (activeDirectory, key) => path.join(activeDirectory, `${__encode(key)}.json`);
const recordMetadata = async (target, key, allowSymbolicLink = false) => {
let metadata;
try { metadata = await lstat(target); }
catch (error) {
if (await __missing(error)) return null;
}
if (allowSymbolicLink && metadata.isSymbolicLink()) return metadata;
if (!metadata.isFile() || metadata.isSymbolicLink() || recordInodeIsShared(metadata)) throw recordBoundaryError(key);
return metadata;
};
const readRecord = async (activeDirectory, key) => {
const target = filename(activeDirectory, key);
if (await recordMetadata(target, key) === null) return null;
if (!Number.isInteger(constants.O_NOFOLLOW)) throw recordBoundaryError(key);
let handle;
try { handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (error) {
if (await __missing(error)) return null;
throw recordBoundaryError(key);
}
let encoded;
try {
const openedMetadata = await handle.stat();
if (!openedMetadata.isFile() || recordInodeIsShared(openedMetadata)) throw recordBoundaryError(key);
encoded = await handle.readFile("utf8");
} finally {
await handle.close();
}
let record;
try {
record = JSON.parse(encoded);
} catch (error) {
throw new Error(`noxid:server storage record for namespace \`${namespace}\`, key \`${key}\` is not valid JSON`, { cause: error });
}
if (!record || record.version !== 1 || !("value" in record) || !(record.expiresAt === null || (typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)))) {
throw new Error(`noxid:server storage record for namespace \`${namespace}\`, key \`${key}\` has an unsupported envelope`);
}
if (record.expiresAt !== null && record.expiresAt <= Date.now()) {
try {
await unlink(filename(activeDirectory, key));
} catch (error) {
await __missing(error);
}
return null;
}
return record;
};
return Object.freeze({
async get(key) {
__assertName(key, "key");
const activeDirectory = await resolveDirectory(false);
if (activeDirectory === null) return null;
const record = await readRecord(activeDirectory, key);
return record === null ? null : __cloneJson(record.value);
},
async set(key, value, options) {
__assertName(key, "key");
const serializedValue = __serializeJson(value);
const expiresAt = __expiresAt(options);
const record = `{"version":1,"expiresAt":${expiresAt === null ? "null" : String(expiresAt)},"value":${serializedValue}}`;
const activeDirectory = await resolveDirectory(true);
const target = filename(activeDirectory, key);
await recordMetadata(target, key);
const temporary = `${target}.${globalThis.process?.pid || "runtime"}.${__temporary += 1}.tmp`;
try {
await writeFile(temporary, record, { encoding: "utf8", mode: 0o600 });
await rename(temporary, target);
} catch (error) {
try { await unlink(temporary); } catch (cleanupError) { await __missing(cleanupError); }
throw error;
}
},
async compareAndSet(key, expected, value, options) {
__assertName(key, "key");
__assertExpected(expected);
const serializedValue = __serializeJson(value);
const expiresAt = __expiresAt(options);
const next = `{"version":1,"expiresAt":${expiresAt === null ? "null" : String(expiresAt)},"value":${serializedValue}}`;
const activeDirectory = await resolveDirectory(false);
if (activeDirectory === null) return false;
const current = await readRecord(activeDirectory, key);
if (current === null || !__expectedMatch(current.value, expected)) return false;
// The claim *is* the rename. `rename` moves the record away atomically,
// so of two callers that both read the same record exactly one finds a
// source to move; the other gets ENOENT and loses. The winner then
// installs the next record under the real name and drops the claim.
const target = filename(activeDirectory, key);
const claim = `${target}.${globalThis.process?.pid || "runtime"}.${__temporary += 1}.claim`;
try { await rename(target, claim); }
catch (error) { if (await __missing(error)) return false; throw error; }
const temporary = `${claim}.tmp`;
try {
await writeFile(temporary, next, { encoding: "utf8", mode: 0o600 });
await rename(temporary, target);
} catch (error) {
try { await unlink(temporary); } catch (cleanupError) { await __missing(cleanupError); }
try { await rename(claim, target); } catch (restoreError) { await __missing(restoreError); }
throw error;
}
try { await unlink(claim); } catch (error) { await __missing(error); }
return true;
},
async delete(key) {
__assertName(key, "key");
const activeDirectory = await resolveDirectory(false);
if (activeDirectory === null) return false;
const target = filename(activeDirectory, key);
if (await recordMetadata(target, key, true) === null) return false;
try {
await unlink(target);
return true;
} catch (error) {
if (await __missing(error)) return false;
}
},
async list(prefix = "") {
__assertName(prefix, "list prefix", true);
const activeDirectory = await resolveDirectory(false);
if (activeDirectory === null) return Object.freeze([]);
let entries;
try {
entries = await readdir(activeDirectory, { withFileTypes: true });
} catch (error) {
if (await __missing(error)) return Object.freeze([]);
}
const keys = [];
for (const entry of entries) {
if (!entry.name.endsWith(".json")) continue;
const encodedKey = entry.name.slice(0, -5);
let key;
try { key = __decode(encodedKey); }
catch { throw recordFilenameError(entry.name); }
if (key.length === 0 || key.includes("\0") || __encode(key) !== encodedKey) throw recordFilenameError(entry.name);
if (!entry.isFile()) throw recordBoundaryError(key);
if (key.startsWith(prefix) && await readRecord(activeDirectory, key) !== null) keys.push(key);
}
return Object.freeze(keys.sort());
},
});
}
"#
}
ServerStorageDriver::Postgres => {
r#"
let __storageDatabasePromise;
function __storageDatabaseUrl() {
const node = globalThis.process?.env?.DATABASE_URL;
if (typeof node === "string" && node.length > 0) return node;
try {
const deno = globalThis.Deno?.env?.get?.("DATABASE_URL");
if (typeof deno === "string" && deno.length > 0) return deno;
} catch {}
return null;
}
async function __storageSql() {
if (__storageDatabasePromise !== undefined) return __storageDatabasePromise;
__storageDatabasePromise = (async () => {
const url = __storageDatabaseUrl();
if (url === null) throw Object.assign(new Error("DATABASE_URL is required for postgres server storage"), { code: "SERVER_STORAGE_DATABASE_URL_REQUIRED" });
let postgres;
try { postgres = (await import("postgres")).default; }
catch { throw Object.assign(new Error("the admitted postgres driver is unavailable"), { code: "SERVER_STORAGE_POSTGRES_DRIVER_MISSING" }); }
const sql = postgres(url, { max: __NOXID_DB_POOL__ });
await sql.begin(async (transaction) => {
await transaction`SELECT pg_advisory_xact_lock(6219286682124112975)`;
await transaction.unsafe(`CREATE TABLE IF NOT EXISTS _noxid_storage (
namespace text NOT NULL,
key text NOT NULL,
value jsonb NOT NULL,
expires_at timestamptz,
PRIMARY KEY (namespace, key)
)`);
await transaction.unsafe("CREATE INDEX IF NOT EXISTS _noxid_storage_expiry ON _noxid_storage (expires_at) WHERE expires_at IS NOT NULL");
});
return sql;
})();
try { return await __storageDatabasePromise; }
catch (error) { __storageDatabasePromise = undefined; throw error; }
}
const __STORAGE_ESCAPED_NAME = "\u001fnoxid:utf16:";
function __storageName(value) {
let escape = value.startsWith(__STORAGE_ESCAPED_NAME);
for (let index = 0; !escape && index < value.length; index += 1) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
index += 1;
continue;
}
}
escape = unit >= 0xd800 && unit <= 0xdfff;
}
if (!escape) return value;
let encoded = __STORAGE_ESCAPED_NAME;
for (let index = 0; index < value.length; index += 1) encoded += value.charCodeAt(index).toString(16).padStart(4, "0");
return encoded;
}
function __storageLogicalName(value) {
if (!value.startsWith(__STORAGE_ESCAPED_NAME)) return value;
const encoded = value.slice(__STORAGE_ESCAPED_NAME.length);
if (encoded.length % 4 !== 0 || !/^[0-9a-f]*$/.test(encoded)) throw Object.assign(new Error("postgres storage contains a malformed escaped name"), { code: "SERVER_STORAGE_NAME_DRIFT" });
let decoded = "";
for (let index = 0; index < encoded.length; index += 4) decoded += String.fromCharCode(Number.parseInt(encoded.slice(index, index + 4), 16));
return decoded;
}
async function __sweepExpired(sql) {
await sql`DELETE FROM _noxid_storage WHERE expires_at IS NOT NULL AND expires_at <= now()`;
}
const __ENDPOINT_RATE_NAMESPACE = __storageName("noxid:endpoint-rate");
const __ENDPOINT_IDEMPOTENCY_NAMESPACE = __storageName("noxid:endpoint-idempotency");
const __ENDPOINT_IDEMPOTENCY_CLAIM = "\u001fnoxid:idempotency-claim";
const __ENDPOINT_RATE_MAX_ENTRIES = 10_000;
const __ENDPOINT_IDEMPOTENCY_MAX_ENTRIES = 1024;
function __validEndpointRateBucket(bucket, now, windowMs) {
return bucket !== null && typeof bucket === "object"
&& typeof bucket.started === "number" && Number.isFinite(bucket.started) && bucket.started >= 0 && bucket.started <= now
&& typeof bucket.count === "number" && Number.isSafeInteger(bucket.count) && bucket.count >= 0
&& bucket.windowMs === windowMs;
}
export async function __noxidEndpointRateLimit(key, requests, windowMs) {
__assertName(key, "key");
if (!Number.isSafeInteger(requests) || requests <= 0 || !Number.isSafeInteger(windowMs) || windowMs <= 0) {
throw Object.assign(new TypeError("compiler-owned endpoint rate policy is invalid"), { code: "ENDPOINT_RATE_POLICY_INVALID" });
}
const sql = await __storageSql();
const storedKey = __storageName(key);
return sql.begin(async (transaction) => {
await transaction`SELECT pg_advisory_xact_lock(hashtextextended(${`${__ENDPOINT_RATE_NAMESPACE}\n${storedKey}`}, 0))`;
const now = Date.now();
let rows = await transaction`SELECT value FROM _noxid_storage WHERE namespace = ${__ENDPOINT_RATE_NAMESPACE} AND key = ${storedKey}`;
if (rows.length === 0) {
await transaction`SELECT pg_advisory_xact_lock(hashtextextended(${__ENDPOINT_RATE_NAMESPACE}, 0))`;
await transaction`DELETE FROM _noxid_storage WHERE namespace = ${__ENDPOINT_RATE_NAMESPACE} AND expires_at IS NOT NULL AND expires_at <= now()`;
rows = await transaction`SELECT value FROM _noxid_storage WHERE namespace = ${__ENDPOINT_RATE_NAMESPACE} AND key = ${storedKey}`;
if (rows.length === 0) {
const counts = await transaction`SELECT count(*)::int AS count FROM _noxid_storage WHERE namespace = ${__ENDPOINT_RATE_NAMESPACE}`;
if (counts[0].count >= __ENDPOINT_RATE_MAX_ENTRIES) {
await transaction`DELETE FROM _noxid_storage WHERE ctid IN (
SELECT ctid FROM _noxid_storage WHERE namespace = ${__ENDPOINT_RATE_NAMESPACE}
ORDER BY expires_at ASC NULLS FIRST, key ASC LIMIT 1
)`;
}
}
}
let bucket = rows.length === 0 ? null : rows[0].value;
if (!__validEndpointRateBucket(bucket, now, windowMs) || bucket.count > requests || now - bucket.started >= windowMs) {
bucket = { started: now, count: 0, windowMs };
}
if (bucket.count >= requests) return Math.max(1, Math.ceil((bucket.started + windowMs - now) / 1000));
const updated = { started: bucket.started, count: bucket.count + 1, windowMs };
await transaction`INSERT INTO _noxid_storage (namespace, key, value, expires_at)
VALUES (${__ENDPOINT_RATE_NAMESPACE}, ${storedKey}, (${transaction.json([updated])} -> 0), ${new Date(bucket.started + windowMs)})
ON CONFLICT (namespace, key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`;
return null;
});
}
function __endpointIdempotencyClaim(value) {
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
const claim = value[__ENDPOINT_IDEMPOTENCY_CLAIM];
return typeof claim === "string" && claim.length > 0 ? claim : null;
}
export async function __noxidEndpointIdempotencyPrepare(key, claim, leaseMs) {
__assertName(key, "key");
__assertName(claim, "idempotency claim");
if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0) throw new TypeError("compiler-owned idempotency lease must be a positive safe integer");
const sql = await __storageSql();
const storedKey = __storageName(key);
return sql.begin(async (transaction) => {
await transaction`SELECT pg_advisory_xact_lock(hashtextextended(${__ENDPOINT_IDEMPOTENCY_NAMESPACE}, 0))`;
await transaction`DELETE FROM _noxid_storage WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE} AND expires_at IS NOT NULL AND expires_at <= now()`;
const rows = await transaction`SELECT value FROM _noxid_storage WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE} AND key = ${storedKey}`;
if (rows.length !== 0) {
const owner = __endpointIdempotencyClaim(rows[0].value);
if (owner === claim) {
await transaction`UPDATE _noxid_storage SET expires_at = ${new Date(Date.now() + leaseMs)} WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE} AND key = ${storedKey}`;
return Object.freeze({ state: "owner" });
}
return owner === null
? Object.freeze({ state: "stored", value: __cloneJson(rows[0].value) })
: Object.freeze({ state: "pending" });
}
const counts = await transaction`SELECT count(*)::int AS count FROM _noxid_storage WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE}`;
if (counts[0].count >= __ENDPOINT_IDEMPOTENCY_MAX_ENTRIES) {
const evicted = await transaction`DELETE FROM _noxid_storage WHERE ctid IN (
SELECT ctid FROM _noxid_storage
WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE} AND value -> ${__ENDPOINT_IDEMPOTENCY_CLAIM} IS NULL
ORDER BY expires_at ASC NULLS FIRST, key ASC LIMIT 1
) RETURNING key`;
if (evicted.length === 0) throw Object.assign(new Error("endpoint idempotency capacity is occupied by active claims"), { code: "ENDPOINT_IDEMPOTENCY_CAPACITY" });
}
const marker = { [__ENDPOINT_IDEMPOTENCY_CLAIM]: claim };
await transaction`INSERT INTO _noxid_storage (namespace, key, value, expires_at)
VALUES (${__ENDPOINT_IDEMPOTENCY_NAMESPACE}, ${storedKey}, (${transaction.json([marker])} -> 0), ${new Date(Date.now() + leaseMs)})`;
return Object.freeze({ state: "owner" });
});
}
export async function __noxidEndpointIdempotencyComplete(key, claim, value, ttlSeconds) {
__assertName(key, "key");
__assertName(claim, "idempotency claim");
const copied = __cloneJson(value);
const expiresAt = __expiresAt({ ttl: ttlSeconds });
const sql = await __storageSql();
const rows = await sql`UPDATE _noxid_storage
SET value = (${sql.json([copied])} -> 0), expires_at = ${new Date(expiresAt)}
WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE} AND key = ${__storageName(key)}
AND value ->> ${__ENDPOINT_IDEMPOTENCY_CLAIM} = ${claim}
RETURNING key`;
if (rows.length !== 1) throw Object.assign(new Error("idempotency claim ownership was lost before completion"), { code: "ENDPOINT_IDEMPOTENCY_CLAIM_LOST" });
}
export async function __noxidEndpointIdempotencyRelease(key, claim) {
__assertName(key, "key");
__assertName(claim, "idempotency claim");
const sql = await __storageSql();
await sql`DELETE FROM _noxid_storage
WHERE namespace = ${__ENDPOINT_IDEMPOTENCY_NAMESPACE} AND key = ${__storageName(key)}
AND value ->> ${__ENDPOINT_IDEMPOTENCY_CLAIM} = ${claim}`;
}
export function storage(namespace) {
__assertName(namespace, "namespace");
const storedNamespace = __storageName(namespace);
return Object.freeze({
async get(key) {
__assertName(key, "key");
const sql = await __storageSql();
await __sweepExpired(sql);
const rows = await sql`SELECT value FROM _noxid_storage WHERE namespace = ${storedNamespace} AND key = ${__storageName(key)} AND (expires_at IS NULL OR expires_at > now())`;
return rows.length === 0 ? null : __cloneJson(rows[0].value);
},
async set(key, value, options) {
__assertName(key, "key");
const copied = __cloneJson(value);
const expiresAt = __expiresAt(options);
const sql = await __storageSql();
await __sweepExpired(sql);
await sql`INSERT INTO _noxid_storage (namespace, key, value, expires_at)
VALUES (${storedNamespace}, ${__storageName(key)}, (${sql.json([copied])} -> 0), ${expiresAt === null ? null : new Date(expiresAt)})
ON CONFLICT (namespace, key) DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`;
},
async compareAndSet(key, expected, value, options) {
__assertName(key, "key");
const guard = __cloneJson(__assertExpected(expected));
const copied = __cloneJson(value);
const expiresAt = __expiresAt(options);
const sql = await __storageSql();
await __sweepExpired(sql);
// One statement is the whole compare-and-swap: the row lock serializes
// concurrent updaters and the loser re-evaluates this WHERE against the
// committed row, matches nothing, and returns no key.
const rows = await sql`UPDATE _noxid_storage
SET value = (${sql.json([copied])} -> 0), expires_at = ${expiresAt === null ? null : new Date(expiresAt)}
WHERE namespace = ${storedNamespace} AND key = ${__storageName(key)}
AND (expires_at IS NULL OR expires_at > now())
AND value @> (${sql.json([guard])} -> 0)
RETURNING key`;
return rows.length !== 0;
},
async delete(key) {
__assertName(key, "key");
const sql = await __storageSql();
await __sweepExpired(sql);
const rows = await sql`DELETE FROM _noxid_storage WHERE namespace = ${storedNamespace} AND key = ${__storageName(key)} RETURNING key`;
return rows.length !== 0;
},
async list(prefix = "") {
__assertName(prefix, "list prefix", true);
const sql = await __storageSql();
await __sweepExpired(sql);
const rows = await sql`SELECT key FROM _noxid_storage WHERE namespace = ${storedNamespace} AND (expires_at IS NULL OR expires_at > now())`;
const keys = rows.map((row) => __storageLogicalName(row.key)).filter((key) => key.startsWith(prefix));
return Object.freeze(keys.sort());
},
});
}
"#
}
ServerStorageDriver::Redis => include_str!("redis_storage_runtime.js"),
};
let implementation = implementation.replace("__NOXID_DB_POOL__", &db_pool.to_string());
let queue_bridge = r#"
export async function enqueue(queue, payload, options) {
const implementation = globalThis.__NOXID_QUEUE_ENQUEUE__;
if (typeof implementation !== "function") {
throw Object.assign(new Error("noxid:server enqueue is unavailable because this build declares no durable queue runtime"), { code: "QUEUE_RUNTIME_UNAVAILABLE" });
}
return implementation(queue, payload, options);
}
"#;
// The WO-30 model bridge. `models.<Name>` and `types.<Name>` are inert
// name tokens so a host module can reference them at import time, before
// the generated handler has installed the runtime; the call functions
// resolve the runtime at call time and fail closed when a build declares
// no models.
let model_bridge = r#"
function __noxidModelRuntime() {
const runtime = globalThis.__NOXID_MODEL_RUNTIME__;
if (runtime === undefined || runtime === null) {
throw Object.assign(new Error("noxid:server model calls are unavailable because this build declares no model; declare one in a direct server/models/<name>.nox file"), { code: "MODEL_RUNTIME_UNAVAILABLE" });
}
return runtime;
}
function __noxidNameHandle(kind) {
return new Proxy(Object.create(null), {
has() { return true; },
getOwnPropertyDescriptor() { return { configurable: true, enumerable: true }; },
get(_target, property) {
if (typeof property !== "string") return undefined;
return Object.freeze({ [kind]: property });
},
});
}
export const models = __noxidNameHandle("model");
export const types = __noxidNameHandle("type");
export async function generateText(model, prompt, options) {
return __noxidModelRuntime().generateText(model, prompt, options);
}
export async function generateObject(model, prompt, type, options) {
return __noxidModelRuntime().generateObject(model, prompt, type, options);
}
export function streamText(model, prompt, options) {
return __noxidModelRuntime().streamText(model, prompt, options);
}
"#;
let blob_runtime = blob_dir.map_or_else(String::new, blob_storage_runtime_javascript);
format!("{shared}{implementation}{blob_runtime}{queue_bridge}{model_bridge}")
}
fn blob_storage_runtime_javascript(blob_dir: &str) -> String {
BLOB_STORAGE_RUNTIME.replace("__NOXID_BLOB_DIR__", &json_escape(blob_dir))
}
const BLOB_STORAGE_RUNTIME: &str = r#"
// WO-44 blob-driver seam. The first admitted driver is filesystem-backed;
// an S3-compatible driver can implement this staging/access contract without
// changing FileRef or the generated multipart boundary.
import { createHash as __noxidBlobHash } from "node:crypto";
import { createReadStream as __noxidBlobReadStream } from "node:fs";
import { lstat as __noxidBlobLstat, link as __noxidBlobLink, mkdir as __noxidBlobMkdir, open as __noxidBlobOpen, readFile as __noxidBlobReadFile, realpath as __noxidBlobRealpath, unlink as __noxidBlobUnlink } from "node:fs/promises";
import __noxidBlobPath from "node:path";
import { Readable as __noxidBlobReadable } from "node:stream";
const __noxidBlobRoot = __noxidBlobPath.resolve(globalThis.process?.cwd?.() || ".", "__NOXID_BLOB_DIR__");
let __noxidBlobTemporary = 0;
function __noxidBlobName(value, label) {
__assertName(value, label);
let encoded = "";
for (let index = 0; index < value.length;) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
encoded += encodeURIComponent(value.slice(index, index + 2));
index += 2;
continue;
}
}
encoded += unit >= 0xd800 && unit <= 0xdfff
? `%u${unit.toString(16).toUpperCase().padStart(4, "0")}`
: encodeURIComponent(value[index]);
index += 1;
}
return encoded.replace(/\./g, "%2E");
}
async function __noxidBlobDirectory(relative) {
await __noxidBlobMkdir(__noxidBlobRoot, { recursive: true });
const rootMetadata = await __noxidBlobLstat(__noxidBlobRoot);
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
throw Object.assign(new Error("[server] blob_dir must resolve to an ordinary directory"), { code: "BLOB_STORAGE_BOUNDARY" });
}
const canonicalRoot = await __noxidBlobRealpath(__noxidBlobRoot);
const directory = __noxidBlobPath.join(canonicalRoot, relative);
try { await __noxidBlobMkdir(directory); }
catch (error) { if (!error || error.code !== "EEXIST") throw error; }
const metadata = await __noxidBlobLstat(directory);
if (!metadata.isDirectory() || metadata.isSymbolicLink() || __noxidBlobPath.dirname(await __noxidBlobRealpath(directory)) !== canonicalRoot) {
throw Object.assign(new Error("blob namespace must be an ordinary directory directly below [server] blob_dir"), { code: "BLOB_STORAGE_BOUNDARY" });
}
return directory;
}
async function __noxidBlobOrdinaryFile(target, expectedSize) {
const metadata = await __noxidBlobLstat(target);
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size !== expectedSize) {
throw Object.assign(new Error("blob object does not match its content-addressed storage contract"), { code: "BLOB_STORAGE_BOUNDARY" });
}
}
export async function __noxidCreateUploadSink(maxSizeBytes) {
if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) throw new TypeError("upload sink requires the declared byte cap");
const incoming = await __noxidBlobDirectory(".incoming");
const temporary = __noxidBlobPath.join(incoming, `${globalThis.process?.pid || "runtime"}-${Date.now()}-${__noxidBlobTemporary += 1}.upload`);
const handle = await __noxidBlobOpen(temporary, "wx", 0o600);
const hash = __noxidBlobHash("sha256");
let size = 0;
let closed = false;
let activePath = temporary;
let persistent = false;
const close = async () => {
if (!closed) { closed = true; await handle.close(); }
};
const dispose = async () => {
await close();
if (persistent) return;
try { await __noxidBlobUnlink(activePath); } catch (error) { if (!error || error.code !== "ENOENT") throw error; }
};
return Object.freeze({
async write(chunk) {
if (closed || !(chunk instanceof Uint8Array)) throw new TypeError("upload sink accepts Uint8Array chunks before finish");
const nextSize = size + chunk.byteLength;
if (!Number.isSafeInteger(nextSize) || nextSize > maxSizeBytes) throw Object.assign(new Error("upload sink received bytes beyond the declared cap"), { code: "FILE_SIZE_LIMIT_EXCEEDED" });
let offset = 0;
while (offset < chunk.byteLength) {
const result = await handle.write(chunk, offset, chunk.byteLength - offset);
if (!result || result.bytesWritten <= 0) throw new Error("upload staging write made no progress");
offset += result.bytesWritten;
}
hash.update(chunk);
size = nextSize;
},
async finish() {
await close();
const sha256 = hash.digest("hex");
const access = Object.freeze({
stream() {
if (activePath === null) throw Object.assign(new Error("FileRef staging bytes are no longer available"), { code: "FILE_REF_RELEASED" });
return __noxidBlobReadable.toWeb(__noxidBlobReadStream(activePath));
},
async bytes() {
if (activePath === null) throw Object.assign(new Error("FileRef staging bytes are no longer available"), { code: "FILE_REF_RELEASED" });
await __noxidBlobOrdinaryFile(activePath, size);
const bytes = await __noxidBlobReadFile(activePath);
if (bytes.byteLength > maxSizeBytes) throw Object.assign(new Error("FileRef bytes exceed the declared cap"), { code: "FILE_SIZE_LIMIT_EXCEEDED" });
return new Uint8Array(bytes);
},
async store(namespace) {
if (activePath === null) throw Object.assign(new Error("FileRef staging bytes are no longer available"), { code: "FILE_REF_RELEASED" });
const directory = await __noxidBlobDirectory(__noxidBlobName(namespace, "blob namespace"));
const target = __noxidBlobPath.join(directory, sha256);
if (activePath !== target) {
try {
await __noxidBlobLink(activePath, target);
} catch (error) {
if (!error || error.code !== "EEXIST") throw error;
await __noxidBlobOrdinaryFile(target, size);
}
if (!persistent) await __noxidBlobUnlink(activePath);
activePath = target;
}
persistent = true;
return Object.freeze({ namespace, key: sha256 });
},
async dispose() { await dispose(); activePath = persistent ? activePath : null; },
});
return Object.freeze({ sha256, size, access });
},
abort: dispose,
});
}
"#;
fn collect_server_sources(
root: &Path,
entry: &Path,
storage_driver: ServerStorageDriver,
) -> Result<Vec<ServerSourceModule>, String> {
collect_server_source_graph(
root,
&[(entry.to_path_buf(), "host.js".into())],
storage_driver,
10,
false,
None,
)
}
/// Transpile a TypeScript server source through the vetted `typescript`
/// package (type stripping via transpileModule — editor/CI carry type
/// CHECKING; the build only erases annotations). Opt-in: pure-JavaScript
/// server sources never shell out.
fn transpile_typescript(root: &Path, display_name: &str, source: &str) -> Result<String, String> {
let mut current = Some(root);
let mut compiler = None;
while let Some(dir) = current {
let candidate = dir.join("node_modules/typescript/lib/typescript.js");
if candidate.is_file() {
compiler = Some(candidate);
break;
}
current = dir.parent();
}
// Projects outside the toolchain tree (tests, scratch apps) may use a
// vetted TypeScript install shipped alongside the executable.
let compiler = compiler.or_else(|| {
std::env::current_exe()
.ok()?
.parent()?
.ancestors()
.map(|directory| directory.join("node_modules/typescript/lib/typescript.js"))
.find(|candidate| candidate.is_file())
});
let Some(compiler) = compiler else {
return Err(format!(
"error[SERVER_TYPESCRIPT_UNAVAILABLE]: {display_name} is TypeScript but node_modules/typescript is not installed above the project"
));
};
let script = r#"const [compilerPath] = process.argv.slice(1);
const { pathToFileURL } = await import("node:url");
const ts = (await import(pathToFileURL(compilerPath).href)).default;
let input = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) input += chunk;
const result = ts.transpileModule(input, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext, useDefineForClassFields: true } });
process.stdout.write(result.outputText);
"#;
let mut child = std::process::Command::new("node")
.args(["--input-type=module", "-e", script])
.arg(&compiler)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| {
format!("error[SERVER_TYPESCRIPT_UNAVAILABLE]: cannot run node to transpile {display_name}: {error}")
})?;
use std::io::Write as _;
child
.stdin
.take()
.ok_or("transpiler stdin unavailable")?
.write_all(source.as_bytes())
.map_err(|error| error.to_string())?;
let output = child
.wait_with_output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(format!(
"error[SERVER_TYPESCRIPT_FAILED]: transpiling {display_name} failed:\n{}",
String::from_utf8_lossy(&output.stderr)
));
}
String::from_utf8(output.stdout).map_err(|error| error.to_string())
}
/// The project's declared WO-30 model names, read from the same direct
/// `server/models/<name>.nox` layout `compile_project_models` enforces. This
/// runs at config load — before any target compiles — because an agent's
/// `model:` must resolve the same way through every door.
fn discover_declared_models(server_dir: &Path) -> Result<Vec<String>, String> {
let directory = server_dir.join("models");
let mut names = Vec::new();
for path in sorted_directory_files(&directory, "nox")? {
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read model {}: {error}", path.display()))?;
let source = noxid_source::SourceFile::new(noxid_source::SourceId(0), &path, text);
for model in noxid_parser::parse(&source).ast.models {
names.push(model.name.text);
}
}
names.sort();
names.dedup();
Ok(names)
}
/// The `server/agents/*.md` instruction assets. An agent's prompt is embedded
/// at build time, so the build reads these once and the running server never
/// touches the directory.
fn discover_agent_instructions(
root: &Path,
server_dir: &Path,
) -> Result<Vec<noxid_compiler_core::AgentInstructionsAsset>, String> {
let directory = server_dir.join("agents");
let mut assets = Vec::new();
for path in sorted_directory_files(&directory, "md")? {
let contents = fs::read_to_string(&path).map_err(|error| {
format!("cannot read agent instructions {}: {error}", path.display())
})?;
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
assets.push(noxid_compiler_core::AgentInstructionsAsset {
path: relative,
contents,
});
}
assets.sort_by(|left, right| left.path.cmp(&right.path));
Ok(assets)
}
/// Direct files of one directory with one extension, in filename order.
/// Missing directories are empty, not an error.
fn sorted_directory_files(directory: &Path, extension: &str) -> Result<Vec<PathBuf>, String> {
if !directory.is_dir() {
return Ok(Vec::new());
}
let mut paths = fs::read_dir(directory)
.map_err(|error| format!("cannot read directory {}: {error}", directory.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("cannot read entry in {}: {error}", directory.display()))?
.into_iter()
.map(|entry| entry.path())
.filter(|path| {
path.is_file() && path.extension().and_then(|value| value.to_str()) == Some(extension)
})
.collect::<Vec<_>>();
paths.sort();
Ok(paths)
}
fn discover_middleware(config: &ProjectConfig) -> Result<Vec<MiddlewareDefinition>, String> {
if !config.middleware_dir.exists() {
if config.global_middleware.is_empty() {
return Ok(vec![]);
}
return Err(format!(
"middleware directory {} does not exist",
config.middleware_dir.display()
));
}
let mut definitions = Vec::new();
for path in discover_named_extension(&config.middleware_dir, "js")? {
if path.parent() != Some(config.middleware_dir.as_path()) {
return Err(format!(
"middleware modules must be direct children of {}",
config.middleware_dir.display()
));
}
let name = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or("middleware filename is not UTF-8")?;
if !is_identifier(name) {
return Err(format!(
"middleware filename `{name}.js` must use a Noxid identifier"
));
}
definitions.push(MiddlewareDefinition {
id: SemanticId::middleware(name),
name: name.into(),
module: format!("assets/middleware/{name}.js"),
});
}
definitions.sort_by(|left, right| left.name.cmp(&right.name));
Ok(definitions)
}
fn discover_server_directory(
root: &Path,
middleware_dir: &Path,
server_dir: &Path,
) -> Result<DiscoveredServerDirectory, String> {
let mut legacy_dirs = vec![root.join("src/middleware/server")];
let configured_legacy = middleware_dir.join("server");
if !legacy_dirs.contains(&configured_legacy) {
legacy_dirs.push(configured_legacy);
}
for legacy_dir in legacy_dirs {
if !legacy_dir.is_dir() {
continue;
}
let mut legacy_files = Vec::new();
walk_files(&legacy_dir, &mut |path| {
legacy_files.push(path.to_path_buf())
})?;
if let Some(source) = legacy_files.first() {
return Err(format!(
"error[SERVER_MIDDLEWARE_MOVED]: server middleware at {} uses the removed `src/middleware/server/` layout; move global middleware to `server/middleware/` and route variants to `server/route-middleware/`",
source.display(),
));
}
}
let typescript_host = server_dir.join("host.ts");
let javascript_host = server_dir.join("host.js");
let server_host = match (typescript_host.is_file(), javascript_host.is_file()) {
(true, true) => {
return Err(format!(
"error[SERVER_HOST_AMBIGUOUS]: both {} and {} exist; keep exactly one auto-detected server host module",
typescript_host.display(),
javascript_host.display(),
));
}
(true, false) => Some(typescript_host),
(false, true) => Some(javascript_host),
(false, false) => None,
};
let global_middleware = discover_server_middleware_sources(
&server_dir.join("middleware"),
ServerMiddlewareKind::Global,
middleware_dir,
)?;
let _ = discover_server_middleware_sources(
&server_dir.join("route-middleware"),
ServerMiddlewareKind::Route,
middleware_dir,
)?;
let plugins = discover_server_plugin_sources(&server_dir.join("plugins"))?;
Ok((server_host, global_middleware, plugins))
}
fn discover_server_plugin_sources(directory: &Path) -> Result<Vec<ServerPluginSource>, String> {
if !directory.exists() {
return Ok(Vec::new());
}
let mut entries = fs::read_dir(directory)
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?;
entries.sort_by_key(|entry| entry.file_name());
let mut plugins = Vec::new();
let mut stems = BTreeMap::<String, PathBuf>::new();
for entry in entries {
let path = entry.path();
let file_type = entry.file_type().map_err(|error| error.to_string())?;
if file_type.is_dir() {
return Err(format!(
"error[SERVER_PLUGIN_NESTED]: server plugins must be direct children of {}; move shared code under server/utils/ and import it from a direct plugin module",
directory.display(),
));
}
if !file_type.is_file()
|| !matches!(
path.extension().and_then(|value| value.to_str()),
Some("js" | "ts")
)
{
continue;
}
let filename = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("server plugin filename at {} is not UTF-8", path.display()))?
.to_string();
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("server plugin filename `{filename}` has no stem"))?
.to_string();
if stem.is_empty()
|| matches!(stem.as_str(), "." | "..")
|| !stem
.chars()
.all(|character| character.is_ascii_alphanumeric() || "._-".contains(character))
{
return Err(format!(
"error[SERVER_PLUGIN_FILENAME_INVALID]: server plugin filename `{filename}` may only use ASCII letters, digits, `.`, `_`, and `-` so its lexical name is a stable startup identity",
));
}
if let Some(previous) = stems.insert(stem.clone(), path.clone()) {
return Err(format!(
"error[SERVER_PLUGIN_DUPLICATE]: {} and {} have the same logical plugin stem `{stem}`; keep exactly one .ts or .js module",
previous.display(),
path.display(),
));
}
plugins.push(ServerPluginSource {
filename,
stem,
source: path,
});
}
Ok(plugins)
}
#[derive(Clone, Copy)]
enum ServerMiddlewareKind {
Global,
Route,
}
fn discover_server_middleware_sources(
directory: &Path,
kind: ServerMiddlewareKind,
browser_middleware_dir: &Path,
) -> Result<Vec<ServerMiddlewareSource>, String> {
if !directory.exists() {
return Ok(Vec::new());
}
let mut entries = fs::read_dir(directory)
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("cannot read {}: {error}", directory.display()))?;
entries.sort_by_key(|entry| entry.file_name());
let mut sources = Vec::new();
let mut stems = BTreeMap::<String, PathBuf>::new();
for entry in entries {
let path = entry.path();
let file_type = entry.file_type().map_err(|error| error.to_string())?;
if file_type.is_dir() {
return Err(format!(
"error[SERVER_MIDDLEWARE_NESTED]: middleware modules must be direct children of {}",
directory.display(),
));
}
if !file_type.is_file()
|| !matches!(
path.extension().and_then(|value| value.to_str()),
Some("js" | "ts")
)
{
continue;
}
let filename = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| {
format!(
"server middleware filename at {} is not UTF-8",
path.display()
)
})?
.to_string();
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("server middleware filename `{filename}` has no stem"))?
.to_string();
if matches!(kind, ServerMiddlewareKind::Global)
&& (stem.is_empty()
|| matches!(stem.as_str(), "." | "..")
|| !stem.chars().all(|character| {
character.is_ascii_alphanumeric() || "._-".contains(character)
}))
{
return Err(format!(
"error[SERVER_MIDDLEWARE_FILENAME_INVALID]: global server middleware filename `{filename}` may only use ASCII letters, digits, `.`, `_`, and `-` so its lexical name is a stable module identity",
));
}
if let Some(previous) = stems.insert(stem.clone(), path.clone()) {
return Err(format!(
"error[SERVER_MIDDLEWARE_DUPLICATE]: {} and {} have the same logical middleware stem `{stem}`; keep exactly one .ts or .js module",
previous.display(),
path.display(),
));
}
if matches!(kind, ServerMiddlewareKind::Route) {
if !is_identifier(&stem) {
return Err(format!(
"server route middleware filename `{filename}` must use a Noxid identifier"
));
}
let declaration = browser_middleware_dir.join(format!("{stem}.js"));
if !declaration.is_file() {
return Err(format!(
"server route middleware `{}` has no declared browser middleware module at {}",
path.display(),
declaration.display(),
));
}
}
sources.push(ServerMiddlewareSource {
filename,
stem,
source: path,
});
}
Ok(sources)
}
fn discover_named_files(root: &Path, name: &str) -> Result<Vec<PathBuf>, String> {
if !root.exists() {
return Err(format!(
"routes directory {} does not exist",
root.display()
));
}
let mut output = Vec::new();
walk_files(root, &mut |path| {
if path.file_name().and_then(|value| value.to_str()) == Some(name) {
output.push(path.to_path_buf());
}
})?;
output.sort();
Ok(output)
}
fn discover_named_extension(root: &Path, extension: &str) -> Result<Vec<PathBuf>, String> {
let mut output = Vec::new();
walk_files(root, &mut |path| {
if path.extension().and_then(|value| value.to_str()) == Some(extension) {
output.push(path.to_path_buf());
}
})?;
output.sort();
Ok(output)
}
fn walk_files(root: &Path, visit: &mut impl FnMut(&Path)) -> Result<(), String> {
let mut entries = fs::read_dir(root)
.map_err(|error| format!("cannot read {}: {error}", root.display()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("cannot read {}: {error}", root.display()))?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let file_type = entry.file_type().map_err(|error| error.to_string())?;
if file_type.is_dir() {
walk_files(&path, visit)?;
} else if file_type.is_file() {
visit(&path);
}
}
Ok(())
}
fn route_segments(root: &Path, directory: &Path) -> Result<Vec<RouteSegment>, String> {
let relative = directory.strip_prefix(root).map_err(|_| {
format!(
"route directory {} is outside {}",
directory.display(),
root.display()
)
})?;
let mut segments = Vec::new();
let mut catch_all = None::<String>;
for component in relative.components() {
let Component::Normal(value) = component else {
continue;
};
let value = value.to_str().ok_or("route segment is not UTF-8")?;
if value.starts_with('(') && value.ends_with(')') {
continue;
}
if let Some(name) = &catch_all {
return Err(format!(
"catch-all route segment `[...{name}]` must be the final URL segment"
));
}
if value.starts_with('[') && value.ends_with(']') {
let name = &value[1..value.len() - 1];
if let Some(name) = name.strip_prefix("...") {
if !is_identifier(name) {
return Err(format!(
"catch-all route segment `[...{name}]` must use a Noxid identifier"
));
}
catch_all = Some(name.into());
segments.push(RouteSegment::CatchAll(name.into()));
} else if !is_identifier(name) {
return Err(format!(
"dynamic route segment `[{name}]` must use a Noxid identifier"
));
} else {
segments.push(RouteSegment::Parameter(name.into()));
}
} else {
if value.contains('[') || value.contains(']') || value.is_empty() {
return Err(format!("invalid route directory `{value}`"));
}
segments.push(RouteSegment::Static(value.into()));
}
}
Ok(segments)
}
fn route_pattern(segments: &[RouteSegment]) -> String {
if segments.is_empty() {
return "/".into();
}
format!(
"/{}",
segments
.iter()
.map(|segment| match segment {
RouteSegment::Static(value) => value.clone(),
RouteSegment::Parameter(name) => format!("{{{name}}}"),
RouteSegment::CatchAll(name) => format!("{{*{name}}}"),
})
.collect::<Vec<_>>()
.join("/")
)
}
fn route_segments_claim_reserved_api(segments: &[RouteSegment]) -> bool {
match segments.first() {
Some(RouteSegment::Static(value)) => value == "api",
Some(RouteSegment::Parameter(_) | RouteSegment::CatchAll(_)) => true,
None => false,
}
}
fn layout_files(root: &Path, page_dir: &Path) -> Result<Vec<PathBuf>, String> {
let relative = page_dir
.strip_prefix(root)
.map_err(|_| "page is outside routes directory")?;
let mut directory = root.to_path_buf();
let mut output = Vec::new();
let root_layout = directory.join("+layout.nox");
if root_layout.is_file() {
output.push(root_layout);
}
for component in relative.components() {
directory.push(component.as_os_str());
let layout = directory.join("+layout.nox");
if layout.is_file() {
output.push(layout);
}
}
Ok(output)
}
fn nearest_named_file(
root: &Path,
page_dir: &Path,
filename: &str,
) -> Result<Option<PathBuf>, String> {
let relative = page_dir
.strip_prefix(root)
.map_err(|_| "page is outside routes directory")?;
let mut directory = root.to_path_buf();
let mut nearest = directory
.join(filename)
.is_file()
.then(|| directory.join(filename));
for component in relative.components() {
directory.push(component.as_os_str());
let candidate = directory.join(filename);
if candidate.is_file() {
nearest = Some(candidate);
}
}
Ok(nearest)
}
fn route_sort_key(pattern: &str) -> (bool, usize, std::cmp::Reverse<usize>, String) {
(
pattern.contains("{*"),
pattern.matches('{').count(),
std::cmp::Reverse(pattern.split('/').count()),
pattern.to_string(),
)
}
fn merge_graph(target: &mut ApplicationGraph, source: &ApplicationGraph) {
for node in source.nodes.values() {
target.add_node(node.clone());
}
for edge in &source.edges {
target.add_edge(edge.from.clone(), edge.kind, edge.to.clone());
}
}
fn project_stamp(config: &ProjectConfig) -> Result<u64, String> {
let mut hasher = DefaultHasher::new();
stamp_content(&config.manifest, &mut hasher)?;
if config.routes_dir.exists() {
walk_files(&config.routes_dir, &mut |path| {
let _ = stamp_content(path, &mut hasher);
})?;
}
if config.components_dir.exists() {
walk_files(&config.components_dir, &mut |path| {
let _ = stamp_content(path, &mut hasher);
})?;
}
let conventional_source = config.root.join("src");
if conventional_source.exists()
&& conventional_source != config.routes_dir
&& conventional_source != config.components_dir
{
walk_files(&conventional_source, &mut |path| {
let _ = stamp_content(path, &mut hasher);
})?;
}
if config.middleware_dir.exists() {
walk_files(&config.middleware_dir, &mut |path| {
let _ = stamp_content(path, &mut hasher);
})?;
}
if let Some(host) = &config.host {
stamp_content(host, &mut hasher)?;
}
if let Some(server_host) = &config.server_host {
// The whole entry import graph feeds the cache key, so editing a
// supporting server module invalidates like editing the entry.
for module in
collect_server_sources(&config.root, server_host, config.server_storage_driver)?
{
module.emitted.hash(&mut hasher);
module.source.hash(&mut hasher);
}
}
for middleware in &config.server_global_middleware {
middleware.filename.hash(&mut hasher);
middleware.stem.hash(&mut hasher);
stamp_content(&middleware.source, &mut hasher)?;
}
if config.server_dir.exists() {
walk_files(&config.server_dir, &mut |path| {
let _ = stamp_content(path, &mut hasher);
})?;
}
if let Some(worker_entry) = &config.worker_entry {
stamp_path(worker_entry, &mut hasher)?;
}
if let Some(global_style) = &config.global_style {
stamp_path(global_style, &mut hasher)?;
}
if let Some(tailwind) = &config.tailwind {
stamp_path(&tailwind.input, &mut hasher)?;
}
let package_root = fs::canonicalize(&config.root)
.map_err(|error| format!("cannot resolve {}: {error}", config.root.display()))?
.ancestors()
.find(|candidate| candidate.join("package.json").is_file())
.map(Path::to_path_buf)
.unwrap_or_else(|| config.root.clone());
for name in [
"package.json",
"pnpm-lock.yaml",
"package-lock.json",
"yarn.lock",
"tailwind.config.js",
"tailwind.config.cjs",
"tailwind.config.mjs",
"tailwind.config.ts",
] {
let path = package_root.join(name);
if path.exists() {
stamp_content(&path, &mut hasher)?;
}
}
Ok(hasher.finish())
}
// Content-addressed stamping for project sources: unchanged bytes hash
// identically across CLI restarts, editors that rewrite mtimes, and
// filesystems with coarse timestamps.
fn stamp_content(path: &Path, hasher: &mut DefaultHasher) -> Result<(), String> {
let bytes =
fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
path.hash(hasher);
bytes.hash(hasher);
Ok(())
}
// Metadata-based stamping, kept for the compiler executable where hashing
// the whole binary per build would be wasteful.
fn stamp_path(path: &Path, hasher: &mut DefaultHasher) -> Result<(), String> {
let metadata = fs::metadata(path)
.map_err(|error| format!("cannot inspect {}: {error}", path.display()))?;
path.hash(hasher);
metadata.len().hash(hasher);
metadata
.modified()
.unwrap_or(UNIX_EPOCH)
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.hash(hasher);
Ok(())
}
fn is_identifier(value: &str) -> bool {
let mut chars = value.chars();
chars
.next()
.is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn write(path: &Path, contents: &str) -> Result<(), String> {
if fs::read_to_string(path).is_ok_and(|existing| existing == contents) {
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
}
fs::write(path, contents).map_err(|error| format!("cannot write {}: {error}", path.display()))
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn page_endpoint_pattern_overlap_is_shape_aware() {
for (page, endpoint) in [
("/api-docs", "/api-docs"),
("/users/{user}", "/users/[id]"),
("/files/{*segments}", "/files/report/latest"),
("/{section}/settings", "/account/settings"),
] {
assert!(
page_endpoint_patterns_overlap(page, endpoint),
"{page} and {endpoint} should overlap"
);
}
for (page, endpoint) in [
("/api-docs", "/status"),
("/users/{user}", "/users/[id]/history"),
("/files/{*segments}", "/files"),
("/account/settings", "/team/settings"),
] {
assert!(
!page_endpoint_patterns_overlap(page, endpoint),
"{page} and {endpoint} should be disjoint"
);
}
}
fn assert_stdout_last_line(stdout: &[u8], expected: &str) {
let stdout = String::from_utf8_lossy(stdout);
assert_eq!(stdout.lines().last(), Some(expected), "{stdout}");
}
#[test]
fn static_import_scanner_ignores_inert_decoys() {
let source = r#"// import { storage } from "noxid:server";
/*
import { storage } from "noxid:server";
*/
const documentation = 'server utilities import from "noxid:server"';
const template = `
import { storage } from "noxid:server";
`;
const continued = 'inert text\
import { storage } from "noxid:server"';
export const note = 'exported prose from "noxid:server"';
import { actual } from "./actual.js";
import { value as from } from "./alias.js";
import {
multiline,
type MultilineType,
} from "./multiline.js";
export { shared } from './shared.js';
export {
sharedMultiline,
} from './shared-multiline.js';
import "./setup.js";
"#;
assert_eq!(
scan_import_specifiers(source),
[
"./actual.js",
"./alias.js",
"./multiline.js",
"./shared.js",
"./shared-multiline.js",
"./setup.js"
]
);
}
#[test]
fn static_server_rewrite_changes_only_a_real_import() {
let source = r#"// import { storage } from "noxid:server";
const documentation = 'server utilities import from "noxid:server"';
import {
storage,
} from "noxid:server";
"#;
let rewritten =
replace_static_server_specifier(source, "noxid:server", "./noxid-server.js");
assert!(rewritten.contains("// import { storage } from \"noxid:server\";"));
assert!(rewritten.contains("'server utilities import from \"noxid:server\"'"));
assert!(rewritten.contains("} from \"./noxid-server.js\";"));
}
#[test]
fn static_server_rewrite_decodes_escape_sequences_in_module_specifiers() {
let source = r#"import { storage } from "noxid:\u0073erver";
export { storage as shared } from 'noxid:\x73erver';
"#;
assert_eq!(
scan_import_specifiers(source),
["noxid:server", "noxid:server"]
);
let rewritten =
replace_static_server_specifier(source, "noxid:server", "./noxid-server.js");
assert_eq!(rewritten.matches("./noxid-server.js").count(), 2);
assert!(!rewritten.contains("noxid:\\u0073erver"));
assert!(!rewritten.contains("noxid:\\x73erver"));
}
#[test]
fn static_import_scanner_finds_same_line_declarations_without_activating_text() {
let source = r#"const note = 'import { decoy } from "noxid:server"'; const marker = 1; import { storage } from "noxid:server";
const template = `; import { templateDecoy } from "noxid:server"`;
const regex = /; import { regexDecoy } from "noxid:server"/;
// const ignored = 1; import { commentDecoy } from "noxid:server";
export { shared } from "./shared.js";"#;
assert_eq!(
scan_import_specifiers(source),
["noxid:server", "./shared.js"]
);
let rewritten =
replace_static_server_specifier(source, "noxid:server", "./noxid-server.js");
assert!(rewritten.contains("import { storage } from \"./noxid-server.js\""));
assert!(rewritten.contains("'import { decoy } from \"noxid:server\"'"));
assert!(rewritten.contains("import { templateDecoy } from \"noxid:server\""));
assert!(rewritten.contains("import { regexDecoy } from \"noxid:server\""));
assert!(rewritten.contains("import { commentDecoy } from \"noxid:server\""));
}
#[test]
fn dynamic_import_scanner_finds_only_executable_literal_specifiers() {
let source = r#"// import("noxid:server");
/* import('noxid:server'); */
const stringDecoy = 'import("noxid:server")';
const templateDecoy = `import("noxid:server")`;
const regexDecoy = /import\("noxid:server"\)/;
export const first = () => import("noxid:\u0073erver");
export const second = () => import(`noxid:server`);
export const computed = () => import(`noxid:${server}`);
"#;
assert_eq!(
scan_dynamic_import_specifiers(source),
["noxid:server", "noxid:server"]
);
}
#[test]
fn server_dynamic_code_scanner_refuses_function_eval_and_aliases() {
for (source, expected) in [
(
r#"const load = Function("value", "return value");"#,
UnsafeServerDynamicCode::FunctionConstructor,
),
(
r#"const Build = Function; const load = new Build("return 1");"#,
UnsafeServerDynamicCode::FunctionConstructor,
),
(
r#"const Build = Funct\u0069on; Build("return 1");"#,
UnsafeServerDynamicCode::FunctionConstructor,
),
(
r#"const value = (0, eval)("1 + 1");"#,
UnsafeServerDynamicCode::Eval,
),
(
r#"const execute = eval; execute("1 + 1");"#,
UnsafeServerDynamicCode::Eval,
),
(
r#"const execute = e\u0076al; execute("1 + 1");"#,
UnsafeServerDynamicCode::Eval,
),
] {
assert_eq!(
unsafe_server_dynamic_code(source),
Some(expected),
"{source}"
);
}
}
#[test]
fn server_dynamic_code_scanner_refuses_node_vm_import_surfaces() {
for source in [
r#"import vm from "node:vm";"#,
r#"export { Script } from "vm";"#,
r#"const vm = require("node:vm");"#,
r#"const vm = await import("vm");"#,
] {
assert_eq!(
unsafe_server_dynamic_code(source),
Some(UnsafeServerDynamicCode::NodeVm),
"{source}"
);
}
}
#[test]
fn server_dynamic_code_scanner_ignores_inert_text() {
let source = r#"// Function("return import(name)"); eval("ignored");
/* import vm from "node:vm"; */
const functionText = "Function";
const evalText = `eval`;
const vmText = "node:vm";
const pattern = /Function|eval|node:vm/;
void functionText; void evalText; void vmText; void pattern;"#;
assert_eq!(unsafe_server_dynamic_code(source), None);
}
#[test]
fn compiler_emitted_storage_runtime_uses_no_dynamic_code_primitive() {
for driver in [
ServerStorageDriver::Memory,
ServerStorageDriver::Fs,
ServerStorageDriver::Postgres,
ServerStorageDriver::Redis,
] {
let source = server_storage_runtime_javascript(driver, 10, None);
assert_eq!(unsafe_server_dynamic_code(&source), None, "{driver:?}");
}
}
#[test]
fn compiler_emitted_server_output_is_eval_free_and_uses_allowlisted_literal_imports() {
let project = temporary_output();
fs::create_dir_all(project.join("server/api")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Generated server ratchet\"\n\n[server]\nstorage = \"postgres\"\n",
)
.unwrap();
fs::write(
project.join("server/api/store.post.nox"),
"endpoint Store { body { value: String } result: String idempotent handler { return value } }\n",
)
.unwrap();
let out = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap();
let emitted = [
"host.js",
"noxid-server.js",
"validators.js",
"middleware.js",
"handler.js",
]
.map(|name| {
(
name,
fs::read_to_string(out.join("server").join(name)).unwrap(),
)
});
for (name, source) in emitted {
assert_eq!(
unsafe_server_dynamic_code(&source),
None,
"compiler-emitted {name} introduced eval, Function, or node:vm"
);
for specifier in scan_dynamic_module_specifiers(&source) {
match specifier {
DynamicModuleSpecifier::Literal(specifier) => assert!(
COMPILER_DYNAMIC_DRIVER_SPECIFIERS.contains(&specifier.as_str()),
"compiler-emitted {name} imported non-allowlisted `{specifier}`"
),
DynamicModuleSpecifier::Unresolvable => {
panic!("compiler-emitted {name} used a non-literal dynamic import")
}
}
}
assert_eq!(
compiler_emitted_unsafe_reason(&source),
None,
"compiler-emitted {name} failed the generated-output ratchet"
);
}
let _ = fs::remove_dir_all(project);
}
#[test]
fn unsafe_sql_allowlist_refuses_every_indirect_import_specifier() {
for source in [
r#"const driverName = "post" + "gres";
const driver = await import(driverName);"#,
r#"const driverName = `postgres`;
const alias = driverName;
const driver = require(alias);"#,
r#"let driverName = "mysql" + "2/promise";
const driver = await import(driverName);"#,
r#"const driverName = `post${"gres"}`;
const driver = await import(driverName);"#,
] {
assert_eq!(
unsafe_sql_reason(source),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON)
);
}
}
#[test]
fn unsafe_sql_allowlist_refuses_reassigned_import_specifier() {
assert_eq!(
unsafe_sql_reason(
r#"let localModule = "./first.js";
localModule = "./second.js";
const note = "post" + "gres";
void note;
const loaded = import(localModule);"#,
),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON)
);
}
#[test]
fn unsafe_sql_allowlist_accepts_direct_non_driver_literals_only() {
assert_eq!(
unsafe_sql_reason(r#"const workos = import("@workos-inc/node");"#),
None
);
assert_eq!(
unsafe_sql_reason(r#"const local = import(`./known-module.js`);"#),
None
);
assert_eq!(
unsafe_sql_reason(r#"const driver = import("post\u0067res");"#),
Some("imports a database driver directly")
);
assert_eq!(
unsafe_sql_reason(r#"const nested = `${await import(driverName)}`;"#),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON)
);
assert_eq!(
unsafe_sql_reason(r#"const commented = import/* still executable */(driverName);"#),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON)
);
assert_eq!(
unsafe_sql_reason(
r#"const stringDecoy = "import(driverName)";
const regexDecoy = /import\(driverName\)/;
const templateDecoy = `import(driverName)`;"#,
),
None
);
}
#[test]
fn unsafe_sql_allowlist_enumerates_compiler_owned_drivers_exactly() {
for specifier in COMPILER_DYNAMIC_DRIVER_SPECIFIERS {
assert_eq!(
compiler_emitted_unsafe_reason(&format!(r#"import("{specifier}");"#)),
None,
"{specifier}"
);
}
assert_eq!(
compiler_emitted_unsafe_reason(r#"import("postgres-extra");"#),
Some(
"uses a compiler-emitted dynamic module load outside the exact platform driver allowlist"
)
);
assert_eq!(
compiler_emitted_unsafe_reason(r#"const specifier = "postgres"; import(specifier);"#),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON)
);
}
#[test]
fn compiler_driver_allowlist_has_vetting_for_every_external_package() {
let repository = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
for specifier in COMPILER_DYNAMIC_DRIVER_SPECIFIERS {
if specifier.starts_with("node:") {
continue;
}
let package = specifier
.split('/')
.next()
.expect("driver package specifier is non-empty");
let record = repository.join("plugins").join(package).join("VETTING.md");
assert!(
record.is_file(),
"allowlisted driver `{specifier}` has no vetting record at {}",
record.display()
);
}
}
fn unsafe_sql_graph_reason(modules: &[(&str, &str)], entry: &str) -> Option<&'static str> {
let source = modules
.iter()
.find_map(|(emitted, source)| (*emitted == entry).then_some(*source))
.expect("entry module exists");
reference_project_scanner_reason(source)
}
#[test]
fn unsafe_sql_allowlist_refuses_every_indirect_esm_binding_form() {
let cases: &[(&str, &[(&str, &str)])] = &[
(
"named import and exported declaration",
&[
(
"server/host.js",
r#"import { driver } from "./origin.js"; import(driver);"#,
),
(
"server/origin.js",
r#"export const driver = "post" + "gres";"#,
),
],
),
(
"renamed import and renamed local export",
&[
(
"server/host.js",
r#"import { exposed as driver } from "./origin.js"; import(driver);"#,
),
(
"server/origin.js",
r#"const local = "post" + "gres"; export { local as exposed };"#,
),
],
),
(
"default import and inline default expression",
&[
(
"server/host.js",
r#"import driver from "./origin.js"; import(driver);"#,
),
("server/origin.js", r#"export default `post${"gres"}`;"#),
],
),
(
"namespace import and dotted member",
&[
(
"server/host.js",
r#"import * as drivers from "./origin.js"; import(drivers.driver);"#,
),
(
"server/origin.js",
r#"export const driver = "post" + "gres";"#,
),
],
),
(
"namespace import and computed literal member",
&[
(
"server/host.js",
r#"import * as drivers from "./origin.js"; import(drivers["driver"]);"#,
),
(
"server/origin.js",
r#"const local = "post" + "gres"; export { local as driver };"#,
),
],
),
(
"renamed re-export",
&[
(
"server/host.js",
r#"import { driver } from "./barrel.js"; import(driver);"#,
),
(
"server/barrel.js",
r#"export { local as driver } from "./origin.js";"#,
),
(
"server/origin.js",
r#"export const local = "post" + "gres";"#,
),
],
),
(
"star re-export",
&[
(
"server/host.js",
r#"import { driver } from "./barrel.js"; import(driver);"#,
),
("server/barrel.js", r#"export * from "./origin.js";"#),
(
"server/origin.js",
r#"export const driver = "post" + "gres";"#,
),
],
),
(
"default re-exported under a name",
&[
(
"server/host.js",
r#"import { driver } from "./barrel.js"; import(driver);"#,
),
(
"server/barrel.js",
r#"export { default as driver } from "./origin.js";"#,
),
("server/origin.js", r#"export default "post" + "gres";"#),
],
),
(
"named binding re-exported as default",
&[
(
"server/host.js",
r#"import driver from "./barrel.js"; import(driver);"#,
),
(
"server/barrel.js",
r#"export { driver as default } from "./origin.js";"#,
),
(
"server/origin.js",
r#"export const driver = "post" + "gres";"#,
),
],
),
(
"namespace re-export projected after a named import",
&[
(
"server/host.js",
r#"import { drivers } from "./barrel.js"; import(drivers.driver);"#,
),
(
"server/barrel.js",
r#"export * as drivers from "./origin.js";"#,
),
(
"server/origin.js",
r#"export const driver = "post" + "gres";"#,
),
],
),
];
for (label, modules) in cases {
assert_eq!(
unsafe_sql_graph_reason(modules, "server/host.js"),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON),
"{label}"
);
}
}
#[test]
fn unsafe_sql_allowlist_refuses_indirect_external_package_binding() {
assert_eq!(
unsafe_sql_graph_reason(
&[(
"server/host.js",
r#"import driver from "vetted-wrapper"; import(driver);"#,
)],
"server/host.js",
),
Some(UNRESOLVED_DYNAMIC_MODULE_REASON)
);
}
#[test]
fn javascript_string_scanner_excludes_comment_string_and_template_contents() {
let source = r#"// "endpoint:Viewer@1": () => "comment";
const stringDecoy = '\"endpoint:Viewer@1\": () => \"string\"';
const templateDecoy = `"endpoint:Viewer@1": () => "template"`;
export const endpoints = { "endpoint:Viewer@1": () => "public" };
"#;
assert_eq!(
scan_javascript_string_literals(source)
.into_iter()
.filter(|literal| literal.value == "endpoint:Viewer@1")
.count(),
1
);
}
#[test]
fn compiler_owned_revalidation_route_is_reserved() {
let project = temporary_output();
write_endpoint_project(&project);
fs::create_dir_all(project.join("server/routes/_noxid")).unwrap();
fs::write(
project.join("server/routes/_noxid/revalidate.post.nox"),
"endpoint ShadowRevalidation { result: String handler { return \"shadow\" } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("ENDPOINT_SYSTEM_ROUTE_RESERVED"), "{error}");
assert!(error.contains("/_noxid/revalidate"), "{error}");
assert!(
error.contains("compiler-owned cache invalidation"),
"{error}"
);
fs::remove_file(project.join("server/routes/_noxid/revalidate.post.nox")).unwrap();
fs::create_dir_all(project.join("server/routes/[system]")).unwrap();
fs::write(
project.join("server/routes/[system]/revalidate.post.nox"),
"endpoint DynamicShadow { params { system: String } result: String handler { return \"shadow\" } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("ENDPOINT_SYSTEM_ROUTE_RESERVED"), "{error}");
assert!(error.contains("/[system]/revalidate"), "{error}");
let _ = fs::remove_dir_all(project);
}
#[test]
fn endpoint_cache_rejects_capability_and_middleware_personalization() {
for (label, declaration) in [
(
"capability",
"endpoint Personalized { capabilities [account.read] cache: swr 30 result: String }\n",
),
(
"middleware",
"endpoint Personalized { middleware { session } cache: isr 30 result: String }\n",
),
] {
let project = temporary_output();
fs::create_dir_all(project.join("server/api")).unwrap();
fs::create_dir_all(project.join("src/middleware")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Personalized cache refusal\"\n",
)
.unwrap();
fs::write(project.join("server/api/personalized.get.nox"), declaration).unwrap();
fs::write(
project.join("src/middleware/session.js"),
"export default () => ({ allow: true });\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(
error.contains("error[ENDPOINT_CACHE_PERSONALIZATION_UNSAFE]"),
"{label}: {error}"
);
assert!(
error.contains("capabilities or request middleware"),
"{error}"
);
assert!(
error.contains("variation/private cache contract"),
"{error}"
);
let _ = fs::remove_dir_all(project);
}
}
#[test]
fn endpoint_cache_rejects_host_handlers_with_request_context() {
let project = temporary_output();
fs::create_dir_all(project.join("server/api")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Opaque host cache refusal\"\n",
)
.unwrap();
fs::write(
project.join("server/api/profile.get.nox"),
"endpoint Profile { cache: swr 30 result: String }\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
r#"export const endpoints = Object.freeze({
"endpoint:Profile@1": async (_input, { environment }) => environment.viewer,
});
"#,
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(
error.contains("error[ENDPOINT_CACHE_HOST_HANDLER_UNANALYZABLE]"),
"{error}"
);
assert!(error.contains("opaque host implementation"), "{error}");
assert!(error.contains("request or environment"), "{error}");
let _ = fs::remove_dir_all(project);
}
#[test]
fn storage_serialization_refuses_inherited_behavior_before_overwrite() {
for driver in [ServerStorageDriver::Memory, ServerStorageDriver::Fs] {
let project = temporary_output();
let storage_root = project.join("storage");
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("runtime.mjs"),
server_storage_runtime_javascript(driver, 10, None),
)
.unwrap();
fs::write(
project.join("assertion.mjs"),
r#"import { storage } from "./runtime.mjs";
const values = storage("prototype-boundary");
await values.set("stable", { generation: 1 });
let calls = 0;
const prototype = Object.create(Array.prototype);
Object.defineProperty(prototype, "toJSON", { value() { calls += 1; return { generation: 999 }; } });
const hostile = ["looks", "valid"];
Object.setPrototypeOf(hostile, prototype);
let refused = false;
try { await values.set("stable", hostile); } catch (error) { refused = error instanceof TypeError; }
if (!refused) throw new Error("custom array prototype was accepted");
if (calls !== 0) throw new Error(`inherited toJSON executed ${calls} time(s)`);
const retained = await values.get("stable");
if (retained?.generation !== 1) throw new Error(`rejected overwrite changed the old value: ${JSON.stringify(retained)}`);
"#,
)
.unwrap();
let output = Command::new("node")
.arg("assertion.mjs")
.env("NOXID_STORAGE_DIR", &storage_root)
.current_dir(&project)
.output()
.unwrap();
assert!(
output.status.success(),
"{driver:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let _ = fs::remove_dir_all(project);
}
}
#[test]
fn storage_runtime_preserves_javascript_string_names_and_refuses_noncanonical_records() {
for driver in [ServerStorageDriver::Memory, ServerStorageDriver::Fs] {
let project = temporary_output();
let storage_root = project.join("storage");
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("runtime.mjs"),
server_storage_runtime_javascript(driver, 10, None),
)
.unwrap();
fs::write(
project.join("assertion.mjs"),
format!(
r#"import {{ storage }} from "./runtime.mjs";
const values = storage("namespace-\ud800");
await values.set("key-\udfff", {{ exact: true }});
const value = await values.get("key-\udfff");
if (value?.exact !== true) throw new Error(`lone-surrogate key did not round trip: ${{JSON.stringify(value)}}`);
const keys = await values.list();
if (keys.length !== 1 || keys[0] !== "key-\udfff") throw new Error(`lone-surrogate key did not survive list: ${{JSON.stringify(keys)}}`);
{}
"#,
if driver == ServerStorageDriver::Fs {
r#"const { writeFile } = await import("node:fs/promises");
const path = (await import("node:path")).default;
const directory = path.join(process.env.NOXID_STORAGE_DIR, "namespace-%uD800");
await writeFile(path.join(directory, "%6Bey-%uDFFF.json"), '{"version":1,"expiresAt":null,"value":{"exact":false}}');
let refused = false;
try { await values.list(); }
catch (error) { refused = /canonical|filename|record/i.test(String(error)); }
if (!refused) throw new Error("noncanonical record filename was silently accepted");"#
} else {
""
}
),
)
.unwrap();
let output = Command::new("node")
.arg("assertion.mjs")
.env("NOXID_STORAGE_DIR", &storage_root)
.current_dir(&project)
.output()
.unwrap();
assert!(
output.status.success(),
"{driver:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
let _ = fs::remove_dir_all(project);
}
}
#[cfg(unix)]
#[test]
fn filesystem_storage_refuses_namespace_symlinks() {
use std::os::unix::fs::symlink;
let project = temporary_output();
let storage_root = project.join("storage");
let outside = project.with_extension("storage-outside");
fs::create_dir_all(&storage_root).unwrap();
fs::create_dir_all(&outside).unwrap();
symlink(&outside, storage_root.join("linked")).unwrap();
fs::write(
project.join("runtime.mjs"),
server_storage_runtime_javascript(ServerStorageDriver::Fs, 10, None),
)
.unwrap();
fs::write(
project.join("assertion.mjs"),
r#"import { storage } from "./runtime.mjs";
let refused = false;
try { await storage("linked").set("escape", true); }
catch { refused = true; }
if (!refused) throw new Error("namespace symlink was followed");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("assertion.mjs")
.env("NOXID_STORAGE_DIR", &storage_root)
.current_dir(&project)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!outside.join("escape.json").exists());
let _ = fs::remove_dir_all(project);
let _ = fs::remove_dir_all(outside);
}
#[cfg(unix)]
#[test]
fn filesystem_storage_refuses_record_symlinks() {
use std::os::unix::fs::symlink;
let project = temporary_output();
let storage_root = project.join("storage");
let outside = project.with_extension("record-outside");
fs::create_dir_all(storage_root.join("records")).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(
outside.join("secret.json"),
r#"{"version":1,"expiresAt":null,"value":{"secret":true}}"#,
)
.unwrap();
symlink(
outside.join("secret.json"),
storage_root.join("records/leak.json"),
)
.unwrap();
fs::write(
project.join("runtime.mjs"),
server_storage_runtime_javascript(ServerStorageDriver::Fs, 10, None),
)
.unwrap();
fs::write(
project.join("assertion.mjs"),
r#"import { storage } from "./runtime.mjs";
let refused = false;
try { await storage("records").get("leak"); }
catch (error) { refused = /ordinary file inside its namespace/.test(String(error?.message)); }
if (!refused) throw new Error("record symlink was followed or refused without its boundary error");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("assertion.mjs")
.env("NOXID_STORAGE_DIR", &storage_root)
.current_dir(&project)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let _ = fs::remove_dir_all(project);
let _ = fs::remove_dir_all(outside);
}
fn example_project() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/routing-app")
}
fn temporary_output() -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let sequence = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"noxid-routing-test-{}-{nonce}-{sequence}",
std::process::id(),
))
}
fn write_endpoint_project(project: &Path) {
fs::create_dir_all(project.join("src/routes")).unwrap();
fs::create_dir_all(project.join("server/api/items")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Endpoint project\"\n",
)
.unwrap();
fs::write(
project.join("src/routes/+page.nox"),
"component Home { view { <main>home</main> } }\n",
)
.unwrap();
fs::write(
project.join("server/api/items/[id].get.nox"),
"endpoint ReadItem { params { id: Int } query { tags: Optional<Array<String>> } result: String }\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export const endpoints = Object.freeze({ 'endpoint:ReadItem@1': async ({ id, tags }) => `${id}:${JSON.stringify(tags)}` });\n",
)
.unwrap();
}
#[test]
fn typed_endpoint_discovery_enriches_graph_manifests_and_fetch_artifact() {
let project = temporary_output();
write_endpoint_project(&project);
let out = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.server_actions, 0);
assert_eq!(build.endpoints, 1);
let security = fs::read_to_string(out.join("server/security.manifest.json")).unwrap();
assert_eq!(
security,
"{\"schemaVersion\":10,\"guarantee\":\"Agents have no query surface; closed typed endpoints are the only data path and declared table scopes are adapter-enforced. Model calls leave only through declared models, using declared secrets. An agent engine dispatches only the endpoints its derived tool registry lists. Every byte an endpoint can ingest is a declared upload whose entry publishes its byte cap, its magic-byte-verified media-type allow-list, and the parser ceilings that bound the request around it.\",\"surfaces\":{\"apiDocs\":false,\"mcp\":false},\"dataPolicies\":[],\"endpoints\":[{\"id\":\"endpoint:ReadItem@1\",\"name\":\"ReadItem\",\"version\":1,\"kind\":\"request-response\",\"method\":\"get\",\"path\":\"/api/items/[id]\",\"dynamicParams\":[\"id\"],\"capabilities\":[],\"timeoutMs\":30000,\"limit\":null,\"cache\":null,\"idempotent\":false,\"resultType\":\"String\",\"resultTypeId\":null,\"uploads\":[]}],\"models\":[],\"agents\":[]}\n"
);
let graph = fs::read_to_string(out.join("app.graph.json")).unwrap();
assert!(graph.contains("endpoint:ReadItem@1"));
let units = fs::read_to_string(out.join("app.semantic-units.json")).unwrap();
assert!(units.contains("\"endpoint\":\"ReadItem\""));
let manifest = fs::read_to_string(out.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"endpoints\": 1"));
assert!(manifest.contains("\"fetchHandler\": \"server/handler.js\""));
assert!(out.join("server/handler.js").is_file());
let _ = fs::remove_dir_all(project);
}
#[test]
fn endpoint_discovery_rejects_declarations_in_transitive_imports_atomically() {
let project = temporary_output();
fs::create_dir_all(project.join("server/api")).unwrap();
fs::create_dir_all(project.join("server/shared")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Imported endpoint refusal\"\n",
)
.unwrap();
fs::write(
project.join("server/shared/deep.nox"),
"type Shared { value: Int }\nendpoint Visible { result: Int handler { return 9 } }\n",
)
.unwrap();
fs::write(
project.join("server/contracts.nox"),
"import { Shared } from \"./shared/deep.nox\"\ntype Payload { item: Shared }\n",
)
.unwrap();
fs::write(
project.join("server/api/visible.get.nox"),
"import { Payload } from \"../contracts.nox\"\nendpoint Visible { result: Payload }\n",
)
.unwrap();
let out = project.join("dist");
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("ENDPOINT_IMPORT_UNROUTED"), "{error}");
assert!(error.contains("server/shared/deep.nox"), "{error}");
assert!(error.contains("`Visible`"), "{error}");
assert!(!out.exists(), "unrouted endpoint published build artifacts");
let _ = fs::remove_dir_all(project);
}
#[test]
fn endpoint_discovery_contracts_fail_before_output_mutation() {
let project = temporary_output();
write_endpoint_project(&project);
let out = project.join("dist");
fs::create_dir_all(&out).unwrap();
fs::write(out.join("last-good.txt"), "preserve").unwrap();
fs::rename(
project.join("server/api/items/[id].get.nox"),
project.join("server/api/items/[id].fetch.nox"),
)
.unwrap();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("ENDPOINT_FILENAME_INVALID"), "{error}");
assert_eq!(
fs::read_to_string(out.join("last-good.txt")).unwrap(),
"preserve"
);
assert_eq!(fs::read_dir(&out).unwrap().count(), 1);
fs::rename(
project.join("server/api/items/[id].fetch.nox"),
project.join("server/api/items/[id].get.nox"),
)
.unwrap();
fs::write(
project.join("server/api/items/[id].get.nox"),
"endpoint ReadItem { params { other: String } body { value: Int } result: String idempotent }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("ENDPOINT_PARAMS_MISMATCH"), "{error}");
fs::write(
project.join("server/api/items/[id].get.nox"),
"endpoint ReadItem { params { id: String } body { value: Int } result: String }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("ENDPOINT_GET_BODY_FORBIDDEN"), "{error}");
fs::write(
project.join("server/api/items/[id].get.nox"),
"endpoint ReadItem { params { id: String } result: String idempotent }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("ENDPOINT_IDEMPOTENT_METHOD"), "{error}");
let _ = fs::remove_dir_all(project);
}
#[test]
fn endpoint_discovery_rejects_reserved_url_dot_segments_atomically() {
let project = temporary_output();
write_endpoint_project(&project);
let out = project.join("dist");
fs::create_dir_all(&out).unwrap();
fs::write(out.join("last-good.txt"), "preserve").unwrap();
let api_dot = project.join("server/api/..get.nox");
fs::write(
&api_dot,
"endpoint DotRoute { result: String handler { return \"dot\" } }\n",
)
.unwrap();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("ENDPOINT_PATH_SEGMENT_INVALID"), "{error}");
assert!(error.contains("reserved by URL normalization"), "{error}");
assert_eq!(
fs::read_to_string(out.join("last-good.txt")).unwrap(),
"preserve"
);
assert_eq!(fs::read_dir(&out).unwrap().count(), 1);
fs::remove_file(api_dot).unwrap();
fs::create_dir_all(project.join("server/routes")).unwrap();
fs::write(
project.join("server/routes/...post.nox"),
"endpoint DotDotRoute { result: String handler { return \"dotdot\" } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("ENDPOINT_PATH_SEGMENT_INVALID"), "{error}");
assert!(error.contains("`..`"), "{error}");
let _ = fs::remove_dir_all(project);
}
#[test]
fn endpoint_names_matchers_and_api_page_prefix_are_unique() {
let project = temporary_output();
write_endpoint_project(&project);
fs::create_dir_all(project.join("server/api/other")).unwrap();
fs::write(
project.join("server/api/other/[slug].get.nox"),
"endpoint ReadItem { params { slug: String } result: String }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("DUPLICATE_PROJECT_ENDPOINT"), "{error}");
fs::write(
project.join("server/api/other/[slug].get.nox"),
"endpoint Other { params { slug: String } result: String }\n",
)
.unwrap();
fs::create_dir_all(project.join("server/api/items/[slug]")).unwrap();
fs::write(
project.join("server/api/items/[slug]/unused.post.nox"),
"endpoint Different { params { slug: String } result: String }\n",
)
.unwrap();
fs::write(
project.join("server/api/items/[slug].get.nox"),
"endpoint Ambiguous { params { slug: String } result: String }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("DUPLICATE_ENDPOINT_ROUTE"), "{error}");
fs::remove_file(project.join("server/api/items/[slug].get.nox")).unwrap();
fs::create_dir_all(project.join("src/routes/api")).unwrap();
fs::write(
project.join("src/routes/api/+page.nox"),
"component ApiPage { view { <main>reserved</main> } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("API_PREFIX_RESERVED"), "{error}");
let _ = fs::remove_dir_all(project);
}
#[test]
fn dynamic_and_catch_all_pages_cannot_overlap_the_api_namespace() {
let project = temporary_output();
write_endpoint_project(&project);
let out = project.join("dist");
fs::create_dir_all(&out).unwrap();
fs::write(out.join("last-good.txt"), "preserve").unwrap();
fs::create_dir_all(project.join("src/routes/docs/[name]")).unwrap();
fs::write(
project.join("src/routes/docs/[name]/+page.nox"),
"component DocsPage { props { name: Static<String> } view { <main>{name}</main> } }\n",
)
.unwrap();
assert!(prepare_project(&project).is_ok());
fs::create_dir_all(project.join("src/routes/[prefix]/[name]")).unwrap();
fs::write(
project.join("src/routes/[prefix]/[name]/+page.nox"),
"component DynamicPage { view { <main>dynamic</main> } }\n",
)
.unwrap();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("API_PREFIX_RESERVED"), "{error}");
assert_eq!(
fs::read_to_string(out.join("last-good.txt")).unwrap(),
"preserve"
);
assert_eq!(fs::read_dir(&out).unwrap().count(), 1);
fs::remove_dir_all(project.join("src/routes/[prefix]")).unwrap();
fs::create_dir_all(project.join("src/routes/[...segments]")).unwrap();
fs::write(
project.join("src/routes/[...segments]/+page.nox"),
"component CatchAllPage { view { <main>catch all</main> } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("API_PREFIX_RESERVED"), "{error}");
assert!(error.contains("/{*segments}"), "{error}");
let _ = fs::remove_dir_all(project);
}
#[test]
fn server_routes_endpoint_maps_from_root_with_dotted_static_names() {
let project = temporary_output();
write_endpoint_project(&project);
fs::create_dir_all(project.join("server/routes")).unwrap();
fs::write(
project.join("server/routes/sitemap.xml.get.nox"),
"endpoint Sitemap { result: String handler { return \"ok\" } }\n",
)
.unwrap();
let prepared = prepare_project(&project).unwrap();
let sitemap = prepared
.endpoints
.values()
.flat_map(|compiled| &compiled.program.endpoints)
.find(|endpoint| endpoint.name == "Sitemap")
.unwrap();
let route = sitemap.route.as_ref().unwrap();
assert_eq!(route.method, EndpointMethod::Get);
assert_eq!(route.path, "/sitemap.xml");
assert!(route.dynamic_params.is_empty());
let _ = fs::remove_dir_all(project);
}
#[test]
fn compiler_owned_endpoint_alone_emits_the_server_handler() {
let project = temporary_output();
fs::create_dir_all(project.join("server/api")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Endpoint only\"\n",
)
.unwrap();
fs::write(
project.join("server/api/health.get.nox"),
"endpoint Health { result: String handler { return \"ready\" } }\n",
)
.unwrap();
let out = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.routes, 0);
assert_eq!(build.endpoints, 1);
assert!(out.join("server/handler.js").is_file());
assert!(
fs::read_to_string(out.join("server/host.js"))
.unwrap()
.contains("Object.freeze({})")
);
let _ = fs::remove_dir_all(project);
}
#[test]
fn compiler_owned_endpoint_executes_shared_computational_statements() {
let project = temporary_output();
fs::create_dir_all(project.join("server/api")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Computational endpoint\"\n",
)
.unwrap();
fs::write(project.join("package.json"), "{\"type\":\"module\"}\n").unwrap();
fs::write(
project.join("server/api/compute.post.nox"),
r#"endpoint Compute {
body { value: Int }
result: Int
handler {
let shifted = value
shifted = shifted + 1
if shifted > 0 { return shifted } else { return 0 }
}
}
"#,
)
.unwrap();
let out = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
fs::write(
out.join("computational-endpoint.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
async function compute(value) {
const response = await handle(new Request("http://noxid.test/api/compute", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ value }),
}));
const payload = await response.json();
if (response.status !== 200 || payload.ok !== true) throw new Error(JSON.stringify(payload));
return payload.value;
}
if (await compute(4) !== 5) throw new Error("then branch or local assignment diverged");
if (await compute(-2) !== 0) throw new Error("else branch diverged");
"#,
)
.unwrap();
let execution = Command::new("node")
.arg("computational-endpoint.mjs")
.current_dir(&out)
.output()
.unwrap();
assert!(
execution.status.success(),
"computational endpoint execution failed:\n{}\n{}",
String::from_utf8_lossy(&execution.stdout),
String::from_utf8_lossy(&execution.stderr),
);
let _ = fs::remove_dir_all(project);
}
#[test]
fn server_directory_discovery_auto_detects_host_and_orders_global_middleware() {
let project = temporary_output();
fs::create_dir_all(project.join("src/middleware")).unwrap();
fs::create_dir_all(project.join("server/middleware")).unwrap();
fs::create_dir_all(project.join("server/route-middleware")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Server layout\"\n",
)
.unwrap();
fs::write(
project.join("server/host.ts"),
"export const actions = Object.freeze({});\n",
)
.unwrap();
fs::write(
project.join("server/middleware/10.audit.js"),
"export default () => ({ allow: true });\n",
)
.unwrap();
fs::write(
project.join("server/middleware/02.session.ts"),
"export default () => ({ allow: true });\n",
)
.unwrap();
fs::write(
project.join("src/middleware/auth.js"),
"export default () => ({ allow: true });\n",
)
.unwrap();
fs::write(
project.join("server/route-middleware/auth.ts"),
"export default () => ({ allow: true });\n",
)
.unwrap();
let config = load_config(&project).unwrap();
assert_eq!(config.server_host, Some(project.join("server/host.ts")));
assert_eq!(
config
.server_global_middleware
.iter()
.map(|middleware| {
(
middleware.filename.as_str(),
middleware.stem.as_str(),
middleware.source.as_path(),
)
})
.collect::<Vec<_>>(),
[
(
"02.session.ts",
"02.session",
project.join("server/middleware/02.session.ts").as_path(),
),
(
"10.audit.js",
"10.audit",
project.join("server/middleware/10.audit.js").as_path(),
),
]
);
assert_eq!(
config.server_route_middleware_dir,
project.join("server/route-middleware")
);
fs::remove_dir_all(project).unwrap();
}
#[test]
fn server_scale_settings_default_validate_and_parse() {
let project = temporary_output();
fs::create_dir_all(&project).unwrap();
fs::write(project.join("Noxid.toml"), "[server]\n").unwrap();
let defaults = load_config(&project).unwrap();
assert_eq!(defaults.db_pool, 10);
assert_eq!(shutdown_timeout_ms(&project).unwrap(), 20_000);
fs::write(
project.join("Noxid.toml"),
"[server]\ndb_pool = 3\nshutdown_timeout_ms = 1250\n",
)
.unwrap();
let configured = load_config(&project).unwrap();
assert_eq!(configured.db_pool, 3);
assert_eq!(shutdown_timeout_ms(&project).unwrap(), 1_250);
for (setting, value, code) in [
("db_pool", "0", "DB_POOL_INVALID"),
("db_pool", "\"3\"", "DB_POOL_INVALID"),
("shutdown_timeout_ms", "0", "SHUTDOWN_TIMEOUT_INVALID"),
("shutdown_timeout_ms", "300001", "SHUTDOWN_TIMEOUT_INVALID"),
] {
fs::write(
project.join("Noxid.toml"),
format!("[server]\n{setting} = {value}\n"),
)
.unwrap();
let error = load_config(&project).unwrap_err();
assert!(error.contains(&format!("error[{code}]")), "{error}");
assert!(error.contains("positive integer"), "{error}");
}
fs::remove_dir_all(project).unwrap();
}
#[test]
fn live_publisher_settings_follow_storage_and_validate_overrides() {
let project = temporary_output();
fs::create_dir_all(&project).unwrap();
fs::write(project.join("Noxid.toml"), "[server]\n").unwrap();
let defaults = load_config(&project).unwrap();
assert_eq!(defaults.server_pubsub_driver, ServerPubSubDriver::Memory);
assert_eq!(defaults.server_pubsub_coalescing_ms, 250);
fs::write(
project.join("Noxid.toml"),
"[server]\nstorage = \"postgres\"\nlive_coalescing_ms = 731\n",
)
.unwrap();
let followed = load_config(&project).unwrap();
assert_eq!(followed.server_pubsub_driver, ServerPubSubDriver::Postgres);
assert_eq!(followed.server_pubsub_coalescing_ms, 731);
fs::write(
project.join("Noxid.toml"),
"[server]\nstorage = \"postgres\"\nlive_driver = \"memory\"\n",
)
.unwrap();
assert_eq!(
load_config(&project).unwrap().server_pubsub_driver,
ServerPubSubDriver::Memory
);
for (manifest, code) in [
(
"[server]\nlive_driver = \"filesystem\"\n",
"SERVER_LIVE_DRIVER_INVALID",
),
(
"[server]\nlive_coalescing_ms = 0\n",
"SERVER_LIVE_COALESCING_INVALID",
),
(
"[server]\nlive_driver = \"redis\"\n",
"SERVER_LIVE_REDIS_SECRET_REQUIRED",
),
] {
fs::write(project.join("Noxid.toml"), manifest).unwrap();
let error = load_config(&project).unwrap_err();
assert!(error.contains(&format!("error[{code}]")), "{error}");
}
fs::remove_dir_all(project).unwrap();
}
#[test]
fn global_server_middleware_precedes_route_middleware_on_ssr_and_action_paths() {
let project = temporary_output();
fs::create_dir_all(project.join("src/routes")).unwrap();
fs::create_dir_all(project.join("src/middleware")).unwrap();
fs::create_dir_all(project.join("server/middleware")).unwrap();
fs::create_dir_all(project.join("server/route-middleware")).unwrap();
fs::create_dir_all(project.join("server/utils")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Global middleware\"\n",
)
.unwrap();
fs::write(
project.join("src/routes/+page.nox"),
r#"component GlobalMiddlewarePage {
route { render: ssr }
middleware { routeGate }
render { mode: server }
props { value: Static<Int> }
actions { server loadValue(): Int { } }
loaders { value = loadValue() }
view { <main>{value}</main> }
}
"#,
)
.unwrap();
fs::write(
project.join("src/middleware/routeGate.js"),
"export default () => ({ allow: true });\n",
)
.unwrap();
fs::write(
project.join("server/utils/trace.js"),
"export const mark = (environment, value) => environment.trace.push(value);\n",
)
.unwrap();
fs::write(
project.join("server/middleware/10.second.js"),
r#"import { mark } from "../utils/trace.js";
export default ({ environment, context }) => {
mark(environment, `global:second:${context.order}`);
return { allow: true, context: { order: "second" }, headers: { "x-global-second": "yes" } };
};
"#,
)
.unwrap();
fs::write(
project.join("server/middleware/02.first.js"),
r#"import { mark } from "../utils/trace.js";
export default ({ environment }) => {
mark(environment, "global:first");
return { allow: true, context: { order: "first" }, headers: { "x-global-first": "yes" } };
};
"#,
)
.unwrap();
fs::write(
project.join("server/middleware/routeGate.js"),
r#"import { mark } from "../utils/trace.js";
export default ({ environment, context }) => {
mark(environment, `global:same-stem:${context.order}`);
return { allow: true, context: { order: "global-routeGate" }, headers: { "x-global-same": "yes" } };
};
"#,
)
.unwrap();
fs::write(
project.join("server/route-middleware/routeGate.js"),
r#"import { mark } from "../utils/trace.js";
export default ({ environment, context }) => {
mark(environment, `route:${context.order}`);
return { allow: true, context: { order: "route" }, headers: { "x-route": "yes" } };
};
"#,
)
.unwrap();
fs::write(
project.join("server/host.js"),
r#"import { mark } from "./utils/trace.js";
export const actions = Object.freeze({
"action:GlobalMiddlewarePage.loadValue": async (_args, context) => {
mark(context.environment, `action:${context.middlewareContext.order}`);
return 7;
},
});
"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.middleware, 4);
let registry = fs::read_to_string(out_dir.join("server/middleware.js")).unwrap();
assert!(registry.contains(
"export const globalMiddleware = Object.freeze([\"02.first\", \"10.second\", \"routeGate\"]);"
));
assert!(registry.contains("export const middleware = Object.freeze"));
assert!(registry.contains("export const globalMiddlewareHandlers = Object.freeze"));
assert!(
out_dir
.join("server/modules/server__utils__trace.js")
.is_file()
);
let first =
fs::read_to_string(out_dir.join("server/global-middleware/02.first.js")).unwrap();
let route = fs::read_to_string(out_dir.join("server/middleware/routeGate.js")).unwrap();
assert!(first.contains("../modules/server__utils__trace.js"));
assert!(route.contains("../modules/server__utils__trace.js"));
fs::write(
out_dir.join("global-middleware-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
const expected = ["global:first", "global:second:first", "global:same-stem:second", "route:global-routeGate", "action:route"];
const ssrTrace = [];
const ssr = await handle(new Request("http://noxid.test/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "http://noxid.test/" }),
}), { trace: ssrTrace }, {});
const ssrBody = await ssr.json();
if (ssr.status !== 200 || ssrBody.ok !== true) throw new Error(JSON.stringify(ssrBody));
if (JSON.stringify(ssrTrace) !== JSON.stringify(expected)) throw new Error(`SSR order/rerun changed: ${JSON.stringify(ssrTrace)}`);
if (JSON.stringify(JSON.parse(ssr.headers.get("x-noxid-ssr-headers"))) !== JSON.stringify([["x-global-first","yes"],["x-global-second","yes"],["x-global-same","yes"],["x-route","yes"]])) throw new Error("SSR headers changed");
const actionTrace = [];
const action = await handle(new Request("http://noxid.test/_noxid/actions/action%3AGlobalMiddlewarePage.loadValue", {
method: "POST",
headers: { "content-type": "application/json", "x-noxid-route-id": "route:/" },
body: JSON.stringify({ arguments: {} }),
}), { trace: actionTrace }, {});
const actionBody = await action.json();
if (action.status !== 200 || actionBody.value !== 7) throw new Error(JSON.stringify(actionBody));
if (JSON.stringify(actionTrace) !== JSON.stringify(expected)) throw new Error(`action order changed: ${JSON.stringify(actionTrace)}`);
if (action.headers.get("x-global-first") !== "yes" || action.headers.get("x-global-second") !== "yes" || action.headers.get("x-global-same") !== "yes" || action.headers.get("x-route") !== "yes") throw new Error("action headers changed");
console.log("global-middleware-ok");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("global-middleware-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
assert_stdout_last_line(&output.stdout, "global-middleware-ok");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn removed_server_entry_key_has_a_teaching_migration_diagnostic() {
let project = temporary_output();
fs::create_dir_all(&project).unwrap();
fs::write(
project.join("Noxid.toml"),
"[server]\nentry = \"src/server.ts\"\n",
)
.unwrap();
let error = load_config(&project).unwrap_err();
assert!(error.contains("error[SERVER_ENTRY_KEY_REMOVED]"));
assert!(error.contains("server/host.ts"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn removed_server_middleware_layout_has_a_teaching_migration_diagnostic() {
let project = temporary_output();
fs::create_dir_all(project.join("src/middleware/server")).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\ntitle = \"Legacy\"\n").unwrap();
fs::write(
project.join("src/middleware/server/auth.js"),
"export default () => ({ allow: true });\n",
)
.unwrap();
let error = load_config(&project).unwrap_err();
assert!(error.contains("error[SERVER_MIDDLEWARE_MOVED]"));
assert!(error.contains("server/middleware/"));
assert!(error.contains("server/route-middleware/"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn server_middleware_rejects_duplicate_ts_and_js_logical_stems() {
let project = temporary_output();
fs::create_dir_all(project.join("server/middleware")).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\ntitle = \"Duplicate\"\n").unwrap();
fs::write(
project.join("server/middleware/01.session.js"),
"export default () => ({ allow: true });\n",
)
.unwrap();
fs::write(
project.join("server/middleware/01.session.ts"),
"export default () => ({ allow: true });\n",
)
.unwrap();
let error = load_config(&project).unwrap_err();
assert!(error.contains("error[SERVER_MIDDLEWARE_DUPLICATE]"));
assert!(error.contains("01.session"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn discovers_typed_routes_layouts_and_inherited_middleware() {
let prepared = prepare_project(&example_project()).unwrap();
assert_eq!(prepared.routes.routes.len(), 4);
let account = prepared
.routes
.routes
.iter()
.find(|route| route.pattern == "/accounts/{id}")
.unwrap();
assert_eq!(account.parameters[0].ty, Type::Int);
assert_eq!(account.query.len(), 3);
assert_eq!(account.query[0].name, "tab");
assert_eq!(account.query[0].ty, Type::String);
assert!(!account.query[0].required);
assert_eq!(account.query[1].ty, Type::Int);
assert_eq!(account.query[2].ty, Type::Boolean);
assert_eq!(
account
.metadata
.as_ref()
.map(|metadata| metadata.title.as_str()),
Some("Account details")
);
assert_eq!(
account
.metadata
.as_ref()
.and_then(|metadata| metadata.description.as_deref()),
Some("Inspect an authorized account with a typed route parameter")
);
assert_eq!(account.layouts[0].component_name, "AppLayout");
assert_eq!(prepared.routes.base_path, "/console");
assert_eq!(
account.loading.as_ref().map(|target| target.id.as_str()),
Some("route-loading:/accounts.AccountLoading")
);
assert_eq!(
account.error.as_ref().map(|target| target.id.as_str()),
Some("route-error:/.RouteError")
);
assert_eq!(
account
.middleware
.iter()
.map(SemanticId::as_str)
.collect::<Vec<_>>(),
[
"middleware:audit",
"middleware:auth",
"middleware:session",
"middleware:accountAccess",
]
);
assert!(prepared.graph.to_json().contains("route:/accounts/{id}"));
assert!(prepared.graph.to_json().contains("protected-by"));
assert!(prepared.graph.to_json().contains("route-loading-boundary"));
assert!(prepared.graph.to_json().contains("route-error-boundary"));
assert!(
prepared
.graph
.to_json()
.contains("route-metadata:AccountPage")
);
assert!(
prepared
.graph
.to_json()
.contains("route-query:AccountPage.page")
);
let documentation = prepared
.routes
.routes
.iter()
.find(|route| route.pattern == "/docs/{*segments}")
.unwrap();
assert_eq!(
documentation.parameters[0].ty,
Type::Array(Box::new(Type::String))
);
assert!(documentation.parameters[0].catch_all);
assert_eq!(
prepared
.routes
.routes
.last()
.map(|route| route.pattern.as_str()),
Some("/docs/{*segments}")
);
assert!(prepared.graph.to_json().contains("route:/docs/{*segments}"));
}
#[test]
fn imported_component_middleware_joins_the_ordered_route_preflight() {
let project = temporary_output();
let routes = project.join("src/routes");
let components = project.join("src/components");
let middleware = project.join("src/middleware");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(&components).unwrap();
fs::create_dir_all(&middleware).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\nglobal_middleware = [\"globalGuard\"]\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component Page { middleware { pageGuard } view { <main><GuardedWidget /></main> } }\n",
)
.unwrap();
fs::write(
components.join("GuardedWidget.nox"),
"component GuardedWidget { middleware { componentGuard } view { <p>Guarded</p> } }\n",
)
.unwrap();
for name in ["globalGuard", "pageGuard", "componentGuard"] {
fs::write(
middleware.join(format!("{name}.js")),
"export default () => ({ allow: true });\n",
)
.unwrap();
}
let prepared = prepare_project(&project).unwrap();
assert_eq!(
prepared.routes.routes[0]
.middleware
.iter()
.map(|id| id.as_str())
.collect::<Vec<_>>(),
vec![
"middleware:globalGuard",
"middleware:pageGuard",
"middleware:componentGuard",
]
);
fs::remove_file(middleware.join("componentGuard.js")).unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("GuardedWidget"));
assert!(error.contains("componentGuard"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn project_build_emits_lazy_route_chunks_and_a_safe_cleanup_ledger() {
let out_dir = temporary_output();
let build = build_project(
&example_project(),
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.routes, 4);
assert_eq!(build.external_browser_modules, 1);
assert!(!build.native_esm_eligible);
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
assert!(app.contains("load: () => import(\"./assets/AccountPage.js\")"));
assert!(app.contains("basePath: \"/console\""));
assert!(app.contains("component: \"RouteLoading\""));
assert!(app.contains("component: \"RouteError\""));
assert!(app.contains("const defaultTitle = \"Noxid Routed Application\""));
assert!(app.contains("id: \"route-metadata:AccountPage\""));
assert!(app.contains("title: \"Account details\""));
assert!(app.contains("id: \"route-query:AccountPage.page\""));
assert!(app.contains("type: \"Int\", required: false"));
assert!(app.contains("load: () => import(\"./assets/DocumentationPage.js\")"));
assert!(app.contains("pattern: \"/docs/{*segments}\""));
assert!(app.contains("type: \"Array<String>\", catchAll: true"));
assert!(!app.contains("import { mountAccountPage"));
let router = fs::read_to_string(out_dir.join("assets/noxid-router.js")).unwrap();
assert!(router.contains("const ROUTER_SUPPORTS_HYDRATION = false;"));
assert!(!router.contains("export function scheduleHydration"));
assert!(!router.contains("import { emitRuntimeEvent }"));
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"routerProfile\": \"universal\""));
assert!(manifest.contains("\"routesWithMetadata\": 4"));
assert!(manifest.contains("\"routesWithQuery\": 1"));
assert!(manifest.contains("\"routesWithCatchAll\": 1"));
assert!(manifest.contains("\"componentImports\": 3"));
assert!(manifest.contains("\"autoComponentImports\": 2"));
assert!(manifest.contains("\"semanticHmr\": true"));
let hmr = fs::read_to_string(out_dir.join("app.hmr.json")).unwrap();
assert!(hmr.contains("\"stateSchemaHash\""));
assert!(hmr.contains("\"component\":\"HomePage\""));
let routes = fs::read_to_string(out_dir.join("app.routes.json")).unwrap();
assert!(routes.contains("\"schemaVersion\": 9"));
assert!(routes.contains("route-metadata:AccountPage"));
assert!(routes.contains("route-query:AccountPage.preview"));
assert!(routes.contains("\"catchAll\":true"));
let home = fs::read_to_string(out_dir.join("assets/HomePage.js")).unwrap();
assert!(!home.contains("AccountPage"));
assert!(home.contains("CombinationPanel"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/AccountPage.js"));
assert!(ledger.contains("assets/middleware/auth.js"));
assert!(ledger.contains("assets/RouteLoading.js"));
assert!(ledger.contains("assets/AccountLoading.js"));
assert!(ledger.contains("assets/RouteError.js"));
assert!(ledger.contains("assets/DocumentationPage.js"));
assert!(ledger.contains("assets/RouteSummary.js"));
assert!(ledger.contains("assets/DocTrail.js"));
let home = fs::read_to_string(out_dir.join("assets/HomePage.js")).unwrap();
assert!(home.contains("import { mountRouteSummary, hydrateRouteSummary }"));
assert!(!home.contains("globalThis.__NOXID_HMR__"));
assert!(!home.contains("DocTrail"));
let docs = fs::read_to_string(out_dir.join("assets/DocumentationPage.js")).unwrap();
assert!(docs.starts_with("import { mountDocTrail, hydrateDocTrail }"));
assert!(!docs.contains("RouteSummary"));
let stale_asset = out_dir.join("assets/StalePage.js");
fs::write(&stale_asset, "export const stale = true;\n").unwrap();
fs::write(
out_dir.join(".noxid-generated-files"),
format!("{ledger}assets/StalePage.js\n"),
)
.unwrap();
build_project(
&example_project(),
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert!(!stale_asset.exists());
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn declared_static_assets_are_copied_tracked_and_cleaned() {
let project = temporary_output();
let routes = project.join("src/routes");
let assets = project.join("src/assets/nested");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(&assets).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\n\n[assets]\ndirectory = \"src/assets\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main>Assets</main> } }\n",
)
.unwrap();
fs::write(assets.join("payload.bin"), b"noxid-static-asset").unwrap();
let out_dir = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(
fs::read(out_dir.join("assets/nested/payload.bin")).unwrap(),
b"noxid-static-asset"
);
assert!(
fs::read_to_string(out_dir.join(".noxid-generated-files"))
.unwrap()
.contains("assets/nested/payload.bin")
);
fs::remove_file(assets.join("payload.bin")).unwrap();
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert!(!out_dir.join("assets/nested/payload.bin").exists());
fs::remove_dir_all(project).unwrap();
}
#[cfg(unix)]
#[test]
fn declared_static_asset_root_must_not_be_a_symlink() {
use std::os::unix::fs::symlink;
let project = temporary_output();
let routes = project.join("src/routes");
let real_assets = project.join("src/real-assets");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(&real_assets).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\n\n[assets]\ndirectory = \"src/assets\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main>Assets</main> } }\n",
)
.unwrap();
fs::write(real_assets.join("payload.txt"), "outside declared root").unwrap();
symlink(&real_assets, project.join("src/assets")).unwrap();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: project.join("dist"),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap_err();
assert!(
error.contains("STATIC_ASSET_SYMLINK_UNSUPPORTED"),
"{error}"
);
fs::remove_dir_all(project).unwrap();
}
#[test]
fn global_stylesheet_is_a_single_project_build_asset() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Styled App\"\nbase = \"/docs\"\n\n[styles]\nglobal = \"src/global.css\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main class=\"docs-shell\">Docs</main> } }\n",
)
.unwrap();
fs::write(
project.join("src/global.css"),
":root { color-scheme: light dark; }\n.docs-shell { min-height: 100dvh; }\n",
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.external_browser_modules, 0);
assert!(build.native_esm_eligible);
assert_eq!(
fs::read_to_string(out_dir.join("assets/global.css")).unwrap(),
":root { color-scheme: light dark; }\n.docs-shell { min-height: 100dvh; }\n"
);
let html = fs::read_to_string(out_dir.join("index.html")).unwrap();
assert!(html.contains("href=\"/docs/assets/global.css\""));
assert!(html.contains("data-noxid-global-style=\"project\""));
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"globalStyles\": [\"assets/global.css\"]"));
assert!(manifest.contains("\"routerProfile\": \"client-core\""));
assert!(manifest.contains("\"externalBrowserModules\": 0"));
assert!(manifest.contains("\"nativeEsmEligible\": true"));
let router = fs::read_to_string(out_dir.join("assets/noxid-router.js")).unwrap();
assert!(!router.contains("scheduleHydration"));
assert!(!router.contains("executeMiddleware"));
assert!(!router.contains("acquireRouteLoaders"));
assert!(!router.contains("executeWorkerBoundary"));
assert!(!router.contains("/_noxid/actions/"));
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
assert!(!app.contains("const middleware"));
assert!(!app.contains("const islands"));
assert!(!app.contains("__NOXID_SSR_PAYLOAD__"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/global.css"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn global_stylesheet_input_must_exist() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\n\n[styles]\nglobal = \"src/missing.css\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main>Missing style</main> } }\n",
)
.unwrap();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: project.join("dist"),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("GLOBAL_STYLE_INPUT_MISSING"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn ordinary_global_css_precedes_tailwind_deterministically() {
let styles = vec!["assets/global.css".into(), "assets/tailwind.css".into()];
let html = project_html("Styles", "/", &styles, None);
let ordinary = html.find("assets/global.css").unwrap();
let tailwind = html.find("assets/tailwind.css").unwrap();
assert!(ordinary < tailwind);
assert!(html.contains("data-noxid-global-style=\"project\""));
assert!(html.contains("data-noxid-global-style=\"tailwind\""));
}
#[cfg(unix)]
#[test]
fn tailwind_is_built_as_a_global_project_asset() {
use std::os::unix::fs::PermissionsExt;
let project = temporary_output();
let routes = project.join("src/routes");
let binary = project.join("node_modules/.bin/tailwindcss");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(binary.parent().unwrap()).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Tailwind App\"\nbase = \"/tools\"\n\n[tailwind]\ninput = \"src/tailwind.css\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main class=\"flex gap-4\">Tailwind</main> } }\n",
)
.unwrap();
fs::write(
project.join("src/tailwind.css"),
"@import \"tailwindcss\";\n@source \"./\";\n",
)
.unwrap();
fs::write(
&binary,
"#!/bin/sh\noutput=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = \"-o\" ]; then\n shift\n output=\"$1\"\n fi\n shift\ndone\nprintf '.flex{display:flex}.gap-4{gap:1rem}\\n' > \"$output\"\n",
)
.unwrap();
let mut permissions = fs::metadata(&binary).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&binary, permissions).unwrap();
let out_dir = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(
fs::read_to_string(out_dir.join("assets/tailwind.css")).unwrap(),
".flex{display:flex}.gap-4{gap:1rem}\n"
);
let html = fs::read_to_string(out_dir.join("index.html")).unwrap();
assert!(html.contains("href=\"/tools/assets/tailwind.css\""));
assert!(html.contains("data-noxid-global-style=\"tailwind\""));
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"globalStyles\": [\"assets/tailwind.css\"]"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/tailwind.css"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn tailwind_input_must_exist() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\n\n[tailwind]\ninput = \"src/missing.css\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main>Missing input</main> } }\n",
)
.unwrap();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: project.join("dist"),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("TAILWIND_INPUT_MISSING"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn development_build_emits_semantic_hmr_and_structured_diagnostics() {
let out_dir = temporary_output();
build_project(
&example_project(),
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap();
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
assert!(app.starts_with("import \"./assets/noxid-hmr-client.js\";"));
assert!(app.contains("import \"./assets/noxid-devtools-panel.js\";"));
let page = fs::read_to_string(out_dir.join("assets/HomePage.js")).unwrap();
assert!(page.contains("export const __noxidHmrSignature"));
assert!(page.contains("stateSchemaHash"));
assert!(page.contains("import.meta.hot.accept"));
assert!(page.contains("__noxidCreateHomePageActions"));
let page_modified = fs::metadata(out_dir.join("assets/HomePage.js"))
.unwrap()
.modified()
.unwrap();
build_project(
&example_project(),
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: true,
strict_npm: false,
},
)
.unwrap();
assert_eq!(
fs::metadata(out_dir.join("assets/HomePage.js"))
.unwrap()
.modified()
.unwrap(),
page_modified,
"byte-identical chunks must not trigger Farm HMR updates",
);
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"hmrRuntime\": \"assets/noxid-hmr-client.js\""));
assert!(manifest.contains("\"devtoolsRuntime\": \"assets/noxid-devtools-panel.js\""));
let metadata = fs::read_to_string(out_dir.join("app.devtools.json")).unwrap();
assert!(metadata.contains("\"schemaVersion\": 4"));
assert!(metadata.contains("\"runtimeJoin\""));
assert!(metadata.contains("component:HomePage"));
assert!(metadata.contains("\"overviewDashboard\":true"));
assert!(metadata.contains("\"routeMatcher\":true"));
assert!(metadata.contains("\"impactGraph\":true"));
assert!(metadata.contains("\"reverseDomInspector\":true"));
assert!(metadata.contains("\"openGraphPreview\":\"reduced\""));
assert!(metadata.contains("\"title\":\"Noxid Routed Application\""));
assert!(metadata.contains("\"basePath\":\"/console\""));
assert!(metadata.contains("\"domScope\":\"noxid-"));
assert!(metadata.contains("\"pattern\":\"/accounts/{id}\""));
assert!(metadata.contains("\"type\":\"Int\",\"catchAll\":false"));
assert!(metadata.contains("\"middleware\":[\"middleware:audit\",\"middleware:auth\",\"middleware:session\",\"middleware:accountAccess\"]"));
assert!(metadata.contains("\"title\":\"Noxid Routing\""));
assert!(
metadata.contains("\"description\":\"A compiler-owned folder routing demonstration\"")
);
assert!(
metadata.contains(
"\"from\":\"route:/\",\"kind\":\"renders\",\"to\":\"component:HomePage\""
)
);
let panel = fs::read_to_string(out_dir.join("assets/noxid-devtools-panel.js")).unwrap();
assert!(panel.contains("__NOXID_DEVTOOLS__"));
assert!(panel.contains("Ctrl + Shift + D"));
assert!(panel.contains("const moduleBase=import.meta.url.slice"));
assert!(panel.contains("import { matchRoute, parseRouteQuery }"));
assert!(panel.contains("data-match-route"));
assert!(panel.contains("data-graph"));
assert!(panel.contains("impactFrom(selectedId)"));
assert!(panel.contains("data-pick-dom"));
assert!(panel.contains("Phase 1 renders only compiler-known {id, title, description?}"));
assert!(!panel.contains("new URL(\"../_noxid/devtools/open-source\",import.meta.url)"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/noxid-hmr-client.js"));
assert!(ledger.contains("assets/noxid-diagnostics.js"));
assert!(ledger.contains("assets/noxid-devtools-panel.js"));
write_development_diagnostics(
&out_dir,
"error[TYPE_MISMATCH]: expected Int but received String",
)
.unwrap();
let diagnostics = fs::read_to_string(out_dir.join("assets/noxid-diagnostics.js")).unwrap();
assert!(diagnostics.contains("code: \"TYPE_MISMATCH\""));
assert!(diagnostics.contains("severity: \"error\""));
assert!(diagnostics.contains("expected Int but received String"));
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn wo29_phase_one_panels_add_zero_production_runtime_cost() {
let project = temporary_output();
fs::create_dir_all(project.join("src/routes")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Production pruning\"\nbase = \"/tools\"\n",
)
.unwrap();
fs::write(
project.join("src/routes/+page.nox"),
"component Home { route { title: \"Production\" description: \"No DevTools runtime\" } view { <main>Ready</main> } }\n",
)
.unwrap();
let out_dir = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
let runtime = fs::read_to_string(out_dir.join("assets/noxid-runtime.js")).unwrap();
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(!app.contains("noxid-devtools-panel"));
assert!(!runtime.contains("configureDevtools"));
assert!(!runtime.contains("devtoolsSnapshot"));
assert!(!runtime.contains("overviewDashboard"));
assert!(!runtime.contains("reverseDomInspector"));
assert!(!runtime.contains("openGraphPreview"));
assert!(manifest.contains("\"devtoolsRuntime\": null"));
for path in [
"assets/noxid-devtools.js",
"assets/noxid-devtools-metadata.js",
"assets/noxid-devtools-panel.js",
] {
assert!(!out_dir.join(path).exists(), "production emitted {path}");
assert!(!ledger.contains(path), "production ledger included {path}");
}
let inert_metadata = fs::read_to_string(out_dir.join("app.devtools.json")).unwrap();
assert!(inert_metadata.contains("\"openGraphPreview\":\"reduced\""));
assert!(inert_metadata.contains("\"pattern\":\"/\""));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn incremental_session_reuses_unaffected_typed_route_targets() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(routes.join("settings")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Incremental\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
"component HomePage { view { <main>Home</main> } }\n",
)
.unwrap();
fs::write(
routes.join("settings/+page.nox"),
"component SettingsPage { view { <main>Settings</main> } }\n",
)
.unwrap();
let out_dir = project.join("target/noxid-dev");
let options = ProjectBuildOptions {
out_dir,
title: None,
development: true,
strict_npm: false,
};
let mut session = ProjectSession::default();
let initial = session.build(&project, &options).unwrap();
assert_eq!(initial.compiled_targets, 2);
assert_eq!(initial.reused_targets, 0);
let unchanged = session.build(&project, &options).unwrap();
assert_eq!(unchanged.compiled_targets, 0);
assert_eq!(unchanged.reused_targets, 2);
fs::write(
routes.join("settings/+page.nox"),
"component SettingsPage { view { <main>Updated settings</main> } }\n",
)
.unwrap();
let selective = session.build(&project, &options).unwrap();
assert_eq!(selective.compiled_targets, 1);
assert_eq!(selective.reused_targets, 1);
fs::remove_dir_all(project).unwrap();
}
#[test]
fn typed_route_state_emits_its_validator_module_without_external_primitives() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Typed state\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
r#"component HomePage {
machine Mode { Ready; Busy(String); }
state { mode: Mode = Ready }
view { <main>#match mode { Ready { <p>Ready</p> } Busy(message) { <p>{message}</p> } }</main> }
}
"#,
)
.unwrap();
let out_dir = project.join("target/noxid-build");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
let page = fs::read_to_string(out_dir.join("assets/HomePage.js")).unwrap();
assert!(page.contains("./HomePage.validators.js"));
let validators = fs::read_to_string(out_dir.join("assets/HomePage.validators.js")).unwrap();
assert!(validators.contains("machine:HomePage.Mode"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn routed_resource_module_emits_its_imported_validator_module() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\ntitle = \"Resources\"\n").unwrap();
fs::write(
routes.join("+page.nox"),
r#"type Item { id: Int }
resource Items(): Array<Item> { get { GET "/items" } }
component ResourcesPage {
resources { items = Items() }
view { <main>Items</main> }
}
"#,
)
.unwrap();
let out_dir = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
let resources =
fs::read_to_string(out_dir.join("assets/ResourcesPage.resources.js")).unwrap();
assert!(resources.contains("./ResourcesPage.validators.js"));
let validators =
fs::read_to_string(out_dir.join("assets/ResourcesPage.validators.js")).unwrap();
assert!(validators.contains("resource:Items"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/ResourcesPage.validators.js"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn imported_resource_component_emits_its_imported_validator_module() {
let project = temporary_output();
let routes = project.join("src/routes");
let components = project.join("src/components");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(&components).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\ntitle = \"Resources\"\n").unwrap();
fs::write(
routes.join("+page.nox"),
"component ResourcesPage { view { <main><ResourceCard /></main> } }\n",
)
.unwrap();
fs::write(
components.join("ResourceCard.nox"),
r#"type Item { id: Int }
resource Items(): Array<Item> { get { GET "/items" } }
component ResourceCard {
resources { items = Items() }
view { <section>Items</section> }
}
"#,
)
.unwrap();
let out_dir = project.join("dist");
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
let resources =
fs::read_to_string(out_dir.join("assets/ResourceCard.resources.js")).unwrap();
assert!(resources.contains("./ResourceCard.validators.js"));
let validators =
fs::read_to_string(out_dir.join("assets/ResourceCard.validators.js")).unwrap();
assert!(validators.contains("resource:Items"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/ResourceCard.validators.js"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn server_actions_emit_a_separate_fetch_graph_without_leaking_host_code() {
let project = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/server-actions");
let out_dir = temporary_output();
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.server_actions, 1);
assert_eq!(build.route_loaders, 0);
assert_eq!(build.edge_actions, 0);
assert_eq!(build.worker_actions, 0);
let browser = fs::read_to_string(out_dir.join("assets/ServerActionsPage.js")).unwrap();
assert!(browser.contains("executeBoundary"));
assert!(browser.contains("type:ServerActionsPage.CombinationRequest"));
assert!(browser.contains("machine:ServerActionsPage.CombinationResponse"));
assert!(!browser.contains("es-toolkit"));
assert!(!browser.contains("SERVER_ONLY_MARKER"));
assert!(!browser.contains("SERVER_MIDDLEWARE_MARKER"));
assert!(
!out_dir
.join("assets/ServerActionsPage.validators.js")
.exists()
);
let handler = fs::read_to_string(out_dir.join("server/handler.js")).unwrap();
assert!(handler.contains("export async function fetch(request"));
assert!(handler.contains("BOUNDARY_ARGUMENT_TYPE"));
assert!(handler.contains("BOUNDARY_RESULT_TYPE"));
assert!(handler.contains("BOUNDARY_CAPABILITY_DENIED"));
assert!(handler.contains("BOUNDARY_MIDDLEWARE_DENIED"));
assert!(handler.contains("BOUNDARY_ROUTE_DENIED"));
assert!(handler.contains("typeValidators"));
assert!(handler.contains("math.combinations"));
assert!(!handler.contains("SERVER_ONLY_MARKER"));
let host = fs::read_to_string(out_dir.join("server/host.js")).unwrap();
assert!(host.contains("es-toolkit/array"));
assert!(host.contains("SERVER_ONLY_MARKER"));
let router = fs::read_to_string(out_dir.join("assets/noxid-router.js")).unwrap();
assert!(router.contains("x-noxid-route-id"));
let server_middleware = fs::read_to_string(out_dir.join("server/middleware.js")).unwrap();
assert!(server_middleware.contains("./middleware/requestAudit.js"));
assert!(server_middleware.contains("./middleware/actionSession.js"));
let server_session =
fs::read_to_string(out_dir.join("server/middleware/actionSession.js")).unwrap();
assert!(server_session.contains("SERVER_MIDDLEWARE_MARKER"));
let browser_session =
fs::read_to_string(out_dir.join("assets/middleware/actionSession.js")).unwrap();
assert!(!browser_session.contains("SERVER_MIDDLEWARE_MARKER"));
let validators = fs::read_to_string(out_dir.join("server/validators.js")).unwrap();
assert!(validators.contains("validateTypeServerActionsPageCombinationRequest"));
assert!(validators.contains("validateTypeServerActionsPageCombinationResponse"));
assert!(validators.contains("case \"Success\""));
assert!(validators.contains("case \"Rejected\""));
let execution = fs::read_to_string(out_dir.join("server/execution.manifest.json")).unwrap();
assert!(execution.contains("action:ServerActionsPage.combinationsFor"));
assert!(execution.contains("result:ServerActionsPage.combinationsFor"));
assert!(execution.contains("CombinationRequest"));
assert!(execution.contains("type:ServerActionsPage.CombinationRequest"));
assert!(execution.contains("CombinationResponse"));
assert!(execution.contains("machine:ServerActionsPage.CombinationResponse"));
assert!(execution.contains("math.combinations"));
assert!(execution.contains("\"route\":\"route:/\""));
let audit = execution.find("middleware:requestAudit").unwrap();
let session = execution.find("middleware:actionSession").unwrap();
assert!(audit < session);
let graph = fs::read_to_string(out_dir.join("app.graph.json")).unwrap();
assert!(graph.contains("\"from\":\"execution:server:ServerActionsPage.combinationsFor\",\"kind\":\"protected-by\",\"to\":\"middleware:requestAudit\""));
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"serverActions\": 1"));
assert!(manifest.contains("\"fetchHandler\": \"server/handler.js\""));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("server/middleware.js"));
assert!(ledger.contains("server/middleware/actionSession.js"));
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn worker_actions_emit_a_lazy_validated_worker_graph_without_page_leakage() {
let project = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/worker-actions");
let out_dir = temporary_output();
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.worker_actions, 1);
assert_eq!(build.server_actions, 0);
let page = fs::read_to_string(out_dir.join("assets/WorkerActionsPage.js")).unwrap();
assert!(page.contains("execution: \"worker\""));
assert!(!page.contains("WORKER_ONLY_MARKER"));
let router = fs::read_to_string(out_dir.join("assets/noxid-router.js")).unwrap();
assert!(router.contains("new Worker"));
assert!(router.contains("executeWorkerBoundary"));
assert!(!router.contains("WORKER_ONLY_MARKER"));
let worker = fs::read_to_string(out_dir.join("assets/noxid-worker.js")).unwrap();
assert!(worker.contains("WORKER_BOUNDARY_TYPE"));
assert!(worker.contains("WORKER_CAPABILITY_DENIED"));
assert!(worker.contains("AbortController"));
assert!(worker.contains("action:WorkerActionsPage.calculate"));
let host = fs::read_to_string(out_dir.join("assets/noxid-worker-host.js")).unwrap();
assert!(host.contains("WORKER_ONLY_MARKER"));
let validators =
fs::read_to_string(out_dir.join("assets/noxid-worker-validators.js")).unwrap();
assert!(validators.contains("validateTypeWorkerActionsPageCalculation"));
let ledger = fs::read_to_string(out_dir.join(".noxid-generated-files")).unwrap();
assert!(ledger.contains("assets/noxid-worker.js"));
assert!(ledger.contains("assets/noxid-worker-host.js"));
assert!(ledger.contains("assets/noxid-worker-validators.js"));
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn compiler_owned_remote_bodies_need_no_host_registry() {
let project = temporary_output();
fs::create_dir_all(project.join("src/routes")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Compiler actions\"\n",
)
.unwrap();
fs::write(
project.join("src/routes/+page.nox"),
r#"
component CompilerActions {
actions {
server double(value: Int): Int { return value * 2 }
worker increment(value: Int): Int { return value + 1 }
}
view { <main>Compiler actions</main> }
}
"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.server_actions, 1);
assert_eq!(build.worker_actions, 1);
let server = fs::read_to_string(out_dir.join("server/handler.js")).unwrap();
assert!(server.contains("args[\"value\"] * 2"));
let worker = fs::read_to_string(out_dir.join("assets/noxid-worker.js")).unwrap();
assert!(worker.contains("args[\"value\"] + 1"));
let page = fs::read_to_string(out_dir.join("assets/CompilerActions.js")).unwrap();
assert!(!page.contains("value * 2"));
assert!(!page.contains("value + 1"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn server_middleware_http_primitives_cover_cookies_endpoints_and_secrets() {
let project = temporary_output();
fs::create_dir_all(project.join("src/routes")).unwrap();
fs::create_dir_all(project.join("src/middleware")).unwrap();
fs::create_dir_all(project.join("server/route-middleware")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"HTTP primitives\"\n\n[server]\nsecrets = [\"NOXID_TEST_SECRET\"]\n",
)
.unwrap();
fs::write(
project.join("src/middleware/httpGate.js"),
"export default async function httpGate() { return { allow: true }; }\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export default Object.freeze({});\n",
)
.unwrap();
fs::write(
project.join("server/route-middleware/httpGate.js"),
r#"const SERVER_MIDDLEWARE_MARKER = "http gate must never enter a browser chunk";
export default async function httpGate({ request, environment }) {
void SERVER_MIDDLEWARE_MARKER;
const mode = request.headers.get("x-http-case");
if (mode === "set-cookie") return { allow: true, headers: { "set-cookie": ["noxid_session=abc; Path=/; HttpOnly", "noxid_theme=dark; Path=/"], "x-noxid-session": "fresh" } };
if (mode === "clear-cookie") return { allow: false, headers: { "set-cookie": "noxid_session=; Path=/; Max-Age=0" } };
if (mode === "bad-header") return { allow: true, headers: { "content-type": "text/evil" } };
if (mode === "header-injection") return { allow: true, headers: { "x-noxid-bad": "a\r\nx-injected: b" } };
if (mode === "cookie-redirect") return { redirect: "/login", headers: { "set-cookie": "noxid_session=; Path=/; Max-Age=0" } };
if (mode === "external-redirect") return { redirect: "https://auth.example.test/start?client=abc", external: true };
if (mode === "external-insecure") return { redirect: "http://auth.example.test/start", external: true };
if (mode === "respond") return { respond: { status: 201, contentType: "application/json", body: JSON.stringify({ hello: "endpoint" }) }, headers: { "set-cookie": "noxid_probe=1; Path=/" } };
if (mode === "respond-bad") return { respond: { status: 200, contentType: "text/javascript", body: "alert(1)" } };
if (mode === "secret-echo") return { respond: { status: 200, contentType: "text/plain", body: environment.secrets?.NOXID_TEST_SECRET ?? "missing" } };
return { allow: true };
}
"#,
)
.unwrap();
fs::write(
project.join("src/routes/+page.nox"),
r#"
component HttpPrimitivesPage {
middleware {
httpGate
}
actions {
server double(value: Int): Int { return value * 2 }
}
view { <main>HTTP primitives</main> }
}
"#,
)
.unwrap();
fs::create_dir_all(project.join("src/routes/gate")).unwrap();
fs::write(
project.join("src/routes/gate/+page.nox"),
r#"
component HttpGatePage {
route {
title: "Gate"
render: ssr
}
middleware {
httpGate
}
render {
mode: server
}
view { <main>Gated endpoint surface</main> }
}
"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.server_actions, 1);
assert_eq!(build.ssr_routes, 1);
fs::write(
out_dir.join("http-primitives-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
const ssr = (mode) => handle(new Request("http://noxid.test/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json", ...(mode ? { "x-http-case": mode } : {}) },
body: JSON.stringify({ url: "http://noxid.test/gate" }),
}), {}, {});
const act = (mode) => handle(new Request("http://noxid.test/_noxid/actions/action%3AHttpPrimitivesPage.double", {
method: "POST",
headers: { "content-type": "application/json", "x-noxid-route-id": "route:/", ...(mode ? { "x-http-case": mode } : {}) },
body: JSON.stringify({ arguments: { value: 21 } }),
}), {}, {});
const transport = (response) => JSON.parse(response.headers.get("x-noxid-ssr-headers") ?? "[]");
// SSR document path: cookies ride the success response.
const ssrCookies = await ssr("set-cookie");
if (ssrCookies.status !== 200 || (await ssrCookies.json()).ok !== true) throw new Error("ssr set-cookie success failed");
if (JSON.stringify(transport(ssrCookies)) !== JSON.stringify([["set-cookie", "noxid_session=abc; Path=/; HttpOnly"], ["set-cookie", "noxid_theme=dark; Path=/"], ["x-noxid-session", "fresh"]])) throw new Error("ssr middleware headers lost: " + ssrCookies.headers.get("x-noxid-ssr-headers"));
// The OAuth-callback shape: redirect plus a cookie.
const ssrRedirect = await ssr("cookie-redirect");
const ssrRedirectBody = await ssrRedirect.json();
if (ssrRedirect.status !== 409 || ssrRedirectBody.error?.code !== "SSR_MIDDLEWARE_REDIRECT" || ssrRedirectBody.redirect !== "/login") throw new Error(JSON.stringify(ssrRedirectBody));
if (JSON.stringify(transport(ssrRedirect)) !== JSON.stringify([["set-cookie", "noxid_session=; Path=/; Max-Age=0"]])) throw new Error("redirect lost its cookie");
// Deny may still clear a cookie.
const ssrDenied = await ssr("clear-cookie");
if (ssrDenied.status !== 403 || (await ssrDenied.json()).error?.code !== "SSR_MIDDLEWARE_DENIED") throw new Error("deny path changed");
if (JSON.stringify(transport(ssrDenied)) !== JSON.stringify([["set-cookie", "noxid_session=; Path=/; Max-Age=0"]])) throw new Error("deny lost its cookie");
// External redirects are an explicit https-only opt-in (OAuth start).
const external = await ssr("external-redirect");
const externalBody = await external.json();
if (external.status !== 409 || externalBody.error?.code !== "SSR_MIDDLEWARE_REDIRECT" || externalBody.redirect !== "https://auth.example.test/start?client=abc") throw new Error(JSON.stringify(externalBody));
const insecure = await ssr("external-insecure");
if (insecure.status !== 500 || (await insecure.json()).error?.code !== "SSR_MIDDLEWARE_REDIRECT_INVALID") throw new Error("insecure external redirect accepted");
// Disallowed names and injected values fail closed.
for (const mode of ["bad-header", "header-injection"]) {
const failed = await ssr(mode);
if (failed.status !== 500 || (await failed.json()).error?.code !== "SSR_MIDDLEWARE_HEADERS_INVALID") throw new Error(mode + " did not fail closed");
}
// respond short-circuits into a raw endpoint response.
const responded = await ssr("respond");
const respondedBody = await responded.json();
if (responded.status !== 409 || respondedBody.error?.code !== "SSR_MIDDLEWARE_RESPONSE") throw new Error(JSON.stringify(respondedBody));
if (respondedBody.respond.status !== 201 || respondedBody.respond.contentType !== "application/json" || JSON.parse(respondedBody.respond.body).hello !== "endpoint") throw new Error("respond payload mangled");
if (JSON.stringify(transport(responded)) !== JSON.stringify([["set-cookie", "noxid_probe=1; Path=/"]])) throw new Error("respond lost its cookie");
const respondBad = await ssr("respond-bad");
if (respondBad.status !== 500 || (await respondBad.json()).error?.code !== "SSR_MIDDLEWARE_RESPONSE_INVALID") throw new Error("bad respond content type accepted");
// Declared secrets resolve onto environment.secrets.
const secret = await ssr("secret-echo");
const secretBody = await secret.json();
if (secretBody.respond?.body !== "vault-ok") throw new Error("secret did not reach middleware: " + JSON.stringify(secretBody));
// Action JSON path: cookies apply, respond and disallowed headers refuse.
const actCookies = await act("set-cookie");
if (actCookies.status !== 200 || (await actCookies.json()).value !== 42) throw new Error("action success changed");
const actSet = actCookies.headers.getSetCookie?.() ?? [];
if (JSON.stringify(actSet) !== JSON.stringify(["noxid_session=abc; Path=/; HttpOnly", "noxid_theme=dark; Path=/"]) || actCookies.headers.get("x-noxid-session") !== "fresh") throw new Error("action middleware headers lost");
const actDenied = await act("clear-cookie");
if (actDenied.status !== 403 || (await actDenied.json()).error?.code !== "BOUNDARY_MIDDLEWARE_DENIED" || (actDenied.headers.getSetCookie?.() ?? []).length !== 1) throw new Error("action deny lost its cookie");
const actBad = await act("bad-header");
if (actBad.status !== 500 || (await actBad.json()).error?.code !== "BOUNDARY_MIDDLEWARE_HEADERS") throw new Error("action disallowed header accepted");
const actRespond = await act("respond");
if (actRespond.status !== 409 || (await actRespond.json()).error?.code !== "BOUNDARY_MIDDLEWARE_RESPONSE") throw new Error("action respond accepted");
console.log("http-primitives-ok");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("http-primitives-test.mjs")
.env("NOXID_TEST_SECRET", "vault-ok")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "http-primitives-ok");
// A declared secret missing from the environment fails the request
// that touches it, closed — while requests that never read secrets
// (and build-time shell rendering) stay unaffected.
fs::write(
out_dir.join("missing-secret-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
const request = (mode) => handle(new Request("http://noxid.test/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json", "x-http-case": mode },
body: JSON.stringify({ url: "http://noxid.test/gate" }),
}), {}, {});
const untouched = await request("set-cookie");
if (untouched.status !== 200) throw new Error("request that never reads secrets was blocked");
const touched = await request("secret-echo");
const body = await touched.json();
if (touched.status !== 500 || body.error?.code !== "SSR_MIDDLEWARE_FAILED") throw new Error("missing secret did not fail the request closed: " + JSON.stringify(body));
console.log("missing-secret-ok");
"#,
)
.unwrap();
let missing = Command::new("node")
.arg("missing-secret-test.mjs")
.env_remove("NOXID_TEST_SECRET")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
missing.status.success(),
"{}",
String::from_utf8_lossy(&missing.stderr)
);
assert_stdout_last_line(&missing.stdout, "missing-secret-ok");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn learn_auth_flow_covers_sessions_gating_and_typescript_server_sources() {
let project = temporary_output();
fs::create_dir_all(project.join("src/routes/auth/signin")).unwrap();
fs::create_dir_all(project.join("src/routes/auth/logout")).unwrap();
fs::create_dir_all(project.join("src/routes/gated")).unwrap();
fs::create_dir_all(project.join("src/middleware")).unwrap();
fs::create_dir_all(project.join("server/api/progress")).unwrap();
fs::create_dir_all(project.join("server/routes/auth")).unwrap();
fs::create_dir_all(project.join("server/route-middleware")).unwrap();
fs::create_dir_all(project.join("server/utils")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Auth flow\"\n\n[server]\nsecrets = [\"SESSION_SECRET\"]\n",
)
.unwrap();
// The session helpers are a separate TypeScript module so the test
// exercises the multi-file server graph, not just the entry.
fs::write(
project.join("server/utils/session.ts"),
r#"import { createHmac, timingSafeEqual } from "node:crypto";
export interface ServerEnvironment { secrets?: Record<string, string>; }
const COOKIE = "noxid_session";
function sign(payload: string, secret: string): string {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
function requireSecret(environment: ServerEnvironment | undefined): string {
const secret = environment?.secrets?.SESSION_SECRET;
if (typeof secret !== "string" || secret.length < 16) throw new Error("error[SESSION_SECRET_REQUIRED]");
return secret;
}
export function createSessionCookie(userId: string, environment: ServerEnvironment | undefined): string {
const expires = Math.floor(Date.now() / 1000) + 3600;
const payload = `v1.${Buffer.from(userId, "utf8").toString("base64url")}.${expires}`;
return `${COOKIE}=${payload}.${sign(payload, requireSecret(environment))}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=3600`;
}
export function clearSessionCookie(): string {
return `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`;
}
export function sessionUserId(request: Request, environment: ServerEnvironment | undefined): string | null {
const secret = environment?.secrets?.SESSION_SECRET;
if (typeof secret !== "string" || secret.length === 0) return null;
const entry = (request.headers.get("cookie") ?? "").split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`));
if (!entry) return null;
const value = entry.slice(COOKIE.length + 1);
const lastDot = value.lastIndexOf(".");
if (lastDot === -1) return null;
const payload = value.slice(0, lastDot);
const expected = sign(payload, secret);
const mac = Buffer.from(value.slice(lastDot + 1));
if (mac.length !== Buffer.from(expected).length) return null;
try { if (!timingSafeEqual(mac, Buffer.from(expected))) return null; } catch { return null; }
const [version, userPart, expiresPart] = payload.split(".");
if (version !== "v1" || !userPart || Number(expiresPart) * 1000 < Date.now()) return null;
return Buffer.from(userPart, "base64url").toString("utf8");
}
"#,
)
.unwrap();
// Stubbed exchange: the WO-16 acceptance runs without a WorkOS
// tenant; the production host swaps in the vetted SDK.
fs::write(
project.join("server/host.ts"),
r#"import { clearSessionCookie, createSessionCookie, sessionUserId, type ServerEnvironment } from "./utils/session.js";
export async function authorizationUrl(_origin: string, _environment: ServerEnvironment): Promise<string> {
return "https://auth.example.test/start?client=abc";
}
export async function exchangeAuthCode(code: string, _environment: ServerEnvironment): Promise<{ userId: string }> {
if (code === "boom") throw new Error("exchange failed");
return { userId: `user_${code}` };
}
export function sessionUser(request: Request, environment: ServerEnvironment): string | null {
return sessionUserId(request, environment);
}
export function sessionCookieFor(userId: string, environment: ServerEnvironment): string {
return createSessionCookie(userId, environment);
}
export function clearedSessionCookie(): string { return clearSessionCookie(); }
export function afterLoginPath(): string { return "/gated"; }
const proofs = new Map<string, Set<string>>();
const surveys = new Map<string, { languages: string[]; intent: string }>();
function requireUser(context: { request: Request; environment: ServerEnvironment }): string {
const userId = sessionUserId(context.request, context.environment);
if (userId === null) {
const error = new Error("Learn requires a signed-in session") as Error & { code: string; expose: boolean };
error.code = "LEARN_SESSION_REQUIRED";
error.expose = true;
throw error;
}
return userId;
}
export const endpoints = Object.freeze({
"endpoint:LoadProgress@1": async (_args: Record<string, never>, context: { request: Request; environment: ServerEnvironment }) => ({
concepts: [...(proofs.get(requireUser(context)) ?? new Set<string>())].sort(),
}),
"endpoint:RecordProgressProof@1": async ({ conceptId }: { conceptId: string }, context: { request: Request; environment: ServerEnvironment }) => {
const userId = requireUser(context);
const current = proofs.get(userId) ?? new Set<string>();
current.add(conceptId);
proofs.set(userId, current);
return true;
},
"endpoint:SaveProgressSurvey@1": async ({ languages, intent }: { languages: string[]; intent: string }, context: { request: Request; environment: ServerEnvironment }) => {
surveys.set(requireUser(context), { languages, intent });
return true;
},
});
"#,
)
.unwrap();
for name in ["authStart", "authCallback", "authLogout", "learnSession"] {
fs::write(
project.join(format!("src/middleware/{name}.js")),
format!("export default async function {name}() {{ return {{ allow: true }}; }}\n"),
)
.unwrap();
}
fs::write(
project.join("server/route-middleware/authStart.ts"),
"export default async function authStart({ url, host, environment }: any) {\n return { redirect: await host.authorizationUrl(url.origin, environment), external: true };\n}\n",
)
.unwrap();
fs::write(
project.join("server/route-middleware/authCallback.ts"),
r#"export default async function authCallback({ url, host, environment }: any) {
const code = url.searchParams.get("code");
if (typeof code !== "string" || code.length === 0) {
return { respond: { status: 400, contentType: "text/plain", body: "Missing authorization code." } };
}
let session: { userId: string };
try { session = await host.exchangeAuthCode(code, environment); }
catch { return { respond: { status: 502, contentType: "text/plain", body: "Sign-in failed." } }; }
return { redirect: host.afterLoginPath(), headers: { "set-cookie": host.sessionCookieFor(session.userId, environment) } };
}
"#,
)
.unwrap();
fs::write(
project.join("server/route-middleware/authLogout.ts"),
"export default async function authLogout({ host }: any) {\n return { redirect: \"/\", headers: { \"set-cookie\": host.clearedSessionCookie() } };\n}\n",
)
.unwrap();
fs::write(
project.join("server/route-middleware/learnSession.ts"),
r#"export default async function learnSession({ request, host, environment }: any) {
if (!host) return { allow: true };
const userId = host.sessionUser(request, environment);
if (userId === null) return { redirect: "/", replace: false };
return { allow: true, context: { userId } };
}
"#,
)
.unwrap();
let auth_page = |component: &str, middleware: &str| {
format!(
r#"
component {component} {{
route {{
title: "{component}"
render: ssr
}}
middleware {{
{middleware}
}}
render {{
mode: universal
hydrate: eager
}}
view {{ <main>{component}</main> }}
}}
"#
)
};
fs::write(
project.join("src/routes/auth/signin/+page.nox"),
auth_page("AuthSigninPage", "authStart"),
)
.unwrap();
fs::write(
project.join("server/routes/auth/callback.get.nox"),
"endpoint AuthCallback { query { code: Optional<String> } result: Boolean middleware { authCallback } handler { return true } }\n",
)
.unwrap();
fs::write(
project.join("server/api/progress/load.get.nox"),
"type ProgressSnapshot { concepts: Array<String> } endpoint LoadProgress { result: ProgressSnapshot middleware { learnSession } }\n",
)
.unwrap();
fs::write(
project.join("server/api/progress/proof.post.nox"),
"endpoint RecordProgressProof { body { conceptId: String } result: Boolean middleware { learnSession } }\n",
)
.unwrap();
fs::write(
project.join("server/api/progress/survey.put.nox"),
"endpoint SaveProgressSurvey { body { languages: Array<String> intent: String } result: Boolean middleware { learnSession } }\n",
)
.unwrap();
fs::write(
project.join("src/routes/auth/logout/+page.nox"),
auth_page("AuthLogoutPage", "authLogout"),
)
.unwrap();
fs::write(
project.join("src/routes/gated/+page.nox"),
auth_page("GatedPage", "learnSession"),
)
.unwrap();
fs::write(
project.join("src/routes/+page.nox"),
"\ncomponent AuthHomePage {\n view { <main>Public home</main> }\n}\n",
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 3);
assert_eq!(build.endpoints, 4);
// The TypeScript graph transpiled: no annotations in the output.
let host = fs::read_to_string(out_dir.join("server/host.js")).unwrap();
assert!(!host.contains(": string"));
assert!(host.contains("./modules/server__utils__session.js"));
fs::write(
out_dir.join("auth-flow-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
const ssr = (path, headers = {}) => handle(new Request("http://noxid.test/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({ url: `http://noxid.test${path}` }),
}), {}, {});
const transport = (response) => JSON.parse(response.headers.get("x-noxid-ssr-headers") ?? "[]");
const endpoint = (path, options = {}) => handle(new Request(`http://noxid.test${path}`, options), {}, {});
const start = await ssr("/auth/signin");
const startBody = await start.json();
if (start.status !== 409 || startBody.redirect !== "https://auth.example.test/start?client=abc") throw new Error(JSON.stringify(startBody));
const missingCode = await endpoint("/auth/callback");
if (missingCode.status !== 400 || await missingCode.text() !== "Missing authorization code.") throw new Error("missing callback code was not refused");
const failed = await endpoint("/auth/callback?code=boom");
if (failed.status !== 502 || !(await failed.text()).includes("Sign-in failed")) throw new Error("failed exchange leaked");
const callback = await endpoint("/auth/callback?code=abc");
if (callback.status !== 307 || callback.headers.get("location") !== "/gated") throw new Error("callback did not redirect");
const setCookie = callback.headers.get("set-cookie");
if (!setCookie || !setCookie.includes("noxid_session=") || !setCookie.includes("HttpOnly")) throw new Error("callback set no session cookie");
const cookie = setCookie.split(";")[0];
const unauthenticatedProgress = await endpoint("/api/progress/load");
if (unauthenticatedProgress.status !== 307 || unauthenticatedProgress.headers.get("location") !== "/") throw new Error("unsigned progress request was not refused");
const record = await endpoint("/api/progress/proof", {
method: "POST",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify({ conceptId: "typed-endpoints" }),
});
if (record.status !== 200 || (await record.json()).value !== true) throw new Error("progress proof failed");
const snapshot = await endpoint("/api/progress/load", { headers: { cookie } });
const snapshotBody = await snapshot.json();
if (snapshot.status !== 200 || JSON.stringify(snapshotBody.value?.concepts) !== JSON.stringify(["typed-endpoints"])) throw new Error(JSON.stringify(snapshotBody));
const savedSurvey = await endpoint("/api/progress/survey", {
method: "PUT",
headers: { "content-type": "application/json", cookie },
body: JSON.stringify({ languages: ["Rust", "TypeScript"], intent: "build" }),
});
if (savedSurvey.status !== 200 || (await savedSurvey.json()).value !== true) throw new Error("survey save failed");
const signedIn = await ssr("/gated", { cookie });
if (signedIn.status !== 200 || (await signedIn.json()).ok !== true) throw new Error("valid session was refused");
const signedOut = await ssr("/gated");
const signedOutBody = await signedOut.json();
if (signedOut.status !== 409 || signedOutBody.redirect !== "/") throw new Error(JSON.stringify(signedOutBody));
const tampered = await ssr("/gated", { cookie: cookie.slice(0, -2) + (cookie.endsWith("a") ? "bb" : "aa") });
if (tampered.status !== 409 || (await tampered.json()).redirect !== "/") throw new Error("tampered session accepted");
const logout = await ssr("/auth/logout", { cookie });
const logoutBody = await logout.json();
if (logout.status !== 409 || logoutBody.redirect !== "/") throw new Error(JSON.stringify(logoutBody));
const cleared = transport(logout).find((pair) => pair[0] === "set-cookie")?.[1] ?? "";
if (!cleared.includes("Max-Age=0")) throw new Error("logout did not clear the cookie");
const emittedHost = await import("./server/host.js");
const keys = Object.keys(emittedHost.endpoints ?? {}).sort();
if (JSON.stringify(keys) !== JSON.stringify(["endpoint:LoadProgress@1", "endpoint:RecordProgressProof@1", "endpoint:SaveProgressSurvey@1"])) throw new Error(`wrong endpoint registry keys: ${JSON.stringify(keys)}`);
console.log("auth-flow-ok");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("auth-flow-test.mjs")
.env("SESSION_SECRET", "test-secret-at-least-16-chars")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "auth-flow-ok");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn typed_route_loaders_run_once_before_mount_through_the_validated_server_boundary() {
let project = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/route-loaders");
let out_dir = temporary_output();
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.server_actions, 1);
assert_eq!(build.route_loaders, 1);
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
assert!(app.contains("id: \"route-loader:CustomerPage.customer\""));
assert!(app.contains("sourceName: \"id\""));
let routes = fs::read_to_string(out_dir.join("app.routes.json")).unwrap();
assert!(routes.contains("\"loaders\":[{"));
assert!(routes.contains("route-loader-argument:CustomerPage.customer.customerId"));
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"routeLoaders\": 1"));
let graph = fs::read_to_string(out_dir.join("app.graph.json")).unwrap();
assert!(graph.contains("\"kind\":\"route-loader\""));
assert!(graph.contains("\"from\":\"route:/\",\"kind\":\"invokes\",\"to\":\"route-loader:CustomerPage.customer\""));
let browser = fs::read_to_string(out_dir.join("assets/CustomerPage.js")).unwrap();
assert!(!browser.contains("SERVER_ONLY_MARKER"));
assert!(!browser.contains("route loader host code must never enter a browser chunk"));
fs::write(
out_dir.join("loader-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
import { acquireRouteLoaders } from "./assets/noxid-router.js";
const response = await handle(new Request("http://noxid.test/loaders/_noxid/actions/action%3ACustomerPage.loadCustomer", {
method: "POST",
headers: { "content-type": "application/json", "x-noxid-route-id": "route:/" },
body: JSON.stringify({ arguments: { customerId: 7 } }),
}), { trace: [] }, {});
const boundary = await response.json();
if (response.status !== 200 || boundary.value.name !== "Ada Lovelace" || "ignored" in boundary.value) {
throw new Error(`validated loader boundary failed: ${JSON.stringify(boundary)}`);
}
const calls = [];
const targets = [{ component: "Page", loaders: [
{ id: "route-loader:Page.first", name: "first", action: "action:Page.first", actionName: "first", execution: "server", result: { type: "Int", typeId: null }, stage: 0, arguments: [{ id: "arg:first.id", name: "value", sourceName: "id", type: "Int" }] },
{ id: "route-loader:Page.second", name: "second", action: "action:Page.second", actionName: "second", execution: "server", result: { type: "Int", typeId: null }, stage: 1, arguments: [{ id: "arg:second.first", name: "value", sourceName: "first", type: "Int" }] },
] }];
const props = await acquireRouteLoaders(targets, { id: 7 }, {
executeBoundary: async (descriptor) => {
calls.push(`${descriptor.loader}:${descriptor.arguments[0].value}`);
return descriptor.arguments[0].value + 1;
},
}, new AbortController().signal);
if (JSON.stringify(calls) !== JSON.stringify(["route-loader:Page.first:7", "route-loader:Page.second:8"])) throw new Error(`wrong loader order: ${JSON.stringify(calls)}`);
if (props.id !== 7 || props.first !== 8 || props.second !== 9 || !Object.isFrozen(props)) throw new Error(`wrong loader props: ${JSON.stringify(props)}`);
console.log("route-loaders-ok");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("loader-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "route-loaders-ok");
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn deployment_base_paths_are_normalized_and_reject_unsafe_values() {
assert_eq!(normalize_base_path("/").unwrap(), "/");
assert_eq!(normalize_base_path("/console/").unwrap(), "/console");
assert!(normalize_base_path("console").is_err());
assert!(normalize_base_path("//other.example").is_err());
assert!(normalize_base_path("/console/../admin").is_err());
assert!(normalize_base_path("/console//admin").is_err());
assert!(normalize_base_path("/customer portal").is_err());
}
#[test]
fn loading_and_error_boundary_contracts_are_checked() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\nbase = \"/test\"\n").unwrap();
fs::write(
routes.join("+page.nox"),
"component Page { view { <main>Page</main> } }\n",
)
.unwrap();
fs::write(
routes.join("+loading.nox"),
"component Loading { props { value: String } view { <p>{value}</p> } }\n",
)
.unwrap();
let loading_error = prepare_project(&project).err().unwrap();
assert!(loading_error.contains("cannot declare props"));
fs::write(
routes.join("+loading.nox"),
"component Loading { view { <p>Loading</p> } }\n",
)
.unwrap();
fs::write(
routes.join("+error.nox"),
"component Error { props { message: String } view { <p>{message}</p> } }\n",
)
.unwrap();
let error_error = prepare_project(&project).err().unwrap();
assert!(error_error.contains("must declare exactly `code: String` and `message: String`"));
fs::write(
routes.join("+error.nox"),
"component Error { props { code: String message: String } view { <p>{code}</p><p>{message}</p> } }\n",
)
.unwrap();
let prepared = prepare_project(&project).unwrap();
assert_eq!(prepared.routes.base_path, "/test");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn route_metadata_is_rejected_outside_page_components() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\nbase = \"/test\"\n").unwrap();
fs::write(
routes.join("+page.nox"),
"component Page { route { title: \"Page\" } view { <main>Page</main> } }\n",
)
.unwrap();
fs::write(
routes.join("+layout.nox"),
"component Layout { route { title: \"Layout\" } view { <main><outlet /></main> } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("route metadata is only allowed in +page.nox components"));
fs::write(
routes.join("+layout.nox"),
"component Layout { query { tab: Optional<String> } view { <main><outlet /></main> } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("query schemas are only allowed in +page.nox components"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn optional_route_query_values_can_be_coalesced_in_page_semantics() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Optional route\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
r#"
component SearchPage {
route { title: "Search" }
query { page: Optional<Int> }
computed { currentPage = page ?? 1 }
view { <main><p>{currentPage}</p></main> }
}
"#,
)
.unwrap();
let prepared = prepare_project(&project).unwrap();
let route = &prepared.routes.routes[0];
assert_eq!(route.query.len(), 1);
assert_eq!(route.query[0].name, "page");
assert!(!route.query[0].required);
let target = prepared
.compiled
.values()
.find(|target| target.component.name == "SearchPage")
.expect("compiled page");
assert_eq!(target.component.computed[0].ty, noxid_types::Type::Int);
assert!(target.semantic_json.contains("\"operator\":\"??\""));
assert!(target.javascript.contains(" ?? 1)"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn catch_all_routes_are_terminal_and_require_string_arrays() {
let project = temporary_output();
let routes = project.join("src/routes");
let nested = routes.join("docs/[...segments]/child");
fs::create_dir_all(&nested).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\nbase = \"/test\"\n").unwrap();
fs::write(
nested.join("+page.nox"),
"component Docs { props { segments: Array<String> } view { <main>{segments}</main> } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("must be the final URL segment"));
fs::remove_dir_all(routes.join("docs")).unwrap();
let catch_all = routes.join("docs/[...segments]");
fs::create_dir_all(&catch_all).unwrap();
fs::write(
catch_all.join("+page.nox"),
"component Docs { props { segments: String } view { <main>{segments}</main> } }\n",
)
.unwrap();
let error = prepare_project(&project).err().unwrap();
assert!(error.contains("must use Array<String>"));
fs::remove_dir_all(project).unwrap();
}
#[test]
fn router_converts_typed_query_values_and_rejects_ambiguous_input() {
let directory = temporary_output();
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("router.mjs"), ROUTER_RUNTIME).unwrap();
fs::write(
directory.join("query-test.mjs"),
r#"import { matchRoute, parseRouteQuery } from "./router.mjs";
const route = {
id: "route:/search",
query: [
{ name: "term", type: "String", required: true },
{ name: "page", type: "Int", required: false },
{ name: "preview", type: "Boolean", required: false },
],
};
const valid = parseRouteQuery(route, new URL("http://noxid.test/search?term=customers&page=2&preview=false"));
const optional = parseRouteQuery(route, new URL("http://noxid.test/search?term=customers"));
const catchRoute = {
id: "route:/docs/{*segments}",
pattern: "/docs/{*segments}",
parameters: [
{ name: "segments", type: "Array<String>", catchAll: true },
],
};
const caught = matchRoute(catchRoute, "/docs/getting%20started/install");
const catchMissing = matchRoute(catchRoute, "/docs");
const stringRoute = { id: "route:/names/{name}", pattern: "/names/{name}", parameters: [{ name: "name", type: "String", catchAll: false }] };
const emptyRoute = { id: "route:/empty/{value}/tail", pattern: "/empty/{value}/tail", parameters: [{ name: "value", type: "String", catchAll: false }] };
const staticPercentRoute = { id: "route:/%20", pattern: "/%20", parameters: [] };
const unsafePaths = ["/names/%2e", "/names/%2e%2e", "/names/%00", "/names/%1f", "/names/%7f"];
const specialNamesSafe = ["__proto__", "constructor", "toString"].every((name) => {
const params = matchRoute({ id: `route:/special/{${name}}`, pattern: `/special/{${name}}`, parameters: [{ name, type: "String", catchAll: false }] }, "/special/own");
return params !== null && Object.getPrototypeOf(params) === null && Object.hasOwn(params, name) && params[name] === "own";
});
const code = (value) => {
try {
parseRouteQuery(route, new URL(value));
return null;
} catch (error) {
return error.code;
}
};
console.log(JSON.stringify({
valid,
optional,
missing: code("http://noxid.test/search?page=2"),
multiple: code("http://noxid.test/search?term=a&term=b"),
invalid: code("http://noxid.test/search?term=a&preview=yes"),
caught,
catchMissing,
encodedSlash: matchRoute(stringRoute, "/names/a%2Fb"),
emptySegment: matchRoute(emptyRoute, "/empty//tail"),
staticPercent: matchRoute(staticPercentRoute, "/%2520") !== null,
unsafeRejected: unsafePaths.every((path) => matchRoute(stringRoute, path) === null),
unsafeCatchRejected: matchRoute(catchRoute, "/docs/ok/%2e%2e") === null,
specialNamesSafe,
}));
"#,
)
.unwrap();
let output = Command::new("node")
.arg("query-test.mjs")
.current_dir(&directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let result = String::from_utf8(output.stdout).unwrap();
assert_eq!(
result.trim(),
r#"{"valid":{"term":"customers","page":2,"preview":false},"optional":{"term":"customers","page":null,"preview":null},"missing":"ROUTE_QUERY_REQUIRED","multiple":"ROUTE_QUERY_MULTIPLE","invalid":"ROUTE_QUERY_INVALID","caught":{"segments":["getting started","install"]},"catchMissing":null,"encodedSlash":{"name":"a/b"},"emptySegment":{"value":""},"staticPercent":true,"unsafeRejected":true,"unsafeCatchRejected":true,"specialNamesSafe":true}"#
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn client_route_beneath_server_layout_renders_and_mounts_into_a_server_shell() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(project.join("server")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Server shell\"\nbase = \"/shell\"\n\n[server]\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export const actions = Object.freeze({});\n",
)
.unwrap();
fs::write(
routes.join("+layout.nox"),
r#"component ShellLayout {
render { mode: server hydrate: never }
view { <section><h1>Server shell</h1><outlet></outlet></section> }
}"#,
)
.unwrap();
fs::write(
routes.join("+page.nox"),
r#"component ShellPage {
route { render: client }
view { <main>Mounted client page</main> }
}"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 0);
assert_eq!(build.server_shell_routes, 1);
let manifest = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(manifest.contains("\"ssrRoutes\": 0"));
assert!(manifest.contains("\"serverShellRoutes\": 1"));
assert!(manifest.contains("\"serverRenderer\": \"server/renderer.js\""));
let replay = format!(
"{}\n{}",
noxid_runtime::NODE_TEST_DOM,
r#"
const { fetch: handle } = await import("./server/handler.js");
const response = await handle(new Request("http://noxid.test/shell/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "http://noxid.test/shell/" }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
if (!result.html.includes("Server shell") || !result.html.includes("<outlet") || result.html.includes("Mounted client page")) throw new Error(result.html);
const hydration = JSON.parse(result.payload);
if (hydration.routeId !== "route:/" || hydration.targets[0].mode !== "server" || hydration.targets[1].mode !== "universal") throw new Error(result.payload);
const root = parseTestHtml(result.html);
globalThis.location = new URL("http://noxid.test/shell/");
globalThis.history = { pushState() {}, replaceState() {} };
globalThis.window = { addEventListener() {}, removeEventListener() {} };
document.addEventListener = () => {};
document.removeEventListener = () => {};
const createElement = document.createElement.bind(document);
document.createElement = (tag) => { const node = createElement(tag); node.dataset ??= {}; return node; };
document.head = document.createElement("head");
document.title = "Server shell";
const { startRouter } = await import("./assets/noxid-router.js");
const routes = [{
id: "route:/",
pattern: "/",
metadata: null,
render: { id: "route-render:/", mode: "client" },
parameters: [],
query: [],
middleware: [],
targets: [
{ component: "ShellLayout", layout: true, renderMode: "server", hydration: "never", css: "", loaders: [], load: null },
{ component: "ShellPage", layout: false, renderMode: "universal", hydration: "eager", css: "", loaders: [], load: () => import("./assets/ShellPage.js") },
],
loading: null,
error: null,
}];
const router = startRouter({ root, routes, basePath: "/shell", hydration });
await new Promise((resolve) => setTimeout(resolve, 20));
const text = (node) => node.nodeType === 3 ? node.data : node.childNodes.map(text).join("");
if (!text(root).includes("Server shell") || !text(root).includes("Mounted client page") || !root.querySelector("outlet")) throw new Error(text(root));
router.dispose();
console.log("server-shell-mount-ok");
"#,
);
fs::write(out_dir.join("server-shell-test.mjs"), replay).unwrap();
let output = Command::new("node")
.arg("server-shell-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"server shell replay failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "server-shell-mount-ok");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn client_server_shells_retain_universal_ancestors_and_deeper_alternating_layouts() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(routes.join("nested/deep/final")).unwrap();
fs::create_dir_all(project.join("server")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Alternating server shells\"\nbase = \"/alternating\"\n\n[server]\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export const actions = Object.freeze({});\n",
)
.unwrap();
fs::write(
routes.join("+layout.nox"),
r#"component UniversalOuter {
render { mode: universal hydrate: eager }
view { <div><h1>Universal outer</h1><outlet></outlet></div> }
}"#,
)
.unwrap();
fs::write(
routes.join("nested/+layout.nox"),
r#"component ServerFirst {
render { mode: server hydrate: never }
view { <section><h2>Server first</h2><outlet></outlet></section> }
}"#,
)
.unwrap();
fs::write(
routes.join("nested/+page.nox"),
r#"component NestedClientLeaf {
route { render: client }
view { <main>Nested client leaf</main> }
}"#,
)
.unwrap();
fs::write(
routes.join("nested/deep/+layout.nox"),
r#"component UniversalMiddle {
render { mode: universal hydrate: eager }
view { <div><h3>Universal middle</h3><outlet></outlet></div> }
}"#,
)
.unwrap();
fs::write(
routes.join("nested/deep/final/+layout.nox"),
r#"component ServerSecond {
render { mode: server hydrate: never }
view { <aside><h4>Server second</h4><outlet></outlet></aside> }
}"#,
)
.unwrap();
fs::write(
routes.join("nested/deep/final/+page.nox"),
r#"component DeepClientLeaf {
route { render: client }
view { <main>Deep client leaf</main> }
}"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 0);
assert_eq!(build.server_shell_routes, 2);
let replay = format!(
"{}\n{}",
noxid_runtime::NODE_TEST_DOM,
r##"
const { fetch: handle } = await import("./server/handler.js");
globalThis.history = { pushState() {}, replaceState() {} };
globalThis.window = { addEventListener() {}, removeEventListener() {} };
document.addEventListener = () => {};
document.removeEventListener = () => {};
const createElement = document.createElement.bind(document);
document.createElement = (tag) => { const node = createElement(tag); node.dataset ??= {}; node.append ??= (...children) => children.forEach((child) => node.appendChild(child)); return node; };
document.head = document.createElement("head");
const appendChild = document.head.appendChild.bind(document.head);
document.head.appendChild = (node) => { const result = appendChild(node); queueMicrotask(() => node.dispatchEvent(new Event("load"))); return result; };
const text = (node) => node.nodeType === 3 ? node.data : node.childNodes.map(text).join("");
async function activate(path, expected, expectedModes) {
const targetUrl = `http://noxid.test/alternating${path}`;
const response = await handle(new Request("http://noxid.test/alternating/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: targetUrl }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
const hydration = JSON.parse(result.payload);
if (hydration.targets.map((target) => target.mode).join(",") !== expectedModes) throw new Error(result.payload);
const root = parseTestHtml(result.html);
globalThis.location = new URL(targetUrl);
document.title = "Alternating server shells";
document.querySelector = (selector) => selector === "#app"
? root
: selector === "#__NOXID_SSR_PAYLOAD__"
? { textContent: result.payload }
: null;
await import(`./app.js?path=${encodeURIComponent(path)}`);
await new Promise((resolve) => setTimeout(resolve, 30));
const visible = text(root);
if (visible !== expected) throw new Error(`${path}: ${visible}`);
globalThis.__NOXID_APP__.router.dispose();
}
await activate(
"/nested",
"Universal outerServer firstNested client leaf",
"universal,server,universal",
);
await activate(
"/nested/deep/final",
"Universal outerServer firstUniversal middleServer secondDeep client leaf",
"universal,server,universal,server,universal",
);
console.log("alternating-server-shells-ok");
"##,
);
fs::write(out_dir.join("alternating-server-shells-test.mjs"), replay).unwrap();
let output = Command::new("node")
.arg("alternating-server-shells-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"alternating server-shell replay failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "alternating-server-shells-ok");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn capability_protected_browser_route_requires_and_wires_an_explicit_host() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::write(project.join("Noxid.toml"), "[app]\ntitle = \"Protected\"\n").unwrap();
fs::write(
routes.join("+page.nox"),
r#"component ProtectedPage {
requires [ account.read ]
view { <main>Authorized page</main> }
}"#,
)
.unwrap();
let out_dir = project.join("dist");
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap_err();
assert!(error.contains("error[CLIENT_AUTHORIZER_HOST_REQUIRED]"));
assert!(error.contains("component:ProtectedPage"));
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Protected\"\nhost = \"src/host.js\"\n",
)
.unwrap();
fs::write(
project.join("src/host.js"),
"export default { authorizeComponent(capability) { return capability === \"capability:account.read\"; }, routeError(error) { globalThis.__NOXID_TEST_ROUTE_ERROR__ = error; } };\n",
)
.unwrap();
build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
assert!(app.contains("const host = (await import(\"./assets/host.js\")).default ?? {};"));
assert!(app.contains("typeof host.authorizeComponent"));
assert!(app.contains("CLIENT_COMPONENT_AUTHORIZER_MISSING"));
assert!(!app.contains("const host = {};"));
let replay = format!(
"{}\n{}",
noxid_runtime::NODE_TEST_DOM,
r##"
globalThis.location = new URL("http://noxid.test/");
globalThis.history = { pushState() {}, replaceState() {} };
globalThis.window = { addEventListener() {}, removeEventListener() {} };
document.addEventListener = () => {};
document.removeEventListener = () => {};
const createElement = document.createElement.bind(document);
document.createElement = (tag) => { const node = createElement(tag); node.dataset ??= {}; return node; };
document.head = document.createElement("head");
document.title = "Protected";
const root = document.createElement("div");
document.querySelector = (selector) => selector === "#app" ? root : null;
const appendChild = document.head.appendChild.bind(document.head);
document.head.appendChild = (node) => { const result = appendChild(node); queueMicrotask(() => node.dispatchEvent(new Event("load"))); return result; };
await import("./app.js");
await new Promise((resolve) => setTimeout(resolve, 20));
const text = (node) => node.nodeType === 3 ? node.data : node.childNodes.map(text).join("");
if (globalThis.__NOXID_TEST_ROUTE_ERROR__) throw globalThis.__NOXID_TEST_ROUTE_ERROR__;
if (!text(root).includes("Authorized page")) throw new Error(text(root));
globalThis.__NOXID_APP__.router.dispose();
console.log("browser-authorizer-host-ok");
"##,
);
fs::write(out_dir.join("browser-authorizer-test.mjs"), replay).unwrap();
let output = Command::new("node")
.arg("browser-authorizer-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"browser authorizer replay failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout)
.lines()
.last()
.unwrap_or_default(),
"browser-authorizer-host-ok"
);
fs::remove_dir_all(project).unwrap();
}
#[test]
fn hybrid_ssr_reuses_typed_loaders_and_prunes_server_component_javascript() {
let project = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/hybrid-ssr");
let out_dir = temporary_output();
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 1);
assert_eq!(build.route_loaders, 1);
assert_eq!(build.components, 4);
assert!(!out_dir.join("assets/HybridLayout.js").exists());
assert!(out_dir.join("assets/HybridLayout.css").exists());
assert!(out_dir.join("assets/HybridBadge.js").exists());
assert!(out_dir.join("assets/LayoutIsland.js").exists());
assert!(out_dir.join("assets/ClientLayoutIsland.js").exists());
let page_javascript = fs::read_to_string(out_dir.join("assets/HybridPage.js")).unwrap();
assert!(page_javascript.contains("export function hydrateHybridPage"));
for hydrator in [
"hydrateIf",
"hydrateMatch",
"hydrateFor",
"hydrateComponent",
] {
assert!(page_javascript.contains(hydrator));
}
let client_island_javascript =
fs::read_to_string(out_dir.join("assets/ClientLayoutIsland.js")).unwrap();
assert!(!client_island_javascript.contains("export function hydrateClientLayoutIsland"));
assert!(!client_island_javascript.contains("hydrateText"));
assert!(!client_island_javascript.contains("hydrateIf"));
assert!(!client_island_javascript.contains("globalThis.__NOXID_HMR__"));
let app = fs::read_to_string(out_dir.join("app.js")).unwrap();
assert!(app.contains("component: \"HybridLayout\""));
assert!(app.contains("renderMode: \"server\""));
assert!(app.contains("load: null"));
assert!(app.contains("\"LayoutIsland\": () => import(\"./assets/LayoutIsland.js\")"));
assert!(
app.contains(
"\"ClientLayoutIsland\": () => import(\"./assets/ClientLayoutIsland.js\")"
)
);
assert!(!app.contains("\"HybridBadge\": () => import"));
let router = fs::read_to_string(out_dir.join("assets/noxid-router.js")).unwrap();
assert!(router.contains("const ROUTER_SUPPORTS_HYDRATION = true;"));
assert!(router.contains("hydrateInitial"));
assert!(router.contains("target.renderMode === \"server\""));
assert!(router.starts_with("import { emitRuntimeEvent, findMarker, hydrateComponent }"));
assert!(router.contains("hydrateIndependentIslands"));
assert!(router.contains("options.deferHydration && target.hydration !== \"eager\""));
assert!(router.contains("requestIdleCallback"));
assert!(router.contains("IntersectionObserver"));
assert!(router.contains("NOXID_DEFERRED_HYDRATION_FAILED"));
assert!(router.contains("event.captured"));
assert!(router.contains("event.replayed"));
let render = fs::read_to_string(out_dir.join("app.render.json")).unwrap();
assert!(render.contains("\"mode\":\"ssr\""));
assert!(render.contains("\"hydration\":\"never\""));
assert!(render.contains("\"hydration\":\"visible\""));
assert!(render.contains("\"middleware\":[\"middleware:ssrAudit\",\"middleware:layoutGuard\",\"middleware:ssrSession\"]"));
for name in ["ssrAudit", "layoutGuard", "ssrSession"] {
assert!(
out_dir
.join(format!("server/middleware/{name}.js"))
.exists()
);
}
let browser_javascript = fs::read_dir(out_dir.join("assets"))
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("js"))
.map(|entry| fs::read_to_string(entry.path()).unwrap())
.collect::<Vec<_>>()
.join("\n");
assert!(
!browser_javascript.contains("SSR session middleware must never enter a browser chunk")
);
fs::write(
out_dir.join("ssr-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
async function render(session) {
const trace = [];
const headers = { "content-type": "application/json" };
if (session) headers["x-example-session"] = session;
const response = await handle(new Request("http://noxid.test/hybrid/_noxid/ssr", {
method: "POST",
headers,
body: JSON.stringify({ url: "http://noxid.test/hybrid/?id=7" }),
}), { trace }, {});
return { response, result: await response.json(), trace };
}
const success = await render();
if (success.response.status !== 200 || success.result.ok !== true) throw new Error(JSON.stringify(success.result));
const streamTrace = [];
const streamed = await handle(new Request("http://noxid.test/hybrid/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "http://noxid.test/hybrid/?id=7", stream: true }),
}), { trace: streamTrace }, {});
if (streamed.status !== 200 || streamed.headers.get("x-noxid-ssr-stream") !== "1") throw new Error("stream transport unavailable");
const streamEvents = (await streamed.text()).trim().split("\n").map((line) => JSON.parse(line));
if (streamEvents.map((event) => event.type).join(",") !== "shell,html,payload,done") throw new Error(JSON.stringify(streamEvents));
if (!streamEvents[1].html.includes("Ada Lovelace") || !streamEvents[2].payload.includes("\"routeId\":\"route:/\"")) throw new Error("stream render lost html or hydration payload");
if (!success.result.html.includes("Ada Lovelace") || !success.result.html.includes("<!--noxid-text-")) throw new Error(success.result.html);
if (!success.result.html.includes("<!--noxid-if-8-->") || !success.result.html.includes("<!--noxid-if-end-8-->")) throw new Error("missing conditional SSR range");
if (!success.result.html.includes("noxid-match-case-13:s:Ready") || !success.result.html.includes("SSR match adopted")) throw new Error("missing exhaustive match SSR range");
if ((success.result.html.match(/noxid-for-item-15:/g) ?? []).length !== 3 || !success.result.html.includes("noxid-for-end-15")) throw new Error("missing keyed SSR blocks");
if (!success.result.html.includes("<!--noxid-component-9-->") || !success.result.html.includes("Nested island: ") || !success.result.html.includes("<!--noxid-component-end-9-->")) throw new Error("missing nested component island");
if (!success.result.html.includes("<!--noxid-island:island-1-->") || !success.result.html.includes("Independently hydrated layout island") || !success.result.html.includes("<!--noxid-island-end:island-1-->")) throw new Error("missing server-parent island range");
if (!success.result.html.includes("<!--noxid-island:island-2-->") || !success.result.html.includes("<!--noxid-island-end:island-2-->") || success.result.html.includes("Client-only layout island")) throw new Error("client-only island was not emitted as an empty range");
const payload = JSON.parse(success.result.payload);
if (payload.props.customer.name !== "Ada Lovelace" || payload.targets[0].hydration !== "never" || payload.targets[1].hydration !== "visible" || payload.domMarkerSchemaVersion !== 2) throw new Error(success.result.payload);
if (payload.islandSchemaVersion !== 2 || payload.islands?.length !== 2 || payload.islands[0].component !== "LayoutIsland" || payload.islands[0].mode !== "universal" || payload.islands[0].hydration !== "interaction" || payload.islands[0].props.label !== "Independently hydrated layout island" || payload.islands[1].component !== "ClientLayoutIsland" || payload.islands[1].mode !== "client" || payload.islands[1].hydration !== "eager" || payload.islands[1].props.label !== "Client-only layout island") throw new Error(success.result.payload);
if (success.result.payload.includes("HybridBadge")) throw new Error("nested hydrated child was incorrectly promoted to a root island");
if (JSON.stringify(payload.middleware) !== JSON.stringify(["ssrAudit", "layoutGuard", "ssrSession"])) throw new Error(success.result.payload);
if (success.result.payload.includes("requestId") || success.result.payload.includes("layoutAuthorized") || success.result.payload.includes("\"session\":")) throw new Error("server middleware context leaked into hydration payload");
const expectedSuccessTrace = ["middleware:ssrAudit:route:/", "middleware:layoutGuard:route:/", "middleware:ssrSession:route:/", "loader-context:anonymous", "ssr:customer:7"];
if (JSON.stringify(success.trace) !== JSON.stringify(expectedSuccessTrace)) throw new Error(JSON.stringify(success.trace));
const denied = await render("denied");
if (denied.response.status !== 403 || denied.result.error?.code !== "SSR_MIDDLEWARE_DENIED" || denied.trace.length !== 3 || denied.trace.some((entry) => entry.startsWith("loader-context:"))) throw new Error(JSON.stringify(denied));
const redirected = await render("redirect");
if (redirected.response.status !== 409 || redirected.result.error?.code !== "SSR_MIDDLEWARE_REDIRECT" || redirected.result.redirect !== "/hybrid/login" || redirected.trace.length !== 3 || redirected.trace.some((entry) => entry.startsWith("loader-context:"))) throw new Error(JSON.stringify(redirected));
const invalidContext = await render("invalid-context");
if (invalidContext.response.status !== 500 || invalidContext.result.error?.code !== "SSR_MIDDLEWARE_CONTEXT_INVALID" || invalidContext.trace.length !== 3 || invalidContext.trace.some((entry) => entry.startsWith("loader-context:"))) throw new Error(JSON.stringify(invalidContext));
const unsafeRedirect = await render("unsafe-redirect");
if (unsafeRedirect.response.status !== 500 || unsafeRedirect.result.error?.code !== "SSR_MIDDLEWARE_REDIRECT_INVALID" || unsafeRedirect.trace.length !== 3 || unsafeRedirect.trace.some((entry) => entry.startsWith("loader-context:"))) throw new Error(JSON.stringify(unsafeRedirect));
console.log("hybrid-ssr-middleware-ok");
"#,
)
.unwrap();
let output = Command::new("node")
.arg("ssr-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "hybrid-ssr-middleware-ok");
fs::write(
out_dir.join("hydration-replay-test.mjs"),
r#"globalThis.Node = { ELEMENT_NODE: 1, COMMENT_NODE: 8 };
const runtime = await import("./assets/noxid-runtime.js");
const router = await import("./assets/noxid-router.js");
if (runtime.configureDevtools) throw new Error("production runtime must prune the devtools feature");
class Scope extends EventTarget {
constructor() { super(); this.nodeType = 1; this.firstElementChild = null; this.parentElement = null; this.isConnected = true; }
click() { this.dispatchEvent(new Event("click", { bubbles: true, cancelable: true })); }
}
const scope = new Scope();
let handled = 0;
let release;
const ready = new Promise((resolve) => { release = () => { scope.addEventListener("click", () => { handled += 1; }); resolve(); }; });
const identity = { semanticId: "component:Replay", runtimeId: "hydration:test", ownerId: null, component: "Replay", scope: "island:test", policy: "interaction" };
const cancel = router.scheduleHydration(scope, "interaction", () => ready, identity);
const original = new Event("click", { bubbles: true, cancelable: true });
scope.dispatchEvent(original);
if (!original.defaultPrevented || handled !== 0) throw new Error("interaction escaped before hydration");
release();
await ready;
await new Promise((resolve) => setTimeout(resolve, 0));
if (handled !== 1) throw new Error(`captured click replayed ${handled} times`);
cancel();
console.log("hydration-replay-ok");
"#,
)
.unwrap();
let replay = Command::new("node")
.arg("hydration-replay-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
replay.status.success(),
"{}",
String::from_utf8_lossy(&replay.stderr)
);
assert_eq!(
String::from_utf8(replay.stdout)
.unwrap()
.lines()
.last()
.unwrap_or_default(),
"hydration-replay-ok"
);
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn ssr_default_slot_hydration_adopts_nodes_and_reaches_parent_actions() {
let project = temporary_output();
let routes = project.join("src/routes");
let components = project.join("src/components");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(&components).unwrap();
fs::create_dir_all(project.join("server")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"SSR slot adoption\"\nbase = \"/slots\"\n\n[server]\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export const actions = Object.freeze({});\n",
)
.unwrap();
fs::write(
components.join("Card.nox"),
r#"component Card {
view { <section class="card"><slot /></section> }
}"#,
)
.unwrap();
fs::write(
routes.join("+layout.nox"),
r#"component SlotLayout {
render { mode: universal hydrate: eager }
state { count: Int = 1 }
actions { increment() { count = count + 1 } }
view {
<main>
<Card>
<button +click={increment}>Count: {count}</button>
</Card>
<outlet></outlet>
</main>
}
}"#,
)
.unwrap();
fs::write(
routes.join("+page.nox"),
r#"component SlotPage {
route { render: ssr }
render { mode: server hydrate: never }
view { <p>Server page inside the slotted layout.</p> }
}"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 1);
let layout_javascript = fs::read_to_string(out_dir.join("assets/SlotLayout.js")).unwrap();
let card_javascript = fs::read_to_string(out_dir.join("assets/Card.js")).unwrap();
let runtime_javascript =
fs::read_to_string(out_dir.join("assets/noxid-runtime.js")).unwrap();
assert!(layout_javascript.contains("$noxHydratingSlot"));
assert!(card_javascript.contains("hydrateSlot"));
assert!(runtime_javascript.contains("export function hydrateSlot"));
let replay = format!(
"{}\n{}",
noxid_runtime::NODE_TEST_DOM,
r#"
const { fetch: handle } = await import("./server/handler.js");
const response = await handle(new Request("http://noxid.test/slots/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "http://noxid.test/slots/" }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
for (const marker of ["<!--noxid-component-1-->", "<!--noxid-slot-1-->", "<!--noxid-slot-end-1-->", "<!--noxid-component-end-1-->"]) {
if (!result.html.includes(marker)) throw new Error(`missing SSR slot marker ${marker}: ${result.html}`);
}
const root = parseTestHtml(result.html);
const runtime = await import("./assets/noxid-runtime.js");
const layout = await import("./assets/SlotLayout.js");
const serverButton = root.querySelector("[data-noxid-event-2]");
const textMarker = runtime.findMarker(root, "noxid-text-3");
const serverText = textMarker.nextSibling;
if (!serverButton || serverText?.data !== "1") throw new Error(`invalid server slot content: ${result.html}`);
const instance = layout.hydrateSlotLayout(root);
runtime.flush();
if (root.querySelector("[data-noxid-event-2]") !== serverButton || runtime.findMarker(root, "noxid-text-3").nextSibling !== serverText) {
throw new Error("slot hydration recreated server DOM nodes");
}
serverButton.dispatchEvent(new Event("click"));
runtime.flush();
if (serverText.data !== "2") throw new Error(`slotted parent action did not update its adopted binding: ${serverText.data}`);
instance.dispose();
console.log("ssr-slot-adoption-ok");
"#,
);
fs::write(out_dir.join("ssr-slot-adoption-test.mjs"), replay).unwrap();
let output = Command::new("node")
.arg("ssr-slot-adoption-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
output.status.success(),
"SSR slot adoption replay failed:\n{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_stdout_last_line(&output.stdout, "ssr-slot-adoption-ok");
fs::remove_dir_all(project).unwrap();
}
#[test]
fn prerender_rejects_dynamic_routes_without_explicit_entries() {
let project = temporary_output();
let routes = project.join("src/routes/items/[id]");
fs::create_dir_all(&routes).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"Invalid prerender\"\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
r#"component DynamicPrerender {
route { render: prerender }
render { mode: server hydrate: never }
props { id: Static<String> }
view { <p>{id}</p> }
}"#,
)
.unwrap();
let out_dir = temporary_output();
let error = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.err()
.unwrap();
assert!(error.contains("PRERENDER_DYNAMIC_ROUTE_REQUIRES_ENTRIES"));
fs::remove_dir_all(project).unwrap();
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn prerender_emits_hydratable_and_zero_javascript_static_routes() {
let project = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/prerender-app");
let out_dir = temporary_output();
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 0);
assert_eq!(build.prerender_routes, 2);
assert!(out_dir.join("server/handler.js").is_file());
prerender_output(&out_dir, &build).unwrap();
let root = fs::read_to_string(out_dir.join("index.html")).unwrap();
assert!(root.contains("Prerendered interactive page"));
assert!(root.contains("Count: <!--noxid-text-1-->0"));
assert!(root.contains("id=\"__NOXID_SSR_PAYLOAD__\""));
assert!(root.contains("data-noxid-prerender=\"route:/\""));
let router = fs::read_to_string(out_dir.join("assets/noxid-router.js")).unwrap();
assert!(router.contains("destination?.render?.mode === \"prerender\""));
let about = fs::read_to_string(out_dir.join("about/index.html")).unwrap();
assert!(about.contains("Zero-JavaScript prerendered page"));
assert!(!about.contains("<script"));
assert!(!about.contains("__NOXID_SSR_PAYLOAD__"));
assert!(!out_dir.join("server").exists());
let manifest = fs::read_to_string(out_dir.join("app.prerender.json")).unwrap();
assert!(manifest.contains("\"url\":\"/prerender/\""));
assert!(manifest.contains("\"output\":\"about/index.html\""));
let application = fs::read_to_string(out_dir.join("app.manifest.json")).unwrap();
assert!(application.contains("\"prerenderRoutes\": 2"));
assert!(application.contains("\"fetchHandler\": null"));
fs::remove_dir_all(out_dir).unwrap();
}
#[test]
fn render_cache_dynamic_prerender_and_persistent_build_cache_are_compiler_products() {
let project = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/render-cache");
let first_out = temporary_output();
let second_out = temporary_output();
let options = |out_dir| ProjectBuildOptions {
out_dir,
title: Some("Persistent render cache test".into()),
development: false,
strict_npm: false,
};
let first = build_project(&project, &options(first_out.clone())).unwrap();
assert_eq!(first.isr_routes, 1);
assert_eq!(first.swr_routes, 1);
assert_eq!(first.prerender_routes, 1);
assert_eq!(first.prerender_entries, 2);
assert!(!first.native_esm_eligible);
let second = build_project(&project, &options(second_out.clone())).unwrap();
assert!(second.persistent_cache_hit);
assert_eq!(second.native_esm_eligible, first.native_esm_eligible);
assert_eq!(
second.external_browser_modules,
first.external_browser_modules
);
assert_eq!(second.compiled_targets, 0);
assert!(second.reused_targets > 0);
prerender_output(&second_out, &second).unwrap();
let render = fs::read_to_string(second_out.join("app.render.json")).unwrap();
assert!(render.contains("\"id\":\"route-cache:/\""));
assert!(render.contains("\"mode\":\"isr\""));
assert!(render.contains("\"mode\":\"swr\""));
assert!(render.contains("\"vary\":[\"header:accept-language\"]"));
assert!(render.contains("\"tags\":[\"content\",\"news\"]"));
let graph = fs::read_to_string(second_out.join("app.graph.json")).unwrap();
assert!(graph.contains("route-cache-policy"));
assert!(graph.contains("caches-as"));
let semantic_units =
fs::read_to_string(second_out.join("app.semantic-units.json")).unwrap();
assert!(semantic_units.contains("\"schemaVersion\":1"));
assert!(semantic_units.contains("\"inputFingerprint\""));
assert!(semantic_units.contains("\"semanticId\":\"component:"));
let manifest = fs::read_to_string(second_out.join("app.prerender.json")).unwrap();
assert!(manifest.contains("\"schemaVersion\": 2"));
assert!(manifest.contains("/cache/products/1/"));
assert!(manifest.contains("/cache/products/2/"));
let first_product = fs::read_to_string(second_out.join("products/1/index.html")).unwrap();
assert!(first_product.contains("Compiler product 1"));
fs::remove_dir_all(first_out).unwrap();
fs::remove_dir_all(second_out).unwrap();
}
#[test]
fn qa_wo02_route_query_coalescing_invalidates_edits_and_reuses_unchanged_sources() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(routes.join("stable")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"WO-02 route QA\"\n",
)
.unwrap();
let page = routes.join("+page.nox");
let first_source = r#"
component SearchPage {
query { page: Optional<Int> }
computed { current = page ?? 1 }
view { <main>{current}</main> }
}
"#;
fs::write(&page, first_source).unwrap();
fs::write(
routes.join("stable/+page.nox"),
"component StablePage { view { <main>stable</main> } }\n",
)
.unwrap();
let options = ProjectBuildOptions {
out_dir: project.join("target/noxid-qa"),
title: None,
development: true,
strict_npm: false,
};
let mut session = ProjectSession::default();
let initial = session.build(&project, &options).unwrap();
assert_eq!((initial.compiled_targets, initial.reused_targets), (2, 0));
let initial_javascript =
fs::read_to_string(options.out_dir.join("assets/SearchPage.js")).unwrap();
assert!(initial_javascript.contains("(page.get() ?? 1)"));
let unchanged = session.build(&project, &options).unwrap();
assert_eq!(
(unchanged.compiled_targets, unchanged.reused_targets),
(0, 2),
"an unchanged route-query expression missed the session cache"
);
let edited_source = first_source.replace("page ?? 1", "page ?? 2");
fs::write(&page, &edited_source).unwrap();
let edited = session.build(&project, &options).unwrap();
assert_eq!(
(edited.compiled_targets, edited.reused_targets),
(1, 1),
"editing a coalescing fallback did not selectively invalidate its route target"
);
let edited_javascript =
fs::read_to_string(options.out_dir.join("assets/SearchPage.js")).unwrap();
assert!(edited_javascript.contains("(page.get() ?? 2)"));
assert!(!edited_javascript.contains("(page.get() ?? 1)"));
fs::write(&page, &edited_source).unwrap();
let touched = session.build(&project, &options).unwrap();
assert_eq!(
(touched.compiled_targets, touched.reused_targets),
(0, 2),
"touch-without-edit should reuse content-addressed route targets"
);
fs::remove_dir_all(project).unwrap();
}
#[test]
fn qa_wo02_client_ssr_and_server_emitters_agree_in_executed_project_behavior() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(project.join("server")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"WO-02 emitter QA\"\nbase = \"/qa\"\n\n[server]\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export const actions = Object.freeze({});\n",
)
.unwrap();
fs::write(
routes.join("+page.nox"),
r#"
component CoalesceParity {
route { render: ssr }
query {
zero: Optional<Int>
flag: Optional<Boolean>
text: Optional<String>
}
computed {
zeroValue = zero ?? 9
flagValue = flag ?? true
textValue = text ?? "fallback"
precedenceValue = flag ?? false || true
}
actions {
server choose(primary: Optional<Int>, secondary: Optional<Int>): Int {
return primary ?? secondary ?? 7
}
}
view {
<main>
<p>zero:{zeroValue}</p>
<p>flag:{flagValue}</p>
<p>text:{textValue}</p>
<p>precedence:{precedenceValue}</p>
</main>
}
}
"#,
)
.unwrap();
let out_dir = project.join("dist");
let build = build_project(
&project,
&ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: false,
strict_npm: false,
},
)
.unwrap();
assert_eq!(build.ssr_routes, 1);
assert_eq!(build.server_actions, 1);
let client = fs::read_to_string(out_dir.join("assets/CoalesceParity.js")).unwrap();
assert!(client.contains("(zero.get() ?? 9)"));
assert!(client.contains("(flag.get() ?? (false || true))"));
let renderer = fs::read_to_string(out_dir.join("server/renderer.js")).unwrap();
assert!(renderer.contains("(zero ?? 9)"));
assert!(renderer.contains("(flag ?? (false || true))"));
assert!(!renderer.contains("zero.get()"));
let server = fs::read_to_string(out_dir.join("server/actions.js")).unwrap();
assert!(server.contains("((args[\"primary\"] ?? args[\"secondary\"]) ?? 7)"));
fs::write(
out_dir.join("wo02-emitter-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
async function render(url) {
const response = await handle(new Request("http://noxid.test/qa/_noxid/ssr", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
return result.html.replace(/<!--.*?-->/g, "");
}
const present = await render("http://noxid.test/qa/?zero=0&flag=false&text=");
for (const fragment of [">zero:0</p>", ">flag:false</p>", ">text:</p>", ">precedence:false</p>"]) {
if (!present.includes(fragment)) throw new Error(`SSR lost nullish value ${fragment}: ${present}`);
}
const missing = await render("http://noxid.test/qa/");
for (const fragment of [">zero:9</p>", ">flag:true</p>", ">text:fallback</p>", ">precedence:true</p>"]) {
if (!missing.includes(fragment)) throw new Error(`SSR default mismatch ${fragment}: ${missing}`);
}
async function choose(primary, secondary) {
const response = await handle(new Request("http://noxid.test/qa/_noxid/actions/action%3ACoalesceParity.choose", {
method: "POST",
headers: { "content-type": "application/json", "x-noxid-route-id": "route:/" },
body: JSON.stringify({ arguments: { primary, secondary } }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
return result.value;
}
if (await choose(null, 0) !== 0) throw new Error("server emitter replaced secondary zero");
if (await choose(0, 5) !== 0) throw new Error("server emitter replaced primary zero");
if (await choose(null, null) !== 7) throw new Error("server emitter missed chained default");
"#,
)
.unwrap();
let execution = Command::new("node")
.arg("wo02-emitter-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
execution.status.success(),
"emitter parity execution failed:\n{}\n{}",
String::from_utf8_lossy(&execution.stdout),
String::from_utf8_lossy(&execution.stderr)
);
fs::remove_dir_all(project).unwrap();
}
#[test]
fn qa_wo02_round2_nested_emitters_recover_after_an_invalid_incremental_edit() {
let project = temporary_output();
let routes = project.join("src/routes");
fs::create_dir_all(&routes).unwrap();
fs::create_dir_all(project.join("server")).unwrap();
fs::write(
project.join("Noxid.toml"),
"[app]\ntitle = \"WO-02 nested emitter QA\"\nbase = \"/qa2\"\n\n[server]\n",
)
.unwrap();
fs::write(
project.join("server/host.js"),
"export const actions = Object.freeze({});\n",
)
.unwrap();
let page = routes.join("+page.nox");
let initial_source = r#"
component NestedEmitterParity {
route { render: ssr }
query {
source: Optional<Int>
backup: Optional<Int>
}
state {
outer: Optional<Optional<Int>> = source
}
computed {
collapsed = outer ?? backup ?? 9
}
actions {
server collapse(candidate: Optional<Optional<Int>>, reserve: Optional<Int>): Int {
return candidate ?? reserve ?? 9
}
}
view { <main><p>collapsed:{collapsed}</p></main> }
}
"#;
fs::write(&page, initial_source).unwrap();
let out_dir = project.join("dist");
let options = ProjectBuildOptions {
out_dir: out_dir.clone(),
title: None,
development: true,
strict_npm: false,
};
let mut session = ProjectSession::default();
let initial = session.build(&project, &options).unwrap();
assert_eq!((initial.compiled_targets, initial.reused_targets), (1, 0));
let client = fs::read_to_string(out_dir.join("assets/NestedEmitterParity.js")).unwrap();
assert!(client.contains("((outer.get() ?? backup.get()) ?? 9)"));
let renderer = fs::read_to_string(out_dir.join("server/renderer.js")).unwrap();
assert!(renderer.contains("((outer ?? backup) ?? 9)"));
assert!(!renderer.contains("outer.get()"));
let server = fs::read_to_string(out_dir.join("server/actions.js")).unwrap();
assert!(server.contains("((args[\"candidate\"] ?? args[\"reserve\"]) ?? 9)"));
let invalid_source = initial_source.replace("?? 9", "?? \"wrong\"");
fs::write(&page, invalid_source).unwrap();
let error = session.build(&project, &options).unwrap_err();
assert!(
error.contains("COALESCE_DEFAULT_TYPE_MISMATCH"),
"invalid nested fallback lost its structured diagnostic: {error}"
);
let last_good_renderer = fs::read_to_string(out_dir.join("server/renderer.js")).unwrap();
assert!(last_good_renderer.contains("((outer ?? backup) ?? 9)"));
assert!(!last_good_renderer.contains("wrong"));
let repaired_source = initial_source.replace("?? 9", "?? 11");
fs::write(&page, repaired_source).unwrap();
let repaired = session.build(&project, &options).unwrap();
assert_eq!(
(repaired.compiled_targets, repaired.reused_targets),
(1, 0),
"a failed nested-Optional edit poisoned the incremental session"
);
for (path, fragment) in [
(
"assets/NestedEmitterParity.js",
"((outer.get() ?? backup.get()) ?? 11)",
),
("server/renderer.js", "((outer ?? backup) ?? 11)"),
(
"server/actions.js",
"((args[\"candidate\"] ?? args[\"reserve\"]) ?? 11)",
),
] {
let javascript = fs::read_to_string(out_dir.join(path)).unwrap();
assert!(
javascript.contains(fragment),
"repaired output `{path}` is stale; missing `{fragment}`"
);
assert!(!javascript.contains("?? 9"));
}
fs::write(
out_dir.join("wo02-round2-emitter-test.mjs"),
r#"import { fetch as handle } from "./server/handler.js";
async function render(query) {
const response = await handle(new Request(`http://noxid.test/qa2/_noxid/ssr`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `http://noxid.test/qa2/${query}` }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
return result.html.replace(/<!--.*?-->/g, "");
}
for (const [query, expected] of [
["?source=0&backup=4", 0],
["?backup=0", 0],
["", 11],
]) {
const html = await render(query);
if (!html.includes(`>collapsed:${expected}</p>`)) {
throw new Error(`SSR nested coalesce mismatch for ${query}: ${html}`);
}
}
async function collapse(candidate, reserve) {
const response = await handle(new Request("http://noxid.test/qa2/_noxid/actions/action%3ANestedEmitterParity.collapse", {
method: "POST",
headers: { "content-type": "application/json", "x-noxid-route-id": "route:/" },
body: JSON.stringify({ arguments: { candidate, reserve } }),
}), {}, {});
const result = await response.json();
if (response.status !== 200 || result.ok !== true) throw new Error(JSON.stringify(result));
return result.value;
}
if (await collapse(null, 0) !== 0) throw new Error("server nested fallback replaced zero");
if (await collapse(0, 5) !== 0) throw new Error("server nested source replaced zero");
if (await collapse(null, null) !== 11) throw new Error("server nested default was stale");
"#,
)
.unwrap();
let execution = Command::new("node")
.arg("wo02-round2-emitter-test.mjs")
.current_dir(&out_dir)
.output()
.unwrap();
assert!(
execution.status.success(),
"nested emitter execution failed:\n{}\n{}",
String::from_utf8_lossy(&execution.stdout),
String::from_utf8_lossy(&execution.stderr)
);
fs::remove_dir_all(project).unwrap();
}
}