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        "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}