use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
use crossbeam_channel::{bounded, Receiver, Sender};
use deno_core::{
extension, op2, ExtensionFileSource, JsRuntime, OpState, PollEventLoopOptions, RuntimeOptions,
};
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) enum Message {
Host {
kind: String,
payload: String,
answer: Sender<String>,
},
Done(Result<String, String>),
}
type Current = Rc<RefCell<Option<Sender<Message>>>>;
#[op2]
#[string]
fn op_apiplant_host(state: &mut OpState, #[string] kind: &str, #[string] payload: &str) -> String {
let current = state.borrow::<Current>().clone();
let replies = current.borrow().clone();
let Some(replies) = replies else {
return r#"{"error":"no invocation in progress"}"#.to_string();
};
let (answer, wait) = bounded(1);
let sent = replies.send(Message::Host {
kind: kind.to_string(),
payload: payload.to_string(),
answer,
});
if sent.is_err() {
return r#"{"error":"the host stopped listening"}"#.to_string();
}
wait.recv()
.unwrap_or_else(|_| r#"{"error":"the host stopped listening"}"#.to_string())
}
const BOOTSTRAP: &str = "ext:apiplant_js/bootstrap.js";
const BOOTSTRAP_SOURCE: &str = include_str!("../assets/bootstrap.js");
extension!(
apiplant_js,
ops = [op_apiplant_host],
esm_entry_point = BOOTSTRAP,
options = { current: Current },
state = |state, options| state.put::<Current>(options.current),
);
fn extension(current: Current) -> deno_core::Extension {
let mut extension = apiplant_js::init(current);
extension.esm_files = std::borrow::Cow::Owned(vec![ExtensionFileSource::new_computed(
BOOTSTRAP,
BOOTSTRAP_SOURCE.into(),
)]);
extension
}
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 = Rc::new(RefCell::new(None));
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![extension(current.clone())],
module_loader: Some(crate::module::Loader::shared()),
..Default::default()
});
let Ok(local) = tokio::runtime::Builder::new_current_thread()
.enable_time()
.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 super::*;
#[test]
fn bootstrap_is_embedded_not_a_path() {
let extension = extension(Rc::new(RefCell::new(None)));
let bootstrap = extension
.esm_files
.iter()
.find(|file| file.specifier == BOOTSTRAP)
.expect("the extension must carry the bootstrap");
assert!(
bootstrap.is_runtime_loadable(),
"{BOOTSTRAP} is a build-machine path, so a released binary cannot load it",
);
for file in extension.esm_files.iter() {
assert!(file.is_runtime_loadable(), "{} is a path", file.specifier);
}
}
#[test]
fn bootstrap_is_the_entry_point() {
let extension = extension(Rc::new(RefCell::new(None)));
assert_eq!(extension.esm_entry_point, Some(BOOTSTRAP));
}
}