use std::time::Duration;
use crossbeam_channel::{bounded, Receiver, Sender};
use deno_core::{JsRuntime, PollEventLoopOptions, RuntimeOptions};
pub(crate) use crate::ext::{Current, Message};
static SNAPSHOT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/APIPLANT_JS_SNAPSHOT.bin"));
fn timeout() -> Duration {
let ms = std::env::var("APIPLANT_JS_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(30_000);
Duration::from_millis(ms)
}
pub(crate) struct Job {
pub name: String,
pub input: String,
pub replies: Sender<Message>,
}
pub(crate) fn spawn(
label: String,
code: String,
jobs: Receiver<Job>,
) -> Result<Option<String>, String> {
let (ready, wait) = bounded::<Result<Option<String>, String>>(1);
std::thread::Builder::new()
.name(format!("apiplant-js:{label}"))
.spawn(move || run(label, code, jobs, ready))
.map_err(|e| format!("cannot start a JavaScript worker thread: {e}"))?;
wait.recv()
.map_err(|_| "the JavaScript worker died during startup".to_string())?
}
fn run(
label: String,
code: String,
jobs: Receiver<Job>,
ready: Sender<Result<Option<String>, String>>,
) {
let current: Current = crate::ext::detached();
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
deno_web::BlobStore::default_arc(),
None,
false,
deno_web::InMemoryBroadcastChannel::default(),
),
crate::ext::extension(current.clone()),
],
startup_snapshot: Some(SNAPSHOT),
module_loader: Some(crate::module::Loader::shared()),
..Default::default()
});
let Ok(local) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
let _ = ready.send(Err("cannot start the JavaScript event loop".into()));
return;
};
let watchdog = Watchdog::spawn(runtime.v8_isolate().thread_safe_handle());
let manifest = local.block_on(evaluate(&mut runtime, &label, code));
let failed = manifest.is_err();
let _ = ready.send(manifest);
if failed {
return;
}
let entry = match global_function(&mut runtime, "__apiplantInvoke") {
Ok(f) => f,
Err(e) => {
tracing::error!(library = %label, error = %e, "javascript bootstrap is broken");
return;
}
};
while let Ok(job) = jobs.recv() {
*current.borrow_mut() = Some(job.replies.clone());
let guard = watchdog.watching();
let result = local.block_on(invoke(&mut runtime, &entry, &job.name, &job.input));
drop(guard);
*current.borrow_mut() = None;
runtime.v8_isolate().cancel_terminate_execution();
let _ = job.replies.send(Message::Done(result));
}
}
async fn evaluate(
runtime: &mut JsRuntime,
label: &str,
code: String,
) -> Result<Option<String>, String> {
let url = deno_core::resolve_url(&format!("file:///{label}.js"))
.map_err(|e| format!("cannot name the module: {e}"))?;
let id = runtime
.load_main_es_module_from_code(&url, code)
.await
.map_err(|e| format!("cannot compile the module: {e}"))?;
let evaluated = runtime.mod_evaluate(id);
runtime
.run_event_loop(PollEventLoopOptions::default())
.await
.map_err(|e| format!("module failed while evaluating: {e}"))?;
evaluated
.await
.map_err(|e| format!("module failed while evaluating: {e}"))?;
let namespace = runtime
.get_module_namespace(id)
.map_err(|e| format!("cannot read the module's exports: {e}"))?;
{
deno_core::scope!(scope, runtime);
let namespace = deno_core::v8::Local::new(scope, namespace);
let global = scope.get_current_context().global(scope);
let key = deno_core::v8::String::new(scope, "__apiplantModule")
.ok_or("out of memory naming the module")?;
global.set(scope, key.into(), namespace.into());
}
let manifest = global_function(runtime, "__apiplantManifest")?;
let manifest = invoke_json(runtime, &manifest, &[]).await?;
Ok(match manifest.as_str() {
"" | "null" => None,
json => Some(json.to_string()),
})
}
async fn invoke(
runtime: &mut JsRuntime,
entry: &deno_core::v8::Global<deno_core::v8::Function>,
name: &str,
input: &str,
) -> Result<String, String> {
let args = {
deno_core::scope!(scope, runtime);
let name: deno_core::v8::Local<deno_core::v8::Value> =
deno_core::v8::String::new(scope, name)
.ok_or("out of memory")?
.into();
let input: deno_core::v8::Local<deno_core::v8::Value> =
deno_core::v8::String::new(scope, input)
.ok_or("out of memory")?
.into();
[
deno_core::v8::Global::new(scope, name),
deno_core::v8::Global::new(scope, input),
]
};
let reply = invoke_json(runtime, entry, &args).await.map_err(|e| {
format!(
"{}javascript function `{name}` failed: {e}",
apiplant_abi::INTERNAL_ERROR_PREFIX
)
})?;
let reply: serde_json::Value = serde_json::from_str(&reply).map_err(|e| {
format!(
"{}invoke returned invalid JSON: {e}",
apiplant_abi::INTERNAL_ERROR_PREFIX
)
})?;
if let Some(error) = reply.get("error").and_then(|e| e.as_str()) {
let caller_fault = reply.get("request").and_then(|r| r.as_bool()) == Some(true);
return Err(if caller_fault {
error.to_string()
} else {
format!("{}{error}", apiplant_abi::INTERNAL_ERROR_PREFIX)
});
}
Ok(match reply.get("ok") {
Some(value) => value.to_string(),
None => "null".to_string(),
})
}
async fn invoke_json(
runtime: &mut JsRuntime,
function: &deno_core::v8::Global<deno_core::v8::Function>,
args: &[deno_core::v8::Global<deno_core::v8::Value>],
) -> Result<String, String> {
let call = runtime.call_with_args(function, args);
let value = runtime
.with_event_loop_promise(call, PollEventLoopOptions::default())
.await
.map_err(|e| e.to_string())?;
deno_core::scope!(scope, runtime);
let value = deno_core::v8::Local::new(scope, value);
if value.is_null_or_undefined() {
return Ok(String::new());
}
Ok(value.to_rust_string_lossy(scope))
}
fn global_function(
runtime: &mut JsRuntime,
name: &str,
) -> Result<deno_core::v8::Global<deno_core::v8::Function>, String> {
deno_core::scope!(scope, runtime);
let global = scope.get_current_context().global(scope);
let key = deno_core::v8::String::new(scope, name).ok_or("out of memory")?;
let value = global
.get(scope, key.into())
.ok_or_else(|| format!("`{name}` is missing from the isolate"))?;
let function: deno_core::v8::Local<deno_core::v8::Function> = value
.try_into()
.map_err(|_| format!("`{name}` is not a function"))?;
Ok(deno_core::v8::Global::new(scope, function))
}
struct Watchdog {
signals: Sender<Signal>,
timeout: Duration,
}
enum Signal {
Begin(Duration),
End,
}
impl Watchdog {
fn spawn(handle: deno_core::v8::IsolateHandle) -> Watchdog {
let (signals, incoming) = bounded::<Signal>(1);
std::thread::Builder::new()
.name("apiplant-js:watchdog".into())
.spawn(move || {
while let Ok(Signal::Begin(limit)) = incoming.recv() {
if incoming.recv_timeout(limit).is_err() {
handle.terminate_execution();
if incoming.recv().is_err() {
return;
}
}
}
})
.ok();
Watchdog {
signals,
timeout: timeout(),
}
}
fn watching(&self) -> WatchGuard<'_> {
let _ = self.signals.send(Signal::Begin(self.timeout));
WatchGuard { watchdog: self }
}
}
struct WatchGuard<'a> {
watchdog: &'a Watchdog,
}
impl Drop for WatchGuard<'_> {
fn drop(&mut self) {
let _ = self.watchdog.signals.send(Signal::End);
}
}
#[cfg(test)]
mod tests {
use crate::ext::{extension, BOOTSTRAP, BOOTSTRAP_SOURCE};
#[test]
fn every_loaded_script_is_in_the_snapshot() {
let consumed = include_str!(concat!(env!("OUT_DIR"), "/consumed_lazy_specifiers.txt"))
.lines()
.collect::<Vec<_>>();
let loaded = BOOTSTRAP_SOURCE
.match_indices("ext(\"")
.map(|(at, _)| {
let rest = &BOOTSTRAP_SOURCE[at + 5..];
&rest[..rest.find('"').expect("an unterminated ext() specifier")]
})
.collect::<Vec<_>>();
assert!(
!loaded.is_empty(),
"no `ext(\"…\")` calls found — has the bootstrap's loader been renamed?",
);
for specifier in loaded {
assert!(
consumed.contains(&specifier),
"{specifier} is not in the startup snapshot, so a released binary \
would read it from the build machine's disk and fail",
);
}
}
#[test]
fn bootstrap_is_the_entry_point() {
let extension = extension(crate::ext::detached());
assert_eq!(extension.esm_entry_point, Some(BOOTSTRAP));
}
}