1mod value;
5mod worker;
6pub use worker::{Admission, ScriptWorkers, WorkerConfig};
7
8use mhome_script_api::{ScriptError, ScriptValue, ValueLimits, MAX_ARTIFACT_BYTES};
9use rquickjs::{
10 function::Args,
11 loader::{ImportAttributes, Loader, Resolver},
12 module::{Declarations, Exports, ModuleDef},
13 promise::MaybePromise,
14 AsyncContext, AsyncRuntime, Ctx, Exception, Function, Module, Promise, Value,
15};
16use std::{
17 future::Future,
18 pin::Pin,
19 sync::{
20 atomic::{AtomicUsize, Ordering},
21 Arc,
22 },
23 time::Instant,
24};
25pub use tokio_util::sync::CancellationToken;
26
27pub const SDK_VERSION: &str = "0.2.0";
29
30pub type HostFuture<'a> =
31 Pin<Box<dyn Future<Output = Result<ScriptValue, ScriptError>> + Send + 'a>>;
32
33pub trait ScriptHost: Send + Sync + 'static {
37 fn call<'a>(
38 &'a self,
39 operation: &'a str,
40 args: Vec<ScriptValue>,
41 control: InvocationControl,
42 ) -> HostFuture<'a>;
43}
44
45pub struct NoHost;
46impl ScriptHost for NoHost {
47 fn call<'a>(&'a self, _: &'a str, _: Vec<ScriptValue>, _: InvocationControl) -> HostFuture<'a> {
48 Box::pin(async {
49 Err(ScriptError::new(
50 "HOST_UNAVAILABLE",
51 "This script has no host capabilities",
52 ))
53 })
54 }
55}
56
57#[derive(Clone, Debug)]
58pub struct InvocationControl {
59 pub deadline: Instant,
60 pub cancel: CancellationToken,
61 shutdown: CancellationToken,
62}
63
64impl InvocationControl {
65 pub fn new(deadline: Instant, cancel: CancellationToken) -> Self {
66 Self {
67 deadline,
68 cancel,
69 shutdown: CancellationToken::new(),
70 }
71 }
72
73 pub async fn cancelled(&self) {
74 tokio::select! {
75 _ = self.cancel.cancelled() => {},
76 _ = self.shutdown.cancelled() => {},
77 }
78 }
79
80 pub fn check(&self) -> Result<(), ScriptError> {
81 if self.cancel.is_cancelled() || self.shutdown.is_cancelled() {
82 Err(ScriptError::new("CANCELLED", "Script invocation cancelled"))
83 } else if Instant::now() >= self.deadline {
84 Err(ScriptError::new(
85 "DEADLINE_EXCEEDED",
86 "Script invocation timed out",
87 ))
88 } else {
89 Ok(())
90 }
91 }
92}
93
94#[derive(Clone, Copy, Debug)]
95pub struct ScriptLimits {
96 pub memory_bytes: usize,
97 pub stack_bytes: usize,
98 pub values: ValueLimits,
99 pub host_calls: usize,
100 pub concurrent_host_calls: usize,
101}
102
103impl Default for ScriptLimits {
104 fn default() -> Self {
105 Self {
106 memory_bytes: 32 * 1024 * 1024,
107 stack_bytes: 512 * 1024,
108 values: ValueLimits::default(),
109 host_calls: 1024,
110 concurrent_host_calls: 32,
111 }
112 }
113}
114
115struct CancelOnDrop(CancellationToken);
116impl Drop for CancelOnDrop {
117 fn drop(&mut self) {
118 self.0.cancel();
119 }
120}
121
122pub async fn invoke(
125 source: &str,
126 args: Vec<ScriptValue>,
127 host: Arc<dyn ScriptHost>,
128 control: InvocationControl,
129 limits: ScriptLimits,
130) -> Result<ScriptValue, ScriptError> {
131 control.check()?;
132 if source.is_empty() || source.len() > MAX_ARTIFACT_BYTES {
133 return Err(ScriptError::new(
134 "INVALID_SCRIPT",
135 "JavaScript source size is out of bounds",
136 ));
137 }
138 let args = ScriptValue::List(args);
139 args.validate(limits.values)
140 .map_err(|e| ScriptError::new("INVALID_ARGUMENT", e.to_string()))?;
141 let control = InvocationControl {
142 cancel: control.cancel.child_token(),
143 ..control
144 };
145 let _cancel_on_drop = CancelOnDrop(control.cancel.clone());
146 let runtime = AsyncRuntime::new().map_err(engine_error)?;
147 runtime.set_memory_limit(limits.memory_bytes).await;
148 runtime.set_max_stack_size(limits.stack_bytes).await;
149 let interrupt = control.clone();
150 runtime
151 .set_interrupt_handler(Some(Box::new(move || interrupt.check().is_err())))
152 .await;
153 runtime.set_loader(HostResolver, HostLoader).await;
154 let rejected = Arc::new(AtomicUsize::new(0));
155 let rejection_count = rejected.clone();
156 runtime
157 .set_host_promise_rejection_tracker(Some(Box::new(move |_, _, _, handled| {
158 if handled {
159 let _ = rejection_count.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
160 Some(n.saturating_sub(1))
161 });
162 } else {
163 rejection_count.fetch_add(1, Ordering::Relaxed);
164 }
165 })))
166 .await;
167 let context = AsyncContext::full(&runtime).await.map_err(engine_error)?;
168 let active = Arc::new(AtomicUsize::new(0));
169 let call = context.async_with(async |ctx| {
170 let result = async {
171 install_host(&ctx, host, control.clone(), limits, active.clone())?;
172 let (module, ready) = Module::declare(ctx.clone(), "entry.mjs", source)?.eval()?;
173 ready.into_future::<()>().await?;
174 let entry: Function = module.get("default")?;
175 let ScriptValue::List(values) = &args else {
176 unreachable!()
177 };
178 let mut arguments = Args::new(ctx.clone(), values.len());
179 for arg in values {
180 arguments.push_arg(value::to_js(&ctx, arg)?)?;
181 }
182 let result: MaybePromise = arguments.apply(&entry)?;
183 let output = result.into_future::<Value>().await?;
184 while ctx.execute_pending_job() {
187 control.check().map_err(|e| throw_host(&ctx, e))?;
188 }
189 value::from_js(output, limits.values)
190 }
191 .await;
192 result.map_err(|error| exception_error(&ctx, error))
193 });
194 let result = tokio::select! {
195 biased;
196 _ = control.cancelled() => Err(ScriptError::new("CANCELLED", "Script invocation cancelled")),
197 _ = tokio::time::sleep_until(control.deadline.into()) => Err(ScriptError::new("DEADLINE_EXCEEDED", "Script invocation timed out")),
198 result = call => result,
199 };
200 control.check()?;
202 if result.is_ok() && active.load(Ordering::Acquire) != 0 {
203 return Err(ScriptError::new(
204 "UNAWAITED_HOST_CALL",
205 "Script returned before its host calls completed",
206 ));
207 }
208 if result.is_ok() && rejected.load(Ordering::Acquire) != 0 {
209 return Err(ScriptError::new(
210 "UNHANDLED_REJECTION",
211 "Script left an unhandled Promise rejection",
212 ));
213 }
214 result
215 }
218
219fn engine_error(error: rquickjs::Error) -> ScriptError {
220 ScriptError::new("SCRIPT_ERROR", bounded(&error.to_string(), 4096))
221}
222
223fn bounded(value: &str, max: usize) -> String {
224 let mut end = value.len().min(max);
225 while !value.is_char_boundary(end) {
226 end -= 1;
227 }
228 value[..end].to_owned()
229}
230
231fn exception_error(ctx: &Ctx<'_>, error: rquickjs::Error) -> ScriptError {
232 if !error.is_exception() {
233 return engine_error(error);
234 }
235 let exception = ctx.catch();
236 if let Some(object) = exception.as_object() {
237 let code = object
238 .get::<_, String>("code")
239 .unwrap_or_else(|_| "SCRIPT_ERROR".into());
240 let message = object
241 .get::<_, String>("message")
242 .unwrap_or_else(|_| "Script failed".into());
243 ScriptError::new(bounded(&code, 128), bounded(&message, 4096))
244 } else if let Some(message) = exception.as_string().and_then(|v| v.to_string().ok()) {
245 ScriptError::new("SCRIPT_ERROR", bounded(&message, 4096))
246 } else {
247 ScriptError::new("SCRIPT_ERROR", "Script threw a non-error value")
248 }
249}
250
251fn throw_host(ctx: &Ctx<'_>, error: ScriptError) -> rquickjs::Error {
252 match Exception::from_message(ctx.clone(), &bounded(&error.message, 4096)) {
253 Ok(exception) => {
254 if let Err(error) = exception.as_object().set("code", bounded(&error.code, 128)) {
255 return error;
256 }
257 exception.throw()
258 }
259 Err(error) => error,
260 }
261}
262
263struct ActiveCall(Arc<AtomicUsize>);
264impl Drop for ActiveCall {
265 fn drop(&mut self) {
266 self.0.fetch_sub(1, Ordering::AcqRel);
267 }
268}
269
270fn install_host<'js>(
271 ctx: &Ctx<'js>,
272 host: Arc<dyn ScriptHost>,
273 control: InvocationControl,
274 limits: ScriptLimits,
275 active: Arc<AtomicUsize>,
276) -> rquickjs::Result<()> {
277 let calls = Arc::new(AtomicUsize::new(0));
278 let function = Function::new(
279 ctx.clone(),
280 move |ctx: Ctx<'js>, operation: String, args: Value<'js>| {
281 control.check().map_err(|e| throw_host(&ctx, e))?;
282 if operation.is_empty() || operation.len() > 128 {
283 return Err(Exception::throw_type(&ctx, "invalid host operation"));
284 }
285 let ScriptValue::List(args) = value::from_js(args, limits.values)? else {
286 return Err(Exception::throw_type(
287 &ctx,
288 "host arguments must be an array",
289 ));
290 };
291 if calls.fetch_add(1, Ordering::Relaxed) >= limits.host_calls {
292 return Err(Exception::throw_range(
293 &ctx,
294 "script host call limit exceeded",
295 ));
296 }
297 if active
298 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
299 (n < limits.concurrent_host_calls).then_some(n + 1)
300 })
301 .is_err()
302 {
303 return Err(Exception::throw_range(
304 &ctx,
305 "script concurrent host call limit exceeded",
306 ));
307 }
308 let guard = ActiveCall(active.clone());
309 let host = host.clone();
310 let control = control.clone();
311 let future_ctx = ctx.clone();
312 Promise::wrap_future(&ctx, async move {
313 let _guard = guard;
314 control.check().map_err(|e| throw_host(&future_ctx, e))?;
315 let result = tokio::select! {
316 biased;
317 _ = control.cancelled() => Err(ScriptError::new("CANCELLED", "Host call cancelled")),
318 _ = tokio::time::sleep_until(control.deadline.into()) => Err(ScriptError::new("DEADLINE_EXCEEDED", "Host call timed out")),
319 result = host.call(&operation, args, control.clone()) => result,
320 }.map_err(|e| throw_host(&future_ctx, e))?;
321 control.check().map_err(|e| throw_host(&future_ctx, e))?;
322 result
323 .validate(limits.values)
324 .map_err(|e| Exception::throw_range(&future_ctx, &e.to_string()))?;
325 value::to_js(&future_ctx, &result)
326 })
327 },
328 )?;
329 ctx.store_userdata(function)
332 .map_err(|_| Exception::throw_internal(ctx, "host bindings already borrowed"))?;
333 Ok(())
334}
335
336const HOST_MODULE: &str = "@mhome/script/host";
337struct HostResolver;
338impl Resolver for HostResolver {
339 fn resolve<'js>(
340 &mut self,
341 _: &Ctx<'js>,
342 base: &str,
343 name: &str,
344 attributes: Option<ImportAttributes<'js>>,
345 ) -> rquickjs::Result<String> {
346 if name == HOST_MODULE && attributes.is_none() {
347 Ok(name.into())
348 } else {
349 Err(rquickjs::Error::new_resolving_message(
350 base,
351 name,
352 "only bundled code and platform host imports are supported",
353 ))
354 }
355 }
356}
357struct HostLoader;
358impl Loader for HostLoader {
359 fn load<'js>(
360 &mut self,
361 ctx: &Ctx<'js>,
362 name: &str,
363 _: Option<ImportAttributes<'js>>,
364 ) -> rquickjs::Result<Module<'js>> {
365 if name == HOST_MODULE {
366 Module::declare_def::<HostModule, _>(ctx.clone(), name)
367 } else {
368 Err(rquickjs::Error::new_loading(name))
369 }
370 }
371}
372struct HostModule;
373impl ModuleDef for HostModule {
374 fn declare<'js>(declarations: &Declarations<'js>) -> rquickjs::Result<()> {
375 declarations.declare("call")?;
376 Ok(())
377 }
378 fn evaluate<'js>(ctx: &Ctx<'js>, exports: &Exports<'js>) -> rquickjs::Result<()> {
379 let function = ctx
380 .remove_userdata::<Function<'js>>()
381 .map_err(|_| Exception::throw_internal(ctx, "host bindings borrowed"))?
382 .ok_or_else(|| Exception::throw_internal(ctx, "host bindings unavailable"))?;
383 exports.export("call", *function)?;
384 Ok(())
385 }
386}