Skip to main content

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        // The one host call whose payload is text rather than an object, and
213        // whose answer is a fact rather than a document: was anybody there to
214        // receive it. The bootstrap sends every payload as JSON, so the chunk
215        // arrives quoted and has to be read back out.
216        "emit" => {
217            let chunk: String = serde_json::from_str(payload).unwrap_or_default();
218            serde_json::json!({ "delivered": host.emit(RStr::from_str(&chunk)) }).to_string()
219        }
220        "config" => host.config().into_string(),
221        "principal_id" => host.principal_id().into_string(),
222        "hook" => host.hook().into_string(),
223        "log" => {
224            let entry: Value = serde_json::from_str(payload).unwrap_or(Value::Null);
225            let message = entry.get("message").and_then(Value::as_str).unwrap_or("");
226            let level = match entry.get("level").and_then(Value::as_str) {
227                Some("trace") => LogLevel::Trace,
228                Some("debug") => LogLevel::Debug,
229                Some("warn") => LogLevel::Warn,
230                Some("error") => LogLevel::Error,
231                _ => LogLevel::Info,
232            };
233            host.log(level, RStr::from_str(message));
234            String::new()
235        }
236        other => serde_json::json!({ "error": format!("unknown host call `{other}`") }).to_string(),
237    }
238}
239
240/// One exported function, presented to the host as an ABI function object.
241///
242/// Every function from a module shares the module's [`Pool`] — they are exports
243/// of the same code, so they are the same isolates.
244struct JsFunction {
245    manifest: FunctionManifest,
246    pool: Arc<Pool>,
247}
248
249impl Function for JsFunction {
250    fn manifest(&self) -> FunctionManifest {
251        self.manifest.clone()
252    }
253
254    fn invoke(
255        &self,
256        host: HostApi_TO<'_, abi_stable::std_types::RBox<()>>,
257        input: RStr<'_>,
258    ) -> RResult<RString, RString> {
259        // This is called across an `extern "C"` boundary: a panic escaping it
260        // would abort the process instead of failing one request.
261        let called = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
262            self.pool
263                .invoke(self.manifest.name.as_str(), input.as_str(), &host)
264        }));
265        match called {
266            Ok(Ok(output)) => RResult::ROk(output.into()),
267            Ok(Err(e)) => RResult::RErr(e.into()),
268            Err(_) => RResult::RErr(
269                format!(
270                    "{}panic while invoking a javascript function",
271                    apiplant_abi::INTERNAL_ERROR_PREFIX
272                )
273                .into(),
274            ),
275        }
276    }
277}
278
279/// Load a `.js` function library: every function its `manifest` declares.
280///
281/// The counterpart of the C-ABI loader, and it fails the same way — a module
282/// that cannot be compiled, that exports no manifest, or whose manifest names a
283/// function it does not export is an error here rather than a surprise at the
284/// first request.
285pub fn load(path: &Path) -> Result<Vec<BoxedFunction>, String> {
286    let code = std::fs::read_to_string(path).map_err(|e| format!("cannot read: {e}"))?;
287    let label = path
288        .file_stem()
289        .map(|s| s.to_string_lossy().into_owned())
290        .unwrap_or_else(|| "function".to_string());
291
292    let (pool, manifest) = Pool::load(&label, &code)?;
293    let pool = Arc::new(pool);
294
295    let entries: Vec<Value> = serde_json::from_str(&manifest)
296        .map_err(|e| format!("`manifest` is not valid JSON: {e}"))?;
297    if entries.is_empty() {
298        return Err("`manifest` is empty; it must describe at least one function".to_string());
299    }
300
301    let mut functions = Vec::with_capacity(entries.len());
302    for entry in &entries {
303        // The same reader the C loader uses: a manifest is a manifest, and an
304        // app porting a function from C to TypeScript should not have to rewrite
305        // the part that has nothing to do with either language.
306        let manifest = apiplant_abi::manifest_from_json(entry)?;
307        functions.push(BoxedFunction::from_value(
308            JsFunction {
309                manifest,
310                pool: pool.clone(),
311            },
312            TD_Opaque,
313        ));
314    }
315    Ok(functions)
316}