apiplant_js/lib.rs
1//! TypeScript and JavaScript functions.
2//!
3//! A function written in Rust, C, Zig or Go arrives as a shared library. A
4//! function written in TypeScript cannot: there is nothing to link. So this
5//! crate provides the other half of what a `.so` gives the host — a manifest to
6//! read at boot and something to call per request — backed by V8 isolates
7//! instead of `dlopen`.
8//!
9//! ```text
10//! functions/greet.ts ← what you write
11//! functions/greet.js ← `apiplant build` strips the types (swc, at build time)
12//! and the server loads *this*, like it loads libgreet.so
13//! ```
14//!
15//! ## Two stages, on purpose
16//!
17//! Types are stripped **at build time**, so the server never parses TypeScript
18//! and a syntax error is a build failure rather than a boot failure. What runs at
19//! request time is plain JavaScript in a V8 isolate — the same split Deno and Bun
20//! make internally, just with the first half hoisted into `apiplant build`.
21//!
22//! No type *checking* happens: swc strips annotations without consulting them,
23//! exactly like `deno run --no-check` or `bun`. `apiplant build` writes an
24//! `apiplant.d.ts` beside your sources so your editor (and `tsc --noEmit`, if you
25//! want it in CI) does the checking with real types.
26//!
27//! ## What a module looks like
28//!
29//! ```ts
30//! import { defineFunctions, db, s } from "apiplant";
31//!
32//! export default defineFunctions({
33//! greet: {
34//! permission: "public",
35//! input: s.object({ name: s.string() }),
36//! handler(input) {
37//! const notes = db.value("SELECT count(*)::int AS n FROM apiplant_note");
38//! return { message: `Hello, ${input.name}!`, notes };
39//! },
40//! },
41//! });
42//! ```
43//!
44//! One module may declare any number of functions, like one `.so` may export
45//! any number. `apiplant` is the only module a function can import; it is
46//! compiled into this crate from `typescript/` at the repository root and served
47//! to the isolate by [`module`], so nothing is installed and nothing can be out
48//! of step with the host. A module that would rather import nothing declares
49//! `export const manifest = [...]` and one export per entry instead; both forms
50//! arrive here the same way.
51//!
52//! ## Concurrency
53//!
54//! An isolate is single-threaded, so a module is loaded into a small pool of them
55//! ([`workers`], `APIPLANT_JS_WORKERS`) that share one job queue. Requests run
56//! concurrently up to the pool size and queue beyond it. Isolates share nothing:
57//! module-level state in a function is per-worker and must not be treated as
58//! shared state — use the database or the cache for that.
59//!
60//! An invocation that runs longer than `APIPLANT_JS_TIMEOUT_MS` (30s by default)
61//! has its isolate terminated and fails that one request; the worker recovers.
62
63mod module;
64mod worker;
65
66#[cfg(feature = "transpile")]
67pub mod transpile;
68
69use std::path::Path;
70use std::sync::Arc;
71
72use abi_stable::sabi_trait::TD_Opaque;
73use abi_stable::std_types::{RResult, RStr, RString};
74use apiplant_abi::{BoxedFunction, Function, FunctionManifest, HostApi_TO, LogLevel};
75use crossbeam_channel::{bounded, Sender};
76use serde_json::Value;
77
78use worker::{Job, Message};
79
80/// The extension a JavaScript function library has on disk.
81pub const EXTENSION: &str = "js";
82
83/// How many isolates a module is loaded into.
84///
85/// Each one is a full V8 heap, so this is deliberately small: it trades memory
86/// for concurrency, and most functions spend their time waiting on the host
87/// (which happens on the *caller's* thread, not the isolate's) rather than
88/// running JavaScript.
89fn workers() -> usize {
90 std::env::var("APIPLANT_JS_WORKERS")
91 .ok()
92 .and_then(|v| v.parse::<usize>().ok())
93 .filter(|n| *n > 0)
94 .unwrap_or(2)
95}
96
97/// A pool of isolates, all holding the same module, behind one job queue.
98///
99/// Whichever isolate is free takes the next job, so a slow function does not
100/// block a fast one behind it while another worker sits idle.
101struct Pool {
102 jobs: Sender<Job>,
103 /// Only for log messages: which library these isolates hold.
104 label: String,
105}
106
107impl Pool {
108 /// Load `code` into [`workers()`] isolates, returning the pool and the
109 /// manifest they all declare.
110 fn load(label: &str, code: &str) -> Result<(Pool, String), String> {
111 // Unbounded would let a queue of doomed requests grow without limit; the
112 // bound makes back-pressure visible as a rejected request instead.
113 let (jobs, incoming) = bounded::<Job>(1024);
114
115 let mut manifest = None;
116 for worker in 0..workers() {
117 let declared = worker::spawn(
118 format!("{label}#{worker}"),
119 code.to_string(),
120 incoming.clone(),
121 )?;
122 // Every isolate runs the same code, so the first answer is the
123 // manifest; the rest are only checked for having started.
124 manifest = manifest.or(declared);
125 }
126
127 let manifest = manifest.ok_or_else(|| {
128 "the module exports no `manifest`; add \
129 `export const manifest = [{ name: \"…\", permission: \"…\" }]`"
130 .to_string()
131 })?;
132
133 Ok((
134 Pool {
135 jobs,
136 label: label.to_string(),
137 },
138 manifest,
139 ))
140 }
141
142 /// Run one function and serve the host calls it makes along the way.
143 ///
144 /// Must be called from a thread that may block on the async runtime — the
145 /// same requirement every other function body has, for the same reason: the
146 /// host API is synchronous and the database is not.
147 fn invoke(
148 &self,
149 name: &str,
150 input: &str,
151 host: &HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
152 ) -> Result<String, String> {
153 let (replies, incoming) = bounded::<Message>(1);
154 let job = Job {
155 name: name.to_string(),
156 input: input.to_string(),
157 replies,
158 };
159 if self.jobs.try_send(job).is_err() {
160 return Err(format!(
161 "{}javascript function `{name}` is overloaded; try again",
162 apiplant_abi::INTERNAL_ERROR_PREFIX
163 ));
164 }
165
166 // Everything the function asks for comes back here, on this thread,
167 // until the isolate says it is done. See `worker`'s module docs.
168 loop {
169 match incoming.recv() {
170 Ok(Message::Host {
171 kind,
172 payload,
173 answer,
174 }) => {
175 let _ = answer.send(serve(host, &kind, &payload));
176 }
177 Ok(Message::Done(result)) => return result,
178 Err(_) => {
179 tracing::error!(library = %self.label, function = %name, "javascript worker died");
180 return Err(format!(
181 "{}the javascript worker died",
182 apiplant_abi::INTERNAL_ERROR_PREFIX
183 ));
184 }
185 }
186 }
187 }
188}
189
190/// Answer one host request from an isolate.
191///
192/// Failures are reported **in band**, as `{"error": …}`, which is the same
193/// convention the C ABI uses — see `apiplant_abi::c::Host::query`. The bootstrap
194/// turns that back into a thrown `Error`, so a function author sees an ordinary
195/// exception and never a magic value.
196fn serve(
197 host: &HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
198 kind: &str,
199 payload: &str,
200) -> String {
201 let in_band = |result: RResult<RString, RString>| match result {
202 RResult::ROk(reply) => reply.into_string(),
203 RResult::RErr(e) => serde_json::json!({ "error": e.as_str() }).to_string(),
204 };
205
206 match kind {
207 "query" => in_band(host.query(RStr::from_str(payload))),
208 "send_email" => in_band(host.send_email(RStr::from_str(payload))),
209 "cache" => in_band(host.cache(RStr::from_str(payload))),
210 "payments" => in_band(host.payments(RStr::from_str(payload))),
211 "ai" => in_band(host.ai(RStr::from_str(payload))),
212 "publish" => in_band(host.publish(RStr::from_str(payload))),
213 // The one host call whose payload is text rather than an object, and
214 // whose answer is a fact rather than a document: was anybody there to
215 // receive it. The bootstrap sends every payload as JSON, so the chunk
216 // arrives quoted and has to be read back out.
217 "emit" => {
218 let chunk: String = serde_json::from_str(payload).unwrap_or_default();
219 serde_json::json!({ "delivered": host.emit(RStr::from_str(&chunk)) }).to_string()
220 }
221 "config" => host.config().into_string(),
222 "principal_id" => host.principal_id().into_string(),
223 "hook" => host.hook().into_string(),
224 "log" => {
225 let entry: Value = serde_json::from_str(payload).unwrap_or(Value::Null);
226 let message = entry.get("message").and_then(Value::as_str).unwrap_or("");
227 let level = match entry.get("level").and_then(Value::as_str) {
228 Some("trace") => LogLevel::Trace,
229 Some("debug") => LogLevel::Debug,
230 Some("warn") => LogLevel::Warn,
231 Some("error") => LogLevel::Error,
232 _ => LogLevel::Info,
233 };
234 host.log(level, RStr::from_str(message));
235 String::new()
236 }
237 other => serde_json::json!({ "error": format!("unknown host call `{other}`") }).to_string(),
238 }
239}
240
241/// One exported function, presented to the host as an ABI function object.
242///
243/// Every function from a module shares the module's [`Pool`] — they are exports
244/// of the same code, so they are the same isolates.
245struct JsFunction {
246 manifest: FunctionManifest,
247 pool: Arc<Pool>,
248}
249
250impl Function for JsFunction {
251 fn manifest(&self) -> FunctionManifest {
252 self.manifest.clone()
253 }
254
255 fn invoke(
256 &self,
257 host: HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
258 input: RStr<'_>,
259 ) -> RResult<RString, RString> {
260 // This is called across an `extern "C"` boundary: a panic escaping it
261 // would abort the process instead of failing one request.
262 let called = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
263 self.pool
264 .invoke(self.manifest.name.as_str(), input.as_str(), &host)
265 }));
266 match called {
267 Ok(Ok(output)) => RResult::ROk(output.into()),
268 Ok(Err(e)) => RResult::RErr(e.into()),
269 Err(_) => RResult::RErr(
270 format!(
271 "{}panic while invoking a javascript function",
272 apiplant_abi::INTERNAL_ERROR_PREFIX
273 )
274 .into(),
275 ),
276 }
277 }
278}
279
280/// Load a `.js` function library: every function its `manifest` declares.
281///
282/// The counterpart of the C-ABI loader, and it fails the same way — a module
283/// that cannot be compiled, that exports no manifest, or whose manifest names a
284/// function it does not export is an error here rather than a surprise at the
285/// first request.
286pub fn load(path: &Path) -> Result<Vec<BoxedFunction>, String> {
287 let code = std::fs::read_to_string(path).map_err(|e| format!("cannot read: {e}"))?;
288 let label = path
289 .file_stem()
290 .map(|s| s.to_string_lossy().into_owned())
291 .unwrap_or_else(|| "function".to_string());
292
293 let (pool, manifest) = Pool::load(&label, &code)?;
294 let pool = Arc::new(pool);
295
296 let entries: Vec<Value> = serde_json::from_str(&manifest)
297 .map_err(|e| format!("`manifest` is not valid JSON: {e}"))?;
298 if entries.is_empty() {
299 return Err("`manifest` is empty; it must describe at least one function".to_string());
300 }
301
302 let mut functions = Vec::with_capacity(entries.len());
303 for entry in &entries {
304 // The same reader the C loader uses: a manifest is a manifest, and an
305 // app porting a function from C to TypeScript should not have to rewrite
306 // the part that has nothing to do with either language.
307 let manifest = apiplant_abi::manifest_from_json(entry)?;
308 functions.push(BoxedFunction::from_value(
309 JsFunction {
310 manifest,
311 pool: pool.clone(),
312 },
313 TD_Opaque,
314 ));
315 }
316 Ok(functions)
317}