mod bindings;
mod convert;
mod headless;
mod http;
mod loader;
mod runner;
pub mod sanitize;
mod state;
mod terminal;
mod websocket;
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use futures::future::BoxFuture;
use url::Url;
pub use wasmtime;
pub use headless::{Script, ScriptCommand};
pub use http::{
CookieJar, OriginPolicy, PolicyError, PolicyGuard, RequestInfo, RequestKind, RequestPolicy,
};
pub use state::HostState;
pub use terminal::{Screen, Stats};
pub const ABI: &str = "rattery:tui@0.2.0;cm-async;wasi:http@0.3.0";
#[macro_export]
macro_rules! embed {
() => {
include_bytes!(env!("RATTERY_APP_WASM"))
};
($env:literal) => {
include_bytes!(env!($env))
};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentInfo {
pub imports: Vec<String>,
pub exports: Vec<String>,
pub compatible: bool,
pub extension_imports: Vec<String>,
}
pub fn inspect(bytes: &[u8]) -> Result<ComponentInfo> {
inspect_with(bytes, &Limits::default())
}
pub fn inspect_with(bytes: &[u8], limits: &Limits) -> Result<ComponentInfo> {
anyhow::ensure!(
bytes.len() <= limits.component_bytes,
"component is {} bytes, over the limit of {} bytes",
bytes.len(),
limits.component_bytes
);
let mut config = wasmtime::Config::new();
config.wasm_component_model_async(true);
let engine = wasmtime::Engine::new(&config)?;
let component = wasmtime::component::Component::new(&engine, bytes)
.map_err(anyhow::Error::from)
.context("not a valid component")?;
let ty = component.component_type();
let imports: Vec<String> = ty
.imports(&engine)
.map(|(name, _)| name.to_owned())
.collect();
let exports: Vec<String> = ty
.exports(&engine)
.map(|(name, _)| name.to_owned())
.collect();
let terminal_ok = imports
.iter()
.any(|i| i.starts_with("rattery:tui/terminal@0.2."));
let compatible = terminal_ok && exports.iter().any(|e| e == "run");
let extension_imports = imports
.iter()
.filter(|i| !(i.starts_with("wasi:") || i.starts_with("rattery:")))
.cloned()
.collect();
Ok(ComponentInfo {
imports,
exports,
compatible,
extension_imports,
})
}
#[derive(Debug, Clone)]
pub struct Resolved {
pub bytes: Vec<u8>,
pub version: Option<String>,
pub origin: Option<String>,
pub location: Option<String>,
}
pub trait Resolver: Send + Sync + 'static {
fn resolve<'a>(&'a self, current: Option<&'a str>) -> BoxFuture<'a, Result<Option<Resolved>>>;
}
#[derive(Clone)]
pub enum Source {
Url(Url),
Path(PathBuf),
Bytes(Vec<u8>),
Resolver(Arc<dyn Resolver>),
}
impl std::fmt::Debug for Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Source::Url(url) => f.debug_tuple("Url").field(url).finish(),
Source::Path(path) => f.debug_tuple("Path").field(path).finish(),
Source::Bytes(bytes) => f.debug_tuple("Bytes").field(&bytes.len()).finish(),
Source::Resolver(_) => f.debug_tuple("Resolver").finish(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Limits {
pub memory_bytes: usize,
pub cpu_time: Option<Duration>,
pub component_bytes: usize,
pub download_timeout: Duration,
pub event_queue: usize,
pub frame_cells: usize,
pub websockets: usize,
pub websocket_queue: usize,
pub websocket_queue_bytes: usize,
pub websocket_message_bytes: usize,
pub http_concurrency: usize,
pub request_body_bytes: usize,
pub response_body_bytes: usize,
pub guest_output_bytes: usize,
pub paste_bytes: usize,
pub message_bytes: usize,
pub resources: usize,
pub tables: usize,
pub table_elements: usize,
pub memories: usize,
pub instances: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
memory_bytes: 256 << 20,
cpu_time: None,
component_bytes: 64 << 20,
download_timeout: Duration::from_secs(60),
event_queue: 1024,
frame_cells: 1 << 20,
websockets: 16,
websocket_queue: 64,
websocket_queue_bytes: 8 << 20,
websocket_message_bytes: 4 << 20,
http_concurrency: 16,
request_body_bytes: 64 << 20,
response_body_bytes: 64 << 20,
guest_output_bytes: 1 << 20,
paste_bytes: 1 << 20,
message_bytes: 16 << 10,
resources: 4096,
tables: 32,
table_elements: 1 << 20,
memories: 8,
instances: 16,
}
}
}
impl Limits {
pub fn validate(&self) -> Result<()> {
anyhow::ensure!(
self.websocket_queue_bytes >= self.websocket_message_bytes,
"websocket_queue_bytes ({}) must be at least websocket_message_bytes ({}) so a \
maximum-sized message can be queued",
self.websocket_queue_bytes,
self.websocket_message_bytes
);
for (name, value) in [
("memory_bytes", self.memory_bytes),
("component_bytes", self.component_bytes),
("event_queue", self.event_queue),
("frame_cells", self.frame_cells),
("websocket_queue", self.websocket_queue),
("websocket_message_bytes", self.websocket_message_bytes),
("http_concurrency", self.http_concurrency),
("request_body_bytes", self.request_body_bytes),
("response_body_bytes", self.response_body_bytes),
("guest_output_bytes", self.guest_output_bytes),
("message_bytes", self.message_bytes),
("resources", self.resources),
("instances", self.instances),
] {
anyhow::ensure!(value > 0, "Limits::{name} must be greater than zero");
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Phase {
Loaded { bytes: usize },
Compiled,
Instantiated,
Ready,
AppReady,
RequestDenied { url: String, reason: String },
Reloading,
Exited(AppStatus),
}
#[derive(Debug, Clone)]
pub struct HeadlessOptions {
pub width: u16,
pub height: u16,
pub script: Script,
pub timeout: Option<Duration>,
}
impl Default for HeadlessOptions {
fn default() -> Self {
Self {
width: 80,
height: 24,
script: Script::default(),
timeout: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppStatus {
Exited(i32),
Trapped(String),
Killed,
TimedOut,
LimitExceeded(String),
}
#[derive(Debug, Clone)]
pub struct Report {
pub status: AppStatus,
pub stdout: String,
pub stderr: String,
pub snapshots: Vec<Screen>,
pub final_screen: Option<Screen>,
pub timings: Timings,
pub stats: Stats,
}
impl Report {
pub fn exit_code(&self) -> i32 {
match &self.status {
AppStatus::Exited(code) => *code,
AppStatus::Trapped(_) => 101,
AppStatus::Killed => 130,
AppStatus::TimedOut => 124,
AppStatus::LimitExceeded(_) => 137,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Timings {
pub load: Duration,
pub compile: Duration,
pub instantiate: Duration,
pub first_draw: Option<Duration>,
pub total: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum CookiePolicy {
#[default]
Persistent,
File(PathBuf),
Ephemeral,
Disabled,
}
type Extension = Box<dyn FnOnce(&mut wasmtime::component::Linker<HostState>) -> Result<()> + Send>;
type PhaseHook = Arc<dyn Fn(Phase) + Send + Sync>;
pub struct App {
pub(crate) source: Source,
pub(crate) origin: Option<String>,
pub(crate) allow_origins: Vec<String>,
pub(crate) allow_all_origins: bool,
pub(crate) mouse: bool,
pub(crate) cache: bool,
pub(crate) env: Vec<(String, String)>,
pub(crate) location: Option<String>,
pub(crate) cookies: CookiePolicy,
pub(crate) watch: bool,
pub(crate) headless: Option<HeadlessOptions>,
pub(crate) limits: Limits,
pub(crate) on_phase: Option<PhaseHook>,
pub(crate) request_policy: Option<Arc<dyn RequestPolicy>>,
pub(crate) extensions: Vec<Extension>,
pub(crate) ext: HashMap<TypeId, Box<dyn Any + Send>>,
}
impl App {
fn new(source: Source) -> Self {
Self {
source,
origin: None,
allow_origins: Vec::new(),
allow_all_origins: false,
mouse: true,
cache: true,
env: Vec::new(),
location: None,
cookies: CookiePolicy::Persistent,
watch: false,
headless: None,
limits: Limits::default(),
on_phase: None,
request_policy: None,
extensions: Vec::new(),
ext: HashMap::new(),
}
}
pub fn from_url(url: impl AsRef<str>) -> Result<Self> {
let url =
Url::parse(url.as_ref()).with_context(|| format!("invalid URL {:?}", url.as_ref()))?;
anyhow::ensure!(
matches!(url.scheme(), "http" | "https"),
"unsupported URL scheme {:?}, expected http or https",
url.scheme()
);
Ok(Self::new(Source::Url(url)))
}
pub fn from_path(path: impl Into<PathBuf>) -> Self {
Self::new(Source::Path(path.into()))
}
pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Self {
Self::new(Source::Bytes(bytes.into()))
}
pub fn from_resolver(resolver: Arc<dyn Resolver>) -> Self {
Self::new(Source::Resolver(resolver))
}
pub fn from_source(source: &str) -> Result<Self> {
match Url::parse(source) {
Ok(url) if matches!(url.scheme(), "http" | "https") => Self::from_url(source),
_ => Ok(Self::from_path(source)),
}
}
pub fn origin(mut self, origin: impl Into<String>) -> Self {
self.origin = Some(origin.into());
self
}
pub fn allow_origin(mut self, origin: impl Into<String>) -> Self {
self.allow_origins.push(origin.into());
self
}
pub fn allow_all_origins(mut self, yes: bool) -> Self {
self.allow_all_origins = yes;
self
}
pub fn mouse(mut self, yes: bool) -> Self {
self.mouse = yes;
self
}
pub fn cache(mut self, yes: bool) -> Self {
self.cache = yes;
self
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env.push((key.into(), value.into()));
self
}
pub fn location(mut self, url: impl Into<String>) -> Self {
self.location = Some(url.into());
self
}
pub fn cookies(mut self, policy: CookiePolicy) -> Self {
self.cookies = policy;
self
}
pub fn watch(mut self, yes: bool) -> Self {
self.watch = yes;
self
}
pub fn headless(mut self, options: HeadlessOptions) -> Self {
self.headless = Some(options);
self
}
pub fn limits(mut self, limits: Limits) -> Self {
self.limits = limits;
self
}
pub fn on_phase(mut self, hook: impl Fn(Phase) + Send + Sync + 'static) -> Self {
self.on_phase = Some(Arc::new(hook));
self
}
pub fn request_policy(mut self, policy: Arc<dyn RequestPolicy>) -> Self {
self.request_policy = Some(policy);
self
}
pub fn extension(
mut self,
register: impl FnOnce(&mut wasmtime::component::Linker<HostState>) -> Result<()>
+ Send
+ 'static,
) -> Self {
self.extensions.push(Box::new(register));
self
}
pub fn state<T: Any + Send>(mut self, value: T) -> Self {
self.ext.insert(TypeId::of::<T>(), Box::new(value));
self
}
pub async fn run(self) -> Result<Report> {
runner::run(self).await
}
pub fn run_blocking(self) -> Result<Report> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("failed to start a tokio runtime")?
.block_on(self.run())
}
}
#[cfg(test)]
mod wit_sync {
#[test]
fn wit_matches_the_app_crate() {
let ours = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("wit");
let theirs = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../rattery-app/wit");
if !theirs.exists() {
return; }
for entry in walk(&theirs) {
let rel = entry.strip_prefix(&theirs).unwrap();
let a = std::fs::read(&entry).unwrap();
let b = std::fs::read(ours.join(rel)).unwrap_or_default();
assert!(
a == b,
"wit/{} differs from crates/rattery-app/wit; copy it over",
rel.display()
);
}
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
out.extend(walk(&path))
} else {
out.push(path)
}
}
out
}
}
#[cfg(test)]
mod limit_tests {
use super::Limits;
#[test]
fn defaults_are_coherent_and_incoherence_is_rejected() {
Limits::default().validate().unwrap();
let bad = Limits {
websocket_queue_bytes: 1 << 20,
websocket_message_bytes: 2 << 20,
..Limits::default()
};
assert!(bad.validate().is_err());
let zero = Limits {
http_concurrency: 0,
..Limits::default()
};
assert!(zero.validate().is_err());
}
}