1use rquickjs::function::Rest;
2use rquickjs::{Context, Ctx, Function, Object, Runtime, Value};
3use std::collections::{HashMap, HashSet};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use crate::error::{Result, XriptError};
9use crate::manifest::{Binding, Manifest, NamespaceBinding};
10
11pub type HostFn =
12 Arc<dyn Fn(&[serde_json::Value]) -> std::result::Result<serde_json::Value, String> + Send + Sync>;
13
14pub struct HostBindings {
15 bindings: HashMap<String, HostBinding>,
16}
17
18enum HostBinding {
19 Function(HostFn),
20 Namespace(HashMap<String, HostFn>),
21}
22
23impl HostBindings {
24 pub fn new() -> Self {
25 Self {
26 bindings: HashMap::new(),
27 }
28 }
29
30 pub fn add_function<F>(&mut self, name: impl Into<String>, f: F)
31 where
32 F: Fn(&[serde_json::Value]) -> std::result::Result<serde_json::Value, String>
33 + Send
34 + Sync
35 + 'static,
36 {
37 self.bindings
38 .insert(name.into(), HostBinding::Function(Arc::new(f)));
39 }
40
41 pub fn add_namespace(&mut self, name: impl Into<String>, members: HashMap<String, HostFn>) {
42 self.bindings
43 .insert(name.into(), HostBinding::Namespace(members));
44 }
45}
46
47impl Default for HostBindings {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53pub struct ConsoleHandler {
54 pub log: Box<dyn Fn(&str) + Send + Sync>,
55 pub warn: Box<dyn Fn(&str) + Send + Sync>,
56 pub error: Box<dyn Fn(&str) + Send + Sync>,
57}
58
59impl Default for ConsoleHandler {
60 fn default() -> Self {
61 Self {
62 log: Box::new(|_| {}),
63 warn: Box::new(|_| {}),
64 error: Box::new(|_| {}),
65 }
66 }
67}
68
69pub struct RuntimeOptions {
70 pub host_bindings: HostBindings,
71 pub capabilities: Vec<String>,
72 pub console: ConsoleHandler,
73}
74
75#[derive(Debug)]
76pub struct ExecutionResult {
77 pub value: serde_json::Value,
78 pub duration_ms: f64,
79}
80
81pub struct XriptRuntime {
82 rt: Runtime,
83 ctx: Context,
84 manifest: Manifest,
85}
86
87impl std::fmt::Debug for XriptRuntime {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("XriptRuntime")
90 .field("manifest_name", &self.manifest.name)
91 .finish_non_exhaustive()
92 }
93}
94
95impl XriptRuntime {
96 pub fn new(manifest: Manifest, options: RuntimeOptions) -> Result<Self> {
97 crate::manifest::validate_structure(&manifest)?;
98
99 let rt = Runtime::new().map_err(|e| XriptError::Engine(e.to_string()))?;
100
101 if let Some(ref limits) = manifest.limits {
102 if let Some(memory_mb) = limits.memory_mb {
103 rt.set_memory_limit(memory_mb as usize * 1024 * 1024);
104 }
105 if let Some(stack) = limits.max_stack_depth {
106 rt.set_max_stack_size(stack * 1024);
107 }
108 }
109
110 let ctx = Context::full(&rt).map_err(|e| XriptError::Engine(e.to_string()))?;
111
112 let granted: HashSet<String> = options.capabilities.into_iter().collect();
113
114 ctx.with(|ctx| -> Result<()> {
115 remove_dangerous_globals(&ctx)?;
116 register_console(&ctx, options.console)?;
117 register_bindings(&ctx, &manifest, options.host_bindings, &granted)?;
118 register_hooks(&ctx, &manifest, &granted)?;
119 register_fragment_hooks(&ctx)?;
120 Ok(())
121 })?;
122
123 Ok(Self { rt, ctx, manifest })
124 }
125
126 pub fn execute(&self, code: &str) -> Result<ExecutionResult> {
127 let timeout_ms = self
128 .manifest
129 .limits
130 .as_ref()
131 .and_then(|l| l.timeout_ms)
132 .unwrap_or(5000);
133
134 let interrupted = Arc::new(AtomicBool::new(false));
135 let interrupted_clone = interrupted.clone();
136 let start = Instant::now();
137 let deadline = start + Duration::from_millis(timeout_ms);
138
139 self.rt
140 .set_interrupt_handler(Some(Box::new(move || {
141 if Instant::now() >= deadline {
142 interrupted_clone.store(true, Ordering::Relaxed);
143 true
144 } else {
145 false
146 }
147 }) as Box<dyn FnMut() -> bool + Send>));
148
149 let result = self.ctx.with(|ctx| {
150 let res: std::result::Result<Value, _> = ctx.eval(code);
151 match res {
152 Ok(val) => {
153 let json = js_value_to_json(&ctx, &val);
154 Ok(json)
155 }
156 Err(_) => {
157 if interrupted.load(Ordering::Relaxed) {
158 Err(XriptError::ExecutionLimit {
159 limit: "timeout_ms".into(),
160 })
161 } else {
162 let msg: std::result::Result<String, _> =
163 ctx.eval("(() => { try { throw undefined; } catch(e) { return String(e); } })()");
164 let error_msg = msg.unwrap_or_else(|_| "unknown script error".into());
165 Err(XriptError::Script(error_msg))
166 }
167 }
168 }
169 });
170
171 self.rt
172 .set_interrupt_handler(None::<Box<dyn FnMut() -> bool + Send>>);
173
174 let duration_ms = start.elapsed().as_secs_f64() * 1000.0;
175
176 result.map(|value| ExecutionResult { value, duration_ms })
177 }
178
179 pub fn manifest(&self) -> &Manifest {
180 &self.manifest
181 }
182
183 pub fn load_mod(
184 &self,
185 mod_manifest_json: &str,
186 fragment_sources: HashMap<String, String>,
187 granted_capabilities: &HashSet<String>,
188 ) -> Result<crate::fragment::ModInstance> {
189 crate::fragment::load_mod(
190 mod_manifest_json,
191 &self.manifest,
192 granted_capabilities,
193 &fragment_sources,
194 )
195 }
196
197 pub fn fire_fragment_hook(
198 &self,
199 fragment_id: &str,
200 lifecycle: &str,
201 bindings: Option<&serde_json::Value>,
202 ) -> Result<Vec<serde_json::Value>> {
203 let bindings_json = match bindings {
204 Some(b) => serde_json::to_string(b).unwrap_or("{}".into()),
205 None => "{}".into(),
206 };
207
208 let code = format!(
209 r#"(function() {{
210 var handlers = globalThis.__xript_fragment_handlers || {{}};
211 var key = "fragment:{lifecycle}:{fid}";
212 var list = handlers[key] || [];
213 var results = [];
214 var bindingsObj = JSON.parse('{bindings_json}');
215 for (var i = 0; i < list.length; i++) {{
216 var ops = [];
217 var proxy = {{
218 toggle: function(sel, cond) {{ ops.push({{ op: "toggle", selector: sel, value: !!cond }}); }},
219 addClass: function(sel, cls) {{ ops.push({{ op: "addClass", selector: sel, value: cls }}); }},
220 removeClass: function(sel, cls) {{ ops.push({{ op: "removeClass", selector: sel, value: cls }}); }},
221 setText: function(sel, txt) {{ ops.push({{ op: "setText", selector: sel, value: txt }}); }},
222 setAttr: function(sel, attr, val) {{ ops.push({{ op: "setAttr", selector: sel, attr: attr, value: val }}); }},
223 replaceChildren: function(sel, html) {{ ops.push({{ op: "replaceChildren", selector: sel, value: html }}); }}
224 }};
225 list[i](bindingsObj, proxy);
226 results.push(ops);
227 }}
228 return JSON.stringify(results);
229 }})()"#,
230 lifecycle = lifecycle,
231 fid = fragment_id,
232 bindings_json = bindings_json.replace('\'', "\\'"),
233 );
234
235 let result = self.execute(&code)?;
236 match serde_json::from_str::<Vec<serde_json::Value>>(
237 result.value.as_str().unwrap_or("[]"),
238 ) {
239 Ok(ops) => Ok(ops),
240 Err(_) => Ok(vec![]),
241 }
242 }
243}
244
245fn remove_dangerous_globals(ctx: &Ctx<'_>) -> Result<()> {
246 let script = r#"
247 delete globalThis.eval;
248 if (typeof globalThis.Function !== 'undefined') {
249 Object.defineProperty(globalThis, 'Function', {
250 get: function() { throw new Error("Function constructor is not permitted. Dynamic code generation is disabled in xript."); },
251 configurable: false
252 });
253 }
254 "#;
255 ctx.eval::<(), _>(script)
256 .map_err(|e| XriptError::Engine(e.to_string()))?;
257 Ok(())
258}
259
260fn register_console(ctx: &Ctx<'_>, console: ConsoleHandler) -> Result<()> {
261 let console_obj = Object::new(ctx.clone()).map_err(|e| XriptError::Engine(e.to_string()))?;
262
263 let log = Arc::new(console.log);
264 let log_clone = log.clone();
265 let log_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
266 let msg = args.0.join(" ");
267 log_clone(&msg);
268 })
269 .map_err(|e| XriptError::Engine(e.to_string()))?;
270
271 let warn = Arc::new(console.warn);
272 let warn_clone = warn.clone();
273 let warn_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
274 let msg = args.0.join(" ");
275 warn_clone(&msg);
276 })
277 .map_err(|e| XriptError::Engine(e.to_string()))?;
278
279 let error = Arc::new(console.error);
280 let error_clone = error.clone();
281 let error_fn = Function::new(ctx.clone(), move |args: Rest<String>| {
282 let msg = args.0.join(" ");
283 error_clone(&msg);
284 })
285 .map_err(|e| XriptError::Engine(e.to_string()))?;
286
287 console_obj
288 .set("log", log_fn)
289 .map_err(|e| XriptError::Engine(e.to_string()))?;
290 console_obj
291 .set("warn", warn_fn)
292 .map_err(|e| XriptError::Engine(e.to_string()))?;
293 console_obj
294 .set("error", error_fn)
295 .map_err(|e| XriptError::Engine(e.to_string()))?;
296
297 ctx.globals()
298 .set("console", console_obj)
299 .map_err(|e| XriptError::Engine(e.to_string()))?;
300
301 Ok(())
302}
303
304fn make_throwing_function<'js>(ctx: &Ctx<'js>, message: &str) -> Result<Function<'js>> {
305 let escaped = message.replace('\\', "\\\\").replace('"', "\\\"");
306 let script = format!("(function() {{ throw new Error(\"{}\"); }})", escaped);
307 ctx.eval::<Function, _>(script.as_str())
308 .map_err(|e| XriptError::Engine(e.to_string()))
309}
310
311fn register_bindings(
312 ctx: &Ctx<'_>,
313 manifest: &Manifest,
314 host_bindings: HostBindings,
315 granted: &HashSet<String>,
316) -> Result<()> {
317 let Some(ref bindings) = manifest.bindings else {
318 return Ok(());
319 };
320
321 for (name, binding) in bindings {
322 match binding {
323 Binding::Function(func_def) => {
324 if let Some(ref cap) = func_def.capability {
325 if !granted.contains(cap) {
326 let msg = format!(
327 "{}() requires the \"{}\" capability, which hasn't been granted to this script",
328 name, cap
329 );
330 let deny_fn = make_throwing_function(ctx, &msg)?;
331 ctx.globals()
332 .set(name.as_str(), deny_fn)
333 .map_err(|e| XriptError::Engine(e.to_string()))?;
334 continue;
335 }
336 }
337
338 match host_bindings.bindings.get(name) {
339 Some(HostBinding::Function(f)) => {
340 let js_fn = create_host_function(ctx, name, f.clone())?;
341 ctx.globals()
342 .set(name.as_str(), js_fn)
343 .map_err(|e| XriptError::Engine(e.to_string()))?;
344 }
345 _ => {
346 let msg = format!("host binding '{}' is not provided", name);
347 let missing_fn = make_throwing_function(ctx, &msg)?;
348 ctx.globals()
349 .set(name.as_str(), missing_fn)
350 .map_err(|e| XriptError::Engine(e.to_string()))?;
351 }
352 }
353 }
354 Binding::Namespace(ns_def) => {
355 register_namespace_binding(ctx, name, ns_def, &host_bindings, granted)?;
356 }
357 }
358 }
359
360 Ok(())
361}
362
363fn register_namespace_binding(
364 ctx: &Ctx<'_>,
365 name: &str,
366 ns_def: &NamespaceBinding,
367 host_bindings: &HostBindings,
368 granted: &HashSet<String>,
369) -> Result<()> {
370 let ns_obj = Object::new(ctx.clone()).map_err(|e| XriptError::Engine(e.to_string()))?;
371
372 let host_ns = match host_bindings.bindings.get(name) {
373 Some(HostBinding::Namespace(members)) => Some(members),
374 _ => None,
375 };
376
377 for (member_name, member_binding) in &ns_def.members {
378 if let Binding::Function(func_def) = member_binding {
379 let full_name = format!("{}.{}", name, member_name);
380
381 if let Some(ref cap) = func_def.capability {
382 if !granted.contains(cap) {
383 let msg = format!(
384 "{}() requires the \"{}\" capability, which hasn't been granted to this script",
385 full_name, cap
386 );
387 let deny_fn = make_throwing_function(ctx, &msg)?;
388 ns_obj
389 .set(member_name.as_str(), deny_fn)
390 .map_err(|e| XriptError::Engine(e.to_string()))?;
391 continue;
392 }
393 }
394
395 if let Some(host_members) = host_ns {
396 if let Some(f) = host_members.get(member_name) {
397 let js_fn = create_host_function(ctx, &full_name, f.clone())?;
398 ns_obj
399 .set(member_name.as_str(), js_fn)
400 .map_err(|e| XriptError::Engine(e.to_string()))?;
401 continue;
402 }
403 }
404
405 let msg = format!("host binding '{}' is not provided", full_name);
406 let missing_fn = make_throwing_function(ctx, &msg)?;
407 ns_obj
408 .set(member_name.as_str(), missing_fn)
409 .map_err(|e| XriptError::Engine(e.to_string()))?;
410 }
411 }
412
413 ctx.globals()
414 .set(name, ns_obj)
415 .map_err(|e| XriptError::Engine(e.to_string()))?;
416
417 let freeze_script = format!(
418 "Object.freeze(globalThis['{}'])",
419 name.replace('\'', "\\'")
420 );
421 ctx.eval::<(), _>(freeze_script.as_str())
422 .map_err(|e| XriptError::Engine(e.to_string()))?;
423
424 Ok(())
425}
426
427fn register_hooks(ctx: &Ctx<'_>, manifest: &Manifest, granted: &HashSet<String>) -> Result<()> {
428 let Some(ref hooks) = manifest.hooks else {
429 return Ok(());
430 };
431
432 if hooks.is_empty() {
433 return Ok(());
434 }
435
436 let mut hook_setup = String::from("globalThis.__xript_hooks = {};\n");
437 hook_setup.push_str("globalThis.hooks = {};\n");
438
439 for (hook_name, hook_def) in hooks {
440 hook_setup.push_str(&format!(
441 "globalThis.__xript_hooks['{}'] = [];\n",
442 hook_name
443 ));
444
445 if let Some(ref phases) = hook_def.phases {
446 if !phases.is_empty() {
447 hook_setup.push_str(&format!("globalThis.hooks['{}'] = {{}};\n", hook_name));
448 for phase in phases {
449 let registration = if let Some(ref cap) = hook_def.capability {
450 if !granted.contains(cap) {
451 format!(
452 "globalThis.hooks['{hook}']['{phase}'] = function() {{ throw new Error(\"{hook}.{phase}() requires the \\\"{cap}\\\" capability\"); }};",
453 hook = hook_name, phase = phase, cap = cap
454 )
455 } else {
456 format!(
457 "globalThis.hooks['{hook}']['{phase}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ phase: '{phase}', handler: handler }}); }};",
458 hook = hook_name, phase = phase
459 )
460 }
461 } else {
462 format!(
463 "globalThis.hooks['{hook}']['{phase}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ phase: '{phase}', handler: handler }}); }};",
464 hook = hook_name, phase = phase
465 )
466 };
467 hook_setup.push_str(®istration);
468 hook_setup.push('\n');
469 }
470 }
471 } else {
472 let registration = if let Some(ref cap) = hook_def.capability {
473 if !granted.contains(cap) {
474 format!(
475 "globalThis.hooks['{hook}'] = function() {{ throw new Error(\"{hook}() requires the \\\"{cap}\\\" capability\"); }};",
476 hook = hook_name, cap = cap
477 )
478 } else {
479 format!(
480 "globalThis.hooks['{hook}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ handler: handler }}); }};",
481 hook = hook_name
482 )
483 }
484 } else {
485 format!(
486 "globalThis.hooks['{hook}'] = function(handler) {{ globalThis.__xript_hooks['{hook}'].push({{ handler: handler }}); }};",
487 hook = hook_name
488 )
489 };
490 hook_setup.push_str(®istration);
491 hook_setup.push('\n');
492 }
493 }
494
495 hook_setup.push_str("Object.freeze(globalThis.hooks);\n");
496
497 ctx.eval::<(), _>(hook_setup.as_str())
498 .map_err(|e| XriptError::Engine(e.to_string()))?;
499
500 Ok(())
501}
502
503fn register_fragment_hooks(ctx: &Ctx<'_>) -> Result<()> {
504 let script = r#"
505 globalThis.__xript_fragment_handlers = {};
506
507 var existingHooks = {};
508 if (typeof globalThis.hooks === 'object' && globalThis.hooks !== null) {
509 var hookKeys = Object.getOwnPropertyNames(globalThis.hooks);
510 for (var i = 0; i < hookKeys.length; i++) {
511 existingHooks[hookKeys[i]] = globalThis.hooks[hookKeys[i]];
512 }
513 }
514
515 var fragmentNs = {};
516 var lifecycles = ['mount', 'unmount', 'update', 'suspend', 'resume'];
517 for (var j = 0; j < lifecycles.length; j++) {
518 (function(lifecycle) {
519 fragmentNs[lifecycle] = function(fragmentId, handler) {
520 var key = "fragment:" + lifecycle + ":" + fragmentId;
521 if (!globalThis.__xript_fragment_handlers[key]) {
522 globalThis.__xript_fragment_handlers[key] = [];
523 }
524 globalThis.__xript_fragment_handlers[key].push(handler);
525 };
526 })(lifecycles[j]);
527 }
528 Object.freeze(fragmentNs);
529 existingHooks.fragment = fragmentNs;
530
531 globalThis.hooks = existingHooks;
532 Object.freeze(globalThis.hooks);
533 "#;
534
535 ctx.eval::<(), _>(script)
536 .map_err(|e| XriptError::Engine(e.to_string()))?;
537
538 Ok(())
539}
540
541fn create_host_function<'js>(
542 ctx: &Ctx<'js>,
543 name: &str,
544 f: HostFn,
545) -> Result<Function<'js>> {
546 let bridge_fn = Function::new(ctx.clone(), move |args_json: String| -> String {
547 let args: Vec<serde_json::Value> = match serde_json::from_str(&args_json) {
548 Ok(a) => a,
549 Err(e) => {
550 let err = serde_json::json!({"__xript_err": format!("invalid args: {}", e)});
551 return serde_json::to_string(&err).unwrap();
552 }
553 };
554 match f(&args) {
555 Ok(result) => {
556 let wrapped = serde_json::json!({"__xript_ok": result});
557 serde_json::to_string(&wrapped).unwrap_or("{\"__xript_ok\":null}".into())
558 }
559 Err(msg) => {
560 let err = serde_json::json!({"__xript_err": msg});
561 serde_json::to_string(&err).unwrap()
562 }
563 }
564 })
565 .map_err(|e| {
566 XriptError::Engine(format!("failed to create host function '{}': {}", name, e))
567 })?;
568
569 ctx.globals()
570 .set("__xript_tmp_bridge", bridge_fn)
571 .map_err(|e| XriptError::Engine(e.to_string()))?;
572
573 let wrapper: Function = ctx.eval(
574 "(function(bridge) { return function() { var args = Array.prototype.slice.call(arguments); var raw = bridge(JSON.stringify(args)); var envelope = JSON.parse(raw); if (envelope.__xript_err !== undefined) { throw new Error(envelope.__xript_err); } return envelope.__xript_ok; }; })(__xript_tmp_bridge)",
575 )
576 .map_err(|e| XriptError::Engine(e.to_string()))?;
577
578 ctx.eval::<(), _>("delete globalThis.__xript_tmp_bridge")
579 .map_err(|e| XriptError::Engine(e.to_string()))?;
580
581 Ok(wrapper)
582}
583
584fn js_value_to_json(ctx: &Ctx<'_>, val: &Value<'_>) -> serde_json::Value {
585 if val.is_undefined() || val.is_null() {
586 return serde_json::Value::Null;
587 }
588
589 if let Some(b) = val.as_bool() {
590 return serde_json::Value::Bool(b);
591 }
592
593 if let Some(n) = val.as_int() {
594 return serde_json::json!(n);
595 }
596
597 if let Some(n) = val.as_float() {
598 if n.is_finite() {
599 return serde_json::json!(n);
600 }
601 return serde_json::Value::Null;
602 }
603
604 if let Some(s) = val.as_string() {
605 if let Ok(s) = s.to_string() {
606 return serde_json::Value::String(s);
607 }
608 }
609
610 let stringify_result: std::result::Result<String, _> = ctx.eval(
611 "((v) => JSON.stringify(v))",
612 );
613 if let Ok(stringify_fn_str) = stringify_result {
614 if let Ok(v) = serde_json::from_str::<serde_json::Value>(&stringify_fn_str) {
615 return v;
616 }
617 }
618
619 serde_json::Value::Null
620}