1use crate::error::{Error, Result};
27use crate::hostcall_io_uring_lane::{
28 HostcallCapabilityClass, HostcallIoHint, IoUringLaneDecisionInput,
29};
30use crate::hostcall_queue::{
31 HOSTCALL_FAST_RING_CAPACITY, HOSTCALL_OVERFLOW_CAPACITY, HostcallQueueEnqueueResult,
32 HostcallQueueTelemetry, HostcallRequestQueue, QueueTenant,
33};
34use crate::scheduler::{Clock as SchedulerClock, HostcallOutcome, Scheduler, WallClock};
35use base64::Engine as _;
36use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
37use rquickjs::function::{Func, Opt};
38use rquickjs::loader::{Loader as JsModuleLoader, Resolver as JsModuleResolver};
39use rquickjs::module::Declared as JsModuleDeclared;
40use rquickjs::{
41 AsyncContext, AsyncRuntime, Coerced, Ctx, Exception, FromJs, Function, IntoJs, Module, Object,
42 Value,
43};
44use sha2::{Digest, Sha256};
45use std::cell::RefCell;
46use std::cmp::Ordering;
47use std::collections::{BTreeSet, BinaryHeap, HashMap, HashSet, VecDeque};
48use std::fmt::Write as _;
49use std::rc::Rc;
50use std::sync::Arc;
51use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
52use std::time::{SystemTime, UNIX_EPOCH};
53use std::{fs, path::Path, path::PathBuf};
54use swc_common::{FileName, GLOBALS, Globals, Mark, SourceMap, sync::Lrc};
55use swc_ecma_ast::{Module as SwcModule, Pass, Program as SwcProgram};
56use swc_ecma_codegen::{Emitter, text_writer::JsWriter};
57use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
58use swc_ecma_transforms_base::resolver;
59use swc_ecma_transforms_typescript::strip;
60use uuid::Uuid;
61
62use crate::extensions::{
67 ExecMediationResult, ExtensionPolicy, ExtensionPolicyMode, SecretBrokerPolicy,
68 evaluate_exec_mediation,
69};
70
71fn check_exec_capability(policy: &ExtensionPolicy, extension_id: Option<&str>) -> bool {
73 let cap = "exec";
74
75 if let Some(id) = extension_id {
77 if let Some(override_config) = policy.per_extension.get(id) {
78 if override_config.deny.iter().any(|c| c == cap) {
79 return false;
80 }
81 if override_config.allow.iter().any(|c| c == cap) {
82 return true;
83 }
84 if let Some(mode) = override_config.mode {
85 return match mode {
86 ExtensionPolicyMode::Permissive => true,
87 ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, };
89 }
90 }
91 }
92
93 if policy.deny_caps.iter().any(|c| c == cap) {
95 return false;
96 }
97
98 if policy.default_caps.iter().any(|c| c == cap) {
100 return true;
101 }
102
103 match policy.mode {
105 ExtensionPolicyMode::Permissive => true,
106 ExtensionPolicyMode::Strict | ExtensionPolicyMode::Prompt => false, }
108}
109
110pub fn is_env_var_allowed(key: &str) -> bool {
115 let policy = SecretBrokerPolicy::default();
116 !policy.is_secret(key)
119}
120
121fn parse_truthy_flag(value: &str) -> bool {
122 matches!(
123 value.trim().to_ascii_lowercase().as_str(),
124 "1" | "true" | "yes" | "on"
125 )
126}
127
128fn is_global_compat_scan_mode() -> bool {
129 cfg!(feature = "ext-conformance")
130 || std::env::var("PI_EXT_COMPAT_SCAN").is_ok_and(|value| parse_truthy_flag(&value))
131}
132
133fn is_compat_scan_mode(env: &HashMap<String, String>) -> bool {
134 is_global_compat_scan_mode()
135 || env
136 .get("PI_EXT_COMPAT_SCAN")
137 .is_some_and(|value| parse_truthy_flag(value))
138}
139
140fn compat_env_fallback_value(key: &str, env: &HashMap<String, String>) -> Option<String> {
145 if !is_compat_scan_mode(env) {
146 return None;
147 }
148
149 let upper = key.to_ascii_uppercase();
150 if upper.ends_with("_API_KEY") {
151 return Some(format!("pi-compat-{}", upper.to_ascii_lowercase()));
152 }
153 if upper == "PI_SEMANTIC_LEGACY" {
154 return Some("1".to_string());
155 }
156
157 None
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum HostcallKind {
167 Tool { name: String },
169 Exec { cmd: String },
171 Http,
173 Session { op: String },
175 Ui { op: String },
177 Events { op: String },
179 Log,
181}
182
183#[derive(Debug, Clone)]
185pub struct HostcallRequest {
186 pub call_id: String,
188 pub kind: HostcallKind,
190 pub payload: serde_json::Value,
192 pub trace_id: u64,
194 pub extension_id: Option<String>,
196}
197
198impl QueueTenant for HostcallRequest {
199 fn tenant_key(&self) -> Option<&str> {
200 self.extension_id.as_deref()
201 }
202}
203
204#[allow(clippy::derive_partial_eq_without_eq)]
206#[derive(Debug, Clone, serde::Deserialize, PartialEq)]
207pub struct ExtensionToolDef {
208 pub name: String,
209 #[serde(default)]
210 pub label: Option<String>,
211 pub description: String,
212 pub parameters: serde_json::Value,
213}
214
215fn hostcall_params_hash(method: &str, params: &serde_json::Value) -> String {
217 crate::extensions::hostcall_params_hash(method, params)
218}
219
220fn canonical_exec_params(cmd: &str, payload: &serde_json::Value) -> serde_json::Value {
221 let mut obj = match payload {
222 serde_json::Value::Object(map) => {
223 let mut out = map.clone();
224 out.remove("command");
225 out
226 }
227 serde_json::Value::Null => serde_json::Map::new(),
228 other => {
229 let mut out = serde_json::Map::new();
230 out.insert("payload".to_string(), other.clone());
231 out
232 }
233 };
234
235 obj.insert(
236 "cmd".to_string(),
237 serde_json::Value::String(cmd.to_string()),
238 );
239 serde_json::Value::Object(obj)
240}
241
242fn canonical_op_params(op: &str, payload: &serde_json::Value) -> serde_json::Value {
243 if payload.is_null() {
246 return serde_json::json!({ "op": op });
247 }
248
249 let mut obj = match payload {
250 serde_json::Value::Object(map) => map.clone(),
251 other => {
252 let mut out = serde_json::Map::new();
253 out.insert("payload".to_string(), other.clone());
255 out
256 }
257 };
258
259 obj.insert("op".to_string(), serde_json::Value::String(op.to_string()));
261 serde_json::Value::Object(obj)
262}
263
264fn builtin_tool_required_capability(name: &str) -> &'static str {
265 let name = name.trim();
266 if name.eq_ignore_ascii_case("read")
267 || name.eq_ignore_ascii_case("grep")
268 || name.eq_ignore_ascii_case("find")
269 || name.eq_ignore_ascii_case("ls")
270 {
271 "read"
272 } else if name.eq_ignore_ascii_case("write") || name.eq_ignore_ascii_case("edit") {
273 "write"
274 } else if name.eq_ignore_ascii_case("bash") {
275 "exec"
276 } else {
277 "tool"
278 }
279}
280
281impl HostcallRequest {
282 #[must_use]
283 pub const fn method(&self) -> &'static str {
284 match self.kind {
285 HostcallKind::Tool { .. } => "tool",
286 HostcallKind::Exec { .. } => "exec",
287 HostcallKind::Http => "http",
288 HostcallKind::Session { .. } => "session",
289 HostcallKind::Ui { .. } => "ui",
290 HostcallKind::Events { .. } => "events",
291 HostcallKind::Log => "log",
292 }
293 }
294
295 #[must_use]
296 pub fn required_capability(&self) -> &'static str {
297 match &self.kind {
298 HostcallKind::Tool { name } => builtin_tool_required_capability(name),
299 HostcallKind::Exec { .. } => "exec",
300 HostcallKind::Http => "http",
301 HostcallKind::Session { .. } => "session",
302 HostcallKind::Ui { .. } => "ui",
303 HostcallKind::Events { .. } => "events",
304 HostcallKind::Log => "log",
305 }
306 }
307
308 #[must_use]
309 pub fn io_uring_capability_class(&self) -> HostcallCapabilityClass {
310 HostcallCapabilityClass::from_capability(self.required_capability())
311 }
312
313 #[must_use]
314 pub fn io_uring_io_hint(&self) -> HostcallIoHint {
315 match &self.kind {
316 HostcallKind::Http => HostcallIoHint::IoHeavy,
317 HostcallKind::Exec { .. } => HostcallIoHint::CpuBound,
318 HostcallKind::Tool { name } => {
319 let name = name.trim();
320 if name.eq_ignore_ascii_case("read")
321 || name.eq_ignore_ascii_case("write")
322 || name.eq_ignore_ascii_case("edit")
323 || name.eq_ignore_ascii_case("grep")
324 || name.eq_ignore_ascii_case("find")
325 || name.eq_ignore_ascii_case("ls")
326 {
327 HostcallIoHint::IoHeavy
328 } else if name.eq_ignore_ascii_case("bash") {
329 HostcallIoHint::CpuBound
330 } else {
331 HostcallIoHint::Unknown
332 }
333 }
334 HostcallKind::Session { .. }
335 | HostcallKind::Ui { .. }
336 | HostcallKind::Events { .. }
337 | HostcallKind::Log => HostcallIoHint::Unknown,
338 }
339 }
340
341 #[must_use]
342 pub fn io_uring_lane_input(
343 &self,
344 queue_depth: usize,
345 force_compat_lane: bool,
346 ) -> IoUringLaneDecisionInput {
347 IoUringLaneDecisionInput {
348 capability: self.io_uring_capability_class(),
349 io_hint: self.io_uring_io_hint(),
350 queue_depth,
351 force_compat_lane,
352 }
353 }
354
355 #[must_use]
366 pub fn params_for_hash(&self) -> serde_json::Value {
367 match &self.kind {
368 HostcallKind::Tool { name } => {
369 serde_json::json!({ "name": name, "input": self.payload.clone() })
370 }
371 HostcallKind::Exec { cmd } => canonical_exec_params(cmd, &self.payload),
372 HostcallKind::Http | HostcallKind::Log => self.payload.clone(),
373 HostcallKind::Session { op }
374 | HostcallKind::Ui { op }
375 | HostcallKind::Events { op } => canonical_op_params(op, &self.payload),
376 }
377 }
378
379 #[must_use]
380 pub fn params_hash(&self) -> String {
381 hostcall_params_hash(self.method(), &self.params_for_hash())
382 }
383}
384
385const MAX_JSON_DEPTH: usize = 64;
386const MAX_JOBS_PER_TICK: usize = 10_000;
387
388#[allow(clippy::option_if_let_else)]
390pub(crate) fn json_to_js<'js>(
391 ctx: &Ctx<'js>,
392 value: &serde_json::Value,
393) -> rquickjs::Result<Value<'js>> {
394 json_to_js_inner(ctx, value, 0)
395}
396
397fn json_to_js_inner<'js>(
398 ctx: &Ctx<'js>,
399 value: &serde_json::Value,
400 depth: usize,
401) -> rquickjs::Result<Value<'js>> {
402 if depth > MAX_JSON_DEPTH {
403 return Err(rquickjs::Error::new_into_js_message(
404 "json",
405 "parse",
406 "JSON object too deep",
407 ));
408 }
409
410 match value {
411 serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
412 serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
413 serde_json::Value::Number(n) => n.as_i64().and_then(|i| i32::try_from(i).ok()).map_or_else(
414 || {
415 n.as_f64().map_or_else(
416 || Ok(Value::new_null(ctx.clone())),
417 |f| Ok(Value::new_float(ctx.clone(), f)),
418 )
419 },
420 |i| Ok(Value::new_int(ctx.clone(), i)),
421 ),
422 serde_json::Value::String(s) => s.as_str().into_js(ctx),
424 serde_json::Value::Array(arr) => {
425 let js_arr = rquickjs::Array::new(ctx.clone())?;
426 for (i, v) in arr.iter().enumerate() {
427 let js_v = json_to_js_inner(ctx, v, depth + 1)?;
428 js_arr.set(i, js_v)?;
429 }
430 Ok(js_arr.into_value())
431 }
432 serde_json::Value::Object(obj) => {
433 let js_obj = Object::new(ctx.clone())?;
434 for (k, v) in obj {
435 let js_v = json_to_js_inner(ctx, v, depth + 1)?;
436 js_obj.set(k.as_str(), js_v)?;
437 }
438 Ok(js_obj.into_value())
439 }
440 }
441}
442
443pub(crate) fn js_to_json(value: &Value<'_>) -> rquickjs::Result<serde_json::Value> {
445 js_to_json_inner(value, 0)
446}
447
448fn js_to_json_inner(value: &Value<'_>, depth: usize) -> rquickjs::Result<serde_json::Value> {
449 if depth > MAX_JSON_DEPTH {
450 return Err(rquickjs::Error::new_into_js_message(
451 "json",
452 "stringify",
453 "Object too deep or contains cycles",
454 ));
455 }
456
457 if value.is_null() || value.is_undefined() {
458 return Ok(serde_json::Value::Null);
459 }
460 if let Some(b) = value.as_bool() {
461 return Ok(serde_json::Value::Bool(b));
462 }
463 if let Some(i) = value.as_int() {
464 return Ok(serde_json::json!(i));
465 }
466 if let Some(f) = value.as_float() {
467 return Ok(serde_json::json!(f));
468 }
469 if let Some(s) = value.as_string() {
470 let s = s.to_string()?;
471 return Ok(serde_json::Value::String(s));
472 }
473 if let Some(arr) = value.as_array() {
474 let len = arr.len();
475 let mut result = Vec::with_capacity(len);
476 for i in 0..len {
477 let v: Value<'_> = arr.get(i)?;
478 result.push(js_to_json_inner(&v, depth + 1)?);
479 }
480 return Ok(serde_json::Value::Array(result));
481 }
482 if let Some(obj) = value.as_object() {
483 let mut result = serde_json::Map::new();
484 for item in obj.props::<String, Value<'_>>() {
485 let (k, v) = item?;
486 result.insert(k, js_to_json_inner(&v, depth + 1)?);
487 }
488 return Ok(serde_json::Value::Object(result));
489 }
490 Ok(serde_json::Value::Null)
492}
493
494pub type HostcallQueue = Rc<RefCell<HostcallRequestQueue<HostcallRequest>>>;
495
496pub trait Clock: Send + Sync {
501 fn now_ms(&self) -> u64;
502}
503
504#[derive(Clone)]
505pub struct ClockHandle(Arc<dyn Clock>);
506
507impl ClockHandle {
508 pub fn new(clock: Arc<dyn Clock>) -> Self {
509 Self(clock)
510 }
511}
512
513impl Clock for ClockHandle {
514 fn now_ms(&self) -> u64 {
515 self.0.now_ms()
516 }
517}
518
519pub struct SystemClock;
520
521impl Clock for SystemClock {
522 fn now_ms(&self) -> u64 {
523 let now = SystemTime::now()
524 .duration_since(UNIX_EPOCH)
525 .unwrap_or_default();
526 u64::try_from(now.as_millis()).unwrap_or(u64::MAX)
527 }
528}
529
530#[derive(Debug)]
531pub struct ManualClock {
532 now_ms: AtomicU64,
533}
534
535impl ManualClock {
536 pub const fn new(start_ms: u64) -> Self {
537 Self {
538 now_ms: AtomicU64::new(start_ms),
539 }
540 }
541
542 pub fn set(&self, ms: u64) {
543 self.now_ms.store(ms, AtomicOrdering::SeqCst);
544 }
545
546 pub fn advance(&self, delta_ms: u64) {
547 self.now_ms.fetch_add(delta_ms, AtomicOrdering::SeqCst);
548 }
549}
550
551impl Clock for ManualClock {
552 fn now_ms(&self) -> u64 {
553 self.now_ms.load(AtomicOrdering::SeqCst)
554 }
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub enum MacrotaskKind {
559 TimerFired { timer_id: u64 },
560 HostcallComplete { call_id: String },
561 InboundEvent { event_id: String },
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct Macrotask {
566 pub seq: u64,
567 pub trace_id: u64,
568 pub kind: MacrotaskKind,
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572struct MacrotaskEntry {
573 seq: u64,
574 trace_id: u64,
575 kind: MacrotaskKind,
576}
577
578impl Ord for MacrotaskEntry {
579 fn cmp(&self, other: &Self) -> Ordering {
580 self.seq.cmp(&other.seq)
581 }
582}
583
584impl PartialOrd for MacrotaskEntry {
585 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
586 Some(self.cmp(other))
587 }
588}
589
590#[derive(Debug, Clone, PartialEq, Eq)]
591struct TimerEntry {
592 deadline_ms: u64,
593 order_seq: u64,
594 timer_id: u64,
595 trace_id: u64,
596}
597
598impl Ord for TimerEntry {
599 fn cmp(&self, other: &Self) -> Ordering {
600 (self.deadline_ms, self.order_seq, self.timer_id).cmp(&(
601 other.deadline_ms,
602 other.order_seq,
603 other.timer_id,
604 ))
605 }
606}
607
608impl PartialOrd for TimerEntry {
609 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
610 Some(self.cmp(other))
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
615struct PendingMacrotask {
616 trace_id: u64,
617 kind: MacrotaskKind,
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
621pub struct TickResult {
622 pub ran_macrotask: bool,
623 pub microtasks_drained: usize,
624}
625
626pub struct PiEventLoop {
627 clock: ClockHandle,
628 seq: u64,
629 next_timer_id: u64,
630 pending: VecDeque<PendingMacrotask>,
631 macro_queue: BinaryHeap<std::cmp::Reverse<MacrotaskEntry>>,
632 timers: BinaryHeap<std::cmp::Reverse<TimerEntry>>,
633 cancelled_timers: HashSet<u64>,
634}
635
636impl PiEventLoop {
637 pub fn new(clock: ClockHandle) -> Self {
638 Self {
639 clock,
640 seq: 0,
641 next_timer_id: 1,
642 pending: VecDeque::new(),
643 macro_queue: BinaryHeap::new(),
644 timers: BinaryHeap::new(),
645 cancelled_timers: HashSet::new(),
646 }
647 }
648
649 pub fn enqueue_hostcall_completion(&mut self, call_id: impl Into<String>) {
650 let trace_id = self.next_seq();
651 self.pending.push_back(PendingMacrotask {
652 trace_id,
653 kind: MacrotaskKind::HostcallComplete {
654 call_id: call_id.into(),
655 },
656 });
657 }
658
659 pub fn enqueue_inbound_event(&mut self, event_id: impl Into<String>) {
660 let trace_id = self.next_seq();
661 self.pending.push_back(PendingMacrotask {
662 trace_id,
663 kind: MacrotaskKind::InboundEvent {
664 event_id: event_id.into(),
665 },
666 });
667 }
668
669 pub fn set_timeout(&mut self, delay_ms: u64) -> u64 {
670 let timer_id = self.next_timer_id;
671 self.next_timer_id = self.next_timer_id.saturating_add(1);
672 let order_seq = self.next_seq();
673 let deadline_ms = self.clock.now_ms().saturating_add(delay_ms);
674 self.timers.push(std::cmp::Reverse(TimerEntry {
675 deadline_ms,
676 order_seq,
677 timer_id,
678 trace_id: order_seq,
679 }));
680 timer_id
681 }
682
683 pub fn clear_timeout(&mut self, timer_id: u64) -> bool {
684 let pending = self.timers.iter().any(|entry| entry.0.timer_id == timer_id)
685 && !self.cancelled_timers.contains(&timer_id);
686
687 if pending {
688 self.cancelled_timers.insert(timer_id)
689 } else {
690 false
691 }
692 }
693
694 pub fn tick(
695 &mut self,
696 mut on_macrotask: impl FnMut(Macrotask),
697 mut drain_microtasks: impl FnMut() -> bool,
698 ) -> TickResult {
699 self.ingest_pending();
700 self.enqueue_due_timers();
701
702 let mut ran_macrotask = false;
703 if let Some(task) = self.pop_next_macrotask() {
704 ran_macrotask = true;
705 on_macrotask(task);
706 }
707
708 let mut microtasks_drained = 0;
709 if ran_macrotask {
710 while drain_microtasks() {
711 microtasks_drained += 1;
712 }
713 }
714
715 TickResult {
716 ran_macrotask,
717 microtasks_drained,
718 }
719 }
720
721 fn ingest_pending(&mut self) {
722 while let Some(pending) = self.pending.pop_front() {
723 self.enqueue_macrotask(pending.trace_id, pending.kind);
724 }
725 }
726
727 fn enqueue_due_timers(&mut self) {
728 let now = self.clock.now_ms();
729 while let Some(std::cmp::Reverse(entry)) = self.timers.peek().cloned() {
730 if entry.deadline_ms > now {
731 break;
732 }
733 let _ = self.timers.pop();
734 if self.cancelled_timers.remove(&entry.timer_id) {
735 continue;
736 }
737 self.enqueue_macrotask(
738 entry.trace_id,
739 MacrotaskKind::TimerFired {
740 timer_id: entry.timer_id,
741 },
742 );
743 }
744 }
745
746 fn enqueue_macrotask(&mut self, trace_id: u64, kind: MacrotaskKind) {
747 let seq = self.next_seq();
748 self.macro_queue.push(std::cmp::Reverse(MacrotaskEntry {
749 seq,
750 trace_id,
751 kind,
752 }));
753 }
754
755 fn pop_next_macrotask(&mut self) -> Option<Macrotask> {
756 self.macro_queue.pop().map(|entry| {
757 let entry = entry.0;
758 Macrotask {
759 seq: entry.seq,
760 trace_id: entry.trace_id,
761 kind: entry.kind,
762 }
763 })
764 }
765
766 const fn next_seq(&mut self) -> u64 {
767 let current = self.seq;
768 self.seq = self.seq.saturating_add(1);
769 current
770 }
771}
772
773fn map_js_error(err: &rquickjs::Error) -> Error {
774 Error::extension(format!("QuickJS: {err:?}"))
775}
776
777fn format_quickjs_exception<'js>(ctx: &Ctx<'js>, caught: Value<'js>) -> String {
778 if let Ok(obj) = caught.clone().try_into_object() {
779 if let Some(exception) = Exception::from_object(obj) {
780 if let Some(message) = exception.message() {
781 if let Some(stack) = exception.stack() {
782 return format!("{message}\n{stack}");
783 }
784 return message;
785 }
786 if let Some(stack) = exception.stack() {
787 return stack;
788 }
789 }
790 }
791
792 match Coerced::<String>::from_js(ctx, caught) {
793 Ok(value) => value.0,
794 Err(err) => format!("(failed to stringify QuickJS exception: {err})"),
795 }
796}
797
798#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
804pub enum RepairPattern {
805 DistToSrc,
808 MissingAsset,
811 MonorepoEscape,
814 MissingNpmDep,
817 ExportShape,
820 ManifestNormalization,
823 ApiMigration,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
835pub enum RepairRisk {
836 Safe,
838 Aggressive,
840}
841
842impl RepairPattern {
843 pub const fn risk(self) -> RepairRisk {
845 match self {
846 Self::DistToSrc | Self::MissingAsset | Self::ManifestNormalization => RepairRisk::Safe,
848 Self::MonorepoEscape | Self::MissingNpmDep | Self::ExportShape | Self::ApiMigration => {
850 RepairRisk::Aggressive
851 }
852 }
853 }
854
855 pub const fn is_allowed_by(self, mode: RepairMode) -> bool {
857 match self.risk() {
858 RepairRisk::Safe => mode.should_apply(),
859 RepairRisk::Aggressive => mode.allows_aggressive(),
860 }
861 }
862}
863
864impl std::fmt::Display for RepairPattern {
865 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
866 match self {
867 Self::DistToSrc => write!(f, "dist_to_src"),
868 Self::MissingAsset => write!(f, "missing_asset"),
869 Self::MonorepoEscape => write!(f, "monorepo_escape"),
870 Self::MissingNpmDep => write!(f, "missing_npm_dep"),
871 Self::ExportShape => write!(f, "export_shape"),
872 Self::ManifestNormalization => write!(f, "manifest_normalization"),
873 Self::ApiMigration => write!(f, "api_migration"),
874 }
875 }
876}
877
878#[derive(Debug, Clone)]
881pub struct ExtensionRepairEvent {
882 pub extension_id: String,
884 pub pattern: RepairPattern,
886 pub original_error: String,
888 pub repair_action: String,
890 pub success: bool,
892 pub timestamp_ms: u64,
894}
895
896#[derive(Debug, Clone)]
902pub struct RepairRule {
903 pub id: &'static str,
905 pub version: &'static str,
907 pub pattern: RepairPattern,
909 pub description: &'static str,
911}
912
913impl RepairRule {
914 pub const fn risk(&self) -> RepairRisk {
916 self.pattern.risk()
917 }
918
919 pub const fn is_allowed_by(&self, mode: RepairMode) -> bool {
921 self.pattern.is_allowed_by(mode)
922 }
923}
924
925pub static REPAIR_RULES: &[RepairRule] = &[
931 RepairRule {
932 id: "dist_to_src_v1",
933 pattern: RepairPattern::DistToSrc,
934 version: "1.0.0",
935 description: "Remap ./dist/X.js to ./src/X.ts when build output is missing",
936 },
937 RepairRule {
938 id: "missing_asset_v1",
939 pattern: RepairPattern::MissingAsset,
940 version: "1.0.0",
941 description: "Return empty string for missing bundled asset reads",
942 },
943 RepairRule {
944 id: "monorepo_escape_v1",
945 pattern: RepairPattern::MonorepoEscape,
946 version: "1.0.0",
947 description: "Stub monorepo sibling imports (../../shared) with empty module",
948 },
949 RepairRule {
950 id: "missing_npm_dep_v1",
951 pattern: RepairPattern::MissingNpmDep,
952 version: "1.0.0",
953 description: "Provide proxy-based stub for unresolvable npm bare specifiers",
954 },
955 RepairRule {
956 id: "export_shape_v1",
957 pattern: RepairPattern::ExportShape,
958 version: "1.0.0",
959 description: "Try alternative lifecycle exports (CJS default, named activate)",
960 },
961 RepairRule {
963 id: "manifest_schema_v1",
964 pattern: RepairPattern::ManifestNormalization,
965 version: "1.0.0",
966 description: "Migrate deprecated manifest fields to current schema",
967 },
968 RepairRule {
970 id: "api_migration_v1",
971 pattern: RepairPattern::ApiMigration,
972 version: "1.0.0",
973 description: "Rewrite known deprecated API calls to current equivalents",
974 },
975];
976
977pub fn applicable_rules(mode: RepairMode) -> Vec<&'static RepairRule> {
979 REPAIR_RULES
980 .iter()
981 .filter(|rule| rule.is_allowed_by(mode))
982 .collect()
983}
984
985pub fn rule_by_id(id: &str) -> Option<&'static RepairRule> {
987 REPAIR_RULES.iter().find(|r| r.id == id)
988}
989
990pub const REPAIR_REGISTRY_VERSION: &str = "1.1.0";
992
993#[derive(Debug, Clone, PartialEq, Eq)]
1003pub enum PatchOp {
1004 ReplaceModulePath { from: String, to: String },
1007 AddExport {
1009 module_path: String,
1010 export_name: String,
1011 export_value: String,
1012 },
1013 RemoveImport {
1015 module_path: String,
1016 specifier: String,
1017 },
1018 InjectStub {
1020 virtual_path: String,
1021 source: String,
1022 },
1023 RewriteRequire {
1025 module_path: String,
1026 from_specifier: String,
1027 to_specifier: String,
1028 },
1029}
1030
1031impl PatchOp {
1032 pub const fn risk(&self) -> RepairRisk {
1034 match self {
1035 Self::ReplaceModulePath { .. } | Self::RewriteRequire { .. } => RepairRisk::Safe,
1037 Self::AddExport { .. } | Self::RemoveImport { .. } | Self::InjectStub { .. } => {
1039 RepairRisk::Aggressive
1040 }
1041 }
1042 }
1043
1044 pub const fn tag(&self) -> &'static str {
1046 match self {
1047 Self::ReplaceModulePath { .. } => "replace_module_path",
1048 Self::AddExport { .. } => "add_export",
1049 Self::RemoveImport { .. } => "remove_import",
1050 Self::InjectStub { .. } => "inject_stub",
1051 Self::RewriteRequire { .. } => "rewrite_require",
1052 }
1053 }
1054}
1055
1056#[derive(Debug, Clone)]
1062pub struct PatchProposal {
1063 pub rule_id: String,
1065 pub ops: Vec<PatchOp>,
1067 pub rationale: String,
1069 pub confidence: Option<f64>,
1071}
1072
1073impl PatchProposal {
1074 pub fn max_risk(&self) -> RepairRisk {
1076 if self
1077 .ops
1078 .iter()
1079 .any(|op| op.risk() == RepairRisk::Aggressive)
1080 {
1081 RepairRisk::Aggressive
1082 } else {
1083 RepairRisk::Safe
1084 }
1085 }
1086
1087 pub fn is_allowed_by(&self, mode: RepairMode) -> bool {
1089 match self.max_risk() {
1090 RepairRisk::Safe => mode.should_apply(),
1091 RepairRisk::Aggressive => mode.allows_aggressive(),
1092 }
1093 }
1094
1095 pub fn op_count(&self) -> usize {
1097 self.ops.len()
1098 }
1099}
1100
1101#[derive(Debug, Clone, PartialEq, Eq)]
1107pub enum ConflictKind {
1108 None,
1110 SameModulePath(String),
1112 SameVirtualPath(String),
1114}
1115
1116impl ConflictKind {
1117 pub const fn is_clear(&self) -> bool {
1119 matches!(self, Self::None)
1120 }
1121}
1122
1123pub fn detect_conflict(a: &PatchProposal, b: &PatchProposal) -> ConflictKind {
1129 for op_a in &a.ops {
1130 for op_b in &b.ops {
1131 if let Some(conflict) = ops_conflict(op_a, op_b) {
1132 return conflict;
1133 }
1134 }
1135 }
1136 ConflictKind::None
1137}
1138
1139fn ops_conflict(a: &PatchOp, b: &PatchOp) -> Option<ConflictKind> {
1141 match (a, b) {
1142 (
1143 PatchOp::ReplaceModulePath { from: fa, .. },
1144 PatchOp::ReplaceModulePath { from: fb, .. },
1145 ) if fa == fb => Some(ConflictKind::SameModulePath(fa.clone())),
1146
1147 (
1148 PatchOp::AddExport {
1149 module_path: pa, ..
1150 },
1151 PatchOp::AddExport {
1152 module_path: pb, ..
1153 },
1154 ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1155
1156 (
1157 PatchOp::RemoveImport {
1158 module_path: pa, ..
1159 },
1160 PatchOp::RemoveImport {
1161 module_path: pb, ..
1162 },
1163 ) if pa == pb => Some(ConflictKind::SameModulePath(pa.clone())),
1164
1165 (
1166 PatchOp::InjectStub {
1167 virtual_path: va, ..
1168 },
1169 PatchOp::InjectStub {
1170 virtual_path: vb, ..
1171 },
1172 ) if va == vb => Some(ConflictKind::SameVirtualPath(va.clone())),
1173
1174 (
1175 PatchOp::RewriteRequire {
1176 module_path: pa,
1177 from_specifier: sa,
1178 ..
1179 },
1180 PatchOp::RewriteRequire {
1181 module_path: pb,
1182 from_specifier: sb,
1183 ..
1184 },
1185 ) if pa == pb && sa == sb => Some(ConflictKind::SameModulePath(pa.clone())),
1186
1187 _ => Option::None,
1188 }
1189}
1190
1191pub fn select_best_candidate(
1202 candidates: &[PatchProposal],
1203 mode: RepairMode,
1204) -> Option<&PatchProposal> {
1205 candidates
1206 .iter()
1207 .filter(|p| p.is_allowed_by(mode))
1208 .min_by(|a, b| compare_proposals(a, b))
1209}
1210
1211fn compare_proposals(a: &PatchProposal, b: &PatchProposal) -> std::cmp::Ordering {
1213 let risk_ord = risk_rank(a.max_risk()).cmp(&risk_rank(b.max_risk()));
1215 if risk_ord != std::cmp::Ordering::Equal {
1216 return risk_ord;
1217 }
1218
1219 let ops_ord = a.op_count().cmp(&b.op_count());
1221 if ops_ord != std::cmp::Ordering::Equal {
1222 return ops_ord;
1223 }
1224
1225 let conf_a = a.confidence.unwrap_or(0.0);
1227 let conf_b = b.confidence.unwrap_or(0.0);
1228 let conf_ord = conf_b
1230 .partial_cmp(&conf_a)
1231 .unwrap_or(std::cmp::Ordering::Equal);
1232 if conf_ord != std::cmp::Ordering::Equal {
1233 return conf_ord;
1234 }
1235
1236 a.rule_id.cmp(&b.rule_id)
1238}
1239
1240const fn risk_rank(risk: RepairRisk) -> u8 {
1242 match risk {
1243 RepairRisk::Safe => 0,
1244 RepairRisk::Aggressive => 1,
1245 }
1246}
1247
1248pub fn resolve_conflicts(proposals: &[PatchProposal]) -> Vec<&PatchProposal> {
1253 if proposals.is_empty() {
1254 return vec![];
1255 }
1256
1257 let mut indexed: Vec<(usize, &PatchProposal)> = proposals.iter().enumerate().collect();
1259 indexed.sort_by(|(_, a), (_, b)| compare_proposals(a, b));
1260
1261 let mut accepted: Vec<&PatchProposal> = Vec::new();
1262 for (_, candidate) in indexed {
1263 let conflicts_with_accepted = accepted
1264 .iter()
1265 .any(|acc| !detect_conflict(acc, candidate).is_clear());
1266 if !conflicts_with_accepted {
1267 accepted.push(candidate);
1268 }
1269 }
1270
1271 accepted
1272}
1273
1274#[derive(Debug, Clone)]
1284pub struct RepairContext {
1285 pub extension_id: String,
1287 pub gating: GatingVerdict,
1289 pub intent: IntentGraph,
1291 pub parse: TolerantParseResult,
1293 pub mode: RepairMode,
1295 pub diagnostics: Vec<String>,
1297 pub allowed_op_tags: Vec<&'static str>,
1299}
1300
1301impl RepairContext {
1302 pub fn new(
1304 extension_id: String,
1305 gating: GatingVerdict,
1306 intent: IntentGraph,
1307 parse: TolerantParseResult,
1308 mode: RepairMode,
1309 diagnostics: Vec<String>,
1310 ) -> Self {
1311 let allowed_op_tags = allowed_op_tags_for_mode(mode);
1312 Self {
1313 extension_id,
1314 gating,
1315 intent,
1316 parse,
1317 mode,
1318 diagnostics,
1319 allowed_op_tags,
1320 }
1321 }
1322}
1323
1324pub fn allowed_op_tags_for_mode(mode: RepairMode) -> Vec<&'static str> {
1326 let mut tags = Vec::new();
1327 if mode.should_apply() {
1328 tags.extend_from_slice(&["replace_module_path", "rewrite_require"]);
1330 }
1331 if mode.allows_aggressive() {
1332 tags.extend_from_slice(&["add_export", "remove_import", "inject_stub"]);
1334 }
1335 tags
1336}
1337
1338#[derive(Debug, Clone, PartialEq, Eq)]
1344pub enum ProposalValidationError {
1345 EmptyProposal,
1347 DisallowedOp { tag: String },
1349 RiskExceedsMode { risk: RepairRisk, mode: RepairMode },
1351 UnknownRule { rule_id: String },
1353 MonotonicityViolation { path: String },
1355}
1356
1357impl std::fmt::Display for ProposalValidationError {
1358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1359 match self {
1360 Self::EmptyProposal => write!(f, "proposal has no operations"),
1361 Self::DisallowedOp { tag } => write!(f, "op '{tag}' not allowed in current mode"),
1362 Self::RiskExceedsMode { risk, mode } => {
1363 write!(f, "{risk:?} risk not allowed in {mode:?} mode")
1364 }
1365 Self::UnknownRule { rule_id } => write!(f, "unknown rule: {rule_id}"),
1366 Self::MonotonicityViolation { path } => {
1367 write!(f, "path escapes extension root: {path}")
1368 }
1369 }
1370 }
1371}
1372
1373pub fn validate_proposal(
1382 proposal: &PatchProposal,
1383 mode: RepairMode,
1384 extension_root: Option<&Path>,
1385) -> Vec<ProposalValidationError> {
1386 let mut errors = Vec::new();
1387
1388 if proposal.ops.is_empty() {
1390 errors.push(ProposalValidationError::EmptyProposal);
1391 return errors;
1392 }
1393
1394 let allowed = allowed_op_tags_for_mode(mode);
1396 for op in &proposal.ops {
1397 if !allowed.contains(&op.tag()) {
1398 errors.push(ProposalValidationError::DisallowedOp {
1399 tag: op.tag().to_string(),
1400 });
1401 }
1402 }
1403
1404 if !proposal.is_allowed_by(mode) {
1406 errors.push(ProposalValidationError::RiskExceedsMode {
1407 risk: proposal.max_risk(),
1408 mode,
1409 });
1410 }
1411
1412 if !proposal.rule_id.is_empty() && rule_by_id(&proposal.rule_id).is_none() {
1414 errors.push(ProposalValidationError::UnknownRule {
1415 rule_id: proposal.rule_id.clone(),
1416 });
1417 }
1418
1419 if let Some(root) = extension_root {
1421 for op in &proposal.ops {
1422 let path_str = op_target_path(op);
1423 let target = Path::new(&path_str);
1424 if target.is_absolute() {
1425 let verdict = verify_repair_monotonicity(root, root, target);
1426 if !verdict.is_safe() {
1427 errors.push(ProposalValidationError::MonotonicityViolation { path: path_str });
1428 }
1429 }
1430 }
1431 }
1432
1433 errors
1434}
1435
1436fn op_target_path(op: &PatchOp) -> String {
1438 match op {
1439 PatchOp::ReplaceModulePath { to, .. } => to.clone(),
1440 PatchOp::AddExport { module_path, .. }
1441 | PatchOp::RemoveImport { module_path, .. }
1442 | PatchOp::RewriteRequire { module_path, .. } => module_path.clone(),
1443 PatchOp::InjectStub { virtual_path, .. } => virtual_path.clone(),
1444 }
1445}
1446
1447#[derive(Debug, Clone)]
1449pub struct ApplicationResult {
1450 pub success: bool,
1452 pub ops_applied: usize,
1454 pub summary: String,
1456}
1457
1458pub fn apply_proposal(
1464 proposal: &PatchProposal,
1465 mode: RepairMode,
1466 extension_root: Option<&Path>,
1467) -> std::result::Result<ApplicationResult, Vec<ProposalValidationError>> {
1468 let errors = validate_proposal(proposal, mode, extension_root);
1469 if !errors.is_empty() {
1470 return Err(errors);
1471 }
1472
1473 Ok(ApplicationResult {
1474 success: true,
1475 ops_applied: proposal.ops.len(),
1476 summary: format!(
1477 "Applied {} op(s) from rule '{}'",
1478 proposal.ops.len(),
1479 proposal.rule_id
1480 ),
1481 })
1482}
1483
1484#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1490pub enum ApprovalRequirement {
1491 AutoApproved,
1493 RequiresApproval,
1495}
1496
1497impl ApprovalRequirement {
1498 pub const fn needs_approval(&self) -> bool {
1500 matches!(self, Self::RequiresApproval)
1501 }
1502}
1503
1504impl std::fmt::Display for ApprovalRequirement {
1505 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1506 match self {
1507 Self::AutoApproved => write!(f, "auto_approved"),
1508 Self::RequiresApproval => write!(f, "requires_approval"),
1509 }
1510 }
1511}
1512
1513#[derive(Debug, Clone)]
1515pub struct ApprovalRequest {
1516 pub extension_id: String,
1518 pub proposal: PatchProposal,
1520 pub risk: RepairRisk,
1522 pub confidence_score: f64,
1524 pub rationale: String,
1526 pub op_summaries: Vec<String>,
1528}
1529
1530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1532pub enum ApprovalResponse {
1533 Approved,
1535 Rejected,
1537}
1538
1539pub fn check_approval_requirement(
1547 proposal: &PatchProposal,
1548 confidence_score: f64,
1549) -> ApprovalRequirement {
1550 if proposal.max_risk() == RepairRisk::Aggressive {
1551 return ApprovalRequirement::RequiresApproval;
1552 }
1553 if confidence_score < 0.5 {
1554 return ApprovalRequirement::RequiresApproval;
1555 }
1556 if proposal.ops.len() >= 3 {
1557 return ApprovalRequirement::RequiresApproval;
1558 }
1559 ApprovalRequirement::AutoApproved
1560}
1561
1562pub fn build_approval_request(
1564 extension_id: &str,
1565 proposal: &PatchProposal,
1566 confidence_score: f64,
1567) -> ApprovalRequest {
1568 let op_summaries = proposal
1569 .ops
1570 .iter()
1571 .map(|op| format!("[{}] {}", op.tag(), op_target_path(op)))
1572 .collect();
1573
1574 ApprovalRequest {
1575 extension_id: extension_id.to_string(),
1576 proposal: proposal.clone(),
1577 risk: proposal.max_risk(),
1578 confidence_score,
1579 rationale: proposal.rationale.clone(),
1580 op_summaries,
1581 }
1582}
1583
1584#[derive(Debug, Clone, PartialEq, Eq)]
1590pub enum StructuralVerdict {
1591 Valid,
1593 Unreadable { path: PathBuf, reason: String },
1595 UnsupportedExtension { path: PathBuf, extension: String },
1597 ParseError { path: PathBuf, message: String },
1599}
1600
1601impl StructuralVerdict {
1602 pub const fn is_valid(&self) -> bool {
1604 matches!(self, Self::Valid)
1605 }
1606}
1607
1608impl std::fmt::Display for StructuralVerdict {
1609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1610 match self {
1611 Self::Valid => write!(f, "valid"),
1612 Self::Unreadable { path, reason } => {
1613 write!(f, "unreadable: {} ({})", path.display(), reason)
1614 }
1615 Self::UnsupportedExtension { path, extension } => {
1616 write!(
1617 f,
1618 "unsupported extension: {} (.{})",
1619 path.display(),
1620 extension
1621 )
1622 }
1623 Self::ParseError { path, message } => {
1624 write!(f, "parse error: {} ({})", path.display(), message)
1625 }
1626 }
1627 }
1628}
1629
1630pub fn validate_repaired_artifact(path: &Path) -> StructuralVerdict {
1639 let source = match fs::read_to_string(path) {
1641 Ok(s) => s,
1642 Err(err) => {
1643 return StructuralVerdict::Unreadable {
1644 path: path.to_path_buf(),
1645 reason: err.to_string(),
1646 };
1647 }
1648 };
1649
1650 let ext = path
1652 .extension()
1653 .and_then(|e| e.to_str())
1654 .unwrap_or("")
1655 .to_ascii_lowercase();
1656
1657 match ext.as_str() {
1658 "ts" | "tsx" => validate_typescript_parse(path, &source, &ext),
1659 "js" | "mjs" => {
1660 StructuralVerdict::Valid
1663 }
1664 "json" => validate_json_parse(path, &source),
1665 _ => StructuralVerdict::UnsupportedExtension {
1666 path: path.to_path_buf(),
1667 extension: ext,
1668 },
1669 }
1670}
1671
1672fn validate_typescript_parse(path: &Path, source: &str, ext: &str) -> StructuralVerdict {
1674 use swc_common::{FileName, GLOBALS, Globals};
1675 use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1676
1677 let globals = Globals::new();
1678 GLOBALS.set(&globals, || {
1679 let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1680 let fm = cm.new_source_file(
1681 FileName::Custom(path.display().to_string()).into(),
1682 source.to_string(),
1683 );
1684 let syntax = Syntax::Typescript(TsSyntax {
1685 tsx: ext == "tsx",
1686 decorators: true,
1687 ..Default::default()
1688 });
1689 let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1690 match parser.parse_module() {
1691 Ok(_) => StructuralVerdict::Valid,
1692 Err(err) => StructuralVerdict::ParseError {
1693 path: path.to_path_buf(),
1694 message: format!("{err:?}"),
1695 },
1696 }
1697 })
1698}
1699
1700fn validate_json_parse(path: &Path, source: &str) -> StructuralVerdict {
1702 match serde_json::from_str::<serde_json::Value>(source) {
1703 Ok(_) => StructuralVerdict::Valid,
1704 Err(err) => StructuralVerdict::ParseError {
1705 path: path.to_path_buf(),
1706 message: err.to_string(),
1707 },
1708 }
1709}
1710
1711#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1717pub enum AmbiguitySignal {
1718 DynamicEval,
1720 DynamicFunction,
1722 DynamicImport,
1724 StarReExport,
1726 DynamicRequire,
1728 ProxyUsage,
1730 WithStatement,
1732 RecoverableParseErrors { count: usize },
1734}
1735
1736impl AmbiguitySignal {
1737 pub fn weight(&self) -> f64 {
1739 match self {
1740 Self::DynamicEval | Self::DynamicFunction => 0.9,
1741 Self::ProxyUsage | Self::WithStatement => 0.7,
1742 Self::DynamicImport | Self::DynamicRequire => 0.5,
1743 Self::StarReExport => 0.3,
1744 Self::RecoverableParseErrors { count } => {
1745 (f64::from(u32::try_from(*count).unwrap_or(u32::MAX)) * 0.2).min(1.0)
1747 }
1748 }
1749 }
1750
1751 pub const fn tag(&self) -> &'static str {
1753 match self {
1754 Self::DynamicEval => "dynamic_eval",
1755 Self::DynamicFunction => "dynamic_function",
1756 Self::DynamicImport => "dynamic_import",
1757 Self::StarReExport => "star_reexport",
1758 Self::DynamicRequire => "dynamic_require",
1759 Self::ProxyUsage => "proxy_usage",
1760 Self::WithStatement => "with_statement",
1761 Self::RecoverableParseErrors { .. } => "recoverable_parse_errors",
1762 }
1763 }
1764}
1765
1766impl std::fmt::Display for AmbiguitySignal {
1767 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1768 match self {
1769 Self::RecoverableParseErrors { count } => {
1770 write!(f, "{}({})", self.tag(), count)
1771 }
1772 _ => write!(f, "{}", self.tag()),
1773 }
1774 }
1775}
1776
1777#[derive(Debug, Clone)]
1779pub struct TolerantParseResult {
1780 pub parsed_ok: bool,
1782 pub statement_count: usize,
1784 pub import_export_count: usize,
1786 pub ambiguities: Vec<AmbiguitySignal>,
1788}
1789
1790impl TolerantParseResult {
1791 pub fn ambiguity_score(&self) -> f64 {
1793 if self.ambiguities.is_empty() {
1794 return 0.0;
1795 }
1796 self.ambiguities
1798 .iter()
1799 .map(AmbiguitySignal::weight)
1800 .fold(0.0_f64, f64::max)
1801 }
1802
1803 pub fn is_legible(&self) -> bool {
1805 self.parsed_ok && self.ambiguity_score() < 0.8
1806 }
1807}
1808
1809pub fn tolerant_parse(source: &str, filename: &str) -> TolerantParseResult {
1816 let ext = Path::new(filename)
1817 .extension()
1818 .and_then(|e| e.to_str())
1819 .unwrap_or("")
1820 .to_ascii_lowercase();
1821
1822 let (parsed_ok, statement_count, import_export_count, parse_errors) = match ext.as_str() {
1823 "ts" | "tsx" | "js" | "mjs" => try_swc_parse(source, filename, &ext),
1824 _ => (false, 0, 0, 0),
1825 };
1826
1827 let mut ambiguities = detect_ambiguity_patterns(source);
1828 if parse_errors > 0 {
1829 ambiguities.push(AmbiguitySignal::RecoverableParseErrors {
1830 count: parse_errors,
1831 });
1832 }
1833
1834 let mut seen = std::collections::HashSet::new();
1836 ambiguities.retain(|s| seen.insert(s.clone()));
1837
1838 TolerantParseResult {
1839 parsed_ok,
1840 statement_count,
1841 import_export_count,
1842 ambiguities,
1843 }
1844}
1845
1846fn try_swc_parse(source: &str, filename: &str, ext: &str) -> (bool, usize, usize, usize) {
1848 use swc_common::{FileName, GLOBALS, Globals};
1849 use swc_ecma_parser::{Parser as SwcParser, StringInput, Syntax, TsSyntax};
1850
1851 let globals = Globals::new();
1852 GLOBALS.set(&globals, || {
1853 let cm: swc_common::sync::Lrc<swc_common::SourceMap> = swc_common::sync::Lrc::default();
1854 let fm = cm.new_source_file(
1855 FileName::Custom(filename.to_string()).into(),
1856 source.to_string(),
1857 );
1858 let is_ts = ext == "ts" || ext == "tsx";
1859 let syntax = if is_ts {
1860 Syntax::Typescript(TsSyntax {
1861 tsx: ext == "tsx",
1862 decorators: true,
1863 ..Default::default()
1864 })
1865 } else {
1866 Syntax::Es(swc_ecma_parser::EsSyntax {
1867 jsx: true,
1868 ..Default::default()
1869 })
1870 };
1871 let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
1872 if let Ok(module) = parser.parse_module() {
1873 let errors = parser.take_errors();
1874 let stmts = module.body.len();
1875 let imports = module
1876 .body
1877 .iter()
1878 .filter(|item| {
1879 matches!(
1880 item,
1881 swc_ecma_ast::ModuleItem::ModuleDecl(
1882 swc_ecma_ast::ModuleDecl::Import(_)
1883 | swc_ecma_ast::ModuleDecl::ExportAll(_)
1884 | swc_ecma_ast::ModuleDecl::ExportNamed(_)
1885 | swc_ecma_ast::ModuleDecl::ExportDefaultDecl(_)
1886 | swc_ecma_ast::ModuleDecl::ExportDefaultExpr(_)
1887 | swc_ecma_ast::ModuleDecl::ExportDecl(_)
1888 )
1889 )
1890 })
1891 .count();
1892 (true, stmts, imports, errors.len())
1893 } else {
1894 let errors = parser.take_errors();
1895 (false, 0, 0, errors.len() + 1)
1897 }
1898 })
1899}
1900
1901fn detect_ambiguity_patterns(source: &str) -> Vec<AmbiguitySignal> {
1903 use std::sync::OnceLock;
1904
1905 static PATTERNS: OnceLock<Vec<(regex::Regex, AmbiguitySignal)>> = OnceLock::new();
1906 static DYN_REQUIRE: OnceLock<regex::Regex> = OnceLock::new();
1907
1908 let patterns = PATTERNS.get_or_init(|| {
1909 vec![
1910 (
1911 regex::Regex::new(r"\beval\s*\(").expect("regex"),
1912 AmbiguitySignal::DynamicEval,
1913 ),
1914 (
1915 regex::Regex::new(r"\bnew\s+Function\s*\(").expect("regex"),
1916 AmbiguitySignal::DynamicFunction,
1917 ),
1918 (
1919 regex::Regex::new(r"\bimport\s*\(").expect("regex"),
1920 AmbiguitySignal::DynamicImport,
1921 ),
1922 (
1923 regex::Regex::new(r"export\s+\*\s+from\b").expect("regex"),
1924 AmbiguitySignal::StarReExport,
1925 ),
1926 (
1927 regex::Regex::new(r"\bnew\s+Proxy\s*\(").expect("regex"),
1928 AmbiguitySignal::ProxyUsage,
1929 ),
1930 (
1931 regex::Regex::new(r"\bwith\s*\(").expect("regex"),
1932 AmbiguitySignal::WithStatement,
1933 ),
1934 ]
1935 });
1936
1937 let dyn_require = DYN_REQUIRE
1938 .get_or_init(|| regex::Regex::new(r#"\brequire\s*\(\s*[^"'`\s)]"#).expect("regex"));
1939
1940 let mut signals = Vec::new();
1941 for (re, signal) in patterns {
1942 if re.is_match(source) {
1943 signals.push(signal.clone());
1944 }
1945 }
1946 if dyn_require.is_match(source) {
1947 signals.push(AmbiguitySignal::DynamicRequire);
1948 }
1949
1950 signals
1951}
1952
1953#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1959pub enum IntentSignal {
1960 RegistersTool(String),
1962 RegistersCommand(String),
1964 RegistersShortcut(String),
1966 RegistersFlag(String),
1968 RegistersProvider(String),
1970 HooksEvent(String),
1972 RequiresCapability(String),
1974 RegistersRenderer(String),
1976}
1977
1978impl IntentSignal {
1979 pub const fn category(&self) -> &'static str {
1981 match self {
1982 Self::RegistersTool(_) => "tool",
1983 Self::RegistersCommand(_) => "command",
1984 Self::RegistersShortcut(_) => "shortcut",
1985 Self::RegistersFlag(_) => "flag",
1986 Self::RegistersProvider(_) => "provider",
1987 Self::HooksEvent(_) => "event_hook",
1988 Self::RequiresCapability(_) => "capability",
1989 Self::RegistersRenderer(_) => "renderer",
1990 }
1991 }
1992
1993 pub fn name(&self) -> &str {
1995 match self {
1996 Self::RegistersTool(n)
1997 | Self::RegistersCommand(n)
1998 | Self::RegistersShortcut(n)
1999 | Self::RegistersFlag(n)
2000 | Self::RegistersProvider(n)
2001 | Self::HooksEvent(n)
2002 | Self::RequiresCapability(n)
2003 | Self::RegistersRenderer(n) => n,
2004 }
2005 }
2006}
2007
2008impl std::fmt::Display for IntentSignal {
2009 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2010 write!(f, "{}:{}", self.category(), self.name())
2011 }
2012}
2013
2014#[derive(Debug, Clone, Default)]
2020pub struct IntentGraph {
2021 pub extension_id: String,
2023 pub signals: Vec<IntentSignal>,
2025}
2026
2027impl IntentGraph {
2028 pub fn from_register_payload(
2030 extension_id: &str,
2031 payload: &serde_json::Value,
2032 capabilities: &[String],
2033 ) -> Self {
2034 let mut signals = Vec::new();
2035
2036 if let Some(tools) = payload.get("tools").and_then(|v| v.as_array()) {
2038 for tool in tools {
2039 if let Some(name) = tool.get("name").and_then(|n| n.as_str()) {
2040 signals.push(IntentSignal::RegistersTool(name.to_string()));
2041 }
2042 }
2043 }
2044
2045 if let Some(cmds) = payload.get("slash_commands").and_then(|v| v.as_array()) {
2047 for cmd in cmds {
2048 if let Some(name) = cmd.get("name").and_then(|n| n.as_str()) {
2049 signals.push(IntentSignal::RegistersCommand(name.to_string()));
2050 }
2051 }
2052 }
2053
2054 if let Some(shortcuts) = payload.get("shortcuts").and_then(|v| v.as_array()) {
2056 for sc in shortcuts {
2057 let label = sc
2058 .get("name")
2059 .or_else(|| sc.get("key"))
2060 .and_then(|n| n.as_str())
2061 .unwrap_or("unknown");
2062 signals.push(IntentSignal::RegistersShortcut(label.to_string()));
2063 }
2064 }
2065
2066 if let Some(flags) = payload.get("flags").and_then(|v| v.as_array()) {
2068 for flag in flags {
2069 if let Some(name) = flag.get("name").and_then(|n| n.as_str()) {
2070 signals.push(IntentSignal::RegistersFlag(name.to_string()));
2071 }
2072 }
2073 }
2074
2075 if let Some(hooks) = payload.get("event_hooks").and_then(|v| v.as_array()) {
2077 for hook in hooks {
2078 if let Some(name) = hook.as_str() {
2079 signals.push(IntentSignal::HooksEvent(name.to_string()));
2080 }
2081 }
2082 }
2083
2084 for cap in capabilities {
2086 signals.push(IntentSignal::RequiresCapability(cap.clone()));
2087 }
2088
2089 let mut seen = std::collections::HashSet::new();
2091 signals.retain(|s| seen.insert(s.clone()));
2092
2093 Self {
2094 extension_id: extension_id.to_string(),
2095 signals,
2096 }
2097 }
2098
2099 pub fn signals_by_category(&self, category: &str) -> Vec<&IntentSignal> {
2101 self.signals
2102 .iter()
2103 .filter(|s| s.category() == category)
2104 .collect()
2105 }
2106
2107 pub fn category_count(&self) -> usize {
2109 let cats: std::collections::HashSet<&str> =
2110 self.signals.iter().map(IntentSignal::category).collect();
2111 cats.len()
2112 }
2113
2114 pub fn is_empty(&self) -> bool {
2116 self.signals.is_empty()
2117 }
2118
2119 pub fn signal_count(&self) -> usize {
2121 self.signals.len()
2122 }
2123}
2124
2125#[derive(Debug, Clone)]
2131pub struct ConfidenceReason {
2132 pub code: String,
2134 pub explanation: String,
2136 pub delta: f64,
2138}
2139
2140#[derive(Debug, Clone)]
2142pub struct ConfidenceReport {
2143 pub score: f64,
2145 pub reasons: Vec<ConfidenceReason>,
2147}
2148
2149impl ConfidenceReport {
2150 pub fn is_repairable(&self) -> bool {
2152 self.score >= 0.5
2153 }
2154
2155 pub fn is_suggestable(&self) -> bool {
2157 self.score >= 0.2
2158 }
2159}
2160
2161#[allow(clippy::too_many_lines)]
2177pub fn compute_confidence(intent: &IntentGraph, parse: &TolerantParseResult) -> ConfidenceReport {
2178 let mut score: f64 = 0.5;
2179 let mut reasons = Vec::new();
2180
2181 if parse.parsed_ok {
2183 let delta = 0.15;
2184 score += delta;
2185 reasons.push(ConfidenceReason {
2186 code: "parsed_ok".to_string(),
2187 explanation: "Source parsed without fatal errors".to_string(),
2188 delta,
2189 });
2190 } else {
2191 let delta = -0.3;
2192 score += delta;
2193 reasons.push(ConfidenceReason {
2194 code: "parse_failed".to_string(),
2195 explanation: "Source failed to parse".to_string(),
2196 delta,
2197 });
2198 }
2199
2200 if parse.statement_count == 0 && parse.parsed_ok {
2202 let delta = -0.1;
2203 score += delta;
2204 reasons.push(ConfidenceReason {
2205 code: "empty_module".to_string(),
2206 explanation: "Module has no statements".to_string(),
2207 delta,
2208 });
2209 }
2210
2211 if parse.import_export_count > 0 {
2213 let delta = 0.05;
2214 score += delta;
2215 reasons.push(ConfidenceReason {
2216 code: "has_imports_exports".to_string(),
2217 explanation: format!(
2218 "{} import/export declarations found",
2219 parse.import_export_count
2220 ),
2221 delta,
2222 });
2223 }
2224
2225 for ambiguity in &parse.ambiguities {
2227 let weight = ambiguity.weight();
2228 let delta = -weight * 0.3;
2229 score += delta;
2230 reasons.push(ConfidenceReason {
2231 code: format!("ambiguity_{}", ambiguity.tag()),
2232 explanation: format!("Ambiguity detected: {ambiguity} (weight={weight:.1})"),
2233 delta,
2234 });
2235 }
2236
2237 let tool_count = intent.signals_by_category("tool").len();
2239 if tool_count > 0 {
2240 let delta = 0.1;
2241 score += delta;
2242 reasons.push(ConfidenceReason {
2243 code: "has_tools".to_string(),
2244 explanation: format!("{tool_count} tool(s) registered"),
2245 delta,
2246 });
2247 }
2248
2249 let hook_count = intent.signals_by_category("event_hook").len();
2250 if hook_count > 0 {
2251 let delta = 0.05;
2252 score += delta;
2253 reasons.push(ConfidenceReason {
2254 code: "has_event_hooks".to_string(),
2255 explanation: format!("{hook_count} event hook(s) registered"),
2256 delta,
2257 });
2258 }
2259
2260 let categories = intent.category_count();
2261 if categories >= 3 {
2262 let delta = 0.1;
2263 score += delta;
2264 reasons.push(ConfidenceReason {
2265 code: "multi_category".to_string(),
2266 explanation: format!("{categories} distinct intent categories"),
2267 delta,
2268 });
2269 }
2270
2271 if intent.is_empty() && parse.parsed_ok {
2272 let delta = -0.15;
2273 score += delta;
2274 reasons.push(ConfidenceReason {
2275 code: "no_registrations".to_string(),
2276 explanation: "No tools, commands, or hooks registered".to_string(),
2277 delta,
2278 });
2279 }
2280
2281 score = score.clamp(0.0, 1.0);
2283
2284 ConfidenceReport { score, reasons }
2285}
2286
2287#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2293pub enum GatingDecision {
2294 Allow,
2296 Suggest,
2298 Deny,
2300}
2301
2302impl GatingDecision {
2303 pub const fn label(&self) -> &'static str {
2305 match self {
2306 Self::Allow => "allow",
2307 Self::Suggest => "suggest",
2308 Self::Deny => "deny",
2309 }
2310 }
2311}
2312
2313impl std::fmt::Display for GatingDecision {
2314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2315 f.write_str(self.label())
2316 }
2317}
2318
2319#[derive(Debug, Clone, PartialEq, Eq)]
2321pub struct GatingReasonCode {
2322 pub code: String,
2324 pub remediation: String,
2326}
2327
2328#[derive(Debug, Clone)]
2330pub struct GatingVerdict {
2331 pub decision: GatingDecision,
2333 pub confidence: ConfidenceReport,
2335 pub reason_codes: Vec<GatingReasonCode>,
2337}
2338
2339impl GatingVerdict {
2340 pub fn allows_repair(&self) -> bool {
2342 self.decision == GatingDecision::Allow
2343 }
2344
2345 pub const fn allows_suggestion(&self) -> bool {
2347 matches!(
2348 self.decision,
2349 GatingDecision::Allow | GatingDecision::Suggest
2350 )
2351 }
2352}
2353
2354pub fn compute_gating_verdict(intent: &IntentGraph, parse: &TolerantParseResult) -> GatingVerdict {
2364 let confidence = compute_confidence(intent, parse);
2365 let decision = if confidence.is_repairable() {
2366 GatingDecision::Allow
2367 } else if confidence.is_suggestable() {
2368 GatingDecision::Suggest
2369 } else {
2370 GatingDecision::Deny
2371 };
2372
2373 let reason_codes = if decision == GatingDecision::Allow {
2374 vec![]
2375 } else {
2376 build_reason_codes(&confidence, parse)
2377 };
2378
2379 GatingVerdict {
2380 decision,
2381 confidence,
2382 reason_codes,
2383 }
2384}
2385
2386fn build_reason_codes(
2388 confidence: &ConfidenceReport,
2389 parse: &TolerantParseResult,
2390) -> Vec<GatingReasonCode> {
2391 let mut codes = Vec::new();
2392
2393 if !parse.parsed_ok {
2394 codes.push(GatingReasonCode {
2395 code: "parse_failed".to_string(),
2396 remediation: "Fix syntax errors in the extension source code".to_string(),
2397 });
2398 }
2399
2400 for ambiguity in &parse.ambiguities {
2401 if ambiguity.weight() >= 0.7 {
2402 codes.push(GatingReasonCode {
2403 code: format!("high_ambiguity_{}", ambiguity.tag()),
2404 remediation: format!(
2405 "Remove or refactor {} usage to improve repair safety",
2406 ambiguity.tag().replace('_', " ")
2407 ),
2408 });
2409 }
2410 }
2411
2412 if confidence.score < 0.2 {
2413 codes.push(GatingReasonCode {
2414 code: "very_low_confidence".to_string(),
2415 remediation: "Extension is too opaque for automated analysis; \
2416 add explicit tool/hook registrations and remove dynamic constructs"
2417 .to_string(),
2418 });
2419 }
2420
2421 codes
2422}
2423
2424#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2426pub struct PiJsTickStats {
2427 pub ran_macrotask: bool,
2429 pub microtask_drains: usize,
2431 pub jobs_drained: usize,
2433 pub pending_hostcalls: usize,
2435 pub hostcalls_total: u64,
2437 pub hostcalls_timed_out: u64,
2439 pub memory_used_bytes: u64,
2441 pub peak_memory_used_bytes: u64,
2443 pub repairs_total: u64,
2445 pub module_cache_hits: u64,
2447 pub module_cache_misses: u64,
2449 pub module_cache_invalidations: u64,
2451 pub module_cache_entries: u64,
2453 pub module_disk_cache_hits: u64,
2455}
2456
2457#[derive(Debug, Clone, Default)]
2458pub struct PiJsRuntimeLimits {
2459 pub memory_limit_bytes: Option<usize>,
2461 pub max_stack_bytes: Option<usize>,
2463 pub interrupt_budget: Option<u64>,
2468 pub hostcall_timeout_ms: Option<u64>,
2470 pub hostcall_fast_queue_capacity: usize,
2474 pub hostcall_overflow_queue_capacity: usize,
2478}
2479
2480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2485pub enum RepairMode {
2486 Off,
2488 Suggest,
2491 #[default]
2494 AutoSafe,
2495 AutoStrict,
2499}
2500
2501impl RepairMode {
2502 pub const fn should_apply(self) -> bool {
2504 matches!(self, Self::AutoSafe | Self::AutoStrict)
2505 }
2506
2507 pub const fn is_active(self) -> bool {
2509 !matches!(self, Self::Off)
2510 }
2511
2512 pub const fn allows_aggressive(self) -> bool {
2514 matches!(self, Self::AutoStrict)
2515 }
2516
2517 pub fn from_str_lossy(s: &str) -> Self {
2519 match s.trim().to_ascii_lowercase().as_str() {
2520 "off" | "none" | "disabled" | "false" | "0" => Self::Off,
2521 "suggest" | "log" | "dry-run" | "dry_run" => Self::Suggest,
2522 "auto-strict" | "auto_strict" | "strict" | "all" => Self::AutoStrict,
2523 _ => Self::AutoSafe,
2525 }
2526 }
2527}
2528
2529impl std::fmt::Display for RepairMode {
2530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2531 match self {
2532 Self::Off => write!(f, "off"),
2533 Self::Suggest => write!(f, "suggest"),
2534 Self::AutoSafe => write!(f, "auto-safe"),
2535 Self::AutoStrict => write!(f, "auto-strict"),
2536 }
2537 }
2538}
2539
2540#[derive(Debug, Clone, PartialEq, Eq)]
2546pub enum MonotonicityVerdict {
2547 Safe,
2549 EscapesRoot {
2551 extension_root: PathBuf,
2552 resolved: PathBuf,
2553 },
2554 CrossExtension {
2556 original_extension: String,
2557 resolved: PathBuf,
2558 },
2559}
2560
2561impl MonotonicityVerdict {
2562 pub const fn is_safe(&self) -> bool {
2563 matches!(self, Self::Safe)
2564 }
2565}
2566
2567pub fn verify_repair_monotonicity(
2577 extension_root: &Path,
2578 _original_path: &Path,
2579 resolved_path: &Path,
2580) -> MonotonicityVerdict {
2581 let canonical_root = crate::extensions::safe_canonicalize(extension_root);
2584
2585 let canonical_resolved = crate::extensions::safe_canonicalize(resolved_path);
2586
2587 if !canonical_resolved.starts_with(&canonical_root) {
2589 return MonotonicityVerdict::EscapesRoot {
2590 extension_root: canonical_root,
2591 resolved: canonical_resolved,
2592 };
2593 }
2594
2595 MonotonicityVerdict::Safe
2596}
2597
2598#[derive(Debug, Clone, PartialEq, Eq)]
2604pub enum CapabilityDelta {
2605 Retained(IntentSignal),
2607 Removed(IntentSignal),
2609 Added(IntentSignal),
2611}
2612
2613impl CapabilityDelta {
2614 pub const fn is_escalation(&self) -> bool {
2616 matches!(self, Self::Added(_))
2617 }
2618
2619 pub const fn is_retained(&self) -> bool {
2621 matches!(self, Self::Retained(_))
2622 }
2623
2624 pub const fn is_removed(&self) -> bool {
2626 matches!(self, Self::Removed(_))
2627 }
2628
2629 pub const fn label(&self) -> &'static str {
2631 match self {
2632 Self::Retained(_) => "retained",
2633 Self::Removed(_) => "removed",
2634 Self::Added(_) => "added",
2635 }
2636 }
2637
2638 pub const fn signal(&self) -> &IntentSignal {
2640 match self {
2641 Self::Retained(s) | Self::Removed(s) | Self::Added(s) => s,
2642 }
2643 }
2644}
2645
2646impl std::fmt::Display for CapabilityDelta {
2647 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2648 write!(f, "{}: {}", self.label(), self.signal())
2649 }
2650}
2651
2652#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2654pub enum CapabilityMonotonicityVerdict {
2655 Monotonic,
2657 Escalation,
2659}
2660
2661impl CapabilityMonotonicityVerdict {
2662 pub const fn is_safe(&self) -> bool {
2664 matches!(self, Self::Monotonic)
2665 }
2666}
2667
2668impl std::fmt::Display for CapabilityMonotonicityVerdict {
2669 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2670 match self {
2671 Self::Monotonic => write!(f, "monotonic"),
2672 Self::Escalation => write!(f, "escalation"),
2673 }
2674 }
2675}
2676
2677#[derive(Debug, Clone)]
2683pub struct CapabilityProofReport {
2684 pub extension_id: String,
2686 pub verdict: CapabilityMonotonicityVerdict,
2688 pub deltas: Vec<CapabilityDelta>,
2690 pub retained_count: usize,
2692 pub removed_count: usize,
2694 pub added_count: usize,
2696}
2697
2698impl CapabilityProofReport {
2699 pub const fn is_safe(&self) -> bool {
2701 self.verdict.is_safe()
2702 }
2703
2704 pub fn escalations(&self) -> Vec<&CapabilityDelta> {
2706 self.deltas.iter().filter(|d| d.is_escalation()).collect()
2707 }
2708}
2709
2710pub fn compute_capability_proof(
2718 before: &IntentGraph,
2719 after: &IntentGraph,
2720) -> CapabilityProofReport {
2721 use std::collections::HashSet;
2722
2723 let before_set: HashSet<&IntentSignal> = before.signals.iter().collect();
2724 let after_set: HashSet<&IntentSignal> = after.signals.iter().collect();
2725
2726 let mut deltas = Vec::new();
2727
2728 for signal in &before.signals {
2730 if after_set.contains(signal) {
2731 deltas.push(CapabilityDelta::Retained(signal.clone()));
2732 } else {
2733 deltas.push(CapabilityDelta::Removed(signal.clone()));
2734 }
2735 }
2736
2737 for signal in &after.signals {
2739 if !before_set.contains(signal) {
2740 deltas.push(CapabilityDelta::Added(signal.clone()));
2741 }
2742 }
2743
2744 let retained_count = deltas.iter().filter(|d| d.is_retained()).count();
2745 let removed_count = deltas.iter().filter(|d| d.is_removed()).count();
2746 let added_count = deltas.iter().filter(|d| d.is_escalation()).count();
2747
2748 let verdict = if added_count == 0 {
2749 CapabilityMonotonicityVerdict::Monotonic
2750 } else {
2751 CapabilityMonotonicityVerdict::Escalation
2752 };
2753
2754 CapabilityProofReport {
2755 extension_id: before.extension_id.clone(),
2756 verdict,
2757 deltas,
2758 retained_count,
2759 removed_count,
2760 added_count,
2761 }
2762}
2763
2764#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2770pub enum HostcallCategory {
2771 Events(String),
2773 Session(String),
2775 Register,
2777 Tool(String),
2779 ModuleResolution(String),
2781}
2782
2783impl HostcallCategory {
2784 pub fn tag(&self) -> String {
2786 match self {
2787 Self::Events(op) => format!("events:{op}"),
2788 Self::Session(op) => format!("session:{op}"),
2789 Self::Register => "register".to_string(),
2790 Self::Tool(op) => format!("tool:{op}"),
2791 Self::ModuleResolution(spec) => format!("module:{spec}"),
2792 }
2793 }
2794}
2795
2796impl std::fmt::Display for HostcallCategory {
2797 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2798 f.write_str(&self.tag())
2799 }
2800}
2801
2802#[derive(Debug, Clone, PartialEq, Eq)]
2804pub enum HostcallDelta {
2805 Retained(HostcallCategory),
2807 Removed(HostcallCategory),
2809 Added(HostcallCategory),
2811}
2812
2813impl HostcallDelta {
2814 pub const fn is_expansion(&self) -> bool {
2816 matches!(self, Self::Added(_))
2817 }
2818
2819 pub const fn label(&self) -> &'static str {
2821 match self {
2822 Self::Retained(_) => "retained",
2823 Self::Removed(_) => "removed",
2824 Self::Added(_) => "added",
2825 }
2826 }
2827
2828 pub const fn category(&self) -> &HostcallCategory {
2830 match self {
2831 Self::Retained(c) | Self::Removed(c) | Self::Added(c) => c,
2832 }
2833 }
2834}
2835
2836impl std::fmt::Display for HostcallDelta {
2837 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2838 write!(f, "{}: {}", self.label(), self.category())
2839 }
2840}
2841
2842#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
2844pub enum SemanticDriftSeverity {
2845 None,
2847 Low,
2849 Medium,
2851 High,
2853}
2854
2855impl SemanticDriftSeverity {
2856 pub const fn is_acceptable(&self) -> bool {
2858 matches!(self, Self::None | Self::Low)
2859 }
2860}
2861
2862impl std::fmt::Display for SemanticDriftSeverity {
2863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2864 match self {
2865 Self::None => write!(f, "none"),
2866 Self::Low => write!(f, "low"),
2867 Self::Medium => write!(f, "medium"),
2868 Self::High => write!(f, "high"),
2869 }
2870 }
2871}
2872
2873#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2875pub enum SemanticParityVerdict {
2876 Equivalent,
2878 AcceptableDrift,
2880 Divergent,
2882}
2883
2884impl SemanticParityVerdict {
2885 pub const fn is_safe(&self) -> bool {
2887 matches!(self, Self::Equivalent | Self::AcceptableDrift)
2888 }
2889}
2890
2891impl std::fmt::Display for SemanticParityVerdict {
2892 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2893 match self {
2894 Self::Equivalent => write!(f, "equivalent"),
2895 Self::AcceptableDrift => write!(f, "acceptable_drift"),
2896 Self::Divergent => write!(f, "divergent"),
2897 }
2898 }
2899}
2900
2901#[derive(Debug, Clone)]
2903pub struct SemanticParityReport {
2904 pub extension_id: String,
2906 pub verdict: SemanticParityVerdict,
2908 pub hostcall_deltas: Vec<HostcallDelta>,
2910 pub drift_severity: SemanticDriftSeverity,
2912 pub expanded_count: usize,
2914 pub removed_count: usize,
2916 pub retained_count: usize,
2918 pub notes: Vec<String>,
2920}
2921
2922impl SemanticParityReport {
2923 pub const fn is_safe(&self) -> bool {
2925 self.verdict.is_safe()
2926 }
2927
2928 pub fn expansions(&self) -> Vec<&HostcallDelta> {
2930 self.hostcall_deltas
2931 .iter()
2932 .filter(|d| d.is_expansion())
2933 .collect()
2934 }
2935}
2936
2937pub fn extract_hostcall_surface(
2942 intent: &IntentGraph,
2943) -> std::collections::HashSet<HostcallCategory> {
2944 let mut surface = std::collections::HashSet::new();
2945
2946 for signal in &intent.signals {
2947 match signal {
2948 IntentSignal::RegistersTool(_)
2949 | IntentSignal::RegistersCommand(_)
2950 | IntentSignal::RegistersShortcut(_)
2951 | IntentSignal::RegistersFlag(_)
2952 | IntentSignal::RegistersProvider(_)
2953 | IntentSignal::RegistersRenderer(_) => {
2954 surface.insert(HostcallCategory::Register);
2955 }
2956 IntentSignal::HooksEvent(name) => {
2957 surface.insert(HostcallCategory::Events(name.clone()));
2958 }
2959 IntentSignal::RequiresCapability(cap) => {
2960 if cap == "session" {
2961 surface.insert(HostcallCategory::Session("*".to_string()));
2962 } else if cap == "tool" {
2963 surface.insert(HostcallCategory::Tool("*".to_string()));
2964 }
2965 }
2966 }
2967 }
2968
2969 surface
2970}
2971
2972pub fn compute_semantic_parity(
2978 before: &IntentGraph,
2979 after: &IntentGraph,
2980 patch_ops: &[PatchOp],
2981) -> SemanticParityReport {
2982 let before_surface = extract_hostcall_surface(before);
2983 let after_surface = extract_hostcall_surface(after);
2984
2985 let mut hostcall_deltas = Vec::new();
2986
2987 for cat in &before_surface {
2989 if after_surface.contains(cat) {
2990 hostcall_deltas.push(HostcallDelta::Retained(cat.clone()));
2991 } else {
2992 hostcall_deltas.push(HostcallDelta::Removed(cat.clone()));
2993 }
2994 }
2995
2996 for cat in &after_surface {
2998 if !before_surface.contains(cat) {
2999 hostcall_deltas.push(HostcallDelta::Added(cat.clone()));
3000 }
3001 }
3002
3003 let expanded_count = hostcall_deltas.iter().filter(|d| d.is_expansion()).count();
3004 let removed_count = hostcall_deltas
3005 .iter()
3006 .filter(|d| matches!(d, HostcallDelta::Removed(_)))
3007 .count();
3008 let retained_count = hostcall_deltas
3009 .iter()
3010 .filter(|d| matches!(d, HostcallDelta::Retained(_)))
3011 .count();
3012
3013 let mut notes = Vec::new();
3015 let drift_severity = assess_drift(patch_ops, expanded_count, removed_count, &mut notes);
3016
3017 let verdict = if expanded_count == 0 && drift_severity.is_acceptable() {
3018 if removed_count == 0 {
3019 SemanticParityVerdict::Equivalent
3020 } else {
3021 notes.push(format!(
3022 "{removed_count} hostcall(s) removed — acceptable reduction"
3023 ));
3024 SemanticParityVerdict::AcceptableDrift
3025 }
3026 } else {
3027 if expanded_count > 0 {
3028 notes.push(format!(
3029 "{expanded_count} new hostcall surface(s) introduced"
3030 ));
3031 }
3032 SemanticParityVerdict::Divergent
3033 };
3034
3035 SemanticParityReport {
3036 extension_id: before.extension_id.clone(),
3037 verdict,
3038 hostcall_deltas,
3039 drift_severity,
3040 expanded_count,
3041 removed_count,
3042 retained_count,
3043 notes,
3044 }
3045}
3046
3047fn assess_drift(
3049 patch_ops: &[PatchOp],
3050 expanded_hostcalls: usize,
3051 _removed_hostcalls: usize,
3052 notes: &mut Vec<String>,
3053) -> SemanticDriftSeverity {
3054 if expanded_hostcalls > 0 {
3056 notes.push("new hostcall surface detected".to_string());
3057 return SemanticDriftSeverity::High;
3058 }
3059
3060 let mut has_aggressive = false;
3061 let mut stub_count = 0_usize;
3062
3063 for op in patch_ops {
3064 match op {
3065 PatchOp::InjectStub { .. } => {
3066 stub_count += 1;
3067 has_aggressive = true;
3068 }
3069 PatchOp::AddExport { .. } | PatchOp::RemoveImport { .. } => {
3070 has_aggressive = true;
3071 }
3072 PatchOp::ReplaceModulePath { .. } | PatchOp::RewriteRequire { .. } => {}
3073 }
3074 }
3075
3076 if stub_count > 2 {
3077 notes.push(format!("{stub_count} stubs injected — medium drift"));
3078 return SemanticDriftSeverity::Medium;
3079 }
3080
3081 if has_aggressive {
3082 notes.push("aggressive ops present — low drift".to_string());
3083 return SemanticDriftSeverity::Low;
3084 }
3085
3086 SemanticDriftSeverity::None
3087}
3088
3089pub type ArtifactChecksum = String;
3095
3096pub fn compute_artifact_checksum(content: &[u8]) -> ArtifactChecksum {
3098 use sha2::{Digest, Sha256};
3099 let hash = Sha256::digest(content);
3100 format!("{hash:x}")
3101}
3102
3103#[derive(Debug, Clone, PartialEq, Eq)]
3105pub struct ChecksumEntry {
3106 pub relative_path: String,
3108 pub checksum: ArtifactChecksum,
3110 pub size_bytes: u64,
3112}
3113
3114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3116pub enum ConformanceReplayVerdict {
3117 Pass,
3119 Fail,
3121 NoFixtures,
3123}
3124
3125impl ConformanceReplayVerdict {
3126 pub const fn is_acceptable(&self) -> bool {
3128 matches!(self, Self::Pass | Self::NoFixtures)
3129 }
3130}
3131
3132impl std::fmt::Display for ConformanceReplayVerdict {
3133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3134 match self {
3135 Self::Pass => write!(f, "pass"),
3136 Self::Fail => write!(f, "fail"),
3137 Self::NoFixtures => write!(f, "no_fixtures"),
3138 }
3139 }
3140}
3141
3142#[derive(Debug, Clone)]
3144pub struct ConformanceFixture {
3145 pub name: String,
3147 pub expected: String,
3149 pub actual: Option<String>,
3151 pub passed: bool,
3153}
3154
3155#[derive(Debug, Clone)]
3157pub struct ConformanceReplayReport {
3158 pub extension_id: String,
3160 pub verdict: ConformanceReplayVerdict,
3162 pub fixtures: Vec<ConformanceFixture>,
3164 pub passed_count: usize,
3166 pub total_count: usize,
3168}
3169
3170impl ConformanceReplayReport {
3171 pub const fn is_acceptable(&self) -> bool {
3173 self.verdict.is_acceptable()
3174 }
3175}
3176
3177pub fn replay_conformance_fixtures(
3183 extension_id: &str,
3184 fixtures: &[ConformanceFixture],
3185) -> ConformanceReplayReport {
3186 if fixtures.is_empty() {
3187 return ConformanceReplayReport {
3188 extension_id: extension_id.to_string(),
3189 verdict: ConformanceReplayVerdict::NoFixtures,
3190 fixtures: Vec::new(),
3191 passed_count: 0,
3192 total_count: 0,
3193 };
3194 }
3195
3196 let passed_count = fixtures.iter().filter(|f| f.passed).count();
3197 let total_count = fixtures.len();
3198 let verdict = if passed_count == total_count {
3199 ConformanceReplayVerdict::Pass
3200 } else {
3201 ConformanceReplayVerdict::Fail
3202 };
3203
3204 ConformanceReplayReport {
3205 extension_id: extension_id.to_string(),
3206 verdict,
3207 fixtures: fixtures.to_vec(),
3208 passed_count,
3209 total_count,
3210 }
3211}
3212
3213#[derive(Debug, Clone)]
3219pub struct GoldenChecksumManifest {
3220 pub extension_id: String,
3222 pub entries: Vec<ChecksumEntry>,
3224 pub generated_at_ms: u64,
3226}
3227
3228impl GoldenChecksumManifest {
3229 pub fn artifact_count(&self) -> usize {
3231 self.entries.len()
3232 }
3233
3234 pub fn verify_entry(&self, relative_path: &str, content: &[u8]) -> Option<bool> {
3236 self.entries
3237 .iter()
3238 .find(|e| e.relative_path == relative_path)
3239 .map(|e| e.checksum == compute_artifact_checksum(content))
3240 }
3241}
3242
3243pub fn build_golden_manifest(
3248 extension_id: &str,
3249 artifacts: &[(&str, &[u8])],
3250 timestamp_ms: u64,
3251) -> GoldenChecksumManifest {
3252 let entries = artifacts
3253 .iter()
3254 .map(|(path, content)| ChecksumEntry {
3255 relative_path: (*path).to_string(),
3256 checksum: compute_artifact_checksum(content),
3257 size_bytes: content.len() as u64,
3258 })
3259 .collect();
3260
3261 GoldenChecksumManifest {
3262 extension_id: extension_id.to_string(),
3263 entries,
3264 generated_at_ms: timestamp_ms,
3265 }
3266}
3267
3268#[derive(Debug, Clone)]
3274pub struct VerificationBundle {
3275 pub extension_id: String,
3277 pub structural: StructuralVerdict,
3279 pub capability_proof: CapabilityProofReport,
3281 pub semantic_proof: SemanticParityReport,
3283 pub conformance: ConformanceReplayReport,
3285 pub checksum_manifest: GoldenChecksumManifest,
3287}
3288
3289impl VerificationBundle {
3290 pub const fn is_verified(&self) -> bool {
3292 self.structural.is_valid()
3293 && self.capability_proof.is_safe()
3294 && self.semantic_proof.is_safe()
3295 && self.conformance.is_acceptable()
3296 }
3297
3298 pub fn failure_reasons(&self) -> Vec<String> {
3300 let mut reasons = Vec::new();
3301 if !self.structural.is_valid() {
3302 reasons.push(format!("structural: {}", self.structural));
3303 }
3304 if !self.capability_proof.is_safe() {
3305 reasons.push(format!(
3306 "capability: {} ({} escalation(s))",
3307 self.capability_proof.verdict, self.capability_proof.added_count
3308 ));
3309 }
3310 if !self.semantic_proof.is_safe() {
3311 reasons.push(format!(
3312 "semantic: {} (drift={})",
3313 self.semantic_proof.verdict, self.semantic_proof.drift_severity
3314 ));
3315 }
3316 if !self.conformance.is_acceptable() {
3317 reasons.push(format!(
3318 "conformance: {} ({}/{} passed)",
3319 self.conformance.verdict,
3320 self.conformance.passed_count,
3321 self.conformance.total_count
3322 ));
3323 }
3324 reasons
3325 }
3326}
3327
3328#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3334pub enum OverlayState {
3335 Staged,
3337 Canary,
3339 Stable,
3341 RolledBack,
3343 Superseded,
3345}
3346
3347impl OverlayState {
3348 pub const fn is_active(&self) -> bool {
3350 matches!(self, Self::Canary | Self::Stable)
3351 }
3352
3353 pub const fn is_terminal(&self) -> bool {
3355 matches!(self, Self::RolledBack | Self::Superseded)
3356 }
3357}
3358
3359impl std::fmt::Display for OverlayState {
3360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3361 match self {
3362 Self::Staged => write!(f, "staged"),
3363 Self::Canary => write!(f, "canary"),
3364 Self::Stable => write!(f, "stable"),
3365 Self::RolledBack => write!(f, "rolled_back"),
3366 Self::Superseded => write!(f, "superseded"),
3367 }
3368 }
3369}
3370
3371#[derive(Debug, Clone)]
3376pub struct OverlayArtifact {
3377 pub overlay_id: String,
3379 pub extension_id: String,
3381 pub extension_version: String,
3383 pub original_checksum: ArtifactChecksum,
3385 pub repaired_checksum: ArtifactChecksum,
3387 pub state: OverlayState,
3389 pub rule_id: String,
3391 pub repair_mode: RepairMode,
3393 pub verification_passed: bool,
3395 pub created_at_ms: u64,
3397 pub updated_at_ms: u64,
3399}
3400
3401impl OverlayArtifact {
3402 pub const fn is_active(&self) -> bool {
3404 self.state.is_active()
3405 }
3406}
3407
3408#[derive(Debug, Clone, PartialEq, Eq)]
3410pub enum OverlayTransitionError {
3411 InvalidTransition {
3413 from: OverlayState,
3414 to: OverlayState,
3415 },
3416 VerificationRequired,
3418}
3419
3420impl std::fmt::Display for OverlayTransitionError {
3421 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3422 match self {
3423 Self::InvalidTransition { from, to } => {
3424 write!(f, "invalid transition: {from} → {to}")
3425 }
3426 Self::VerificationRequired => {
3427 write!(f, "verification must pass before deployment")
3428 }
3429 }
3430 }
3431}
3432
3433pub fn transition_overlay(
3443 artifact: &mut OverlayArtifact,
3444 target: OverlayState,
3445 now_ms: u64,
3446) -> std::result::Result<(), OverlayTransitionError> {
3447 let valid = matches!(
3448 (artifact.state, target),
3449 (
3450 OverlayState::Staged,
3451 OverlayState::Canary | OverlayState::RolledBack
3452 ) | (
3453 OverlayState::Canary,
3454 OverlayState::Stable | OverlayState::RolledBack
3455 ) | (
3456 OverlayState::Stable,
3457 OverlayState::RolledBack | OverlayState::Superseded
3458 )
3459 );
3460
3461 if !valid {
3462 return Err(OverlayTransitionError::InvalidTransition {
3463 from: artifact.state,
3464 to: target,
3465 });
3466 }
3467
3468 if target == OverlayState::Canary && !artifact.verification_passed {
3470 return Err(OverlayTransitionError::VerificationRequired);
3471 }
3472
3473 artifact.state = target;
3474 artifact.updated_at_ms = now_ms;
3475 Ok(())
3476}
3477
3478#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3484pub enum CanaryRoute {
3485 Original,
3487 Overlay,
3489}
3490
3491impl std::fmt::Display for CanaryRoute {
3492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3493 match self {
3494 Self::Original => write!(f, "original"),
3495 Self::Overlay => write!(f, "overlay"),
3496 }
3497 }
3498}
3499
3500#[derive(Debug, Clone)]
3502pub struct CanaryConfig {
3503 pub extension_id: String,
3505 pub extension_version: String,
3507 pub overlay_percent: u8,
3509 pub enabled: bool,
3511}
3512
3513impl CanaryConfig {
3514 pub const fn route(&self, hash_bucket: u8) -> CanaryRoute {
3516 if self.enabled && hash_bucket < self.overlay_percent {
3517 CanaryRoute::Overlay
3518 } else {
3519 CanaryRoute::Original
3520 }
3521 }
3522
3523 pub const fn is_full_rollout(&self) -> bool {
3525 self.enabled && self.overlay_percent >= 100
3526 }
3527}
3528
3529pub fn compute_canary_bucket(extension_id: &str, environment: &str) -> u8 {
3531 use sha2::{Digest, Sha256};
3532 let mut hasher = Sha256::new();
3533 hasher.update(extension_id.as_bytes());
3534 hasher.update(b":");
3535 hasher.update(environment.as_bytes());
3536 let hash = hasher.finalize();
3537 let val = u16::from_be_bytes([hash[0], hash[1]]);
3539 (val % 100) as u8
3540}
3541
3542#[derive(Debug, Clone)]
3548pub struct HealthSignal {
3549 pub name: String,
3551 pub value: f64,
3553 pub threshold: f64,
3555}
3556
3557impl HealthSignal {
3558 pub fn is_healthy(&self) -> bool {
3560 self.value <= self.threshold
3561 }
3562}
3563
3564#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3566pub enum SloVerdict {
3567 Healthy,
3569 Violated,
3571}
3572
3573impl SloVerdict {
3574 pub const fn is_healthy(&self) -> bool {
3576 matches!(self, Self::Healthy)
3577 }
3578}
3579
3580impl std::fmt::Display for SloVerdict {
3581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3582 match self {
3583 Self::Healthy => write!(f, "healthy"),
3584 Self::Violated => write!(f, "violated"),
3585 }
3586 }
3587}
3588
3589#[derive(Debug, Clone)]
3591pub struct HealthReport {
3592 pub extension_id: String,
3594 pub verdict: SloVerdict,
3596 pub signals: Vec<HealthSignal>,
3598 pub violations: Vec<String>,
3600}
3601
3602impl HealthReport {
3603 pub const fn is_healthy(&self) -> bool {
3605 self.verdict.is_healthy()
3606 }
3607}
3608
3609pub fn evaluate_health(extension_id: &str, signals: &[HealthSignal]) -> HealthReport {
3611 let violations: Vec<String> = signals
3612 .iter()
3613 .filter(|s| !s.is_healthy())
3614 .map(|s| format!("{}: {:.3} > {:.3}", s.name, s.value, s.threshold))
3615 .collect();
3616
3617 let verdict = if violations.is_empty() {
3618 SloVerdict::Healthy
3619 } else {
3620 SloVerdict::Violated
3621 };
3622
3623 HealthReport {
3624 extension_id: extension_id.to_string(),
3625 verdict,
3626 signals: signals.to_vec(),
3627 violations,
3628 }
3629}
3630
3631pub const fn should_auto_rollback(health: &HealthReport) -> bool {
3633 !health.is_healthy()
3634}
3635
3636#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3642pub enum PromotionDecision {
3643 Promote,
3645 Hold,
3647 Rollback,
3649}
3650
3651impl std::fmt::Display for PromotionDecision {
3652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3653 match self {
3654 Self::Promote => write!(f, "promote"),
3655 Self::Hold => write!(f, "hold"),
3656 Self::Rollback => write!(f, "rollback"),
3657 }
3658 }
3659}
3660
3661pub const fn decide_promotion(
3668 health: &HealthReport,
3669 canary_start_ms: u64,
3670 now_ms: u64,
3671 canary_window_ms: u64,
3672) -> PromotionDecision {
3673 if !health.is_healthy() {
3674 return PromotionDecision::Rollback;
3675 }
3676 if now_ms.saturating_sub(canary_start_ms) >= canary_window_ms {
3677 return PromotionDecision::Promote;
3678 }
3679 PromotionDecision::Hold
3680}
3681
3682pub fn execute_promotion(
3684 artifact: &mut OverlayArtifact,
3685 now_ms: u64,
3686) -> std::result::Result<(), OverlayTransitionError> {
3687 transition_overlay(artifact, OverlayState::Stable, now_ms)
3688}
3689
3690pub fn execute_rollback(
3692 artifact: &mut OverlayArtifact,
3693 now_ms: u64,
3694) -> std::result::Result<(), OverlayTransitionError> {
3695 transition_overlay(artifact, OverlayState::RolledBack, now_ms)
3696}
3697
3698#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3704pub enum AuditEntryKind {
3705 Analysis,
3707 GatingDecision,
3709 ProposalGenerated,
3711 ProposalValidated,
3713 VerificationEvaluated,
3715 ApprovalRequested,
3717 ApprovalResponse,
3719 Activated,
3721 RolledBack,
3723 Promoted,
3725 Superseded,
3727}
3728
3729impl std::fmt::Display for AuditEntryKind {
3730 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3731 match self {
3732 Self::Analysis => write!(f, "analysis"),
3733 Self::GatingDecision => write!(f, "gating_decision"),
3734 Self::ProposalGenerated => write!(f, "proposal_generated"),
3735 Self::ProposalValidated => write!(f, "proposal_validated"),
3736 Self::VerificationEvaluated => write!(f, "verification_evaluated"),
3737 Self::ApprovalRequested => write!(f, "approval_requested"),
3738 Self::ApprovalResponse => write!(f, "approval_response"),
3739 Self::Activated => write!(f, "activated"),
3740 Self::RolledBack => write!(f, "rolled_back"),
3741 Self::Promoted => write!(f, "promoted"),
3742 Self::Superseded => write!(f, "superseded"),
3743 }
3744 }
3745}
3746
3747#[derive(Debug, Clone)]
3749pub struct AuditEntry {
3750 pub sequence: u64,
3752 pub timestamp_ms: u64,
3754 pub extension_id: String,
3756 pub kind: AuditEntryKind,
3758 pub summary: String,
3760 pub details: Vec<(String, String)>,
3762}
3763
3764#[derive(Debug, Clone, Default)]
3769pub struct AuditLedger {
3770 entries: Vec<AuditEntry>,
3771 next_sequence: u64,
3772}
3773
3774impl AuditLedger {
3775 pub const fn new() -> Self {
3777 Self {
3778 entries: Vec::new(),
3779 next_sequence: 0,
3780 }
3781 }
3782
3783 pub fn append(
3785 &mut self,
3786 timestamp_ms: u64,
3787 extension_id: &str,
3788 kind: AuditEntryKind,
3789 summary: String,
3790 details: Vec<(String, String)>,
3791 ) -> u64 {
3792 let seq = self.next_sequence;
3793 self.entries.push(AuditEntry {
3794 sequence: seq,
3795 timestamp_ms,
3796 extension_id: extension_id.to_string(),
3797 kind,
3798 summary,
3799 details,
3800 });
3801 self.next_sequence = self.next_sequence.saturating_add(1);
3802 seq
3803 }
3804
3805 pub fn len(&self) -> usize {
3807 self.entries.len()
3808 }
3809
3810 pub fn is_empty(&self) -> bool {
3812 self.entries.is_empty()
3813 }
3814
3815 pub fn get(&self, sequence: u64) -> Option<&AuditEntry> {
3817 self.entries.iter().find(|e| e.sequence == sequence)
3818 }
3819
3820 pub fn entries_for_extension(&self, extension_id: &str) -> Vec<&AuditEntry> {
3822 self.entries
3823 .iter()
3824 .filter(|e| e.extension_id == extension_id)
3825 .collect()
3826 }
3827
3828 pub fn entries_by_kind(&self, kind: AuditEntryKind) -> Vec<&AuditEntry> {
3830 self.entries.iter().filter(|e| e.kind == kind).collect()
3831 }
3832
3833 pub fn all_entries(&self) -> &[AuditEntry] {
3835 &self.entries
3836 }
3837}
3838
3839#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3845pub enum TelemetryMetric {
3846 RepairAttempted,
3848 RepairEligible,
3850 RepairDenied,
3852 VerificationFailed,
3854 OverlayRolledBack,
3856 OverlayPromoted,
3858 ApprovalLatencyMs,
3860}
3861
3862impl std::fmt::Display for TelemetryMetric {
3863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3864 match self {
3865 Self::RepairAttempted => write!(f, "repair.attempted"),
3866 Self::RepairEligible => write!(f, "repair.eligible"),
3867 Self::RepairDenied => write!(f, "repair.denied"),
3868 Self::VerificationFailed => write!(f, "verification.failed"),
3869 Self::OverlayRolledBack => write!(f, "overlay.rolled_back"),
3870 Self::OverlayPromoted => write!(f, "overlay.promoted"),
3871 Self::ApprovalLatencyMs => write!(f, "approval.latency_ms"),
3872 }
3873 }
3874}
3875
3876#[derive(Debug, Clone)]
3878pub struct TelemetryPoint {
3879 pub metric: TelemetryMetric,
3881 pub value: f64,
3883 pub timestamp_ms: u64,
3885 pub tags: Vec<(String, String)>,
3887}
3888
3889#[derive(Debug, Clone, Default)]
3891pub struct TelemetryCollector {
3892 points: Vec<TelemetryPoint>,
3893}
3894
3895impl TelemetryCollector {
3896 pub const fn new() -> Self {
3898 Self { points: Vec::new() }
3899 }
3900
3901 pub fn record(
3903 &mut self,
3904 metric: TelemetryMetric,
3905 value: f64,
3906 timestamp_ms: u64,
3907 tags: Vec<(String, String)>,
3908 ) {
3909 self.points.push(TelemetryPoint {
3910 metric,
3911 value,
3912 timestamp_ms,
3913 tags,
3914 });
3915 }
3916
3917 pub fn increment(
3919 &mut self,
3920 metric: TelemetryMetric,
3921 timestamp_ms: u64,
3922 tags: Vec<(String, String)>,
3923 ) {
3924 self.record(metric, 1.0, timestamp_ms, tags);
3925 }
3926
3927 pub fn count(&self, metric: TelemetryMetric) -> usize {
3929 self.points.iter().filter(|p| p.metric == metric).count()
3930 }
3931
3932 pub fn sum(&self, metric: TelemetryMetric) -> f64 {
3934 self.points
3935 .iter()
3936 .filter(|p| p.metric == metric)
3937 .map(|p| p.value)
3938 .sum()
3939 }
3940
3941 pub fn all_points(&self) -> &[TelemetryPoint] {
3943 &self.points
3944 }
3945
3946 pub fn len(&self) -> usize {
3948 self.points.len()
3949 }
3950
3951 pub fn is_empty(&self) -> bool {
3953 self.points.is_empty()
3954 }
3955}
3956
3957#[derive(Debug, Clone)]
3963pub struct InspectionRecord {
3964 pub extension_id: String,
3966 pub timeline: Vec<String>,
3968 pub gating_summary: String,
3970 pub overlay_state: Option<String>,
3972 pub verification_summary: String,
3974}
3975
3976pub fn build_inspection(
3978 extension_id: &str,
3979 ledger: &AuditLedger,
3980 overlay_state: Option<OverlayState>,
3981 verification_passed: bool,
3982) -> InspectionRecord {
3983 let entries = ledger.entries_for_extension(extension_id);
3984 let timeline: Vec<String> = entries
3985 .iter()
3986 .map(|e| format!("[seq={}] {} — {}", e.sequence, e.kind, e.summary))
3987 .collect();
3988
3989 let gating_entries = entries
3990 .iter()
3991 .filter(|e| e.kind == AuditEntryKind::GatingDecision)
3992 .collect::<Vec<_>>();
3993 let gating_summary = gating_entries.last().map_or_else(
3994 || "no gating decision recorded".to_string(),
3995 |e| e.summary.clone(),
3996 );
3997
3998 let verification_summary = if verification_passed {
3999 "all proofs passed".to_string()
4000 } else {
4001 "one or more proofs failed".to_string()
4002 };
4003
4004 InspectionRecord {
4005 extension_id: extension_id.to_string(),
4006 timeline,
4007 gating_summary,
4008 overlay_state: overlay_state.map(|s| s.to_string()),
4009 verification_summary,
4010 }
4011}
4012
4013pub fn explain_gating(verdict: &GatingVerdict) -> Vec<String> {
4015 let mut lines = Vec::new();
4016 lines.push(format!(
4017 "Decision: {} (confidence: {:.2})",
4018 verdict.decision, verdict.confidence.score
4019 ));
4020 for reason in &verdict.confidence.reasons {
4021 lines.push(format!(
4022 " [{:+.2}] {} — {}",
4023 reason.delta, reason.code, reason.explanation
4024 ));
4025 }
4026 for code in &verdict.reason_codes {
4027 lines.push(format!(" REASON: {} — {}", code.code, code.remediation));
4028 }
4029 lines
4030}
4031
4032pub fn format_proposal_diff(proposal: &PatchProposal) -> Vec<String> {
4034 let mut lines = Vec::new();
4035 lines.push(format!(
4036 "Rule: {} ({} op(s), risk: {:?})",
4037 proposal.rule_id,
4038 proposal.op_count(),
4039 proposal.max_risk()
4040 ));
4041 if !proposal.rationale.is_empty() {
4042 lines.push(format!("Rationale: {}", proposal.rationale));
4043 }
4044 for (i, op) in proposal.ops.iter().enumerate() {
4045 lines.push(format!(
4046 " Op {}: [{}] {}",
4047 i + 1,
4048 op.tag(),
4049 op_target_path(op)
4050 ));
4051 }
4052 lines
4053}
4054
4055#[derive(Debug, Clone)]
4064pub struct ForensicBundle {
4065 pub extension_id: String,
4067 pub overlay: Option<OverlayArtifact>,
4069 pub verification: Option<VerificationBundle>,
4071 pub audit_entries: Vec<AuditEntry>,
4073 pub telemetry_points: Vec<TelemetryPoint>,
4075 pub health_report: Option<HealthReport>,
4077 pub checksum_manifest: Option<GoldenChecksumManifest>,
4079 pub exported_at_ms: u64,
4081}
4082
4083impl ForensicBundle {
4084 pub fn audit_count(&self) -> usize {
4086 self.audit_entries.len()
4087 }
4088
4089 pub const fn has_verification(&self) -> bool {
4091 self.verification.is_some()
4092 }
4093
4094 pub const fn has_health_data(&self) -> bool {
4096 self.health_report.is_some()
4097 }
4098}
4099
4100#[allow(clippy::too_many_arguments)]
4102pub fn build_forensic_bundle(
4103 extension_id: &str,
4104 overlay: Option<&OverlayArtifact>,
4105 verification: Option<&VerificationBundle>,
4106 ledger: &AuditLedger,
4107 collector: &TelemetryCollector,
4108 health_report: Option<&HealthReport>,
4109 checksum_manifest: Option<&GoldenChecksumManifest>,
4110 exported_at_ms: u64,
4111) -> ForensicBundle {
4112 let audit_entries = ledger
4113 .entries_for_extension(extension_id)
4114 .into_iter()
4115 .cloned()
4116 .collect();
4117
4118 let telemetry_points = collector
4119 .all_points()
4120 .iter()
4121 .filter(|p| {
4122 p.tags
4123 .iter()
4124 .any(|(k, v)| k == "extension_id" && v == extension_id)
4125 })
4126 .cloned()
4127 .collect();
4128
4129 ForensicBundle {
4130 extension_id: extension_id.to_string(),
4131 overlay: overlay.cloned(),
4132 verification: verification.cloned(),
4133 audit_entries,
4134 telemetry_points,
4135 health_report: health_report.cloned(),
4136 checksum_manifest: checksum_manifest.cloned(),
4137 exported_at_ms,
4138 }
4139}
4140
4141pub struct LisrAdr;
4151
4152impl LisrAdr {
4153 pub const ID: &'static str = "ADR-LISR-001";
4155
4156 pub const TITLE: &'static str =
4158 "Dynamic Secure Extension Repair with Intent-Legible Self-Healing";
4159
4160 pub const CONTEXT: &'static str = "\
4162Extensions frequently break during updates when build artifacts (dist/) \
4163diverge from source (src/). Manual repair is slow, error-prone, and blocks \
4164the agent workflow. LISR provides automated repair within strict safety \
4165boundaries to restore extension functionality without human intervention.";
4166
4167 pub const DECISION: &'static str = "\
4169Adopt a layered repair pipeline with fail-closed defaults: \
4170(1) security policy framework bounds all repairs, \
4171(2) intent legibility analysis gates repair eligibility, \
4172(3) deterministic rules execute safe repairs, \
4173(4) model-assisted repairs are constrained to whitelisted primitives, \
4174(5) all repairs require structural + capability + semantic proof, \
4175(6) overlay deployment uses canary routing with health rollback, \
4176(7) every action is recorded in an append-only audit ledger, \
4177(8) governance checks are codified in the release process.";
4178
4179 pub const THREATS: &'static [&'static str] = &[
4181 "T1: Privilege escalation via repair adding new capabilities",
4182 "T2: Code injection via model-generated repair proposals",
4183 "T3: Supply-chain compromise via path traversal beyond extension root",
4184 "T4: Silent behavioral drift from opaque automated repairs",
4185 "T5: Loss of auditability preventing incident forensics",
4186 "T6: Governance decay from undocumented safety invariants",
4187 ];
4188
4189 pub const FAIL_CLOSED_RATIONALE: &'static str = "\
4191Any uncertainty in repair safety defaults to denial. A broken extension \
4192that remains broken is safer than a repaired extension that silently \
4193escalates privileges or introduces semantic drift. The cost of a false \
4194negative (missed repair) is low; the cost of a false positive (unsafe \
4195repair applied) is catastrophic.";
4196
4197 pub const INVARIANTS: &'static [&'static str] = &[
4199 "I1: Repairs never add capabilities absent from the original extension",
4200 "I2: All file paths stay within the extension root (monotonicity)",
4201 "I3: Model proposals are restricted to whitelisted PatchOp primitives",
4202 "I4: Structural validity is verified via SWC parse before activation",
4203 "I5: Every repair decision is recorded in the append-only audit ledger",
4204 "I6: Canary rollback triggers automatically on SLO violation",
4205 ];
4206}
4207
4208pub struct OperatorPlaybook;
4214
4215impl OperatorPlaybook {
4216 pub const MODE_GUIDANCE: &'static [(&'static str, &'static str)] = &[
4218 (
4219 "Off",
4220 "Disable all automated repairs. Use when investigating a repair-related incident.",
4221 ),
4222 (
4223 "Suggest",
4224 "Log repair suggestions without applying. Use during initial rollout or audit.",
4225 ),
4226 (
4227 "AutoSafe",
4228 "Apply only safe (path-remap) repairs automatically. Default for production.",
4229 ),
4230 (
4231 "AutoStrict",
4232 "Apply both safe and aggressive repairs. Use only with explicit approval.",
4233 ),
4234 ];
4235
4236 pub const CANARY_PROCEDURE: &'static [&'static str] = &[
4238 "1. Create overlay artifact from repair pipeline",
4239 "2. Verify all proofs pass (structural, capability, semantic, conformance)",
4240 "3. Transition to Canary state with initial overlay_percent (e.g., 10%)",
4241 "4. Monitor health signals for canary_window_ms (default: 300_000)",
4242 "5. If SLO violated → automatic rollback",
4243 "6. If canary window passes → promote to Stable",
4244 "7. Record all transitions in audit ledger",
4245 ];
4246
4247 pub const INCIDENT_RESPONSE: &'static [&'static str] = &[
4249 "1. Set repair_mode to Off immediately",
4250 "2. Export forensic bundle for the affected extension",
4251 "3. Review audit ledger for the repair timeline",
4252 "4. Check verification bundle for proof failures",
4253 "5. Inspect health signals that triggered rollback",
4254 "6. Root-cause the repair rule or model proposal",
4255 "7. File ADR amendment if safety invariant was violated",
4256 ];
4257}
4258
4259pub struct DeveloperGuide;
4265
4266impl DeveloperGuide {
4267 pub const ADD_RULE_CHECKLIST: &'static [&'static str] = &[
4269 "1. Define a RepairPattern variant with clear trigger semantics",
4270 "2. Define a RepairRule with: id, name, pattern, description, risk, ops",
4271 "3. Risk must be Safe unless the rule modifies code (then Aggressive)",
4272 "4. Add the rule to REPAIR_RULES static registry",
4273 "5. Implement matching logic in the extension loader",
4274 "6. Add unit tests covering: match, no-match, edge cases",
4275 "7. Add integration test with real extension fixture",
4276 "8. Verify monotonicity: rule must not escape extension root",
4277 "9. Verify capability monotonicity: rule must not add capabilities",
4278 "10. Run full conformance suite to check for regressions",
4279 ];
4280
4281 pub const ANTI_PATTERNS: &'static [(&'static str, &'static str)] = &[
4283 (
4284 "Unconstrained path rewriting",
4285 "Always validate target paths are within extension root via verify_repair_monotonicity()",
4286 ),
4287 (
4288 "Model-generated code execution",
4289 "Model proposals must use PatchOp primitives only — never eval or Function()",
4290 ),
4291 (
4292 "Skipping verification",
4293 "Every repair must pass the full VerificationBundle gate before activation",
4294 ),
4295 (
4296 "Mutable audit entries",
4297 "AuditLedger is append-only — never expose delete or update methods",
4298 ),
4299 (
4300 "Implicit capability grants",
4301 "compute_capability_proof() must show no Added deltas for the repair to pass",
4302 ),
4303 ];
4304
4305 pub const TESTING_EXPECTATIONS: &'static [&'static str] = &[
4307 "Unit test: rule matches intended pattern and rejects non-matching input",
4308 "Unit test: generated PatchOps have correct risk classification",
4309 "Integration test: repair applied to real extension fixture succeeds",
4310 "Monotonicity test: repaired path stays within extension root",
4311 "Capability test: compute_capability_proof returns Monotonic",
4312 "Semantic test: compute_semantic_parity returns Equivalent or AcceptableDrift",
4313 "Conformance test: extension still passes conformance replay after repair",
4314 ];
4315}
4316
4317#[derive(Debug, Clone)]
4323pub struct GovernanceCheck {
4324 pub id: String,
4326 pub description: String,
4328 pub passed: bool,
4330 pub detail: String,
4332}
4333
4334#[derive(Debug, Clone)]
4336pub struct GovernanceReport {
4337 pub checks: Vec<GovernanceCheck>,
4339 pub passed_count: usize,
4341 pub total_count: usize,
4343}
4344
4345impl GovernanceReport {
4346 pub const fn all_passed(&self) -> bool {
4348 self.passed_count == self.total_count
4349 }
4350
4351 pub fn failures(&self) -> Vec<&GovernanceCheck> {
4353 self.checks.iter().filter(|c| !c.passed).collect()
4354 }
4355}
4356
4357pub fn run_governance_checklist() -> GovernanceReport {
4367 let mut checks = Vec::new();
4368
4369 checks.push(GovernanceCheck {
4371 id: "GOV-001".to_string(),
4372 description: "Repair registry contains at least one rule".to_string(),
4373 passed: !REPAIR_RULES.is_empty(),
4374 detail: if REPAIR_RULES.is_empty() {
4375 "REPAIR_RULES is empty".to_string()
4376 } else {
4377 String::new()
4378 },
4379 });
4380
4381 let empty_ids: Vec<_> = REPAIR_RULES
4383 .iter()
4384 .filter(|r| r.id.is_empty())
4385 .map(|r| r.description)
4386 .collect();
4387 checks.push(GovernanceCheck {
4388 id: "GOV-002".to_string(),
4389 description: "All repair rules have non-empty IDs".to_string(),
4390 passed: empty_ids.is_empty(),
4391 detail: if empty_ids.is_empty() {
4392 String::new()
4393 } else {
4394 format!("Rules with empty IDs: {empty_ids:?}")
4395 },
4396 });
4397
4398 checks.push(GovernanceCheck {
4400 id: "GOV-003".to_string(),
4401 description: "Architecture ADR is defined".to_string(),
4402 passed: !LisrAdr::INVARIANTS.is_empty(),
4403 detail: String::new(),
4404 });
4405
4406 checks.push(GovernanceCheck {
4408 id: "GOV-004".to_string(),
4409 description: "Threat model is documented".to_string(),
4410 passed: !LisrAdr::THREATS.is_empty(),
4411 detail: String::new(),
4412 });
4413
4414 let invariant_count = LisrAdr::INVARIANTS.len();
4416 checks.push(GovernanceCheck {
4417 id: "GOV-005".to_string(),
4418 description: "Safety invariants cover all critical areas (>=6)".to_string(),
4419 passed: invariant_count >= 6,
4420 detail: if invariant_count < 6 {
4421 format!("Only {invariant_count} invariants defined (need >=6)")
4422 } else {
4423 String::new()
4424 },
4425 });
4426
4427 checks.push(GovernanceCheck {
4429 id: "GOV-006".to_string(),
4430 description: "Developer testing expectations are documented".to_string(),
4431 passed: !DeveloperGuide::TESTING_EXPECTATIONS.is_empty(),
4432 detail: String::new(),
4433 });
4434
4435 let passed_count = checks.iter().filter(|c| c.passed).count();
4436 let total_count = checks.len();
4437
4438 GovernanceReport {
4439 checks,
4440 passed_count,
4441 total_count,
4442 }
4443}
4444
4445#[derive(Debug, Clone)]
4446pub struct PiJsRuntimeConfig {
4447 pub cwd: String,
4448 pub args: Vec<String>,
4449 pub env: HashMap<String, String>,
4450 pub limits: PiJsRuntimeLimits,
4451 pub repair_mode: RepairMode,
4453 pub allow_unsafe_sync_exec: bool,
4459 pub deny_env: bool,
4462 pub disk_cache_dir: Option<PathBuf>,
4469}
4470
4471impl PiJsRuntimeConfig {
4472 pub const fn auto_repair_enabled(&self) -> bool {
4474 self.repair_mode.should_apply()
4475 }
4476}
4477
4478impl Default for PiJsRuntimeConfig {
4479 fn default() -> Self {
4480 Self {
4481 cwd: ".".to_string(),
4482 args: Vec::new(),
4483 env: HashMap::new(),
4484 limits: PiJsRuntimeLimits::default(),
4485 repair_mode: RepairMode::default(),
4486 allow_unsafe_sync_exec: false,
4487 deny_env: true,
4488 disk_cache_dir: runtime_disk_cache_dir(),
4489 }
4490 }
4491}
4492
4493fn runtime_disk_cache_dir() -> Option<PathBuf> {
4498 if let Some(raw) = std::env::var_os("PIJS_MODULE_CACHE_DIR") {
4499 return if raw.is_empty() {
4500 None
4501 } else {
4502 Some(PathBuf::from(raw))
4503 };
4504 }
4505 dirs::home_dir().map(|home| home.join(".pi").join("agent").join("cache").join("modules"))
4506}
4507
4508#[derive(Debug)]
4509struct InterruptBudget {
4510 configured: Option<u64>,
4511 remaining: std::cell::Cell<Option<u64>>,
4512 tripped: std::cell::Cell<bool>,
4513}
4514
4515impl InterruptBudget {
4516 const fn new(configured: Option<u64>) -> Self {
4517 Self {
4518 configured,
4519 remaining: std::cell::Cell::new(None),
4520 tripped: std::cell::Cell::new(false),
4521 }
4522 }
4523
4524 fn reset(&self) {
4525 self.remaining.set(self.configured);
4526 self.tripped.set(false);
4527 }
4528
4529 fn on_interrupt(&self) -> bool {
4530 let Some(remaining) = self.remaining.get() else {
4531 return false;
4532 };
4533 if remaining == 0 {
4534 self.tripped.set(true);
4535 return true;
4536 }
4537 self.remaining.set(Some(remaining - 1));
4538 false
4539 }
4540
4541 fn did_trip(&self) -> bool {
4542 self.tripped.get()
4543 }
4544
4545 fn clear_trip(&self) {
4546 self.tripped.set(false);
4547 }
4548}
4549
4550#[derive(Debug, Default)]
4551struct HostcallTracker {
4552 pending: HashSet<String>,
4553 call_to_timer: HashMap<String, u64>,
4554 timer_to_call: HashMap<u64, String>,
4555 enqueued_at_ms: HashMap<String, u64>,
4556}
4557
4558enum HostcallCompletion {
4559 Delivered {
4560 #[allow(dead_code)]
4561 timer_id: Option<u64>,
4562 },
4563 Unknown,
4564}
4565
4566impl HostcallTracker {
4567 fn clear(&mut self) {
4568 self.pending.clear();
4569 self.call_to_timer.clear();
4570 self.timer_to_call.clear();
4571 self.enqueued_at_ms.clear();
4572 }
4573
4574 fn register(&mut self, call_id: String, timer_id: Option<u64>, enqueued_at_ms: u64) {
4575 self.pending.insert(call_id.clone());
4576 if let Some(timer_id) = timer_id {
4577 self.call_to_timer.insert(call_id.clone(), timer_id);
4578 self.timer_to_call.insert(timer_id, call_id.clone());
4579 }
4580 self.enqueued_at_ms.insert(call_id, enqueued_at_ms);
4582 }
4583
4584 fn pending_count(&self) -> usize {
4585 self.pending.len()
4586 }
4587
4588 fn is_pending(&self, call_id: &str) -> bool {
4589 self.pending.contains(call_id)
4590 }
4591
4592 fn queue_wait_ms(&self, call_id: &str, now_ms: u64) -> Option<u64> {
4593 self.enqueued_at_ms
4594 .get(call_id)
4595 .copied()
4596 .map(|enqueued| now_ms.saturating_sub(enqueued))
4597 }
4598
4599 fn on_complete(&mut self, call_id: &str) -> HostcallCompletion {
4600 if !self.pending.remove(call_id) {
4601 return HostcallCompletion::Unknown;
4602 }
4603
4604 let timer_id = self.call_to_timer.remove(call_id);
4605 self.enqueued_at_ms.remove(call_id);
4606 if let Some(timer_id) = timer_id {
4607 self.timer_to_call.remove(&timer_id);
4608 }
4609
4610 HostcallCompletion::Delivered { timer_id }
4611 }
4612
4613 fn take_timed_out_call(&mut self, timer_id: u64) -> Option<String> {
4614 let call_id = self.timer_to_call.remove(&timer_id)?;
4615 self.call_to_timer.remove(&call_id);
4616 self.enqueued_at_ms.remove(&call_id);
4617 if !self.pending.remove(&call_id) {
4618 return None;
4619 }
4620 Some(call_id)
4621 }
4622}
4623
4624fn enqueue_hostcall_request_with_backpressure<C: SchedulerClock>(
4625 queue: &HostcallQueue,
4626 tracker: &Rc<RefCell<HostcallTracker>>,
4627 scheduler: &Rc<RefCell<Scheduler<C>>>,
4628 request: HostcallRequest,
4629) {
4630 let call_id = request.call_id.clone();
4631 let trace_id = request.trace_id;
4632 let extension_id = request.extension_id.clone();
4633 match queue.borrow_mut().push_back(request) {
4634 HostcallQueueEnqueueResult::FastPath { depth } => {
4635 tracing::trace!(
4636 event = "pijs.hostcall.queue.fast_path",
4637 call_id = %call_id,
4638 trace_id,
4639 extension_id = ?extension_id,
4640 depth,
4641 "Hostcall queued on fast-path ring"
4642 );
4643 }
4644 HostcallQueueEnqueueResult::OverflowPath {
4645 depth,
4646 overflow_depth,
4647 } => {
4648 tracing::debug!(
4649 event = "pijs.hostcall.queue.overflow_path",
4650 call_id = %call_id,
4651 trace_id,
4652 extension_id = ?extension_id,
4653 depth,
4654 overflow_depth,
4655 "Hostcall spilled to overflow queue"
4656 );
4657 }
4658 HostcallQueueEnqueueResult::Rejected {
4659 depth,
4660 overflow_depth,
4661 } => {
4662 let completion = tracker.borrow_mut().on_complete(&call_id);
4663 if let HostcallCompletion::Delivered { timer_id } = completion {
4664 if let Some(timer_id) = timer_id {
4665 let _ = scheduler.borrow_mut().clear_timeout(timer_id);
4666 }
4667 scheduler.borrow_mut().enqueue_hostcall_complete(
4668 call_id.clone(),
4669 HostcallOutcome::Error {
4670 code: "overloaded".to_string(),
4671 message: format!(
4672 "Hostcall queue overloaded (depth={depth}, overflow_depth={overflow_depth})"
4673 ),
4674 },
4675 );
4676 }
4677 tracing::warn!(
4678 event = "pijs.hostcall.queue.rejected",
4679 call_id = %call_id,
4680 trace_id,
4681 extension_id = ?extension_id,
4682 depth,
4683 overflow_depth,
4684 "Hostcall rejected by queue backpressure policy"
4685 );
4686 }
4687 }
4688}
4689
4690#[derive(Debug)]
4695struct PiJsModuleState {
4696 static_virtual_modules: Arc<HashMap<String, String>>,
4698 dynamic_virtual_modules: HashMap<String, String>,
4700 dynamic_virtual_named_exports: HashMap<String, BTreeSet<String>>,
4702 compiled_sources: HashMap<String, CompiledModuleCacheEntry>,
4703 module_cache_counters: ModuleCacheCounters,
4704 repair_mode: RepairMode,
4707 extension_roots: Vec<PathBuf>,
4710 extension_root_tiers: HashMap<PathBuf, ProxyStubSourceTier>,
4713 extension_root_scopes: HashMap<PathBuf, String>,
4716 repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4718 disk_cache_dir: Option<PathBuf>,
4720}
4721
4722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4723enum ProxyStubSourceTier {
4724 Official,
4725 Community,
4726 Unknown,
4727}
4728
4729#[derive(Debug, Clone)]
4730struct CompiledModuleCacheEntry {
4731 cache_key: Option<String>,
4732 source: Arc<[u8]>,
4733}
4734
4735#[derive(Debug, Clone, Copy, Default)]
4736struct ModuleCacheCounters {
4737 hits: u64,
4738 misses: u64,
4739 invalidations: u64,
4740 disk_hits: u64,
4741}
4742
4743impl PiJsModuleState {
4744 fn new() -> Self {
4745 Self {
4746 static_virtual_modules: default_virtual_modules_shared(),
4747 dynamic_virtual_modules: HashMap::new(),
4748 dynamic_virtual_named_exports: HashMap::new(),
4749 compiled_sources: HashMap::new(),
4750 module_cache_counters: ModuleCacheCounters::default(),
4751 repair_mode: RepairMode::default(),
4752 extension_roots: Vec::new(),
4753 extension_root_tiers: HashMap::new(),
4754 extension_root_scopes: HashMap::new(),
4755 repair_events: Arc::new(std::sync::Mutex::new(Vec::new())),
4756 disk_cache_dir: None,
4757 }
4758 }
4759
4760 const fn with_repair_mode(mut self, mode: RepairMode) -> Self {
4761 self.repair_mode = mode;
4762 self
4763 }
4764
4765 fn with_repair_events(
4766 mut self,
4767 events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
4768 ) -> Self {
4769 self.repair_events = events;
4770 self
4771 }
4772
4773 fn with_disk_cache_dir(mut self, dir: Option<PathBuf>) -> Self {
4774 self.disk_cache_dir = dir;
4775 self
4776 }
4777}
4778
4779#[derive(Clone, Debug)]
4780struct PiJsResolver {
4781 state: Rc<RefCell<PiJsModuleState>>,
4782}
4783
4784fn canonical_node_builtin(spec: &str) -> Option<&'static str> {
4785 match spec {
4786 "fs" | "node:fs" => Some("node:fs"),
4787 "fs/promises" | "node:fs/promises" => Some("node:fs/promises"),
4788 "path" | "node:path" => Some("node:path"),
4789 "os" | "node:os" => Some("node:os"),
4790 "child_process" | "node:child_process" => Some("node:child_process"),
4791 "crypto" | "node:crypto" => Some("node:crypto"),
4792 "http" | "node:http" => Some("node:http"),
4793 "https" | "node:https" => Some("node:https"),
4794 "http2" | "node:http2" => Some("node:http2"),
4795 "util" | "node:util" => Some("node:util"),
4796 "readline" | "node:readline" => Some("node:readline"),
4797 "url" | "node:url" => Some("node:url"),
4798 "net" | "node:net" => Some("node:net"),
4799 "events" | "node:events" => Some("node:events"),
4800 "buffer" | "node:buffer" => Some("node:buffer"),
4801 "assert" | "node:assert" => Some("node:assert"),
4802 "stream" | "node:stream" => Some("node:stream"),
4803 "stream/web" | "node:stream/web" => Some("node:stream/web"),
4804 "module" | "node:module" => Some("node:module"),
4805 "string_decoder" | "node:string_decoder" => Some("node:string_decoder"),
4806 "querystring" | "node:querystring" => Some("node:querystring"),
4807 "process" | "node:process" => Some("node:process"),
4808 "stream/promises" | "node:stream/promises" => Some("node:stream/promises"),
4809 "constants" | "node:constants" => Some("node:constants"),
4810 "tls" | "node:tls" => Some("node:tls"),
4811 "tty" | "node:tty" => Some("node:tty"),
4812 "zlib" | "node:zlib" => Some("node:zlib"),
4813 "perf_hooks" | "node:perf_hooks" => Some("node:perf_hooks"),
4814 "vm" | "node:vm" => Some("node:vm"),
4815 "v8" | "node:v8" => Some("node:v8"),
4816 "worker_threads" | "node:worker_threads" => Some("node:worker_threads"),
4817 _ => None,
4818 }
4819}
4820
4821fn is_network_specifier(spec: &str) -> bool {
4822 spec.starts_with("http://")
4823 || spec.starts_with("https://")
4824 || spec.starts_with("http:")
4825 || spec.starts_with("https:")
4826}
4827
4828fn is_bare_package_specifier(spec: &str) -> bool {
4829 if spec.starts_with("./")
4830 || spec.starts_with("../")
4831 || spec.starts_with('/')
4832 || spec.starts_with("file://")
4833 || spec.starts_with("node:")
4834 {
4835 return false;
4836 }
4837 !spec.contains(':')
4838}
4839
4840fn unsupported_module_specifier_message(spec: &str) -> String {
4841 if is_network_specifier(spec) {
4842 return format!("Network module imports are not supported in PiJS: {spec}");
4843 }
4844 if is_bare_package_specifier(spec) {
4845 return format!("Package module specifiers are not supported in PiJS: {spec}");
4846 }
4847 format!("Unsupported module specifier: {spec}")
4848}
4849
4850fn split_scoped_package(spec: &str) -> Option<(&str, &str)> {
4851 if !spec.starts_with('@') {
4852 return None;
4853 }
4854 let mut parts = spec.split('/');
4855 let scope = parts.next()?;
4856 let package = parts.next()?;
4857 Some((scope, package))
4858}
4859
4860fn package_scope(spec: &str) -> Option<&str> {
4861 split_scoped_package(spec).map(|(scope, _)| scope)
4862}
4863
4864fn read_extension_package_scope(root: &Path) -> Option<String> {
4865 let package_json = root.join("package.json");
4866 let raw = fs::read_to_string(package_json).ok()?;
4867 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
4868 let name = parsed.get("name").and_then(serde_json::Value::as_str)?;
4869 let (scope, _) = split_scoped_package(name.trim())?;
4870 Some(scope.to_string())
4871}
4872
4873fn root_path_hint_tier(root: &Path) -> ProxyStubSourceTier {
4874 let normalized = root
4875 .to_string_lossy()
4876 .replace('\\', "/")
4877 .to_ascii_lowercase();
4878 let community_hints = [
4879 "/community/",
4880 "/npm/",
4881 "/agents-",
4882 "/third-party",
4883 "/third_party",
4884 "/plugins-community/",
4885 ];
4886 if community_hints.iter().any(|hint| normalized.contains(hint)) {
4887 return ProxyStubSourceTier::Community;
4888 }
4889
4890 let official_hints = ["/official-pi-mono/", "/plugins-official/", "/official/"];
4891 if official_hints.iter().any(|hint| normalized.contains(hint)) {
4892 return ProxyStubSourceTier::Official;
4893 }
4894
4895 ProxyStubSourceTier::Unknown
4896}
4897
4898fn classify_proxy_stub_source_tier(extension_id: &str, root: &Path) -> ProxyStubSourceTier {
4899 let id = extension_id.trim().to_ascii_lowercase();
4900 if id.starts_with("community/")
4901 || id.starts_with("npm/")
4902 || id.starts_with("agents-")
4903 || id.starts_with("plugins-community/")
4904 || id.starts_with("third-party")
4905 || id.starts_with("third_party")
4906 {
4907 return ProxyStubSourceTier::Community;
4908 }
4909
4910 if id.starts_with("plugins-official/") {
4911 return ProxyStubSourceTier::Official;
4912 }
4913
4914 root_path_hint_tier(root)
4915}
4916
4917fn resolve_extension_root_for_base<'a>(base: &str, roots: &'a [PathBuf]) -> Option<&'a PathBuf> {
4918 let base_path = Path::new(base);
4919 let canonical_base = crate::extensions::safe_canonicalize(base_path);
4920 roots
4921 .iter()
4922 .filter(|root| {
4923 let canonical_root = crate::extensions::safe_canonicalize(root);
4924 canonical_base.starts_with(&canonical_root)
4925 })
4926 .max_by_key(|root| root.components().count())
4927}
4928
4929fn is_proxy_blocklisted_package(spec: &str) -> bool {
4930 if spec.starts_with("node:") {
4931 return true;
4932 }
4933
4934 let top = spec.split('/').next().unwrap_or(spec);
4935 matches!(
4936 top,
4937 "fs" | "path"
4938 | "child_process"
4939 | "net"
4940 | "http"
4941 | "https"
4942 | "crypto"
4943 | "tls"
4944 | "dgram"
4945 | "dns"
4946 | "vm"
4947 | "worker_threads"
4948 | "cluster"
4949 | "module"
4950 | "os"
4951 | "process"
4952 )
4953}
4954
4955fn is_proxy_allowlisted_package(spec: &str) -> bool {
4956 const ALLOWLIST_SCOPES: &[&str] = &["@sourcegraph", "@marckrenn", "@aliou"];
4957 const ALLOWLIST_PACKAGES: &[&str] = &[
4958 "openai",
4959 "adm-zip",
4960 "linkedom",
4961 "p-limit",
4962 "unpdf",
4963 "node-pty",
4964 "chokidar",
4965 "jsdom",
4966 "turndown",
4967 "beautiful-mermaid",
4968 ];
4969
4970 if ALLOWLIST_PACKAGES.contains(&spec) {
4971 return true;
4972 }
4973
4974 if let Some((scope, package)) = split_scoped_package(spec) {
4975 if ALLOWLIST_SCOPES.contains(&scope) {
4976 return true;
4977 }
4978
4979 if package.starts_with("pi-") {
4981 return true;
4982 }
4983 }
4984
4985 false
4986}
4987
4988const MAX_MODULE_SOURCE_BYTES: u64 = 1024 * 1024 * 1024;
4990
4991fn should_auto_stub_package(
4992 spec: &str,
4993 base: &str,
4994 extension_roots: &[PathBuf],
4995 extension_root_tiers: &HashMap<PathBuf, ProxyStubSourceTier>,
4996 extension_root_scopes: &HashMap<PathBuf, String>,
4997) -> bool {
4998 if !is_bare_package_specifier(spec) || is_proxy_blocklisted_package(spec) {
4999 return false;
5000 }
5001
5002 let (tier, root_for_scope) = resolve_extension_root_for_base(base, extension_roots).map_or(
5003 (ProxyStubSourceTier::Unknown, None),
5004 |root| {
5005 (
5006 extension_root_tiers
5007 .get(root)
5008 .copied()
5009 .unwrap_or(ProxyStubSourceTier::Unknown),
5010 Some(root),
5011 )
5012 },
5013 );
5014
5015 let same_scope = if let Some(spec_scope) = package_scope(spec)
5016 && let Some(root) = root_for_scope
5017 && let Some(extension_scope) = extension_root_scopes.get(root)
5018 {
5019 extension_scope == spec_scope
5020 } else {
5021 false
5022 };
5023
5024 if is_proxy_allowlisted_package(spec) {
5025 return true;
5026 }
5027
5028 if same_scope {
5029 return true;
5030 }
5031
5032 tier != ProxyStubSourceTier::Official
5039}
5040
5041fn is_valid_js_export_name(name: &str) -> bool {
5042 let mut chars = name.chars();
5043 let Some(first) = chars.next() else {
5044 return false;
5045 };
5046 let is_start = first == '_' || first == '$' || first.is_ascii_alphabetic();
5047 if !is_start {
5048 return false;
5049 }
5050 chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
5051}
5052
5053fn generate_proxy_stub_module(spec: &str, named_exports: &BTreeSet<String>) -> String {
5054 let spec_literal = serde_json::to_string(spec).unwrap_or_else(|_| "\"<unknown>\"".to_string());
5055 let mut source = format!(
5056 r"// Auto-generated npm proxy stub (Pattern 4) for {spec_literal}
5057const __pkg = {spec_literal};
5058const __handler = {{
5059 get(_target, prop) {{
5060 if (typeof prop === 'symbol') {{
5061 if (prop === Symbol.toPrimitive) return () => '';
5062 return undefined;
5063 }}
5064 if (prop === '__esModule') return true;
5065 if (prop === 'default') return __stub;
5066 if (prop === 'toString') return () => '';
5067 if (prop === 'valueOf') return () => '';
5068 if (prop === 'name') return __pkg;
5069 // Promise assimilation guard: do not pretend to be then-able.
5070 if (prop === 'then') return undefined;
5071 return __stub;
5072 }},
5073 apply() {{ return __stub; }},
5074 construct() {{ return __stub; }},
5075 has() {{ return false; }},
5076 ownKeys() {{ return []; }},
5077 getOwnPropertyDescriptor() {{
5078 return {{ configurable: true, enumerable: false }};
5079 }},
5080}};
5081const __stub = new Proxy(function __pijs_noop() {{}}, __handler);
5082"
5083 );
5084
5085 for name in named_exports {
5086 if name == "default" || name == "__esModule" || !is_valid_js_export_name(name) {
5087 continue;
5088 }
5089 let _ = writeln!(source, "export const {name} = __stub;");
5090 }
5091
5092 source.push_str("export default __stub;\n");
5093 source.push_str("export const __pijs_proxy_stub = __stub;\n");
5094 source.push_str("export const __esModule = true;\n");
5095 source
5096}
5097
5098fn builtin_specifier_aliases(spec: &str, canonical: &str) -> Vec<String> {
5099 let mut aliases = Vec::new();
5100 let mut seen = HashSet::new();
5101 let mut push_alias = |candidate: &str| {
5102 if candidate.is_empty() {
5103 return;
5104 }
5105 if seen.insert(candidate.to_string()) {
5106 aliases.push(candidate.to_string());
5107 }
5108 };
5109
5110 push_alias(spec);
5111 push_alias(canonical);
5112
5113 if let Some(bare) = spec.strip_prefix("node:") {
5114 push_alias(bare);
5115 }
5116 if let Some(bare) = canonical.strip_prefix("node:") {
5117 push_alias(bare);
5118 }
5119
5120 aliases
5121}
5122
5123fn extract_builtin_import_names(source: &str, spec: &str, canonical: &str) -> BTreeSet<String> {
5124 let mut names = BTreeSet::new();
5125 for alias in builtin_specifier_aliases(spec, canonical) {
5126 for name in extract_import_names(source, &alias) {
5127 if name == "default" || name == "__esModule" {
5128 continue;
5129 }
5130 if is_valid_js_export_name(&name) {
5131 names.insert(name);
5132 }
5133 }
5134 }
5135 names
5136}
5137
5138fn generate_builtin_compat_overlay_module(
5139 canonical: &str,
5140 named_exports: &BTreeSet<String>,
5141) -> String {
5142 let spec_literal =
5143 serde_json::to_string(canonical).unwrap_or_else(|_| "\"node:unknown\"".to_string());
5144 let mut source = format!(
5145 r"// Auto-generated Node builtin compatibility overlay for {canonical}
5146import * as __pijs_builtin_ns from {spec_literal};
5147const __pijs_builtin_default =
5148 __pijs_builtin_ns.default !== undefined ? __pijs_builtin_ns.default : __pijs_builtin_ns;
5149export default __pijs_builtin_default;
5150"
5151 );
5152
5153 for name in named_exports {
5154 if !is_valid_js_export_name(name) || name == "default" || name == "__esModule" {
5155 continue;
5156 }
5157 let _ = writeln!(
5158 source,
5159 "export const {name} = __pijs_builtin_ns.{name} !== undefined ? __pijs_builtin_ns.{name} : (__pijs_builtin_default && __pijs_builtin_default.{name});"
5160 );
5161 }
5162
5163 source.push_str("export const __esModule = true;\n");
5164 source
5165}
5166
5167fn builtin_overlay_module_key(base: &str, canonical: &str) -> String {
5168 let mut hasher = Sha256::new();
5169 hasher.update(base.as_bytes());
5170 let digest = format!("{:x}", hasher.finalize());
5171 let short = &digest[..16];
5172 format!("pijs-compat://builtin/{canonical}/{short}")
5173}
5174
5175fn maybe_register_builtin_compat_overlay(
5176 state: &mut PiJsModuleState,
5177 base: &str,
5178 spec: &str,
5179 canonical: &str,
5180) -> Option<String> {
5181 if !canonical.starts_with("node:") {
5182 return None;
5183 }
5184
5185 let source = std::fs::read_to_string(base).ok()?;
5186 let extracted_names = extract_builtin_import_names(&source, spec, canonical);
5187 if extracted_names.is_empty() {
5188 return None;
5189 }
5190
5191 let overlay_key = builtin_overlay_module_key(base, canonical);
5192 let needs_rebuild = state
5193 .dynamic_virtual_named_exports
5194 .get(&overlay_key)
5195 .is_none_or(|existing| existing != &extracted_names)
5196 || !state.dynamic_virtual_modules.contains_key(&overlay_key);
5197
5198 if needs_rebuild {
5199 state
5200 .dynamic_virtual_named_exports
5201 .insert(overlay_key.clone(), extracted_names.clone());
5202 let overlay = generate_builtin_compat_overlay_module(canonical, &extracted_names);
5203 state
5204 .dynamic_virtual_modules
5205 .insert(overlay_key.clone(), overlay);
5206 if state.compiled_sources.remove(&overlay_key).is_some() {
5207 state.module_cache_counters.invalidations =
5208 state.module_cache_counters.invalidations.saturating_add(1);
5209 }
5210 }
5211
5212 Some(overlay_key)
5213}
5214
5215impl JsModuleResolver for PiJsResolver {
5216 #[allow(clippy::too_many_lines)]
5217 fn resolve(&mut self, _ctx: &Ctx<'_>, base: &str, name: &str) -> rquickjs::Result<String> {
5218 let spec = name.trim();
5219 if spec.is_empty() {
5220 return Err(rquickjs::Error::new_resolving(base, name));
5221 }
5222
5223 let canonical = canonical_node_builtin(spec).unwrap_or(spec);
5225 let compat_scan_mode = is_global_compat_scan_mode();
5226
5227 let repair_mode = {
5228 let mut state = self.state.borrow_mut();
5229 if state.dynamic_virtual_modules.contains_key(canonical)
5230 || state.static_virtual_modules.contains_key(canonical)
5231 {
5232 if compat_scan_mode
5233 && let Some(overlay_key) =
5234 maybe_register_builtin_compat_overlay(&mut state, base, spec, canonical)
5235 {
5236 tracing::debug!(
5237 event = "pijs.compat.builtin_overlay",
5238 base = %base,
5239 specifier = %spec,
5240 canonical = %canonical,
5241 overlay = %overlay_key,
5242 "compat overlay for builtin named imports"
5243 );
5244 return Ok(overlay_key);
5245 }
5246 return Ok(canonical.to_string());
5247 }
5248 state.repair_mode
5249 };
5250
5251 let roots = self.state.borrow().extension_roots.clone();
5252 if let Some(path) = resolve_module_path(base, spec, repair_mode, &roots) {
5253 let canonical = crate::extensions::safe_canonicalize(&path);
5256
5257 let is_safe = roots.iter().any(|root| {
5258 let canonical_root = crate::extensions::safe_canonicalize(root);
5259 canonical.starts_with(&canonical_root)
5260 });
5261
5262 if !is_safe {
5263 tracing::warn!(
5264 event = "pijs.resolve.escape",
5265 base = %base,
5266 specifier = %spec,
5267 resolved = %canonical.display(),
5268 "import resolved to path outside extension roots"
5269 );
5270 return Err(rquickjs::Error::new_resolving(base, name));
5271 }
5272
5273 return Ok(canonical.to_string_lossy().replace('\\', "/"));
5274 }
5275
5276 if spec.starts_with('.') && repair_mode.allows_aggressive() {
5281 let state = self.state.borrow();
5282 let roots = state.extension_roots.clone();
5283 drop(state);
5284
5285 if let Some(escaped_path) = detect_monorepo_escape(base, spec, &roots) {
5286 let source = std::fs::read_to_string(base).unwrap_or_default();
5288 let names = extract_import_names(&source, spec);
5289
5290 let stub = generate_monorepo_stub(&names);
5291 let virtual_key = format!("pijs-repair://monorepo/{}", escaped_path.display());
5292
5293 tracing::info!(
5294 event = "pijs.repair.monorepo_escape",
5295 base = %base,
5296 specifier = %spec,
5297 resolved = %escaped_path.display(),
5298 exports = ?names,
5299 "auto-repair: generated monorepo escape stub"
5300 );
5301
5302 let state = self.state.borrow();
5304 if let Ok(mut events) = state.repair_events.lock() {
5305 events.push(ExtensionRepairEvent {
5306 extension_id: String::new(),
5307 pattern: RepairPattern::MonorepoEscape,
5308 original_error: format!(
5309 "monorepo escape: {} from {base}",
5310 escaped_path.display()
5311 ),
5312 repair_action: format!(
5313 "generated stub with {} exports: {virtual_key}",
5314 names.len()
5315 ),
5316 success: true,
5317 timestamp_ms: 0,
5318 });
5319 }
5320 drop(state);
5321
5322 let mut state = self.state.borrow_mut();
5324 state
5325 .dynamic_virtual_modules
5326 .insert(virtual_key.clone(), stub);
5327 return Ok(virtual_key);
5328 }
5329 }
5330
5331 if is_bare_package_specifier(spec) && (repair_mode.allows_aggressive() || compat_scan_mode)
5339 {
5340 let state = self.state.borrow();
5341 let roots = state.extension_roots.clone();
5342 let tiers = state.extension_root_tiers.clone();
5343 let scopes = state.extension_root_scopes.clone();
5344 drop(state);
5345
5346 if should_auto_stub_package(spec, base, &roots, &tiers, &scopes) {
5347 tracing::info!(
5348 event = "pijs.repair.missing_npm_dep",
5349 base = %base,
5350 specifier = %spec,
5351 "auto-repair: generated proxy stub for missing npm dependency"
5352 );
5353
5354 let source = std::fs::read_to_string(base).unwrap_or_default();
5355 let extracted_names = extract_import_names(&source, spec);
5356 let mut state = self.state.borrow_mut();
5357 let entry_key = spec.to_string();
5358 let mut exports_changed = false;
5359 {
5360 let exports = state
5361 .dynamic_virtual_named_exports
5362 .entry(entry_key.clone())
5363 .or_default();
5364 for name in extracted_names {
5365 exports_changed |= exports.insert(name);
5366 }
5367 }
5368
5369 let export_names = state
5370 .dynamic_virtual_named_exports
5371 .get(&entry_key)
5372 .cloned()
5373 .unwrap_or_default();
5374 if exports_changed || !state.dynamic_virtual_modules.contains_key(spec) {
5375 let stub = generate_proxy_stub_module(spec, &export_names);
5376 state.dynamic_virtual_modules.insert(entry_key, stub);
5377 if state.compiled_sources.remove(spec).is_some() {
5378 state.module_cache_counters.invalidations =
5379 state.module_cache_counters.invalidations.saturating_add(1);
5380 }
5381 }
5382
5383 if let Ok(mut events) = state.repair_events.lock() {
5384 events.push(ExtensionRepairEvent {
5385 extension_id: String::new(),
5386 pattern: RepairPattern::MissingNpmDep,
5387 original_error: format!("missing npm dependency: {spec} from {base}"),
5388 repair_action: format!(
5389 "generated proxy stub for package '{spec}' with {} named export(s)",
5390 export_names.len()
5391 ),
5392 success: true,
5393 timestamp_ms: 0,
5394 });
5395 }
5396
5397 return Ok(spec.to_string());
5398 }
5399 }
5400
5401 Err(rquickjs::Error::new_resolving_message(
5402 base,
5403 name,
5404 unsupported_module_specifier_message(spec),
5405 ))
5406 }
5407}
5408
5409#[derive(Clone, Debug)]
5410struct PiJsLoader {
5411 state: Rc<RefCell<PiJsModuleState>>,
5412}
5413
5414impl JsModuleLoader for PiJsLoader {
5415 fn load<'js>(
5416 &mut self,
5417 ctx: &Ctx<'js>,
5418 name: &str,
5419 ) -> rquickjs::Result<Module<'js, JsModuleDeclared>> {
5420 let source = {
5421 let mut state = self.state.borrow_mut();
5422 load_compiled_module_source(&mut state, name)?
5423 };
5424
5425 Module::declare(ctx.clone(), name, source)
5426 }
5427}
5428
5429fn compile_module_source(
5430 static_virtual_modules: &HashMap<String, String>,
5431 dynamic_virtual_modules: &HashMap<String, String>,
5432 name: &str,
5433) -> rquickjs::Result<Vec<u8>> {
5434 if let Some(source) = dynamic_virtual_modules
5435 .get(name)
5436 .or_else(|| static_virtual_modules.get(name))
5437 {
5438 return Ok(prefix_import_meta_url(name, source));
5439 }
5440
5441 let path = Path::new(name);
5442 if !path.is_file() {
5443 return Err(rquickjs::Error::new_loading_message(
5444 name,
5445 "Module is not a file",
5446 ));
5447 }
5448
5449 let metadata = fs::metadata(path)
5450 .map_err(|err| rquickjs::Error::new_loading_message(name, format!("metadata: {err}")))?;
5451 if metadata.len() > MAX_MODULE_SOURCE_BYTES {
5452 return Err(rquickjs::Error::new_loading_message(
5453 name,
5454 format!(
5455 "Module source exceeds size limit: {} > {}",
5456 metadata.len(),
5457 MAX_MODULE_SOURCE_BYTES
5458 ),
5459 ));
5460 }
5461
5462 let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
5463 let raw = fs::read_to_string(path)
5464 .map_err(|err| rquickjs::Error::new_loading_message(name, format!("read: {err}")))?;
5465
5466 let compiled = match extension {
5467 "ts" | "tsx" => {
5468 let transpiled = transpile_typescript_module(&raw, name).map_err(|message| {
5469 rquickjs::Error::new_loading_message(name, format!("transpile: {message}"))
5470 })?;
5471 rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&transpiled))
5472 }
5473 "js" | "mjs" => rewrite_legacy_private_identifiers(&maybe_cjs_to_esm(&raw)),
5474 "json" => json_module_to_esm(&raw, name).map_err(|message| {
5475 rquickjs::Error::new_loading_message(name, format!("json: {message}"))
5476 })?,
5477 other => {
5478 return Err(rquickjs::Error::new_loading_message(
5479 name,
5480 format!("Unsupported module extension: {other}"),
5481 ));
5482 }
5483 };
5484
5485 Ok(prefix_import_meta_url(name, &compiled))
5486}
5487
5488fn module_cache_key(
5489 static_virtual_modules: &HashMap<String, String>,
5490 dynamic_virtual_modules: &HashMap<String, String>,
5491 name: &str,
5492) -> Option<String> {
5493 if let Some(source) = dynamic_virtual_modules
5494 .get(name)
5495 .or_else(|| static_virtual_modules.get(name))
5496 {
5497 let mut hasher = Sha256::new();
5498 hasher.update(b"virtual\0");
5499 hasher.update(name.as_bytes());
5500 hasher.update(b"\0");
5501 hasher.update(source.as_bytes());
5502 return Some(format!("v:{:x}", hasher.finalize()));
5503 }
5504
5505 let path = Path::new(name);
5506 if !path.is_file() {
5507 return None;
5508 }
5509
5510 let metadata = fs::metadata(path).ok()?;
5511 let modified_nanos = metadata
5512 .modified()
5513 .ok()
5514 .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
5515 .map_or(0, |duration| duration.as_nanos());
5516
5517 Some(format!("f:{name}:{}:{modified_nanos}", metadata.len()))
5518}
5519
5520fn disk_cache_path(cache_dir: &Path, cache_key: &str) -> PathBuf {
5529 let mut hasher = Sha256::new();
5530 hasher.update(cache_key.as_bytes());
5531 let hex = format!("{:x}", hasher.finalize());
5532 let prefix = &hex[..2];
5533 cache_dir.join(prefix).join(format!("{hex}.js"))
5534}
5535
5536fn try_load_from_disk_cache(cache_dir: &Path, cache_key: &str) -> Option<Vec<u8>> {
5538 let path = disk_cache_path(cache_dir, cache_key);
5539 fs::read(path).ok()
5540}
5541
5542fn store_to_disk_cache(cache_dir: &Path, cache_key: &str, source: &[u8]) {
5544 let path = disk_cache_path(cache_dir, cache_key);
5545 if let Some(parent) = path.parent() {
5546 if let Err(err) = fs::create_dir_all(parent) {
5547 tracing::debug!(event = "pijs.module_cache.disk.mkdir_failed", path = %parent.display(), %err);
5548 return;
5549 }
5550 }
5551
5552 let temp_path = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4().simple()));
5553 if let Err(err) = fs::write(&temp_path, source) {
5554 tracing::debug!(event = "pijs.module_cache.disk.write_failed", path = %temp_path.display(), %err);
5555 return;
5556 }
5557
5558 if let Err(err) = fs::rename(&temp_path, &path) {
5559 tracing::debug!(event = "pijs.module_cache.disk.rename_failed", from = %temp_path.display(), to = %path.display(), %err);
5560 let _ = fs::remove_file(&temp_path);
5561 }
5562}
5563
5564fn load_compiled_module_source(
5565 state: &mut PiJsModuleState,
5566 name: &str,
5567) -> rquickjs::Result<Vec<u8>> {
5568 let cache_key = module_cache_key(
5569 &state.static_virtual_modules,
5570 &state.dynamic_virtual_modules,
5571 name,
5572 );
5573
5574 if let Some(cached) = state.compiled_sources.get(name) {
5576 if cached.cache_key == cache_key {
5577 state.module_cache_counters.hits = state.module_cache_counters.hits.saturating_add(1);
5578 return Ok(cached.source.to_vec());
5579 }
5580
5581 state.module_cache_counters.invalidations =
5582 state.module_cache_counters.invalidations.saturating_add(1);
5583 }
5584
5585 if let Some(cache_key_str) = cache_key.as_deref()
5587 && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5588 && let Some(disk_cached) = try_load_from_disk_cache(cache_dir, cache_key_str)
5589 {
5590 state.module_cache_counters.disk_hits =
5591 state.module_cache_counters.disk_hits.saturating_add(1);
5592 let source: Arc<[u8]> = disk_cached.into();
5593 state.compiled_sources.insert(
5594 name.to_string(),
5595 CompiledModuleCacheEntry {
5596 cache_key,
5597 source: Arc::clone(&source),
5598 },
5599 );
5600 return Ok(source.to_vec());
5601 }
5602
5603 state.module_cache_counters.misses = state.module_cache_counters.misses.saturating_add(1);
5605 let compiled = compile_module_source(
5606 &state.static_virtual_modules,
5607 &state.dynamic_virtual_modules,
5608 name,
5609 )?;
5610 let source: Arc<[u8]> = compiled.into();
5611 state.compiled_sources.insert(
5612 name.to_string(),
5613 CompiledModuleCacheEntry {
5614 cache_key: cache_key.clone(),
5615 source: Arc::clone(&source),
5616 },
5617 );
5618
5619 if let Some(cache_key_str) = cache_key.as_deref()
5621 && let Some(cache_dir) = state.disk_cache_dir.as_deref()
5622 {
5623 store_to_disk_cache(cache_dir, cache_key_str, &source);
5624 }
5625
5626 Ok(source.to_vec())
5627}
5628
5629#[derive(Debug, Clone)]
5649pub struct WarmIsolatePool {
5650 template: PiJsRuntimeConfig,
5652 created_count: Arc<AtomicU64>,
5654 reset_count: Arc<AtomicU64>,
5656}
5657
5658impl WarmIsolatePool {
5659 pub fn new(template: PiJsRuntimeConfig) -> Self {
5661 Self {
5662 template,
5663 created_count: Arc::new(AtomicU64::new(0)),
5664 reset_count: Arc::new(AtomicU64::new(0)),
5665 }
5666 }
5667
5668 pub fn make_config(&self) -> PiJsRuntimeConfig {
5670 self.created_count.fetch_add(1, AtomicOrdering::Relaxed);
5671 self.template.clone()
5672 }
5673
5674 pub fn record_reset(&self) {
5676 self.reset_count.fetch_add(1, AtomicOrdering::Relaxed);
5677 }
5678
5679 pub fn created_count(&self) -> u64 {
5681 self.created_count.load(AtomicOrdering::Relaxed)
5682 }
5683
5684 pub fn reset_count(&self) -> u64 {
5686 self.reset_count.load(AtomicOrdering::Relaxed)
5687 }
5688}
5689
5690impl Default for WarmIsolatePool {
5691 fn default() -> Self {
5692 Self::new(PiJsRuntimeConfig::default())
5693 }
5694}
5695
5696fn prefix_import_meta_url(module_name: &str, body: &str) -> Vec<u8> {
5697 let url = if module_name.starts_with('/') {
5698 format!("file://{module_name}")
5699 } else if module_name.starts_with("file://") {
5700 module_name.to_string()
5701 } else if module_name.len() > 2
5702 && module_name.as_bytes()[1] == b':'
5703 && (module_name.as_bytes()[2] == b'/' || module_name.as_bytes()[2] == b'\\')
5704 {
5705 format!("file:///{module_name}")
5707 } else {
5708 format!("pi://{module_name}")
5709 };
5710 let url_literal = serde_json::to_string(&url).unwrap_or_else(|_| "\"\"".to_string());
5711 format!("import.meta.url = {url_literal};\n{body}").into_bytes()
5712}
5713
5714fn resolve_module_path(
5715 base: &str,
5716 specifier: &str,
5717 repair_mode: RepairMode,
5718 roots: &[PathBuf],
5719) -> Option<PathBuf> {
5720 let specifier = specifier.trim();
5721 if specifier.is_empty() {
5722 return None;
5723 }
5724
5725 if let Some(path) = specifier.strip_prefix("file://") {
5726 let resolved = resolve_existing_file(PathBuf::from(path))?;
5727 if !roots.is_empty() {
5728 let canonical = crate::extensions::safe_canonicalize(&resolved);
5729 let allowed = roots.iter().any(|root| {
5730 let canonical_root = crate::extensions::safe_canonicalize(root);
5731 canonical.starts_with(&canonical_root)
5732 });
5733 if !allowed {
5734 tracing::warn!(
5735 event = "pijs.resolve.monotonicity_violation",
5736 resolved = %resolved.display(),
5737 "resolution blocked: file:// path escapes extension root"
5738 );
5739 return None;
5740 }
5741 }
5742 return Some(resolved);
5743 }
5744
5745 let path = if specifier.starts_with('/') {
5746 PathBuf::from(specifier)
5747 } else if specifier.len() > 2
5748 && specifier.as_bytes()[1] == b':'
5749 && (specifier.as_bytes()[2] == b'/' || specifier.as_bytes()[2] == b'\\')
5750 {
5751 PathBuf::from(specifier)
5753 } else if specifier.starts_with('.') {
5754 let base_path = Path::new(base);
5755 let base_dir = base_path.parent()?;
5756 base_dir.join(specifier)
5757 } else {
5758 return None;
5759 };
5760
5761 if !roots.is_empty() {
5765 let canonical = crate::extensions::safe_canonicalize(&path);
5766 let allowed = roots.iter().any(|root| {
5767 let canonical_root = crate::extensions::safe_canonicalize(root);
5768 canonical.starts_with(&canonical_root)
5769 });
5770
5771 if !allowed {
5772 return None;
5773 }
5774 }
5775
5776 if let Some(resolved) = resolve_existing_module_candidate(path.clone()) {
5777 if !roots.is_empty() {
5781 let canonical_resolved = crate::extensions::safe_canonicalize(&resolved);
5782 let allowed = roots.iter().any(|root| {
5783 let canonical_root = crate::extensions::safe_canonicalize(root);
5784 canonical_resolved.starts_with(&canonical_root)
5785 });
5786
5787 if !allowed {
5788 tracing::warn!(
5789 event = "pijs.resolve.monotonicity_violation",
5790 original = %path.display(),
5791 resolved = %resolved.display(),
5792 "resolution blocked: resolved path escapes extension root"
5793 );
5794 return None;
5795 }
5796 }
5797 return Some(resolved);
5798 }
5799
5800 if repair_mode.should_apply() {
5804 try_dist_to_src_fallback(&path)
5805 } else {
5806 if repair_mode == RepairMode::Suggest {
5807 if let Some(resolved) = try_dist_to_src_fallback(&path) {
5809 tracing::info!(
5810 event = "pijs.repair.suggest",
5811 pattern = "dist_to_src",
5812 original = %path.display(),
5813 resolved = %resolved.display(),
5814 "repair suggestion: would resolve dist/ → src/ (mode=suggest)"
5815 );
5816 }
5817 }
5818 None
5819 }
5820}
5821
5822fn try_dist_to_src_fallback(path: &Path) -> Option<PathBuf> {
5827 let path_str = path.to_string_lossy();
5828 let idx = path_str.find("/dist/")?;
5829
5830 let extension_root = PathBuf::from(&path_str[..idx]);
5832
5833 let src_path = format!("{}/src/{}", &path_str[..idx], &path_str[idx + 6..]);
5834
5835 let candidate = PathBuf::from(&src_path);
5836
5837 if let Some(resolved) = resolve_existing_module_candidate(candidate) {
5838 let verdict = verify_repair_monotonicity(&extension_root, path, &resolved);
5841 if !verdict.is_safe() {
5842 tracing::warn!(
5843 event = "pijs.repair.monotonicity_violation",
5844 original = %path_str,
5845 resolved = %resolved.display(),
5846 verdict = ?verdict,
5847 "repair blocked: resolved path escapes extension root"
5848 );
5849 return None;
5850 }
5851
5852 let structural = validate_repaired_artifact(&resolved);
5855 if !structural.is_valid() {
5856 tracing::warn!(
5857 event = "pijs.repair.structural_validation_failed",
5858 original = %path_str,
5859 resolved = %resolved.display(),
5860 verdict = %structural,
5861 "repair blocked: resolved artifact failed structural validation"
5862 );
5863 return None;
5864 }
5865
5866 tracing::info!(
5867 event = "pijs.repair.dist_to_src",
5868 original = %path_str,
5869 resolved = %resolved.display(),
5870 "auto-repair: resolved dist/ → src/ fallback"
5871 );
5872 return Some(resolved);
5873 }
5874
5875 None
5876}
5877
5878fn resolve_existing_file(path: PathBuf) -> Option<PathBuf> {
5879 if path.is_file() {
5880 return Some(path);
5881 }
5882 None
5883}
5884
5885fn resolve_existing_module_candidate(path: PathBuf) -> Option<PathBuf> {
5886 if path.is_file() {
5887 return Some(path);
5888 }
5889
5890 if path.is_dir() {
5891 for candidate in [
5892 "index.ts",
5893 "index.tsx",
5894 "index.js",
5895 "index.mjs",
5896 "index.json",
5897 ] {
5898 let full = path.join(candidate);
5899 if full.is_file() {
5900 return Some(full);
5901 }
5902 }
5903 return None;
5904 }
5905
5906 let extension = path.extension().and_then(|ext| ext.to_str());
5907 match extension {
5908 Some("js" | "mjs") => {
5909 for ext in ["ts", "tsx"] {
5910 let fallback = path.with_extension(ext);
5911 if fallback.is_file() {
5912 return Some(fallback);
5913 }
5914 }
5915 }
5916 None => {
5917 for ext in ["ts", "tsx", "js", "mjs", "json"] {
5918 let candidate = path.with_extension(ext);
5919 if candidate.is_file() {
5920 return Some(candidate);
5921 }
5922 }
5923 }
5924 _ => {}
5925 }
5926
5927 None
5928}
5929
5930static IMPORT_NAMES_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
5937
5938fn import_names_regex() -> &'static regex::Regex {
5939 IMPORT_NAMES_RE.get_or_init(|| {
5940 regex::Regex::new(r#"(?ms)import\s+(?:[^{};]*?,\s*)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]"#)
5941 .expect("import names regex")
5942 })
5943}
5944
5945static REQUIRE_DESTRUCTURE_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
5948
5949fn require_destructure_regex() -> &'static regex::Regex {
5950 REQUIRE_DESTRUCTURE_RE.get_or_init(|| {
5951 regex::Regex::new(
5952 r#"(?m)(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]"#,
5953 )
5954 .expect("require destructure regex")
5955 })
5956}
5957
5958fn detect_monorepo_escape(
5961 base: &str,
5962 specifier: &str,
5963 extension_roots: &[PathBuf],
5964) -> Option<PathBuf> {
5965 if !specifier.starts_with('.') {
5966 return None;
5967 }
5968 let base_dir = Path::new(base).parent()?;
5969 let resolved = base_dir.join(specifier);
5970
5971 let effective = std::fs::canonicalize(&resolved)
5974 .map(crate::extensions::strip_unc_prefix)
5975 .or_else(|_| {
5976 resolved
5977 .parent()
5978 .and_then(|p| {
5979 std::fs::canonicalize(p)
5980 .map(crate::extensions::strip_unc_prefix)
5981 .ok()
5982 })
5983 .map(|p| p.join(resolved.file_name().unwrap_or_default()))
5984 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no parent"))
5985 })
5986 .unwrap_or_else(|_| resolved.clone());
5987
5988 for root in extension_roots {
5989 if effective.starts_with(root) {
5990 return None; }
5992 }
5993
5994 Some(resolved)
5995}
5996
5997pub fn extract_import_names(source: &str, specifier: &str) -> Vec<String> {
6003 let mut names = Vec::new();
6004 let re_esm = import_names_regex();
6005 let re_cjs = require_destructure_regex();
6006
6007 for cap in re_esm.captures_iter(source) {
6008 let spec_in_source = &cap[2];
6009 if spec_in_source != specifier {
6010 continue;
6011 }
6012 parse_import_list(&cap[1], &mut names);
6013 }
6014
6015 for cap in re_cjs.captures_iter(source) {
6016 let spec_in_source = &cap[2];
6017 if spec_in_source != specifier {
6018 continue;
6019 }
6020 parse_import_list(&cap[1], &mut names);
6021 }
6022
6023 names.sort();
6024 names.dedup();
6025 names
6026}
6027
6028fn parse_import_list(raw: &str, out: &mut Vec<String>) {
6030 for token in raw.split(',') {
6031 let token = token.trim();
6032 if token.is_empty() {
6033 continue;
6034 }
6035 if token.starts_with("type ") || token.starts_with("type\t") {
6037 continue;
6038 }
6039 let name = token.split_whitespace().next().unwrap_or(token).trim();
6041 if !name.is_empty() {
6042 out.push(name.to_string());
6043 }
6044 }
6045}
6046
6047pub fn generate_monorepo_stub(names: &[String]) -> String {
6057 let mut lines = Vec::with_capacity(names.len() + 1);
6058 lines.push("// Auto-generated monorepo escape stub (Pattern 3)".to_string());
6059
6060 for name in names {
6061 if !is_valid_js_export_name(name) {
6062 continue;
6063 }
6064
6065 let export = if name == "default" {
6066 "export default () => {};".to_string()
6067 } else if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && !name.is_empty() {
6068 format!("export const {name} = [];")
6070 } else if name.starts_with("is") || name.starts_with("has") || name.starts_with("check") {
6071 format!("export const {name} = () => false;")
6072 } else if name.starts_with("get")
6073 || name.starts_with("detect")
6074 || name.starts_with("find")
6075 || name.starts_with("create")
6076 || name.starts_with("make")
6077 {
6078 format!("export const {name} = () => ({{}});")
6079 } else if name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) {
6080 format!("export class {name} {{}}")
6082 } else {
6083 format!("export const {name} = () => {{}};")
6085 };
6086 lines.push(export);
6087 }
6088
6089 lines.join("\n")
6090}
6091
6092fn source_declares_binding(source: &str, name: &str) -> bool {
6093 let patterns = [
6094 format!("const {name}"),
6095 format!("let {name}"),
6096 format!("var {name}"),
6097 format!("function {name}"),
6098 format!("class {name}"),
6099 format!("export const {name}"),
6100 format!("export let {name}"),
6101 format!("export var {name}"),
6102 format!("export function {name}"),
6103 format!("export class {name}"),
6104 ];
6105 source.lines().any(|line| {
6106 let trimmed = line.trim_start();
6107 if trimmed.starts_with("//") || trimmed.starts_with('*') {
6108 return false;
6109 }
6110 patterns.iter().any(|pattern| trimmed.starts_with(pattern))
6111 })
6112}
6113
6114#[allow(clippy::too_many_lines)]
6120fn extract_static_require_specifiers(source: &str) -> Vec<String> {
6121 const REQUIRE: &[u8] = b"require";
6122
6123 let bytes = source.as_bytes();
6124 let mut out = Vec::new();
6125 let mut seen = HashSet::new();
6126
6127 let mut i = 0usize;
6128 let mut in_line_comment = false;
6129 let mut in_block_comment = false;
6130 let mut in_single = false;
6131 let mut in_double = false;
6132 let mut in_template = false;
6133 let mut escaped = false;
6134
6135 while i < bytes.len() {
6136 let b = bytes[i];
6137
6138 if in_line_comment {
6139 if b == b'\n' {
6140 in_line_comment = false;
6141 }
6142 i += 1;
6143 continue;
6144 }
6145
6146 if in_block_comment {
6147 if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
6148 in_block_comment = false;
6149 i += 2;
6150 } else {
6151 i += 1;
6152 }
6153 continue;
6154 }
6155
6156 if in_single {
6157 if escaped {
6158 escaped = false;
6159 } else if b == b'\\' {
6160 escaped = true;
6161 } else if b == b'\'' {
6162 in_single = false;
6163 }
6164 i += 1;
6165 continue;
6166 }
6167
6168 if in_double {
6169 if escaped {
6170 escaped = false;
6171 } else if b == b'\\' {
6172 escaped = true;
6173 } else if b == b'"' {
6174 in_double = false;
6175 }
6176 i += 1;
6177 continue;
6178 }
6179
6180 if in_template {
6181 if escaped {
6182 escaped = false;
6183 } else if b == b'\\' {
6184 escaped = true;
6185 } else if b == b'`' {
6186 in_template = false;
6187 }
6188 i += 1;
6189 continue;
6190 }
6191
6192 if b == b'/' && i + 1 < bytes.len() {
6193 match bytes[i + 1] {
6194 b'/' => {
6195 in_line_comment = true;
6196 i += 2;
6197 continue;
6198 }
6199 b'*' => {
6200 in_block_comment = true;
6201 i += 2;
6202 continue;
6203 }
6204 _ => {}
6205 }
6206 }
6207
6208 if b == b'\'' {
6209 in_single = true;
6210 i += 1;
6211 continue;
6212 }
6213 if b == b'"' {
6214 in_double = true;
6215 i += 1;
6216 continue;
6217 }
6218 if b == b'`' {
6219 in_template = true;
6220 i += 1;
6221 continue;
6222 }
6223
6224 if i + REQUIRE.len() <= bytes.len() && &bytes[i..i + REQUIRE.len()] == REQUIRE {
6225 let has_ident_before = i > 0 && is_js_ident_continue(bytes[i - 1]);
6226 let after_ident_idx = i + REQUIRE.len();
6227 let has_ident_after =
6228 after_ident_idx < bytes.len() && is_js_ident_continue(bytes[after_ident_idx]);
6229 if has_ident_before || has_ident_after {
6230 i += 1;
6231 continue;
6232 }
6233
6234 let mut j = after_ident_idx;
6235 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6236 j += 1;
6237 }
6238 if j >= bytes.len() || bytes[j] != b'(' {
6239 i += 1;
6240 continue;
6241 }
6242
6243 j += 1;
6244 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6245 j += 1;
6246 }
6247 if j >= bytes.len() || (bytes[j] != b'"' && bytes[j] != b'\'') {
6248 i += 1;
6249 continue;
6250 }
6251
6252 let quote = bytes[j];
6253 let spec_start = j + 1;
6254 j += 1;
6255 let mut lit_escaped = false;
6256 while j < bytes.len() {
6257 let c = bytes[j];
6258 if lit_escaped {
6259 lit_escaped = false;
6260 j += 1;
6261 continue;
6262 }
6263 if c == b'\\' {
6264 lit_escaped = true;
6265 j += 1;
6266 continue;
6267 }
6268 if c == quote {
6269 break;
6270 }
6271 j += 1;
6272 }
6273 if j >= bytes.len() {
6274 break;
6275 }
6276
6277 let spec = &source[spec_start..j];
6278 j += 1;
6279 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
6280 j += 1;
6281 }
6282 if j < bytes.len() && bytes[j] == b')' && seen.insert(spec.to_string()) {
6283 out.push(spec.to_string());
6284 i = j + 1;
6285 continue;
6286 }
6287 }
6288
6289 i += 1;
6290 }
6291
6292 out
6293}
6294
6295#[allow(clippy::too_many_lines)]
6304fn maybe_cjs_to_esm(source: &str) -> String {
6305 let has_require = source.contains("require(");
6306 let has_module_exports = source.contains("module.exports")
6307 || source.contains("module[\"exports\"]")
6308 || source.contains("module['exports']");
6309 let has_exports_usage = source.contains("exports.") || source.contains("exports[");
6310 let has_dirname_refs = source.contains("__dirname") || source.contains("__filename");
6311
6312 if !has_require && !has_module_exports && !has_exports_usage && !has_dirname_refs {
6313 return source.to_string();
6314 }
6315
6316 let has_esm = source.lines().any(|line| {
6317 let trimmed = line.trim();
6318 (trimmed.starts_with("import ") || trimmed.starts_with("export "))
6319 && !trimmed.starts_with("//")
6320 });
6321 let has_export_default = source.contains("export default");
6322
6323 let specifiers = extract_static_require_specifiers(source);
6325
6326 if specifiers.is_empty() && !has_module_exports && !has_exports_usage && !has_dirname_refs {
6327 return source.to_string();
6328 }
6329 if specifiers.is_empty()
6330 && has_esm
6331 && !has_module_exports
6332 && !has_exports_usage
6333 && !has_dirname_refs
6334 {
6335 return source.to_string();
6336 }
6337
6338 let mut output = String::with_capacity(source.len() + 512);
6339
6340 for (i, spec) in specifiers.iter().enumerate() {
6342 let _ = writeln!(output, "import * as __cjs_req_{i} from {spec:?};");
6343 }
6344
6345 let has_require_binding = source_declares_binding(source, "require");
6347 if !specifiers.is_empty() && !has_require_binding {
6348 output.push_str("const __cjs_req_map = {");
6349 for (i, spec) in specifiers.iter().enumerate() {
6350 if i > 0 {
6351 output.push(',');
6352 }
6353 let _ = write!(output, "\n {spec:?}: __cjs_req_{i}");
6354 }
6355 output.push_str("\n};\n");
6356 output.push_str(
6357 "function require(s) {\n\
6358 \x20 const m = __cjs_req_map[s];\n\
6359 \x20 if (!m) throw new Error('Cannot find module: ' + s);\n\
6360 \x20 return m.default !== undefined && typeof m.default === 'object' \
6361 ? m.default : m;\n\
6362 }\n",
6363 );
6364 }
6365
6366 let has_filename_binding = source_declares_binding(source, "__filename");
6367 let has_dirname_binding = source_declares_binding(source, "__dirname");
6368 let has_module_binding = source_declares_binding(source, "module");
6369 let has_exports_binding = source_declares_binding(source, "exports");
6370 let needs_filename = has_dirname_refs && !has_filename_binding;
6371 let needs_dirname = has_dirname_refs && !has_dirname_binding;
6372 let needs_module = (has_module_exports || has_exports_usage) && !has_module_binding;
6373 let needs_exports = (has_module_exports || has_exports_usage) && !has_exports_binding;
6374
6375 if needs_filename || needs_dirname || needs_module || needs_exports {
6376 if needs_filename {
6378 output.push_str(
6379 "const __filename = (() => {\n\
6380 \x20 try { return new URL(import.meta.url).pathname || ''; } catch { return ''; }\n\
6381 })();\n",
6382 );
6383 }
6384 if needs_dirname {
6385 output.push_str(
6386 "const __dirname = __filename ? __filename.replace(/[/\\\\][^/\\\\]*$/, '') : '.';\n",
6387 );
6388 }
6389 if needs_module {
6390 output.push_str("const module = { exports: {} };\n");
6391 }
6392 if needs_exports {
6393 output.push_str("const exports = module.exports;\n");
6394 }
6395 }
6396
6397 output.push_str(source);
6398 output.push('\n');
6399
6400 if !has_export_default && (!has_esm || has_module_exports || has_exports_usage) {
6401 output.push_str("export default module.exports;\n");
6403 }
6404
6405 output
6406}
6407
6408const fn is_js_ident_start(byte: u8) -> bool {
6409 (byte as char).is_ascii_alphabetic() || byte == b'_' || byte == b'$'
6410}
6411
6412const fn is_js_ident_continue(byte: u8) -> bool {
6413 is_js_ident_start(byte) || (byte as char).is_ascii_digit()
6414}
6415
6416#[allow(clippy::too_many_lines)]
6420fn rewrite_legacy_private_identifiers(source: &str) -> String {
6421 if !source.contains('#') || !source.is_ascii() {
6422 return source.to_string();
6423 }
6424
6425 let bytes = source.as_bytes();
6426 let mut out = String::with_capacity(source.len() + 32);
6427 let mut i = 0usize;
6428 let mut in_single = false;
6429 let mut in_double = false;
6430 let mut in_template = false;
6431 let mut escaped = false;
6432 let mut line_comment = false;
6433 let mut block_comment = false;
6434
6435 while i < bytes.len() {
6436 let b = bytes[i];
6437 let next = bytes.get(i + 1).copied();
6438
6439 if line_comment {
6440 out.push(b as char);
6441 if b == b'\n' {
6442 line_comment = false;
6443 }
6444 i += 1;
6445 continue;
6446 }
6447
6448 if block_comment {
6449 if b == b'*' && next == Some(b'/') {
6450 out.push('*');
6451 out.push('/');
6452 i += 2;
6453 block_comment = false;
6454 continue;
6455 }
6456 out.push(b as char);
6457 i += 1;
6458 continue;
6459 }
6460
6461 if in_single {
6462 out.push(b as char);
6463 if escaped {
6464 escaped = false;
6465 } else if b == b'\\' {
6466 escaped = true;
6467 } else if b == b'\'' {
6468 in_single = false;
6469 }
6470 i += 1;
6471 continue;
6472 }
6473
6474 if in_double {
6475 out.push(b as char);
6476 if escaped {
6477 escaped = false;
6478 } else if b == b'\\' {
6479 escaped = true;
6480 } else if b == b'"' {
6481 in_double = false;
6482 }
6483 i += 1;
6484 continue;
6485 }
6486
6487 if in_template {
6488 out.push(b as char);
6489 if escaped {
6490 escaped = false;
6491 } else if b == b'\\' {
6492 escaped = true;
6493 } else if b == b'`' {
6494 in_template = false;
6495 }
6496 i += 1;
6497 continue;
6498 }
6499
6500 if b == b'/' && next == Some(b'/') {
6501 line_comment = true;
6502 out.push('/');
6503 i += 1;
6504 continue;
6505 }
6506 if b == b'/' && next == Some(b'*') {
6507 block_comment = true;
6508 out.push('/');
6509 i += 1;
6510 continue;
6511 }
6512 if b == b'\'' {
6513 in_single = true;
6514 out.push('\'');
6515 i += 1;
6516 continue;
6517 }
6518 if b == b'"' {
6519 in_double = true;
6520 out.push('"');
6521 i += 1;
6522 continue;
6523 }
6524 if b == b'`' {
6525 in_template = true;
6526 out.push('`');
6527 i += 1;
6528 continue;
6529 }
6530
6531 if b == b'#' && next.is_some_and(is_js_ident_start) {
6532 let prev_is_ident = i > 0 && is_js_ident_continue(bytes[i - 1]);
6533 if !prev_is_ident {
6534 out.push_str("__pijs_private_");
6535 i += 1;
6536 while i < bytes.len() && is_js_ident_continue(bytes[i]) {
6537 out.push(bytes[i] as char);
6538 i += 1;
6539 }
6540 continue;
6541 }
6542 }
6543
6544 out.push(b as char);
6545 i += 1;
6546 }
6547
6548 out
6549}
6550
6551fn json_module_to_esm(raw: &str, name: &str) -> std::result::Result<String, String> {
6552 let value: serde_json::Value =
6553 serde_json::from_str(raw).map_err(|err| format!("parse {name}: {err}"))?;
6554 let literal = serde_json::to_string(&value).map_err(|err| format!("encode {name}: {err}"))?;
6555 Ok(format!("export default {literal};\n"))
6556}
6557
6558fn transpile_typescript_module(source: &str, name: &str) -> std::result::Result<String, String> {
6559 let globals = Globals::new();
6560 GLOBALS.set(&globals, || {
6561 let cm: Lrc<SourceMap> = Lrc::default();
6562 let fm = cm.new_source_file(
6563 FileName::Custom(name.to_string()).into(),
6564 source.to_string(),
6565 );
6566
6567 let syntax = Syntax::Typescript(TsSyntax {
6568 tsx: Path::new(name)
6569 .extension()
6570 .is_some_and(|ext| ext.eq_ignore_ascii_case("tsx")),
6571 decorators: true,
6572 ..Default::default()
6573 });
6574
6575 let mut parser = SwcParser::new(syntax, StringInput::from(&*fm), None);
6576 let module: SwcModule = parser
6577 .parse_module()
6578 .map_err(|err| format!("parse {name}: {err:?}"))?;
6579
6580 let unresolved_mark = Mark::new();
6581 let top_level_mark = Mark::new();
6582 let mut program = SwcProgram::Module(module);
6583 {
6584 let mut pass = resolver(unresolved_mark, top_level_mark, false);
6585 pass.process(&mut program);
6586 }
6587 {
6588 let mut pass = strip(unresolved_mark, top_level_mark);
6589 pass.process(&mut program);
6590 }
6591 let SwcProgram::Module(module) = program else {
6592 return Err(format!("transpile {name}: expected module"));
6593 };
6594
6595 let mut buf = Vec::new();
6596 {
6597 let mut emitter = Emitter {
6598 cfg: swc_ecma_codegen::Config::default(),
6599 comments: None,
6600 cm: cm.clone(),
6601 wr: JsWriter::new(cm, "\n", &mut buf, None),
6602 };
6603 emitter
6604 .emit_module(&module)
6605 .map_err(|err| format!("emit {name}: {err}"))?;
6606 }
6607
6608 String::from_utf8(buf).map_err(|err| format!("utf8 {name}: {err}"))
6609 })
6610}
6611
6612#[allow(clippy::too_many_lines)]
6616fn build_node_os_module() -> String {
6617 let node_platform = match std::env::consts::OS {
6619 "macos" => "darwin",
6620 "windows" => "win32",
6621 other => other, };
6623 let node_arch = match std::env::consts::ARCH {
6624 "x86_64" => "x64",
6625 "aarch64" => "arm64",
6626 "x86" => "ia32",
6627 "arm" => "arm",
6628 other => other,
6629 };
6630 let node_type = match std::env::consts::OS {
6631 "linux" => "Linux",
6632 "macos" => "Darwin",
6633 "windows" => "Windows_NT",
6634 other => other,
6635 };
6636 let tmpdir = std::env::temp_dir()
6638 .display()
6639 .to_string()
6640 .replace('\\', "\\\\");
6641 let homedir = std::env::var("HOME")
6642 .or_else(|_| std::env::var("USERPROFILE"))
6643 .unwrap_or_else(|_| "/home/unknown".to_string())
6644 .replace('\\', "\\\\");
6645 let hostname = std::fs::read_to_string("/etc/hostname")
6647 .ok()
6648 .map(|s| s.trim().to_string())
6649 .filter(|s| !s.is_empty())
6650 .or_else(|| std::env::var("HOSTNAME").ok())
6651 .or_else(|| std::env::var("COMPUTERNAME").ok())
6652 .unwrap_or_else(|| "localhost".to_string());
6653 let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
6654 let eol = if cfg!(windows) { "\\r\\n" } else { "\\n" };
6655 let dev_null = if cfg!(windows) {
6656 "\\\\\\\\.\\\\NUL"
6657 } else {
6658 "/dev/null"
6659 };
6660 let username = std::env::var("USER")
6661 .or_else(|_| std::env::var("USERNAME"))
6662 .unwrap_or_else(|_| "unknown".to_string());
6663 let shell = std::env::var("SHELL").unwrap_or_else(|_| {
6664 if cfg!(windows) {
6665 "cmd.exe".to_string()
6666 } else {
6667 "/bin/sh".to_string()
6668 }
6669 });
6670 let (uid, gid) = read_proc_uid_gid().unwrap_or((1000, 1000));
6672
6673 format!(
6677 r#"
6678const _platform = "{node_platform}";
6679const _arch = "{node_arch}";
6680const _type = "{node_type}";
6681const _tmpdir = "{tmpdir}";
6682const _homedir = "{homedir}";
6683const _hostname = "{hostname}";
6684const _eol = "{eol}";
6685const _devNull = "{dev_null}";
6686const _uid = {uid};
6687const _gid = {gid};
6688const _username = "{username}";
6689const _shell = "{shell}";
6690const _numCpus = {num_cpus};
6691const _cpus = [];
6692for (let i = 0; i < _numCpus; i++) _cpus.push({{ model: "cpu", speed: 2400, times: {{ user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }} }});
6693
6694export function homedir() {{
6695 const env_home =
6696 globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
6697 ? globalThis.pi.env.get("HOME")
6698 : undefined;
6699 return env_home || _homedir;
6700}}
6701export function tmpdir() {{ return _tmpdir; }}
6702export function hostname() {{ return _hostname; }}
6703export function platform() {{ return _platform; }}
6704export function arch() {{ return _arch; }}
6705export function type() {{ return _type; }}
6706export function release() {{ return "6.0.0"; }}
6707export function cpus() {{ return _cpus; }}
6708export function totalmem() {{ return 8 * 1024 * 1024 * 1024; }}
6709export function freemem() {{ return 4 * 1024 * 1024 * 1024; }}
6710export function uptime() {{ return Math.floor(Date.now() / 1000); }}
6711export function loadavg() {{ return [0.0, 0.0, 0.0]; }}
6712export function networkInterfaces() {{ return {{}}; }}
6713export function userInfo(_options) {{
6714 return {{
6715 uid: _uid,
6716 gid: _gid,
6717 username: _username,
6718 homedir: homedir(),
6719 shell: _shell,
6720 }};
6721}}
6722export function endianness() {{ return "LE"; }}
6723export const EOL = _eol;
6724export const devNull = _devNull;
6725export const constants = {{
6726 signals: {{}},
6727 errno: {{}},
6728 priority: {{ PRIORITY_LOW: 19, PRIORITY_BELOW_NORMAL: 10, PRIORITY_NORMAL: 0, PRIORITY_ABOVE_NORMAL: -7, PRIORITY_HIGH: -14, PRIORITY_HIGHEST: -20 }},
6729}};
6730export default {{ homedir, tmpdir, hostname, platform, arch, type, release, cpus, totalmem, freemem, uptime, loadavg, networkInterfaces, userInfo, endianness, EOL, devNull, constants }};
6731"#
6732 )
6733 .trim()
6734 .to_string()
6735}
6736
6737fn read_proc_uid_gid() -> Option<(u32, u32)> {
6740 let status = std::fs::read_to_string("/proc/self/status").ok()?;
6741 let mut uid = None;
6742 let mut gid = None;
6743 for line in status.lines() {
6744 if let Some(rest) = line.strip_prefix("Uid:") {
6745 uid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
6746 } else if let Some(rest) = line.strip_prefix("Gid:") {
6747 gid = rest.split_whitespace().next().and_then(|v| v.parse().ok());
6748 }
6749 if uid.is_some() && gid.is_some() {
6750 break;
6751 }
6752 }
6753 Some((uid?, gid?))
6754}
6755
6756#[allow(clippy::too_many_lines)]
6757fn default_virtual_modules() -> HashMap<String, String> {
6758 let mut modules = HashMap::new();
6759
6760 modules.insert(
6761 "@sinclair/typebox".to_string(),
6762 r#"
6763export const Type = {
6764 String: (opts = {}) => ({ type: "string", ...opts }),
6765 Number: (opts = {}) => ({ type: "number", ...opts }),
6766 Boolean: (opts = {}) => ({ type: "boolean", ...opts }),
6767 Array: (items, opts = {}) => ({ type: "array", items, ...opts }),
6768 Object: (props = {}, opts = {}) => {
6769 const required = [];
6770 const properties = {};
6771 for (const [k, v] of Object.entries(props)) {
6772 if (v && typeof v === "object" && v.__pi_optional) {
6773 properties[k] = v.schema;
6774 } else {
6775 properties[k] = v;
6776 required.push(k);
6777 }
6778 }
6779 const out = { type: "object", properties, ...opts };
6780 if (required.length) out.required = required;
6781 return out;
6782 },
6783 Optional: (schema) => ({ __pi_optional: true, schema }),
6784 Literal: (value, opts = {}) => ({ const: value, ...opts }),
6785 Any: (opts = {}) => ({ ...opts }),
6786 Union: (schemas, opts = {}) => ({ anyOf: schemas, ...opts }),
6787 Enum: (values, opts = {}) => ({ enum: values, ...opts }),
6788 Integer: (opts = {}) => ({ type: "integer", ...opts }),
6789 Null: (opts = {}) => ({ type: "null", ...opts }),
6790 Unknown: (opts = {}) => ({ ...opts }),
6791 Tuple: (items, opts = {}) => ({ type: "array", items, minItems: items.length, maxItems: items.length, ...opts }),
6792 Record: (keySchema, valueSchema, opts = {}) => ({ type: "object", additionalProperties: valueSchema, ...opts }),
6793 Ref: (ref, opts = {}) => ({ $ref: ref, ...opts }),
6794 Intersect: (schemas, opts = {}) => ({ allOf: schemas, ...opts }),
6795};
6796export default { Type };
6797"#
6798 .trim()
6799 .to_string(),
6800 );
6801
6802 modules.insert(
6803 "@mariozechner/pi-ai".to_string(),
6804 r#"
6805export function StringEnum(values, opts = {}) {
6806 const list = Array.isArray(values) ? values.map((v) => String(v)) : [];
6807 return { type: "string", enum: list, ...opts };
6808}
6809
6810export function calculateCost() {}
6811
6812export function createAssistantMessageEventStream() {
6813 return {
6814 push: () => {},
6815 end: () => {},
6816 };
6817}
6818
6819export function streamSimpleAnthropic() {
6820 throw new Error("@mariozechner/pi-ai.streamSimpleAnthropic is not available in PiJS");
6821}
6822
6823export function streamSimpleOpenAIResponses() {
6824 throw new Error("@mariozechner/pi-ai.streamSimpleOpenAIResponses is not available in PiJS");
6825}
6826
6827export async function complete(_model, _messages, _opts = {}) {
6828 // Return a minimal completion response stub
6829 return { content: "", model: _model ?? "unknown", usage: { input_tokens: 0, output_tokens: 0 } };
6830}
6831
6832// Stub: completeSimple returns a simple text completion without streaming
6833export async function completeSimple(_model, _prompt, _opts = {}) {
6834 // Return an empty string completion
6835 return "";
6836}
6837
6838export function getModel() {
6839 // Return a default model identifier
6840 return "claude-sonnet-4-5";
6841}
6842
6843export function getApiProvider() {
6844 // Return a default provider identifier
6845 return "anthropic";
6846}
6847
6848export function getModels() {
6849 // Return a list of available model identifiers
6850 return ["claude-sonnet-4-5", "claude-haiku-3-5"];
6851}
6852
6853export async function loginOpenAICodex(_opts = {}) {
6854 return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
6855}
6856
6857export async function refreshOpenAICodexToken(_refreshToken) {
6858 return { accessToken: "", refreshToken: "", expiresAt: Date.now() + 3600000 };
6859}
6860
6861export default { StringEnum, calculateCost, createAssistantMessageEventStream, streamSimpleAnthropic, streamSimpleOpenAIResponses, complete, completeSimple, getModel, getApiProvider, getModels, loginOpenAICodex, refreshOpenAICodexToken };
6862"#
6863 .trim()
6864 .to_string(),
6865 );
6866
6867 modules.insert(
6868 "@mariozechner/pi-tui".to_string(),
6869 r#"
6870export function matchesKey(_data, _key) {
6871 return false;
6872}
6873
6874export function truncateToWidth(text, width) {
6875 const s = String(text ?? "");
6876 const w = Number(width ?? 0);
6877 if (!w || w <= 0) return "";
6878 return s.length <= w ? s : s.slice(0, w);
6879}
6880
6881export class Text {
6882 constructor(text, x = 0, y = 0) {
6883 this.text = String(text ?? "");
6884 this.x = x;
6885 this.y = y;
6886 }
6887}
6888
6889export class TruncatedText extends Text {
6890 constructor(text, width = 80, x = 0, y = 0) {
6891 super(text, x, y);
6892 this.width = Number(width ?? 80);
6893 }
6894}
6895
6896export class Container {
6897 constructor(..._args) {}
6898}
6899
6900export class Markdown {
6901 constructor(..._args) {}
6902}
6903
6904export class Spacer {
6905 constructor(..._args) {}
6906}
6907
6908export function visibleWidth(str) {
6909 return String(str ?? "").length;
6910}
6911
6912export function wrapTextWithAnsi(text, _width) {
6913 return String(text ?? "");
6914}
6915
6916export class Editor {
6917 constructor(_opts = {}) {
6918 this.value = "";
6919 }
6920}
6921
6922export const CURSOR_MARKER = "▌";
6923
6924export function isKeyRelease(_data) {
6925 return false;
6926}
6927
6928export function parseKey(key) {
6929 return { key: String(key ?? "") };
6930}
6931
6932export class Box {
6933 constructor(_padX = 0, _padY = 0, _styleFn = null) {
6934 this.children = [];
6935 }
6936
6937 addChild(child) {
6938 this.children.push(child);
6939 }
6940}
6941
6942export class SelectList {
6943 constructor(items = [], _opts = {}) {
6944 this.items = Array.isArray(items) ? items : [];
6945 this.selected = 0;
6946 }
6947
6948 setItems(items) {
6949 this.items = Array.isArray(items) ? items : [];
6950 }
6951
6952 select(index) {
6953 const i = Number(index ?? 0);
6954 this.selected = Number.isFinite(i) ? i : 0;
6955 }
6956}
6957
6958export class Input {
6959 constructor(_opts = {}) {
6960 this.value = "";
6961 }
6962}
6963
6964export const Key = {
6965 // Special keys
6966 escape: "escape",
6967 esc: "esc",
6968 enter: "enter",
6969 tab: "tab",
6970 space: "space",
6971 backspace: "backspace",
6972 delete: "delete",
6973 home: "home",
6974 end: "end",
6975 pageUp: "pageUp",
6976 pageDown: "pageDown",
6977 up: "up",
6978 down: "down",
6979 left: "left",
6980 right: "right",
6981 // Single modifiers
6982 ctrl: (key) => `ctrl+${key}`,
6983 shift: (key) => `shift+${key}`,
6984 alt: (key) => `alt+${key}`,
6985 // Combined modifiers
6986 ctrlShift: (key) => `ctrl+shift+${key}`,
6987 shiftCtrl: (key) => `shift+ctrl+${key}`,
6988 ctrlAlt: (key) => `ctrl+alt+${key}`,
6989 altCtrl: (key) => `alt+ctrl+${key}`,
6990 shiftAlt: (key) => `shift+alt+${key}`,
6991 altShift: (key) => `alt+shift+${key}`,
6992 ctrlAltShift: (key) => `ctrl+alt+shift+${key}`,
6993};
6994
6995export class DynamicBorder {
6996 constructor(_styleFn = null) {
6997 this.styleFn = _styleFn;
6998 }
6999}
7000
7001export class SettingsList {
7002 constructor(_opts = {}) {
7003 this.items = [];
7004 }
7005
7006 setItems(items) {
7007 this.items = Array.isArray(items) ? items : [];
7008 }
7009}
7010
7011// Fuzzy string matching for filtering lists
7012export function fuzzyMatch(query, text, _opts = {}) {
7013 const q = String(query ?? '').toLowerCase();
7014 const t = String(text ?? '').toLowerCase();
7015 if (!q) return { match: true, score: 0, positions: [] };
7016 if (!t) return { match: false, score: 0, positions: [] };
7017
7018 const positions = [];
7019 let qi = 0;
7020 for (let ti = 0; ti < t.length && qi < q.length; ti++) {
7021 if (t[ti] === q[qi]) {
7022 positions.push(ti);
7023 qi++;
7024 }
7025 }
7026
7027 const match = qi === q.length;
7028 const score = match ? (q.length / t.length) * 100 : 0;
7029 return { match, score, positions };
7030}
7031
7032// Get editor keybindings configuration
7033export function getEditorKeybindings() {
7034 return {
7035 save: 'ctrl+s',
7036 quit: 'ctrl+q',
7037 copy: 'ctrl+c',
7038 paste: 'ctrl+v',
7039 undo: 'ctrl+z',
7040 redo: 'ctrl+y',
7041 find: 'ctrl+f',
7042 replace: 'ctrl+h',
7043 };
7044}
7045
7046// Filter an array of items using fuzzy matching
7047export function fuzzyFilter(query, items, _opts = {}) {
7048 const q = String(query ?? '').toLowerCase();
7049 if (!q) return items;
7050 if (!Array.isArray(items)) return [];
7051 return items.filter(item => {
7052 const text = typeof item === 'string' ? item : String(item?.label ?? item?.name ?? item);
7053 return fuzzyMatch(q, text).match;
7054 });
7055}
7056
7057// Cancellable loader widget - shows loading state with optional cancel
7058export class CancellableLoader {
7059 constructor(message = 'Loading...', opts = {}) {
7060 this.message = String(message ?? 'Loading...');
7061 this.cancelled = false;
7062 this.onCancel = opts.onCancel ?? null;
7063 }
7064
7065 cancel() {
7066 this.cancelled = true;
7067 if (typeof this.onCancel === 'function') {
7068 this.onCancel();
7069 }
7070 }
7071
7072 render() {
7073 return this.cancelled ? [] : [this.message];
7074 }
7075}
7076
7077export class Image {
7078 constructor(src, _opts = {}) {
7079 this.src = String(src ?? "");
7080 this.width = 0;
7081 this.height = 0;
7082 }
7083}
7084
7085export default { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, Text, TruncatedText, Container, Markdown, Spacer, Editor, Box, SelectList, Input, Image, CURSOR_MARKER, isKeyRelease, parseKey, Key, DynamicBorder, SettingsList, fuzzyMatch, getEditorKeybindings, fuzzyFilter, CancellableLoader };
7086"#
7087 .trim()
7088 .to_string(),
7089 );
7090
7091 modules.insert(
7092 "@mariozechner/pi-coding-agent".to_string(),
7093 r#"
7094export const VERSION = "0.0.0";
7095
7096export const DEFAULT_MAX_LINES = 2000;
7097export const DEFAULT_MAX_BYTES = 50 * 1024;
7098
7099export function formatSize(bytes) {
7100 const b = Number(bytes ?? 0);
7101 const KB = 1024;
7102 const MB = 1024 * 1024;
7103 if (b >= MB) return `${(b / MB).toFixed(1)}MB`;
7104 if (b >= KB) return `${(b / KB).toFixed(1)}KB`;
7105 return `${Math.trunc(b)}B`;
7106}
7107
7108function jsBytes(value) {
7109 return String(value ?? "").length;
7110}
7111
7112export function truncateHead(text, opts = {}) {
7113 const raw = String(text ?? "");
7114 const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7115 const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7116
7117 const lines = raw.split("\n");
7118 const totalLines = lines.length;
7119 const totalBytes = jsBytes(raw);
7120
7121 const out = [];
7122 let outBytes = 0;
7123 let truncatedBy = null;
7124
7125 for (const line of lines) {
7126 if (out.length >= maxLines) {
7127 truncatedBy = "lines";
7128 break;
7129 }
7130
7131 const candidate = out.length ? `\n${line}` : line;
7132 const candidateBytes = jsBytes(candidate);
7133 if (outBytes + candidateBytes > maxBytes) {
7134 truncatedBy = "bytes";
7135 break;
7136 }
7137 out.push(line);
7138 outBytes += candidateBytes;
7139 }
7140
7141 const content = out.join("\n");
7142 return {
7143 content,
7144 truncated: truncatedBy != null,
7145 truncatedBy,
7146 totalLines,
7147 totalBytes,
7148 outputLines: out.length,
7149 outputBytes: jsBytes(content),
7150 lastLinePartial: false,
7151 firstLineExceedsLimit: false,
7152 maxLines,
7153 maxBytes,
7154 };
7155}
7156
7157export function truncateTail(text, opts = {}) {
7158 const raw = String(text ?? "");
7159 const maxLines = Number(opts.maxLines ?? DEFAULT_MAX_LINES);
7160 const maxBytes = Number(opts.maxBytes ?? DEFAULT_MAX_BYTES);
7161
7162 const lines = raw.split("\n");
7163 const totalLines = lines.length;
7164 const totalBytes = jsBytes(raw);
7165
7166 const out = [];
7167 let outBytes = 0;
7168 let truncatedBy = null;
7169
7170 for (let i = lines.length - 1; i >= 0; i--) {
7171 if (out.length >= maxLines) {
7172 truncatedBy = "lines";
7173 break;
7174 }
7175 const line = lines[i];
7176 const candidate = out.length ? `${line}\n` : line;
7177 const candidateBytes = jsBytes(candidate);
7178 if (outBytes + candidateBytes > maxBytes) {
7179 truncatedBy = "bytes";
7180 break;
7181 }
7182 out.unshift(line);
7183 outBytes += candidateBytes;
7184 }
7185
7186 const content = out.join("\n");
7187 return {
7188 content,
7189 truncated: truncatedBy != null,
7190 truncatedBy,
7191 totalLines,
7192 totalBytes,
7193 outputLines: out.length,
7194 outputBytes: jsBytes(content),
7195 lastLinePartial: false,
7196 firstLineExceedsLimit: false,
7197 maxLines,
7198 maxBytes,
7199 };
7200}
7201
7202export function parseSessionEntries(text) {
7203 const raw = String(text ?? "");
7204 const out = [];
7205 for (const line of raw.split(/\r?\n/)) {
7206 const trimmed = line.trim();
7207 if (!trimmed) continue;
7208 try {
7209 out.push(JSON.parse(trimmed));
7210 } catch {
7211 // ignore malformed lines
7212 }
7213 }
7214 return out;
7215}
7216
7217export function convertToLlm(entries) {
7218 return entries;
7219}
7220
7221export function serializeConversation(entries) {
7222 try {
7223 return JSON.stringify(entries ?? []);
7224 } catch {
7225 return String(entries ?? "");
7226 }
7227}
7228
7229export function parseFrontmatter(text) {
7230 const raw = String(text ?? "");
7231 if (!raw.startsWith("---")) return { frontmatter: {}, body: raw };
7232 const end = raw.indexOf("\n---", 3);
7233 if (end === -1) return { frontmatter: {}, body: raw };
7234
7235 const header = raw.slice(3, end).trim();
7236 const body = raw.slice(end + 4).replace(/^\n/, "");
7237 const frontmatter = {};
7238 for (const line of header.split(/\r?\n/)) {
7239 const idx = line.indexOf(":");
7240 if (idx === -1) continue;
7241 const key = line.slice(0, idx).trim();
7242 const val = line.slice(idx + 1).trim();
7243 if (!key) continue;
7244 frontmatter[key] = val;
7245 }
7246 return { frontmatter, body };
7247}
7248
7249export function getMarkdownTheme() {
7250 return {};
7251}
7252
7253export function getSettingsListTheme() {
7254 return {};
7255}
7256
7257export function getSelectListTheme() {
7258 return {};
7259}
7260
7261export class DynamicBorder {
7262 constructor(..._args) {}
7263}
7264
7265export class BorderedLoader {
7266 constructor(..._args) {}
7267}
7268
7269export class CustomEditor {
7270 constructor(_opts = {}) {
7271 this.value = "";
7272 }
7273
7274 handleInput(_data) {}
7275
7276 render(_width) {
7277 return [];
7278 }
7279}
7280
7281export function createBashTool(_cwd, _opts = {}) {
7282 return {
7283 name: "bash",
7284 label: "bash",
7285 description: "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
7286 parameters: {
7287 type: "object",
7288 properties: {
7289 command: { type: "string", description: "The bash command to execute" },
7290 timeout: { type: "number", description: "Optional timeout in seconds" },
7291 },
7292 required: ["command"],
7293 },
7294 async execute(_id, params) {
7295 return { content: [{ type: "text", text: String(params?.command ?? "") }], details: {} };
7296 },
7297 };
7298}
7299
7300export function createReadTool(_cwd, _opts = {}) {
7301 return {
7302 name: "read",
7303 label: "read",
7304 description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
7305 parameters: {
7306 type: "object",
7307 properties: {
7308 path: { type: "string", description: "The path to the file to read" },
7309 offset: { type: "number", description: "Line offset to start reading from (0-indexed)" },
7310 limit: { type: "number", description: "Maximum number of lines to read" },
7311 },
7312 required: ["path"],
7313 },
7314 async execute(_id, _params) {
7315 return { content: [{ type: "text", text: "" }], details: {} };
7316 },
7317 };
7318}
7319
7320export function createLsTool(_cwd, _opts = {}) {
7321 return {
7322 name: "ls",
7323 label: "ls",
7324 description: "List files and directories. Returns names, sizes, and metadata.",
7325 parameters: {
7326 type: "object",
7327 properties: {
7328 path: { type: "string", description: "The path to list" },
7329 },
7330 required: ["path"],
7331 },
7332 async execute(_id, _params) {
7333 return { content: [{ type: "text", text: "" }], details: {} };
7334 },
7335 };
7336}
7337
7338export function createGrepTool(_cwd, _opts = {}) {
7339 return {
7340 name: "grep",
7341 label: "grep",
7342 description: "Search file contents using regular expressions.",
7343 parameters: {
7344 type: "object",
7345 properties: {
7346 pattern: { type: "string", description: "The regex pattern to search for" },
7347 path: { type: "string", description: "The path to search in" },
7348 },
7349 required: ["pattern"],
7350 },
7351 async execute(_id, _params) {
7352 return { content: [{ type: "text", text: "" }], details: {} };
7353 },
7354 };
7355}
7356
7357export function createWriteTool(_cwd, _opts = {}) {
7358 return {
7359 name: "write",
7360 label: "write",
7361 description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
7362 parameters: {
7363 type: "object",
7364 properties: {
7365 path: { type: "string", description: "The path to the file to write" },
7366 content: { type: "string", description: "The content to write to the file" },
7367 },
7368 required: ["path", "content"],
7369 },
7370 async execute(_id, _params) {
7371 return { content: [{ type: "text", text: "" }], details: {} };
7372 },
7373 };
7374}
7375
7376export function createEditTool(_cwd, _opts = {}) {
7377 return {
7378 name: "edit",
7379 label: "edit",
7380 description: "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
7381 parameters: {
7382 type: "object",
7383 properties: {
7384 path: { type: "string", description: "The path to the file to edit" },
7385 oldText: { type: "string", minLength: 1, description: "The exact text to find and replace" },
7386 newText: { type: "string", description: "The text to replace oldText with" },
7387 },
7388 required: ["path", "oldText", "newText"],
7389 },
7390 async execute(_id, _params) {
7391 return { content: [{ type: "text", text: "" }], details: {} };
7392 },
7393 };
7394}
7395
7396export function copyToClipboard(_text) {
7397 return;
7398}
7399
7400export function getAgentDir() {
7401 const home =
7402 globalThis.pi && globalThis.pi.env && typeof globalThis.pi.env.get === "function"
7403 ? globalThis.pi.env.get("HOME")
7404 : undefined;
7405 return home ? `${home}/.pi/agent` : "/home/unknown/.pi/agent";
7406}
7407
7408// Stub: keyHint returns a keyboard shortcut hint string for UI display
7409export function keyHint(action, fallback = "") {
7410 // Map action names to default key bindings
7411 const keyMap = {
7412 expandTools: "Ctrl+E",
7413 copy: "Ctrl+C",
7414 paste: "Ctrl+V",
7415 save: "Ctrl+S",
7416 quit: "Ctrl+Q",
7417 help: "?",
7418 };
7419 return keyMap[action] || fallback || action;
7420}
7421
7422// Stub: compact performs conversation compaction via LLM
7423export async function compact(_preparation, _model, _apiKey, _customInstructions, _signal) {
7424 // Return a minimal compaction result
7425 return {
7426 summary: "Conversation summary placeholder",
7427 firstKeptEntryId: null,
7428 tokensBefore: 0,
7429 tokensAfter: 0,
7430 };
7431}
7432
7433/// Stub: AssistantMessageComponent for rendering assistant messages
7434export class AssistantMessageComponent {
7435 constructor(message, editable = false) {
7436 this.message = message;
7437 this.editable = editable;
7438 }
7439
7440 render() {
7441 return [];
7442 }
7443}
7444
7445// Stub: ToolExecutionComponent for rendering tool executions
7446export class ToolExecutionComponent {
7447 constructor(toolName, args, opts = {}, result, ui) {
7448 this.toolName = toolName;
7449 this.args = args;
7450 this.opts = opts;
7451 this.result = result;
7452 this.ui = ui;
7453 }
7454
7455 render() {
7456 return [];
7457 }
7458}
7459
7460// Stub: UserMessageComponent for rendering user messages
7461export class UserMessageComponent {
7462 constructor(text) {
7463 this.text = text;
7464 }
7465
7466 render() {
7467 return [];
7468 }
7469}
7470
7471export class SessionManager {
7472 constructor() {}
7473 static inMemory() { return new SessionManager(); }
7474 getSessionFile() { return ""; }
7475 getSessionDir() { return ""; }
7476 getSessionId() { return ""; }
7477}
7478
7479export class SettingsManager {
7480 constructor(cwd = "", agentDir = "") {
7481 this.cwd = String(cwd ?? "");
7482 this.agentDir = String(agentDir ?? "");
7483 }
7484 static create(cwd, agentDir) { return new SettingsManager(cwd, agentDir); }
7485}
7486
7487export class DefaultResourceLoader {
7488 constructor(opts = {}) {
7489 this.opts = opts;
7490 }
7491 async reload() { return; }
7492}
7493
7494export function highlightCode(code, _lang, _theme) {
7495 return String(code ?? "");
7496}
7497
7498export function getLanguageFromPath(filePath) {
7499 const ext = String(filePath ?? "").split(".").pop() || "";
7500 const map = { ts: "typescript", js: "javascript", py: "python", rs: "rust", go: "go", md: "markdown", json: "json", html: "html", css: "css", sh: "bash" };
7501 return map[ext] || ext;
7502}
7503
7504export function isBashToolResult(result) {
7505 return result && typeof result === "object" && result.name === "bash";
7506}
7507
7508export async function loadSkills() {
7509 return [];
7510}
7511
7512export function truncateToVisualLines(text, maxLines = DEFAULT_MAX_LINES) {
7513 const raw = String(text ?? "");
7514 const lines = raw.split(/\r?\n/);
7515 if (!Number.isFinite(maxLines) || maxLines <= 0) return "";
7516 return lines.slice(0, Math.floor(maxLines)).join("\n");
7517}
7518
7519export function estimateTokens(input) {
7520 const raw = typeof input === "string" ? input : JSON.stringify(input ?? "");
7521 // Deterministic rough heuristic (chars / 4).
7522 return Math.max(1, Math.ceil(String(raw).length / 4));
7523}
7524
7525export function isToolCallEventType(value) {
7526 const t = String(value?.type ?? value ?? "").toLowerCase();
7527 return t === "tool_call" || t === "tool-call" || t === "toolcall";
7528}
7529
7530export class AuthStorage {
7531 constructor() {}
7532 static load() { return new AuthStorage(); }
7533 static async loadAsync() { return new AuthStorage(); }
7534 resolveApiKey(_provider) { return undefined; }
7535 get(_provider) { return undefined; }
7536}
7537
7538export function createAgentSession(opts = {}) {
7539 const state = {
7540 id: String(opts.id ?? "session"),
7541 messages: Array.isArray(opts.messages) ? opts.messages.slice() : [],
7542 };
7543 return {
7544 id: state.id,
7545 messages: state.messages,
7546 append(entry) { state.messages.push(entry); },
7547 toJSON() { return { id: state.id, messages: state.messages.slice() }; },
7548 };
7549}
7550
7551export default {
7552 VERSION,
7553 DEFAULT_MAX_LINES,
7554 DEFAULT_MAX_BYTES,
7555 formatSize,
7556 truncateHead,
7557 truncateTail,
7558 parseSessionEntries,
7559 convertToLlm,
7560 serializeConversation,
7561 parseFrontmatter,
7562 getMarkdownTheme,
7563 getSettingsListTheme,
7564 getSelectListTheme,
7565 DynamicBorder,
7566 BorderedLoader,
7567 CustomEditor,
7568 createBashTool,
7569 createReadTool,
7570 createLsTool,
7571 createGrepTool,
7572 createWriteTool,
7573 createEditTool,
7574 copyToClipboard,
7575 getAgentDir,
7576 keyHint,
7577 compact,
7578 AssistantMessageComponent,
7579 ToolExecutionComponent,
7580 UserMessageComponent,
7581 SessionManager,
7582 SettingsManager,
7583 DefaultResourceLoader,
7584 highlightCode,
7585 getLanguageFromPath,
7586 isBashToolResult,
7587 loadSkills,
7588 truncateToVisualLines,
7589 estimateTokens,
7590 isToolCallEventType,
7591 AuthStorage,
7592 createAgentSession,
7593};
7594"#
7595 .trim()
7596 .to_string(),
7597 );
7598
7599 modules.insert(
7600 "@anthropic-ai/sdk".to_string(),
7601 r"
7602export default class Anthropic {
7603 constructor(_opts = {}) {}
7604}
7605"
7606 .trim()
7607 .to_string(),
7608 );
7609
7610 modules.insert(
7611 "@anthropic-ai/sandbox-runtime".to_string(),
7612 r"
7613export const SandboxManager = {
7614 initialize: async (_config) => {},
7615 reset: async () => {},
7616};
7617export default { SandboxManager };
7618"
7619 .trim()
7620 .to_string(),
7621 );
7622
7623 modules.insert(
7624 "ms".to_string(),
7625 r#"
7626function parseMs(text) {
7627 const s = String(text ?? "").trim();
7628 if (!s) return undefined;
7629
7630 const match = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w|y)?$/i);
7631 if (!match) return undefined;
7632 const value = Number(match[1]);
7633 const unit = (match[2] || "ms").toLowerCase();
7634 const mult = unit === "ms" ? 1 :
7635 unit === "s" ? 1000 :
7636 unit === "m" ? 60000 :
7637 unit === "h" ? 3600000 :
7638 unit === "d" ? 86400000 :
7639 unit === "w" ? 604800000 :
7640 unit === "y" ? 31536000000 : 1;
7641 return Math.round(value * mult);
7642}
7643
7644export default function ms(value) {
7645 return parseMs(value);
7646}
7647
7648export const parse = parseMs;
7649"#
7650 .trim()
7651 .to_string(),
7652 );
7653
7654 modules.insert(
7655 "jsonwebtoken".to_string(),
7656 r#"
7657export function sign() {
7658 throw new Error("jsonwebtoken.sign is not available in PiJS");
7659}
7660
7661export function verify() {
7662 throw new Error("jsonwebtoken.verify is not available in PiJS");
7663}
7664
7665export function decode() {
7666 return null;
7667}
7668
7669export default { sign, verify, decode };
7670"#
7671 .trim()
7672 .to_string(),
7673 );
7674
7675 modules.insert(
7677 "shell-quote".to_string(),
7678 r#"
7679export function parse(cmd) {
7680 if (typeof cmd !== 'string') return [];
7681 const args = [];
7682 let current = '';
7683 let inSingle = false;
7684 let inDouble = false;
7685 let escaped = false;
7686 for (let i = 0; i < cmd.length; i++) {
7687 const ch = cmd[i];
7688 if (escaped) { current += ch; escaped = false; continue; }
7689 if (ch === '\\' && !inSingle) { escaped = true; continue; }
7690 if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
7691 if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
7692 if ((ch === ' ' || ch === '\t') && !inSingle && !inDouble) {
7693 if (current) { args.push(current); current = ''; }
7694 continue;
7695 }
7696 current += ch;
7697 }
7698 if (current) args.push(current);
7699 return args;
7700}
7701export function quote(args) {
7702 if (!Array.isArray(args)) return '';
7703 return args.map(a => {
7704 if (/[^a-zA-Z0-9_\-=:./]/.test(a)) return "'" + a.replace(/'/g, "'\\''") + "'";
7705 return a;
7706 }).join(' ');
7707}
7708export default { parse, quote };
7709"#
7710 .trim()
7711 .to_string(),
7712 );
7713
7714 {
7716 let vls = r"
7717export const DiagnosticSeverity = { Error: 1, Warning: 2, Information: 3, Hint: 4 };
7718export const CodeActionKind = { QuickFix: 'quickfix', Refactor: 'refactor', RefactorExtract: 'refactor.extract', RefactorInline: 'refactor.inline', RefactorRewrite: 'refactor.rewrite', Source: 'source', SourceOrganizeImports: 'source.organizeImports', SourceFixAll: 'source.fixAll' };
7719export const DocumentDiagnosticReportKind = { Full: 'full', Unchanged: 'unchanged' };
7720export const SymbolKind = { File: 1, Module: 2, Namespace: 3, Package: 4, Class: 5, Method: 6, Property: 7, Field: 8, Constructor: 9, Enum: 10, Interface: 11, Function: 12, Variable: 13, Constant: 14 };
7721function makeReqType(m) { return { type: { get method() { return m; } }, method: m }; }
7722function makeNotifType(m) { return { type: { get method() { return m; } }, method: m }; }
7723export const InitializeRequest = makeReqType('initialize');
7724export const DefinitionRequest = makeReqType('textDocument/definition');
7725export const ReferencesRequest = makeReqType('textDocument/references');
7726export const HoverRequest = makeReqType('textDocument/hover');
7727export const SignatureHelpRequest = makeReqType('textDocument/signatureHelp');
7728export const DocumentSymbolRequest = makeReqType('textDocument/documentSymbol');
7729export const RenameRequest = makeReqType('textDocument/rename');
7730export const CodeActionRequest = makeReqType('textDocument/codeAction');
7731export const DocumentDiagnosticRequest = makeReqType('textDocument/diagnostic');
7732export const WorkspaceDiagnosticRequest = makeReqType('workspace/diagnostic');
7733export const InitializedNotification = makeNotifType('initialized');
7734export const DidOpenTextDocumentNotification = makeNotifType('textDocument/didOpen');
7735export const DidChangeTextDocumentNotification = makeNotifType('textDocument/didChange');
7736export const DidCloseTextDocumentNotification = makeNotifType('textDocument/didClose');
7737export const DidSaveTextDocumentNotification = makeNotifType('textDocument/didSave');
7738export const PublishDiagnosticsNotification = makeNotifType('textDocument/publishDiagnostics');
7739export function createMessageConnection(_reader, _writer) {
7740 return {
7741 listen() {},
7742 sendRequest() { return Promise.resolve(null); },
7743 sendNotification() {},
7744 onNotification() {},
7745 onRequest() {},
7746 onClose() {},
7747 dispose() {},
7748 };
7749}
7750export class StreamMessageReader { constructor(_s) {} }
7751export class StreamMessageWriter { constructor(_s) {} }
7752"
7753 .trim()
7754 .to_string();
7755
7756 modules.insert("vscode-languageserver-protocol".to_string(), vls.clone());
7757 modules.insert(
7758 "vscode-languageserver-protocol/node.js".to_string(),
7759 vls.clone(),
7760 );
7761 modules.insert("vscode-languageserver-protocol/node".to_string(), vls);
7762 }
7763
7764 {
7766 let mcp_client = r"
7767export class Client {
7768 constructor(_opts = {}) {}
7769 async connect(_transport) {}
7770 async listTools() { return { tools: [] }; }
7771 async listResources() { return { resources: [] }; }
7772 async callTool(_name, _args) { return { content: [] }; }
7773 async close() {}
7774}
7775"
7776 .trim()
7777 .to_string();
7778
7779 let mcp_transport = r"
7780export class StdioClientTransport {
7781 constructor(_opts = {}) {}
7782 async start() {}
7783 async close() {}
7784}
7785"
7786 .trim()
7787 .to_string();
7788
7789 modules.insert(
7790 "@modelcontextprotocol/sdk/client/index.js".to_string(),
7791 mcp_client.clone(),
7792 );
7793 modules.insert(
7794 "@modelcontextprotocol/sdk/client/index".to_string(),
7795 mcp_client,
7796 );
7797 modules.insert(
7798 "@modelcontextprotocol/sdk/client/stdio.js".to_string(),
7799 mcp_transport,
7800 );
7801 modules.insert(
7802 "@modelcontextprotocol/sdk/client/streamableHttp.js".to_string(),
7803 r"
7804export class StreamableHTTPClientTransport {
7805 constructor(_opts = {}) {}
7806 async start() {}
7807 async close() {}
7808}
7809"
7810 .trim()
7811 .to_string(),
7812 );
7813 modules.insert(
7814 "@modelcontextprotocol/sdk/client/sse.js".to_string(),
7815 r"
7816export class SSEClientTransport {
7817 constructor(_opts = {}) {}
7818 async start() {}
7819 async close() {}
7820}
7821"
7822 .trim()
7823 .to_string(),
7824 );
7825 }
7826
7827 modules.insert(
7829 "glob".to_string(),
7830 r#"
7831export function globSync(pattern, _opts = {}) { return []; }
7832export function glob(pattern, optsOrCb, cb) {
7833 const callback = typeof optsOrCb === "function" ? optsOrCb : cb;
7834 if (typeof callback === "function") callback(null, []);
7835 return Promise.resolve([]);
7836}
7837export class Glob {
7838 constructor(_pattern, _opts = {}) { this.found = []; }
7839 on() { return this; }
7840}
7841export default { globSync, glob, Glob };
7842"#
7843 .trim()
7844 .to_string(),
7845 );
7846
7847 modules.insert(
7849 "uuid".to_string(),
7850 r#"
7851function randomHex(n) {
7852 let out = "";
7853 for (let i = 0; i < n; i++) out += Math.floor(Math.random() * 16).toString(16);
7854 return out;
7855}
7856export function v4() {
7857 return [randomHex(8), randomHex(4), "4" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
7858}
7859export function v7() {
7860 const ts = Date.now().toString(16).padStart(12, "0");
7861 return [ts.slice(0, 8), ts.slice(8) + randomHex(1), "7" + randomHex(3), ((8 + Math.floor(Math.random() * 4)).toString(16)) + randomHex(3), randomHex(12)].join("-");
7862}
7863export function v1() { return v4(); }
7864export function v3() { return v4(); }
7865export function v5() { return v4(); }
7866export function validate(uuid) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(uuid ?? "")); }
7867export function version(uuid) { return parseInt(String(uuid ?? "").charAt(14), 16) || 0; }
7868export default { v1, v3, v4, v5, v7, validate, version };
7869"#
7870 .trim()
7871 .to_string(),
7872 );
7873
7874 modules.insert(
7876 "diff".to_string(),
7877 r#"
7878export function createTwoFilesPatch(oldFile, newFile, oldStr, newStr, _oldHeader, _newHeader, _opts) {
7879 const oldLines = String(oldStr ?? "").split("\n");
7880 const newLines = String(newStr ?? "").split("\n");
7881 let patch = `--- ${oldFile}\n+++ ${newFile}\n@@ -1,${oldLines.length} +1,${newLines.length} @@\n`;
7882 for (const line of oldLines) patch += `-${line}\n`;
7883 for (const line of newLines) patch += `+${line}\n`;
7884 return patch;
7885}
7886export function createPatch(fileName, oldStr, newStr, oldH, newH, opts) {
7887 return createTwoFilesPatch(fileName, fileName, oldStr, newStr, oldH, newH, opts);
7888}
7889export function diffLines(oldStr, newStr) {
7890 return [{ value: String(oldStr ?? ""), removed: true, added: false }, { value: String(newStr ?? ""), removed: false, added: true }];
7891}
7892export function diffChars(o, n) { return diffLines(o, n); }
7893export function diffWords(o, n) { return diffLines(o, n); }
7894export function applyPatch() { return false; }
7895export default { createTwoFilesPatch, createPatch, diffLines, diffChars, diffWords, applyPatch };
7896"#
7897 .trim()
7898 .to_string(),
7899 );
7900
7901 modules.insert(
7903 "just-bash".to_string(),
7904 r#"
7905export function bash(_cmd, _opts) { return Promise.resolve({ stdout: "", stderr: "", exitCode: 0 }); }
7906export { bash as Bash };
7907export default bash;
7908"#
7909 .trim()
7910 .to_string(),
7911 );
7912
7913 modules.insert(
7915 "bunfig".to_string(),
7916 r"
7917export function define(_schema) { return {}; }
7918export async function loadConfig(opts) {
7919 const defaults = (opts && opts.defaultConfig) ? opts.defaultConfig : {};
7920 return { ...defaults };
7921}
7922export default { define, loadConfig };
7923"
7924 .trim()
7925 .to_string(),
7926 );
7927
7928 modules.insert(
7930 "bun".to_string(),
7931 r"
7932const bun = globalThis.Bun || {};
7933export const argv = bun.argv || [];
7934export const file = (...args) => bun.file(...args);
7935export const write = (...args) => bun.write(...args);
7936export const spawn = (...args) => bun.spawn(...args);
7937export const which = (...args) => bun.which(...args);
7938export default bun;
7939"
7940 .trim()
7941 .to_string(),
7942 );
7943
7944 modules.insert(
7946 "dotenv".to_string(),
7947 r#"
7948export function config(_opts) { return { parsed: {} }; }
7949export function parse(src) {
7950 const result = {};
7951 for (const line of String(src ?? "").split("\n")) {
7952 const idx = line.indexOf("=");
7953 if (idx === -1) continue;
7954 const key = line.slice(0, idx).trim();
7955 const val = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
7956 if (key) result[key] = val;
7957 }
7958 return result;
7959}
7960export default { config, parse };
7961"#
7962 .trim()
7963 .to_string(),
7964 );
7965
7966 modules.insert(
7967 "node:path".to_string(),
7968 r#"
7969function __pi_is_abs(s) {
7970 return s.startsWith("/") || (s.length >= 3 && s[1] === ":" && s[2] === "/");
7971}
7972
7973export function join(...parts) {
7974 const cleaned = parts.map((p) => String(p ?? "").replace(/\\/g, "/")).filter((p) => p.length > 0);
7975 if (cleaned.length === 0) return ".";
7976 return normalize(cleaned.join("/"));
7977}
7978
7979export function dirname(p) {
7980 const s = String(p ?? "").replace(/\\/g, "/");
7981 const idx = s.lastIndexOf("/");
7982 if (idx <= 0) return s.startsWith("/") ? "/" : ".";
7983 const dir = s.slice(0, idx);
7984 // Keep trailing slash for drive root: D:/ not D:
7985 if (dir.length === 2 && dir[1] === ":") return dir + "/";
7986 return dir;
7987}
7988
7989export function resolve(...parts) {
7990 const base =
7991 globalThis.pi && globalThis.pi.process && typeof globalThis.pi.process.cwd === "string"
7992 ? globalThis.pi.process.cwd
7993 : "/";
7994 const cleaned = parts
7995 .map((p) => String(p ?? "").replace(/\\/g, "/"))
7996 .filter((p) => p.length > 0);
7997
7998 let out = "";
7999 for (const part of cleaned) {
8000 if (__pi_is_abs(part)) {
8001 out = part;
8002 continue;
8003 }
8004 out = out === "" || out.endsWith("/") ? out + part : out + "/" + part;
8005 }
8006 if (!__pi_is_abs(out)) {
8007 out = base.endsWith("/") ? base + out : base + "/" + out;
8008 }
8009 return normalize(out);
8010}
8011
8012export function basename(p, ext) {
8013 const s = String(p ?? "").replace(/\\/g, "/").replace(/\/+$/, "");
8014 const idx = s.lastIndexOf("/");
8015 const name = idx === -1 ? s : s.slice(idx + 1);
8016 if (ext && name.endsWith(ext)) {
8017 return name.slice(0, -ext.length);
8018 }
8019 return name;
8020}
8021
8022export function relative(from, to) {
8023 const fromParts = String(from ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8024 const toParts = String(to ?? "").replace(/\\/g, "/").split("/").filter(Boolean);
8025
8026 let common = 0;
8027 while (common < fromParts.length && common < toParts.length && fromParts[common] === toParts[common]) {
8028 common++;
8029 }
8030
8031 const up = fromParts.length - common;
8032 const downs = toParts.slice(common);
8033 const result = [...Array(up).fill(".."), ...downs];
8034 return result.join("/") || ".";
8035}
8036
8037export function isAbsolute(p) {
8038 const s = String(p ?? "").replace(/\\/g, "/");
8039 return __pi_is_abs(s);
8040}
8041
8042export function extname(p) {
8043 const s = String(p ?? "").replace(/\\/g, "/");
8044 const b = s.lastIndexOf("/");
8045 const name = b === -1 ? s : s.slice(b + 1);
8046 const dot = name.lastIndexOf(".");
8047 if (dot <= 0) return "";
8048 return name.slice(dot);
8049}
8050
8051export function normalize(p) {
8052 const s = String(p ?? "").replace(/\\/g, "/");
8053 const isAbs = __pi_is_abs(s);
8054 const parts = s.split("/").filter(Boolean);
8055 const out = [];
8056 for (const part of parts) {
8057 if (part === "..") { if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); else if (!isAbs) out.push(part); }
8058 else if (part !== ".") out.push(part);
8059 }
8060 const result = out.join("/");
8061 if (out.length > 0 && out[0].length === 2 && out[0][1] === ":") return result;
8062 return isAbs ? "/" + result : result || ".";
8063}
8064
8065export function parse(p) {
8066 const s = String(p ?? "").replace(/\\/g, "/");
8067 const isAbs = s.startsWith("/");
8068 const lastSlash = s.lastIndexOf("/");
8069 const dir = lastSlash === -1 ? "" : s.slice(0, lastSlash) || (isAbs ? "/" : "");
8070 const base = lastSlash === -1 ? s : s.slice(lastSlash + 1);
8071 const ext = extname(base);
8072 const name = ext ? base.slice(0, -ext.length) : base;
8073 const root = isAbs ? "/" : "";
8074 return { root, dir, base, ext, name };
8075}
8076
8077export function format(pathObj) {
8078 const dir = pathObj.dir || pathObj.root || "";
8079 const base = pathObj.base || (pathObj.name || "") + (pathObj.ext || "");
8080 if (!dir) return base;
8081 return dir === pathObj.root ? dir + base : dir + "/" + base;
8082}
8083
8084export const sep = "/";
8085export const delimiter = ":";
8086export const posix = { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter };
8087
8088const win32Stub = new Proxy({}, { get(_, prop) { throw new Error("path.win32." + String(prop) + " is not supported (Pi runs on POSIX only)"); } });
8089export const win32 = win32Stub;
8090
8091export default { join, dirname, resolve, basename, relative, isAbsolute, extname, normalize, parse, format, sep, delimiter, posix, win32 };
8092"#
8093 .trim()
8094 .to_string(),
8095 );
8096
8097 modules.insert("node:os".to_string(), build_node_os_module());
8098
8099 modules.insert(
8100 "node:child_process".to_string(),
8101 r#"
8102const __pi_child_process_state = (() => {
8103 if (globalThis.__pi_child_process_state) {
8104 return globalThis.__pi_child_process_state;
8105 }
8106 const state = {
8107 nextPid: 1000,
8108 children: new Map(),
8109 };
8110 globalThis.__pi_child_process_state = state;
8111 return state;
8112})();
8113
8114function __makeEmitter() {
8115 const listeners = new Map();
8116 const emitter = {
8117 on(event, listener) {
8118 const key = String(event);
8119 if (!listeners.has(key)) listeners.set(key, []);
8120 listeners.get(key).push(listener);
8121 return emitter;
8122 },
8123 once(event, listener) {
8124 const wrapper = (...args) => {
8125 emitter.off(event, wrapper);
8126 listener(...args);
8127 };
8128 return emitter.on(event, wrapper);
8129 },
8130 off(event, listener) {
8131 const key = String(event);
8132 const bucket = listeners.get(key);
8133 if (!bucket) return emitter;
8134 const idx = bucket.indexOf(listener);
8135 if (idx >= 0) bucket.splice(idx, 1);
8136 if (bucket.length === 0) listeners.delete(key);
8137 return emitter;
8138 },
8139 removeListener(event, listener) {
8140 return emitter.off(event, listener);
8141 },
8142 emit(event, ...args) {
8143 const key = String(event);
8144 const bucket = listeners.get(key) || [];
8145 for (const listener of [...bucket]) {
8146 try {
8147 listener(...args);
8148 } catch (_) {}
8149 }
8150 return emitter;
8151 },
8152 };
8153 return emitter;
8154}
8155
8156function __emitCloseOnce(child, code, signal = null) {
8157 if (child.__pi_done) return;
8158 child.__pi_done = true;
8159 child.exitCode = code;
8160 child.signalCode = signal;
8161 __pi_child_process_state.children.delete(child.pid);
8162 child.emit("exit", code, signal);
8163 child.emit("close", code, signal);
8164}
8165
8166function __parseSpawnOptions(raw) {
8167 const options = raw && typeof raw === "object" ? raw : {};
8168 const allowed = new Set(["cwd", "detached", "shell", "stdio", "timeout"]);
8169 for (const key of Object.keys(options)) {
8170 if (!allowed.has(key)) {
8171 throw new Error(`node:child_process.spawn: unsupported option '${key}'`);
8172 }
8173 }
8174
8175 if (options.shell !== undefined && options.shell !== false) {
8176 throw new Error("node:child_process.spawn: only shell=false is supported in PiJS");
8177 }
8178
8179 let stdio = ["pipe", "pipe", "pipe"];
8180 if (options.stdio !== undefined) {
8181 if (!Array.isArray(options.stdio)) {
8182 throw new Error("node:child_process.spawn: options.stdio must be an array");
8183 }
8184 if (options.stdio.length !== 3) {
8185 throw new Error("node:child_process.spawn: options.stdio must have exactly 3 entries");
8186 }
8187 stdio = options.stdio.map((entry, idx) => {
8188 const value = String(entry ?? "");
8189 if (value !== "ignore" && value !== "pipe") {
8190 throw new Error(
8191 `node:child_process.spawn: unsupported stdio[${idx}] value '${value}'`,
8192 );
8193 }
8194 return value;
8195 });
8196 }
8197
8198 const cwd =
8199 typeof options.cwd === "string" && options.cwd.trim().length > 0
8200 ? options.cwd
8201 : undefined;
8202 let timeoutMs = undefined;
8203 if (options.timeout !== undefined) {
8204 if (
8205 typeof options.timeout !== "number" ||
8206 !Number.isFinite(options.timeout) ||
8207 options.timeout < 0
8208 ) {
8209 throw new Error(
8210 "node:child_process.spawn: options.timeout must be a non-negative number",
8211 );
8212 }
8213 timeoutMs = Math.floor(options.timeout);
8214 }
8215
8216 return {
8217 cwd,
8218 detached: Boolean(options.detached),
8219 stdio,
8220 timeoutMs,
8221 };
8222}
8223
8224function __installProcessKillBridge() {
8225 globalThis.__pi_process_kill_impl = (pidValue, signal = "SIGTERM") => {
8226 const pidNumeric = Number(pidValue);
8227 if (!Number.isFinite(pidNumeric) || pidNumeric === 0) {
8228 const err = new Error(`kill EINVAL: invalid pid ${String(pidValue)}`);
8229 err.code = "EINVAL";
8230 throw err;
8231 }
8232 const pid = Math.abs(Math.trunc(pidNumeric));
8233 const child = __pi_child_process_state.children.get(pid);
8234 if (!child) {
8235 const err = new Error(`kill ESRCH: no such process ${pid}`);
8236 err.code = "ESRCH";
8237 throw err;
8238 }
8239 child.kill(signal);
8240 return true;
8241 };
8242}
8243
8244__installProcessKillBridge();
8245
8246export function spawn(command, args = [], options = {}) {
8247 const cmd = String(command ?? "").trim();
8248 if (!cmd) {
8249 throw new Error("node:child_process.spawn: command is required");
8250 }
8251 if (!Array.isArray(args)) {
8252 throw new Error("node:child_process.spawn: args must be an array");
8253 }
8254
8255 const argv = args.map((arg) => String(arg));
8256 const opts = __parseSpawnOptions(options);
8257
8258 const child = __makeEmitter();
8259 child.pid = __pi_child_process_state.nextPid++;
8260 child.killed = false;
8261 child.exitCode = null;
8262 child.signalCode = null;
8263 child.__pi_done = false;
8264 child.__pi_kill_resolver = null;
8265 child.stdout = opts.stdio[1] === "pipe" ? __makeEmitter() : null;
8266 child.stderr = opts.stdio[2] === "pipe" ? __makeEmitter() : null;
8267 child.stdin = opts.stdio[0] === "pipe" ? __makeEmitter() : null;
8268
8269 child.kill = (signal = "SIGTERM") => {
8270 if (child.__pi_done) return false;
8271 child.killed = true;
8272 if (typeof child.__pi_kill_resolver === "function") {
8273 child.__pi_kill_resolver({
8274 kind: "killed",
8275 signal: String(signal || "SIGTERM"),
8276 });
8277 child.__pi_kill_resolver = null;
8278 }
8279 __emitCloseOnce(child, null, String(signal || "SIGTERM"));
8280 return true;
8281 };
8282
8283 __pi_child_process_state.children.set(child.pid, child);
8284
8285 const execOptions = {};
8286 if (opts.cwd !== undefined) execOptions.cwd = opts.cwd;
8287 if (opts.timeoutMs !== undefined) execOptions.timeout = opts.timeoutMs;
8288 const execPromise = pi.exec(cmd, argv, execOptions).then(
8289 (result) => ({ kind: "result", result }),
8290 (error) => ({ kind: "error", error }),
8291 );
8292
8293 const killPromise = new Promise((resolve) => {
8294 child.__pi_kill_resolver = resolve;
8295 });
8296
8297 Promise.race([execPromise, killPromise]).then((outcome) => {
8298 if (!outcome || child.__pi_done) return;
8299
8300 if (outcome.kind === "result") {
8301 const result = outcome.result || {};
8302 if (child.stdout && result.stdout !== undefined && result.stdout !== null && result.stdout !== "") {
8303 child.stdout.emit("data", String(result.stdout));
8304 }
8305 if (child.stderr && result.stderr !== undefined && result.stderr !== null && result.stderr !== "") {
8306 child.stderr.emit("data", String(result.stderr));
8307 }
8308 if (result.killed) {
8309 child.killed = true;
8310 }
8311 const code =
8312 typeof result.code === "number" && Number.isFinite(result.code)
8313 ? result.code
8314 : 0;
8315 const signal =
8316 result.killed || child.killed
8317 ? String(result.signal || "SIGTERM")
8318 : null;
8319 __emitCloseOnce(child, signal ? null : code, signal);
8320 return;
8321 }
8322
8323 if (outcome.kind === "error") {
8324 const source = outcome.error || {};
8325 const error =
8326 source instanceof Error
8327 ? source
8328 : new Error(String(source.message || source || "spawn failed"));
8329 if (!error.code && source && source.code !== undefined) {
8330 error.code = String(source.code);
8331 }
8332 child.emit("error", error);
8333 __emitCloseOnce(child, 1, null);
8334 }
8335 });
8336
8337 return child;
8338}
8339
8340function __parseExecSyncResult(raw, command) {
8341 const result = JSON.parse(raw);
8342 if (result.error) {
8343 const err = new Error(`Command failed: ${command}\n${result.error}`);
8344 err.status = null;
8345 err.stdout = result.stdout || "";
8346 err.stderr = result.stderr || "";
8347 err.pid = result.pid || 0;
8348 err.signal = null;
8349 throw err;
8350 }
8351 if (result.killed) {
8352 const err = new Error(`Command timed out: ${command}`);
8353 err.killed = true;
8354 err.status = result.status;
8355 err.stdout = result.stdout || "";
8356 err.stderr = result.stderr || "";
8357 err.pid = result.pid || 0;
8358 err.signal = "SIGTERM";
8359 throw err;
8360 }
8361 return result;
8362}
8363
8364export function spawnSync(command, argsInput, options) {
8365 const cmd = String(command ?? "").trim();
8366 if (!cmd) {
8367 throw new Error("node:child_process.spawnSync: command is required");
8368 }
8369 const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
8370 const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
8371 ? argsInput
8372 : (options || {});
8373 const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
8374 const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
8375 const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
8376
8377 let result;
8378 try {
8379 const raw = __pi_exec_sync_native(cmd, JSON.stringify(args), cwd, timeout, maxBuffer);
8380 result = JSON.parse(raw);
8381 } catch (e) {
8382 return {
8383 pid: 0,
8384 output: [null, "", e.message || ""],
8385 stdout: "",
8386 stderr: e.message || "",
8387 status: null,
8388 signal: null,
8389 error: e,
8390 };
8391 }
8392
8393 if (result.error) {
8394 const err = new Error(result.error);
8395 return {
8396 pid: result.pid || 0,
8397 output: [null, result.stdout || "", result.stderr || ""],
8398 stdout: result.stdout || "",
8399 stderr: result.stderr || "",
8400 status: null,
8401 signal: result.killed ? "SIGTERM" : null,
8402 error: err,
8403 };
8404 }
8405
8406 return {
8407 pid: result.pid || 0,
8408 output: [null, result.stdout || "", result.stderr || ""],
8409 stdout: result.stdout || "",
8410 stderr: result.stderr || "",
8411 status: result.status ?? 0,
8412 signal: result.killed ? "SIGTERM" : null,
8413 error: undefined,
8414 };
8415}
8416
8417export function execSync(command, options) {
8418 const cmdStr = String(command ?? "").trim();
8419 if (!cmdStr) {
8420 throw new Error("node:child_process.execSync: command is required");
8421 }
8422 const opts = options || {};
8423 const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
8424 const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
8425 const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
8426
8427 // execSync runs through a shell, so pass via sh -c
8428 const raw = __pi_exec_sync_native("sh", JSON.stringify(["-c", cmdStr]), cwd, timeout, maxBuffer);
8429 const result = __parseExecSyncResult(raw, cmdStr);
8430
8431 if (result.status !== 0 && result.status !== null) {
8432 const err = new Error(
8433 `Command failed: ${cmdStr}\n${result.stderr || ""}`,
8434 );
8435 err.status = result.status;
8436 err.stdout = result.stdout || "";
8437 err.stderr = result.stderr || "";
8438 err.pid = result.pid || 0;
8439 err.signal = null;
8440 throw err;
8441 }
8442
8443 const stdout = result.stdout || "";
8444 if (stdout.length > maxBuffer) {
8445 const err = new Error(`stdout maxBuffer length exceeded`);
8446 err.stdout = stdout.slice(0, maxBuffer);
8447 err.stderr = result.stderr || "";
8448 throw err;
8449 }
8450
8451 const encoding = opts.encoding;
8452 if (encoding === "buffer" || encoding === null) {
8453 // Return a "buffer-like" string (QuickJS doesn't have real Buffer)
8454 return stdout;
8455 }
8456 return stdout;
8457}
8458
8459function __normalizeExecOptions(raw) {
8460 const options = raw && typeof raw === "object" ? raw : {};
8461 let timeoutMs = undefined;
8462 if (
8463 typeof options.timeout === "number" &&
8464 Number.isFinite(options.timeout) &&
8465 options.timeout >= 0
8466 ) {
8467 timeoutMs = Math.floor(options.timeout);
8468 }
8469 const maxBuffer =
8470 typeof options.maxBuffer === "number" &&
8471 Number.isFinite(options.maxBuffer) &&
8472 options.maxBuffer > 0
8473 ? Math.floor(options.maxBuffer)
8474 : 1024 * 1024;
8475 return {
8476 cwd: typeof options.cwd === "string" && options.cwd.trim().length > 0 ? options.cwd : undefined,
8477 timeoutMs,
8478 maxBuffer,
8479 encoding: options.encoding,
8480 };
8481}
8482
8483function __wrapExecLike(commandForError, child, opts, callback) {
8484 let stdout = "";
8485 let stderr = "";
8486 let callbackDone = false;
8487 const finish = (err, out, errOut) => {
8488 if (callbackDone) return;
8489 callbackDone = true;
8490 if (typeof callback === "function") {
8491 callback(err, out, errOut);
8492 }
8493 };
8494
8495 child.stdout?.on("data", (chunk) => {
8496 stdout += String(chunk ?? "");
8497 });
8498 child.stderr?.on("data", (chunk) => {
8499 stderr += String(chunk ?? "");
8500 });
8501
8502 child.on("error", (error) => {
8503 finish(
8504 error instanceof Error ? error : new Error(String(error)),
8505 "",
8506 "",
8507 );
8508 });
8509
8510 child.on("close", (code) => {
8511 let out = stdout;
8512 let errOut = stderr;
8513
8514 if (out.length > opts.maxBuffer) {
8515 const err = new Error("stdout maxBuffer length exceeded");
8516 err.stdout = out.slice(0, opts.maxBuffer);
8517 err.stderr = errOut;
8518 finish(err, err.stdout, errOut);
8519 return;
8520 }
8521
8522 if (errOut.length > opts.maxBuffer) {
8523 const err = new Error("stderr maxBuffer length exceeded");
8524 err.stdout = out;
8525 err.stderr = errOut.slice(0, opts.maxBuffer);
8526 finish(err, out, err.stderr);
8527 return;
8528 }
8529
8530 if (opts.encoding !== "buffer" && opts.encoding !== null) {
8531 out = String(out);
8532 errOut = String(errOut);
8533 }
8534
8535 if (code !== 0 && code !== undefined && code !== null) {
8536 const err = new Error(`Command failed: ${commandForError}`);
8537 err.code = code;
8538 err.killed = Boolean(child.killed);
8539 err.stdout = out;
8540 err.stderr = errOut;
8541 finish(err, out, errOut);
8542 return;
8543 }
8544
8545 if (child.killed) {
8546 const err = new Error(`Command timed out: ${commandForError}`);
8547 err.code = null;
8548 err.killed = true;
8549 err.signal = child.signalCode || "SIGTERM";
8550 err.stdout = out;
8551 err.stderr = errOut;
8552 finish(err, out, errOut);
8553 return;
8554 }
8555
8556 finish(null, out, errOut);
8557 });
8558
8559 return child;
8560}
8561
8562export function exec(command, optionsOrCallback, callbackArg) {
8563 const opts = typeof optionsOrCallback === "object" ? optionsOrCallback : {};
8564 const callback = typeof optionsOrCallback === "function"
8565 ? optionsOrCallback
8566 : callbackArg;
8567 const cmdStr = String(command ?? "").trim();
8568 const normalized = __normalizeExecOptions(opts);
8569 const spawnOpts = {
8570 shell: false,
8571 stdio: ["ignore", "pipe", "pipe"],
8572 };
8573 if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
8574 if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
8575 const child = spawn("sh", ["-c", cmdStr], spawnOpts);
8576 return __wrapExecLike(cmdStr, child, normalized, callback);
8577}
8578
8579export function execFileSync(file, argsInput, options) {
8580 const fileStr = String(file ?? "").trim();
8581 if (!fileStr) {
8582 throw new Error("node:child_process.execFileSync: file is required");
8583 }
8584 const args = Array.isArray(argsInput) ? argsInput.map(String) : [];
8585 const opts = (typeof argsInput === "object" && !Array.isArray(argsInput))
8586 ? argsInput
8587 : (options || {});
8588 const cwd = typeof opts.cwd === "string" ? opts.cwd : "";
8589 const timeout = typeof opts.timeout === "number" ? opts.timeout : 0;
8590 const maxBuffer = typeof opts.maxBuffer === "number" ? opts.maxBuffer : 1024 * 1024;
8591
8592 const raw = __pi_exec_sync_native(fileStr, JSON.stringify(args), cwd, timeout, maxBuffer);
8593 const result = __parseExecSyncResult(raw, fileStr);
8594
8595 if (result.status !== 0 && result.status !== null) {
8596 const err = new Error(
8597 `Command failed: ${fileStr}\n${result.stderr || ""}`,
8598 );
8599 err.status = result.status;
8600 err.stdout = result.stdout || "";
8601 err.stderr = result.stderr || "";
8602 err.pid = result.pid || 0;
8603 throw err;
8604 }
8605
8606 return result.stdout || "";
8607}
8608
8609export function execFile(file, argsOrOptsOrCb, optsOrCb, callbackArg) {
8610 const fileStr = String(file ?? "").trim();
8611 let args = [];
8612 let opts = {};
8613 let callback;
8614 if (typeof argsOrOptsOrCb === "function") {
8615 callback = argsOrOptsOrCb;
8616 } else if (Array.isArray(argsOrOptsOrCb)) {
8617 args = argsOrOptsOrCb.map(String);
8618 if (typeof optsOrCb === "function") {
8619 callback = optsOrCb;
8620 } else {
8621 opts = optsOrCb || {};
8622 callback = callbackArg;
8623 }
8624 } else if (typeof argsOrOptsOrCb === "object") {
8625 opts = argsOrOptsOrCb || {};
8626 callback = typeof optsOrCb === "function" ? optsOrCb : callbackArg;
8627 }
8628
8629 const normalized = __normalizeExecOptions(opts);
8630 const spawnOpts = {
8631 shell: false,
8632 stdio: ["ignore", "pipe", "pipe"],
8633 };
8634 if (normalized.cwd !== undefined) spawnOpts.cwd = normalized.cwd;
8635 if (normalized.timeoutMs !== undefined) spawnOpts.timeout = normalized.timeoutMs;
8636 const child = spawn(fileStr, args, spawnOpts);
8637 return __wrapExecLike(fileStr, child, normalized, callback);
8638}
8639
8640export function fork(_modulePath, _args, _opts) {
8641 throw new Error("node:child_process.fork is not available in PiJS");
8642}
8643
8644export default { spawn, spawnSync, execSync, execFileSync, exec, execFile, fork };
8645"#
8646 .trim()
8647 .to_string(),
8648 );
8649
8650 modules.insert(
8651 "node:module".to_string(),
8652 r#"
8653import * as fs from "node:fs";
8654import * as fsPromises from "node:fs/promises";
8655import * as path from "node:path";
8656import * as os from "node:os";
8657import * as crypto from "node:crypto";
8658import * as url from "node:url";
8659import * as processMod from "node:process";
8660import * as buffer from "node:buffer";
8661import * as childProcess from "node:child_process";
8662import * as http from "node:http";
8663import * as https from "node:https";
8664import * as net from "node:net";
8665import * as events from "node:events";
8666import * as stream from "node:stream";
8667import * as streamPromises from "node:stream/promises";
8668import * as streamWeb from "node:stream/web";
8669import * as stringDecoder from "node:string_decoder";
8670import * as http2 from "node:http2";
8671import * as util from "node:util";
8672import * as readline from "node:readline";
8673import * as querystring from "node:querystring";
8674import * as assertMod from "node:assert";
8675import * as constantsMod from "node:constants";
8676import * as tls from "node:tls";
8677import * as tty from "node:tty";
8678import * as zlib from "node:zlib";
8679import * as perfHooks from "node:perf_hooks";
8680import * as vm from "node:vm";
8681import * as v8 from "node:v8";
8682import * as workerThreads from "node:worker_threads";
8683
8684function __normalizeBuiltin(id) {
8685 const spec = String(id ?? "");
8686 switch (spec) {
8687 case "fs":
8688 case "node:fs":
8689 return "node:fs";
8690 case "fs/promises":
8691 case "node:fs/promises":
8692 return "node:fs/promises";
8693 case "path":
8694 case "node:path":
8695 return "node:path";
8696 case "os":
8697 case "node:os":
8698 return "node:os";
8699 case "crypto":
8700 case "node:crypto":
8701 return "node:crypto";
8702 case "url":
8703 case "node:url":
8704 return "node:url";
8705 case "process":
8706 case "node:process":
8707 return "node:process";
8708 case "buffer":
8709 case "node:buffer":
8710 return "node:buffer";
8711 case "child_process":
8712 case "node:child_process":
8713 return "node:child_process";
8714 case "http":
8715 case "node:http":
8716 return "node:http";
8717 case "https":
8718 case "node:https":
8719 return "node:https";
8720 case "net":
8721 case "node:net":
8722 return "node:net";
8723 case "events":
8724 case "node:events":
8725 return "node:events";
8726 case "stream":
8727 case "node:stream":
8728 return "node:stream";
8729 case "stream/web":
8730 case "node:stream/web":
8731 return "node:stream/web";
8732 case "stream/promises":
8733 case "node:stream/promises":
8734 return "node:stream/promises";
8735 case "string_decoder":
8736 case "node:string_decoder":
8737 return "node:string_decoder";
8738 case "http2":
8739 case "node:http2":
8740 return "node:http2";
8741 case "util":
8742 case "node:util":
8743 return "node:util";
8744 case "readline":
8745 case "node:readline":
8746 return "node:readline";
8747 case "querystring":
8748 case "node:querystring":
8749 return "node:querystring";
8750 case "assert":
8751 case "node:assert":
8752 return "node:assert";
8753 case "module":
8754 case "node:module":
8755 return "node:module";
8756 case "constants":
8757 case "node:constants":
8758 return "node:constants";
8759 case "tls":
8760 case "node:tls":
8761 return "node:tls";
8762 case "tty":
8763 case "node:tty":
8764 return "node:tty";
8765 case "zlib":
8766 case "node:zlib":
8767 return "node:zlib";
8768 case "perf_hooks":
8769 case "node:perf_hooks":
8770 return "node:perf_hooks";
8771 case "vm":
8772 case "node:vm":
8773 return "node:vm";
8774 case "v8":
8775 case "node:v8":
8776 return "node:v8";
8777 case "worker_threads":
8778 case "node:worker_threads":
8779 return "node:worker_threads";
8780 default:
8781 return spec;
8782 }
8783}
8784
8785const __builtinModules = {
8786 "node:fs": fs,
8787 "node:fs/promises": fsPromises,
8788 "node:path": path,
8789 "node:os": os,
8790 "node:crypto": crypto,
8791 "node:url": url,
8792 "node:process": processMod,
8793 "node:buffer": buffer,
8794 "node:child_process": childProcess,
8795 "node:http": http,
8796 "node:https": https,
8797 "node:net": net,
8798 "node:events": events,
8799 "node:stream": stream,
8800 "node:stream/web": streamWeb,
8801 "node:stream/promises": streamPromises,
8802 "node:string_decoder": stringDecoder,
8803 "node:http2": http2,
8804 "node:util": util,
8805 "node:readline": readline,
8806 "node:querystring": querystring,
8807 "node:assert": assertMod,
8808 "node:module": { createRequire },
8809 "node:constants": constantsMod,
8810 "node:tls": tls,
8811 "node:tty": tty,
8812 "node:zlib": zlib,
8813 "node:perf_hooks": perfHooks,
8814 "node:vm": vm,
8815 "node:v8": v8,
8816 "node:worker_threads": workerThreads,
8817};
8818
8819const __missingRequireCache = Object.create(null);
8820
8821function __isBarePackageSpecifier(spec) {
8822 return (
8823 typeof spec === "string" &&
8824 spec.length > 0 &&
8825 !spec.startsWith("./") &&
8826 !spec.startsWith("../") &&
8827 !spec.startsWith("/") &&
8828 !spec.startsWith("file://") &&
8829 !spec.includes(":")
8830 );
8831}
8832
8833function __makeMissingRequireStub(spec) {
8834 if (__missingRequireCache[spec]) {
8835 return __missingRequireCache[spec];
8836 }
8837 const handler = {
8838 get(_target, prop) {
8839 if (typeof prop === "symbol") {
8840 if (prop === Symbol.toPrimitive) return () => "";
8841 return undefined;
8842 }
8843 if (prop === "__esModule") return true;
8844 if (prop === "default") return stub;
8845 if (prop === "toString") return () => "";
8846 if (prop === "valueOf") return () => "";
8847 if (prop === "name") return spec;
8848 if (prop === "then") return undefined;
8849 return stub;
8850 },
8851 apply() { return stub; },
8852 construct() { return stub; },
8853 has() { return false; },
8854 ownKeys() { return []; },
8855 getOwnPropertyDescriptor() {
8856 return { configurable: true, enumerable: false };
8857 },
8858 };
8859 const stub = new Proxy(function __pijs_missing_require_stub() {}, handler);
8860 __missingRequireCache[spec] = stub;
8861 return stub;
8862}
8863
8864export function createRequire(_path) {
8865 function require(id) {
8866 const normalized = __normalizeBuiltin(id);
8867 const builtIn = __builtinModules[normalized];
8868 if (builtIn) {
8869 if (builtIn && Object.prototype.hasOwnProperty.call(builtIn, "default") && builtIn.default !== undefined) {
8870 return builtIn.default;
8871 }
8872 return builtIn;
8873 }
8874 const raw = String(id ?? "");
8875 if (raw.startsWith("node:") || __isBarePackageSpecifier(raw)) {
8876 return __makeMissingRequireStub(raw);
8877 }
8878 throw new Error(`Cannot find module '${raw}' in PiJS require()`);
8879 }
8880 require.resolve = function resolve(id) {
8881 // Return a synthetic path for the requested module. This satisfies
8882 // extensions that call require.resolve() to locate a binary entry
8883 // point (e.g. @sourcegraph/scip-python) without actually needing the
8884 // real node_modules tree.
8885 return `/pijs-virtual/${String(id ?? "unknown")}`;
8886 };
8887 require.resolve.paths = function() { return []; };
8888 return require;
8889}
8890
8891export default { createRequire };
8892"#
8893 .trim()
8894 .to_string(),
8895 );
8896
8897 modules.insert(
8898 "node:fs".to_string(),
8899 r#"
8900import { Readable, Writable } from "node:stream";
8901
8902export const constants = {
8903 R_OK: 4,
8904 W_OK: 2,
8905 X_OK: 1,
8906 F_OK: 0,
8907 O_RDONLY: 0,
8908 O_WRONLY: 1,
8909 O_RDWR: 2,
8910 O_CREAT: 64,
8911 O_EXCL: 128,
8912 O_TRUNC: 512,
8913 O_APPEND: 1024,
8914};
8915const __pi_vfs = (() => {
8916 if (globalThis.__pi_vfs_state) {
8917 return globalThis.__pi_vfs_state;
8918 }
8919
8920 const state = {
8921 files: new Map(),
8922 dirs: new Set(["/"]),
8923 symlinks: new Map(),
8924 fds: new Map(),
8925 nextFd: 100,
8926 };
8927
8928 function checkWriteAccess(resolved) {
8929 if (typeof globalThis.__pi_host_check_write_access === "function") {
8930 globalThis.__pi_host_check_write_access(resolved);
8931 }
8932 }
8933
8934 function normalizePath(input) {
8935 let raw = String(input ?? "").replace(/\\/g, "/");
8936 // Strip Windows UNC verbatim prefix that canonicalize() produces.
8937 // \\?\C:\... becomes /?/C:/... after separator normalization.
8938 if (raw.startsWith("/?/") && raw.length > 5 && /^[A-Za-z]:/.test(raw.substring(3, 5))) {
8939 raw = raw.slice(3);
8940 }
8941 // Detect Windows drive-letter absolute paths (e.g. "C:/Users/...")
8942 const hasDriveLetter = raw.length >= 3 && /^[A-Za-z]:\//.test(raw);
8943 const isAbsolute = raw.startsWith("/") || hasDriveLetter;
8944 const base = isAbsolute
8945 ? raw
8946 : `${(globalThis.process && typeof globalThis.process.cwd === "function" ? globalThis.process.cwd() : "/").replace(/\\/g, "/")}/${raw}`;
8947 const parts = [];
8948 for (const part of base.split("/")) {
8949 if (!part || part === ".") continue;
8950 if (part === "..") {
8951 if (parts.length > 0) parts.pop();
8952 continue;
8953 }
8954 parts.push(part);
8955 }
8956 // Preserve drive letter prefix on Windows (D:/...) instead of /D:/...
8957 if (parts.length > 0 && /^[A-Za-z]:$/.test(parts[0])) {
8958 return `${parts[0]}/${parts.slice(1).join("/")}`;
8959 }
8960 return `/${parts.join("/")}`;
8961 }
8962
8963 function dirname(path) {
8964 const normalized = normalizePath(path);
8965 if (normalized === "/") return "/";
8966 const idx = normalized.lastIndexOf("/");
8967 return idx <= 0 ? "/" : normalized.slice(0, idx);
8968 }
8969
8970 function ensureDir(path) {
8971 const normalized = normalizePath(path);
8972 if (normalized === "/") return "/";
8973 const parts = normalized.slice(1).split("/");
8974 let current = "";
8975 for (const part of parts) {
8976 current = `${current}/${part}`;
8977 state.dirs.add(current);
8978 }
8979 return normalized;
8980 }
8981
8982 function toBytes(data, opts) {
8983 const encoding =
8984 typeof opts === "string"
8985 ? opts
8986 : opts && typeof opts === "object" && typeof opts.encoding === "string"
8987 ? opts.encoding
8988 : undefined;
8989 const normalizedEncoding = encoding ? String(encoding).toLowerCase() : "utf8";
8990
8991 if (typeof data === "string") {
8992 if (normalizedEncoding === "base64") {
8993 return Buffer.from(data, "base64");
8994 }
8995 return new TextEncoder().encode(data);
8996 }
8997 if (data instanceof Uint8Array) {
8998 return new Uint8Array(data);
8999 }
9000 if (data instanceof ArrayBuffer) {
9001 return new Uint8Array(data);
9002 }
9003 if (ArrayBuffer.isView(data)) {
9004 return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
9005 }
9006 if (Array.isArray(data)) {
9007 return new Uint8Array(data);
9008 }
9009 return new TextEncoder().encode(String(data ?? ""));
9010 }
9011
9012 function decodeBytes(bytes, opts) {
9013 const encoding =
9014 typeof opts === "string"
9015 ? opts
9016 : opts && typeof opts === "object" && typeof opts.encoding === "string"
9017 ? opts.encoding
9018 : undefined;
9019 if (!encoding || String(encoding).toLowerCase() === "buffer") {
9020 return Buffer.from(bytes);
9021 }
9022 const normalized = String(encoding).toLowerCase();
9023 if (normalized === "base64") {
9024 let bin = "";
9025 for (let i = 0; i < bytes.length; i++) {
9026 bin += String.fromCharCode(bytes[i] & 0xff);
9027 }
9028 return btoa(bin);
9029 }
9030 return new TextDecoder().decode(bytes);
9031 }
9032
9033 function resolveSymlinkPath(linkPath, target) {
9034 const raw = String(target ?? "");
9035 if (raw.startsWith("/")) {
9036 return normalizePath(raw);
9037 }
9038 return normalizePath(`${dirname(linkPath)}/${raw}`);
9039 }
9040
9041 function resolvePath(path, followSymlinks = true) {
9042 let normalized = normalizePath(path);
9043 if (!followSymlinks) {
9044 return normalized;
9045 }
9046
9047 const seen = new Set();
9048 while (state.symlinks.has(normalized)) {
9049 if (seen.has(normalized)) {
9050 throw new Error(`ELOOP: too many symbolic links encountered, stat '${String(path ?? "")}'`);
9051 }
9052 seen.add(normalized);
9053 normalized = resolveSymlinkPath(normalized, state.symlinks.get(normalized));
9054 }
9055 return normalized;
9056 }
9057
9058 function parseOpenFlags(rawFlags) {
9059 if (typeof rawFlags === "number" && Number.isFinite(rawFlags)) {
9060 const flags = rawFlags | 0;
9061 const accessMode = flags & 3;
9062 const readable = accessMode === constants.O_RDONLY || accessMode === constants.O_RDWR;
9063 const writable = accessMode === constants.O_WRONLY || accessMode === constants.O_RDWR;
9064 return {
9065 readable,
9066 writable,
9067 append: (flags & constants.O_APPEND) !== 0,
9068 create: (flags & constants.O_CREAT) !== 0,
9069 truncate: (flags & constants.O_TRUNC) !== 0,
9070 exclusive: (flags & constants.O_EXCL) !== 0,
9071 };
9072 }
9073
9074 const normalized = String(rawFlags ?? "r");
9075 switch (normalized) {
9076 case "r":
9077 case "rs":
9078 return { readable: true, writable: false, append: false, create: false, truncate: false, exclusive: false };
9079 case "r+":
9080 case "rs+":
9081 return { readable: true, writable: true, append: false, create: false, truncate: false, exclusive: false };
9082 case "w":
9083 return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: false };
9084 case "w+":
9085 return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: false };
9086 case "wx":
9087 return { readable: false, writable: true, append: false, create: true, truncate: true, exclusive: true };
9088 case "wx+":
9089 return { readable: true, writable: true, append: false, create: true, truncate: true, exclusive: true };
9090 case "a":
9091 case "as":
9092 return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: false };
9093 case "a+":
9094 case "as+":
9095 return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: false };
9096 case "ax":
9097 return { readable: false, writable: true, append: true, create: true, truncate: false, exclusive: true };
9098 case "ax+":
9099 return { readable: true, writable: true, append: true, create: true, truncate: false, exclusive: true };
9100 default:
9101 throw new Error(`EINVAL: invalid open flags '${normalized}'`);
9102 }
9103 }
9104
9105 function getFdEntry(fd) {
9106 const entry = state.fds.get(fd);
9107 if (!entry) {
9108 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9109 }
9110 return entry;
9111 }
9112
9113 function toWritableView(buffer) {
9114 if (buffer instanceof Uint8Array) {
9115 return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9116 }
9117 if (buffer instanceof ArrayBuffer) {
9118 return new Uint8Array(buffer);
9119 }
9120 if (ArrayBuffer.isView(buffer)) {
9121 return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
9122 }
9123 throw new Error("TypeError: buffer must be an ArrayBuffer view");
9124 }
9125
9126 function makeDirent(name, entryKind) {
9127 return {
9128 name,
9129 isDirectory() { return entryKind === "dir"; },
9130 isFile() { return entryKind === "file"; },
9131 isSymbolicLink() { return entryKind === "symlink"; },
9132 };
9133 }
9134
9135 function listChildren(path, withFileTypes) {
9136 const normalized = normalizePath(path);
9137 const prefix = normalized === "/" ? "/" : `${normalized}/`;
9138 const children = new Map();
9139
9140 for (const dir of state.dirs) {
9141 if (!dir.startsWith(prefix) || dir === normalized) continue;
9142 const rest = dir.slice(prefix.length);
9143 if (!rest || rest.includes("/")) continue;
9144 children.set(rest, "dir");
9145 }
9146 for (const file of state.files.keys()) {
9147 if (!file.startsWith(prefix)) continue;
9148 const rest = file.slice(prefix.length);
9149 if (!rest || rest.includes("/")) continue;
9150 if (!children.has(rest)) children.set(rest, "file");
9151 }
9152 for (const link of state.symlinks.keys()) {
9153 if (!link.startsWith(prefix)) continue;
9154 const rest = link.slice(prefix.length);
9155 if (!rest || rest.includes("/")) continue;
9156 if (!children.has(rest)) children.set(rest, "symlink");
9157 }
9158
9159 const names = Array.from(children.keys()).sort();
9160 if (withFileTypes) {
9161 return names.map((name) => makeDirent(name, children.get(name)));
9162 }
9163 return names;
9164 }
9165
9166 function makeStat(path, followSymlinks = true) {
9167 const normalized = normalizePath(path);
9168 const linkTarget = state.symlinks.get(normalized);
9169 if (linkTarget !== undefined) {
9170 if (!followSymlinks) {
9171 const size = new TextEncoder().encode(String(linkTarget)).byteLength;
9172 return {
9173 isFile() { return false; },
9174 isDirectory() { return false; },
9175 isSymbolicLink() { return true; },
9176 isBlockDevice() { return false; },
9177 isCharacterDevice() { return false; },
9178 isFIFO() { return false; },
9179 isSocket() { return false; },
9180 size,
9181 mode: 0o777,
9182 uid: 0,
9183 gid: 0,
9184 atimeMs: 0,
9185 mtimeMs: 0,
9186 ctimeMs: 0,
9187 birthtimeMs: 0,
9188 atime: new Date(0),
9189 mtime: new Date(0),
9190 ctime: new Date(0),
9191 birthtime: new Date(0),
9192 dev: 0,
9193 ino: 0,
9194 nlink: 1,
9195 rdev: 0,
9196 blksize: 4096,
9197 blocks: 0,
9198 };
9199 }
9200 return makeStat(resolvePath(normalized, true), true);
9201 }
9202
9203 const isDir = state.dirs.has(normalized);
9204 let bytes = state.files.get(normalized);
9205 if (!isDir && bytes === undefined && typeof globalThis.__pi_host_read_file_sync === "function") {
9206 try {
9207 const content = globalThis.__pi_host_read_file_sync(normalized);
9208 bytes = toBytes(content);
9209 ensureDir(dirname(normalized));
9210 state.files.set(normalized, bytes);
9211 } catch (e) {
9212 const message = String((e && e.message) ? e.message : e);
9213 if (message.includes("host read denied")) {
9214 throw e;
9215 }
9216 /* not on host FS */
9217 }
9218 }
9219 const isFile = bytes !== undefined;
9220 if (!isDir && !isFile) {
9221 throw new Error(`ENOENT: no such file or directory, stat '${String(path ?? "")}'`);
9222 }
9223 const size = isFile ? bytes.byteLength : 0;
9224 return {
9225 isFile() { return isFile; },
9226 isDirectory() { return isDir; },
9227 isSymbolicLink() { return false; },
9228 isBlockDevice() { return false; },
9229 isCharacterDevice() { return false; },
9230 isFIFO() { return false; },
9231 isSocket() { return false; },
9232 size,
9233 mode: isDir ? 0o755 : 0o644,
9234 uid: 0,
9235 gid: 0,
9236 atimeMs: 0,
9237 mtimeMs: 0,
9238 ctimeMs: 0,
9239 birthtimeMs: 0,
9240 atime: new Date(0),
9241 mtime: new Date(0),
9242 ctime: new Date(0),
9243 birthtime: new Date(0),
9244 dev: 0,
9245 ino: 0,
9246 nlink: 1,
9247 rdev: 0,
9248 blksize: 4096,
9249 blocks: 0,
9250 };
9251 }
9252
9253 state.normalizePath = normalizePath;
9254 state.dirname = dirname;
9255 state.ensureDir = ensureDir;
9256 state.toBytes = toBytes;
9257 state.decodeBytes = decodeBytes;
9258 state.listChildren = listChildren;
9259 state.makeStat = makeStat;
9260 state.resolvePath = resolvePath;
9261 state.checkWriteAccess = checkWriteAccess;
9262 state.parseOpenFlags = parseOpenFlags;
9263 state.getFdEntry = getFdEntry;
9264 state.toWritableView = toWritableView;
9265 globalThis.__pi_vfs_state = state;
9266 return state;
9267})();
9268
9269export function existsSync(path) {
9270 try {
9271 statSync(path);
9272 return true;
9273 } catch (_err) {
9274 return false;
9275 }
9276}
9277
9278export function readFileSync(path, encoding) {
9279 const resolved = __pi_vfs.resolvePath(path, true);
9280 let bytes = __pi_vfs.files.get(resolved);
9281 let hostError;
9282 if (!bytes && typeof globalThis.__pi_host_read_file_sync === "function") {
9283 try {
9284 const content = globalThis.__pi_host_read_file_sync(resolved);
9285 bytes = __pi_vfs.toBytes(content);
9286 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9287 __pi_vfs.files.set(resolved, bytes);
9288 } catch (e) {
9289 const message = String((e && e.message) ? e.message : e);
9290 if (message.includes("host read denied")) {
9291 throw e;
9292 }
9293 hostError = message;
9294 /* fall through to ENOENT */
9295 }
9296 }
9297 if (!bytes) {
9298 const detail = hostError ? ` (host: ${hostError})` : "";
9299 throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'${detail}`);
9300 }
9301 return __pi_vfs.decodeBytes(bytes, encoding);
9302}
9303
9304export function appendFileSync(path, data, opts) {
9305 const resolved = __pi_vfs.resolvePath(path, true);
9306 __pi_vfs.checkWriteAccess(resolved);
9307 const current = __pi_vfs.files.get(resolved) || new Uint8Array();
9308 const next = __pi_vfs.toBytes(data, opts);
9309 const merged = new Uint8Array(current.byteLength + next.byteLength);
9310 merged.set(current, 0);
9311 merged.set(next, current.byteLength);
9312 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9313 __pi_vfs.files.set(resolved, merged);
9314}
9315
9316export function writeFileSync(path, data, opts) {
9317 const resolved = __pi_vfs.resolvePath(path, true);
9318 __pi_vfs.checkWriteAccess(resolved);
9319 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9320 __pi_vfs.files.set(resolved, __pi_vfs.toBytes(data, opts));
9321}
9322
9323export function readdirSync(path, opts) {
9324 const resolved = __pi_vfs.resolvePath(path, true);
9325 if (!__pi_vfs.dirs.has(resolved)) {
9326 throw new Error(`ENOENT: no such file or directory, scandir '${String(path ?? "")}'`);
9327 }
9328 const withFileTypes = !!(opts && typeof opts === "object" && opts.withFileTypes);
9329 return __pi_vfs.listChildren(resolved, withFileTypes);
9330}
9331
9332const __fakeStat = {
9333 isFile() { return false; },
9334 isDirectory() { return false; },
9335 isSymbolicLink() { return false; },
9336 isBlockDevice() { return false; },
9337 isCharacterDevice() { return false; },
9338 isFIFO() { return false; },
9339 isSocket() { return false; },
9340 size: 0, mode: 0o644, uid: 0, gid: 0,
9341 atimeMs: 0, mtimeMs: 0, ctimeMs: 0, birthtimeMs: 0,
9342 atime: new Date(0), mtime: new Date(0), ctime: new Date(0), birthtime: new Date(0),
9343 dev: 0, ino: 0, nlink: 1, rdev: 0, blksize: 4096, blocks: 0,
9344};
9345export function statSync(path) { return __pi_vfs.makeStat(path, true); }
9346export function lstatSync(path) { return __pi_vfs.makeStat(path, false); }
9347export function mkdtempSync(prefix, _opts) {
9348 const p = String(prefix ?? "/tmp/tmp-");
9349 const out = `${p}${Date.now().toString(36)}`;
9350 __pi_vfs.ensureDir(out);
9351 return out;
9352}
9353export function realpathSync(path, _opts) {
9354 return __pi_vfs.resolvePath(path, true);
9355}
9356export function unlinkSync(path) {
9357 const normalized = __pi_vfs.normalizePath(path);
9358 __pi_vfs.checkWriteAccess(normalized);
9359 if (__pi_vfs.symlinks.delete(normalized)) {
9360 return;
9361 }
9362 if (!__pi_vfs.files.delete(normalized)) {
9363 throw new Error(`ENOENT: no such file or directory, unlink '${String(path ?? "")}'`);
9364 }
9365}
9366export function rmdirSync(path, _opts) {
9367 const normalized = __pi_vfs.normalizePath(path);
9368 __pi_vfs.checkWriteAccess(normalized);
9369 if (normalized === "/") {
9370 throw new Error("EBUSY: resource busy or locked, rmdir '/'");
9371 }
9372 if (__pi_vfs.symlinks.has(normalized)) {
9373 throw new Error(`ENOTDIR: not a directory, rmdir '${String(path ?? "")}'`);
9374 }
9375 for (const filePath of __pi_vfs.files.keys()) {
9376 if (filePath.startsWith(`${normalized}/`)) {
9377 throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
9378 }
9379 }
9380 for (const dirPath of __pi_vfs.dirs) {
9381 if (dirPath.startsWith(`${normalized}/`)) {
9382 throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
9383 }
9384 }
9385 for (const linkPath of __pi_vfs.symlinks.keys()) {
9386 if (linkPath.startsWith(`${normalized}/`)) {
9387 throw new Error(`ENOTEMPTY: directory not empty, rmdir '${String(path ?? "")}'`);
9388 }
9389 }
9390 if (!__pi_vfs.dirs.delete(normalized)) {
9391 throw new Error(`ENOENT: no such file or directory, rmdir '${String(path ?? "")}'`);
9392 }
9393}
9394export function rmSync(path, opts) {
9395 const normalized = __pi_vfs.normalizePath(path);
9396 __pi_vfs.checkWriteAccess(normalized);
9397 if (__pi_vfs.files.has(normalized)) {
9398 __pi_vfs.files.delete(normalized);
9399 return;
9400 }
9401 if (__pi_vfs.symlinks.has(normalized)) {
9402 __pi_vfs.symlinks.delete(normalized);
9403 return;
9404 }
9405 if (__pi_vfs.dirs.has(normalized)) {
9406 const recursive = !!(opts && typeof opts === "object" && opts.recursive);
9407 if (!recursive) {
9408 rmdirSync(normalized);
9409 return;
9410 }
9411 for (const filePath of Array.from(__pi_vfs.files.keys())) {
9412 if (filePath === normalized || filePath.startsWith(`${normalized}/`)) {
9413 __pi_vfs.files.delete(filePath);
9414 }
9415 }
9416 for (const dirPath of Array.from(__pi_vfs.dirs)) {
9417 if (dirPath === normalized || dirPath.startsWith(`${normalized}/`)) {
9418 __pi_vfs.dirs.delete(dirPath);
9419 }
9420 }
9421 for (const linkPath of Array.from(__pi_vfs.symlinks.keys())) {
9422 if (linkPath === normalized || linkPath.startsWith(`${normalized}/`)) {
9423 __pi_vfs.symlinks.delete(linkPath);
9424 }
9425 }
9426 if (!__pi_vfs.dirs.has("/")) {
9427 __pi_vfs.dirs.add("/");
9428 }
9429 return;
9430 }
9431 throw new Error(`ENOENT: no such file or directory, rm '${String(path ?? "")}'`);
9432}
9433export function copyFileSync(src, dest, _mode) {
9434 writeFileSync(dest, readFileSync(src));
9435}
9436export function renameSync(oldPath, newPath) {
9437 const src = __pi_vfs.normalizePath(oldPath);
9438 const dst = __pi_vfs.normalizePath(newPath);
9439 __pi_vfs.checkWriteAccess(src);
9440 __pi_vfs.checkWriteAccess(dst);
9441 const linkTarget = __pi_vfs.symlinks.get(src);
9442 if (linkTarget !== undefined) {
9443 __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
9444 __pi_vfs.symlinks.set(dst, linkTarget);
9445 __pi_vfs.symlinks.delete(src);
9446 return;
9447 }
9448 const bytes = __pi_vfs.files.get(src);
9449 if (bytes !== undefined) {
9450 __pi_vfs.ensureDir(__pi_vfs.dirname(dst));
9451 __pi_vfs.files.set(dst, bytes);
9452 __pi_vfs.files.delete(src);
9453 return;
9454 }
9455 throw new Error(`ENOENT: no such file or directory, rename '${String(oldPath ?? "")}'`);
9456}
9457export function mkdirSync(path, _opts) {
9458 const resolved = __pi_vfs.resolvePath(path, true);
9459 __pi_vfs.checkWriteAccess(resolved);
9460 __pi_vfs.ensureDir(path);
9461 return __pi_vfs.normalizePath(path);
9462}
9463export function accessSync(path, _mode) {
9464 if (!existsSync(path)) {
9465 throw new Error("ENOENT: no such file or directory");
9466 }
9467}
9468export function chmodSync(_path, _mode) { return; }
9469export function chownSync(_path, _uid, _gid) { return; }
9470export function readlinkSync(path, opts) {
9471 const normalized = __pi_vfs.normalizePath(path);
9472 if (!__pi_vfs.symlinks.has(normalized)) {
9473 if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized)) {
9474 throw new Error(`EINVAL: invalid argument, readlink '${String(path ?? "")}'`);
9475 }
9476 throw new Error(`ENOENT: no such file or directory, readlink '${String(path ?? "")}'`);
9477 }
9478 const target = String(__pi_vfs.symlinks.get(normalized));
9479 const encoding =
9480 typeof opts === "string"
9481 ? opts
9482 : opts && typeof opts === "object" && typeof opts.encoding === "string"
9483 ? opts.encoding
9484 : undefined;
9485 if (encoding && String(encoding).toLowerCase() === "buffer") {
9486 return Buffer.from(target, "utf8");
9487 }
9488 return target;
9489}
9490export function symlinkSync(target, path, _type) {
9491 const normalized = __pi_vfs.normalizePath(path);
9492 __pi_vfs.checkWriteAccess(normalized);
9493 const parent = __pi_vfs.dirname(normalized);
9494 if (!__pi_vfs.dirs.has(parent)) {
9495 throw new Error(`ENOENT: no such file or directory, symlink '${String(path ?? "")}'`);
9496 }
9497 if (__pi_vfs.files.has(normalized) || __pi_vfs.dirs.has(normalized) || __pi_vfs.symlinks.has(normalized)) {
9498 throw new Error(`EEXIST: file already exists, symlink '${String(path ?? "")}'`);
9499 }
9500 __pi_vfs.symlinks.set(normalized, String(target ?? ""));
9501}
9502export function openSync(path, flags = "r", _mode) {
9503 const resolved = __pi_vfs.resolvePath(path, true);
9504 const opts = __pi_vfs.parseOpenFlags(flags);
9505
9506 if (opts.writable || opts.create || opts.append || opts.truncate) {
9507 __pi_vfs.checkWriteAccess(resolved);
9508 }
9509
9510 if (__pi_vfs.dirs.has(resolved)) {
9511 throw new Error(`EISDIR: illegal operation on a directory, open '${String(path ?? "")}'`);
9512 }
9513
9514 const exists = __pi_vfs.files.has(resolved);
9515 if (!exists && !opts.create) {
9516 throw new Error(`ENOENT: no such file or directory, open '${String(path ?? "")}'`);
9517 }
9518 if (exists && opts.create && opts.exclusive) {
9519 throw new Error(`EEXIST: file already exists, open '${String(path ?? "")}'`);
9520 }
9521 if (!exists && opts.create) {
9522 __pi_vfs.ensureDir(__pi_vfs.dirname(resolved));
9523 __pi_vfs.files.set(resolved, new Uint8Array());
9524 }
9525 if (opts.truncate && opts.writable) {
9526 __pi_vfs.files.set(resolved, new Uint8Array());
9527 }
9528
9529 const fd = __pi_vfs.nextFd++;
9530 const current = __pi_vfs.files.get(resolved) || new Uint8Array();
9531 __pi_vfs.fds.set(fd, {
9532 path: resolved,
9533 readable: opts.readable,
9534 writable: opts.writable,
9535 append: opts.append,
9536 position: opts.append ? current.byteLength : 0,
9537 });
9538 return fd;
9539}
9540export function closeSync(fd) {
9541 if (!__pi_vfs.fds.delete(fd)) {
9542 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9543 }
9544}
9545export function readSync(fd, buffer, offset = 0, length, position = null) {
9546 const entry = __pi_vfs.getFdEntry(fd);
9547 if (!entry.readable) {
9548 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9549 }
9550 const out = __pi_vfs.toWritableView(buffer);
9551 const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
9552 const maxLen =
9553 Number.isInteger(length) && length >= 0
9554 ? length
9555 : Math.max(0, out.byteLength - start);
9556 let cursor =
9557 typeof position === "number" && Number.isFinite(position) && position >= 0
9558 ? Math.floor(position)
9559 : entry.position;
9560 const source = __pi_vfs.files.get(entry.path) || new Uint8Array();
9561 if (cursor >= source.byteLength || maxLen <= 0 || start >= out.byteLength) {
9562 return 0;
9563 }
9564 const readLen = Math.min(maxLen, out.byteLength - start, source.byteLength - cursor);
9565 out.set(source.subarray(cursor, cursor + readLen), start);
9566 if (position === null || position === undefined) {
9567 entry.position = cursor + readLen;
9568 }
9569 return readLen;
9570}
9571export function writeSync(fd, buffer, offset, length, position) {
9572 const entry = __pi_vfs.getFdEntry(fd);
9573 if (!entry.writable) {
9574 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9575 }
9576
9577 let chunk;
9578 let explicitPosition = false;
9579 let cursor = null;
9580
9581 if (typeof buffer === "string") {
9582 const encoding =
9583 typeof length === "string"
9584 ? length
9585 : typeof offset === "string"
9586 ? offset
9587 : undefined;
9588 chunk = __pi_vfs.toBytes(buffer, encoding);
9589 if (
9590 arguments.length >= 3 &&
9591 typeof offset === "number" &&
9592 Number.isFinite(offset) &&
9593 offset >= 0
9594 ) {
9595 explicitPosition = true;
9596 cursor = Math.floor(offset);
9597 }
9598 } else {
9599 const input = __pi_vfs.toWritableView(buffer);
9600 const start = Number.isInteger(offset) && offset >= 0 ? offset : 0;
9601 const maxLen =
9602 Number.isInteger(length) && length >= 0
9603 ? length
9604 : Math.max(0, input.byteLength - start);
9605 chunk = input.subarray(start, Math.min(input.byteLength, start + maxLen));
9606 if (typeof position === "number" && Number.isFinite(position) && position >= 0) {
9607 explicitPosition = true;
9608 cursor = Math.floor(position);
9609 }
9610 }
9611
9612 if (!explicitPosition) {
9613 cursor = entry.append
9614 ? (__pi_vfs.files.get(entry.path)?.byteLength || 0)
9615 : entry.position;
9616 }
9617
9618 const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
9619 const required = cursor + chunk.byteLength;
9620 const next = new Uint8Array(Math.max(current.byteLength, required));
9621 next.set(current, 0);
9622 next.set(chunk, cursor);
9623 __pi_vfs.files.set(entry.path, next);
9624
9625 if (!explicitPosition) {
9626 entry.position = cursor + chunk.byteLength;
9627 }
9628 return chunk.byteLength;
9629}
9630export function fstatSync(fd) {
9631 const entry = __pi_vfs.getFdEntry(fd);
9632 return __pi_vfs.makeStat(entry.path, true);
9633}
9634export function ftruncateSync(fd, len = 0) {
9635 const entry = __pi_vfs.getFdEntry(fd);
9636 if (!entry.writable) {
9637 throw new Error(`EBADF: bad file descriptor, fd ${String(fd)}`);
9638 }
9639 const targetLen =
9640 Number.isInteger(len) && len >= 0 ? len : 0;
9641 const current = __pi_vfs.files.get(entry.path) || new Uint8Array();
9642 const next = new Uint8Array(targetLen);
9643 next.set(current.subarray(0, Math.min(current.byteLength, targetLen)));
9644 __pi_vfs.files.set(entry.path, next);
9645 if (entry.position > targetLen) {
9646 entry.position = targetLen;
9647 }
9648}
9649export function futimesSync(_fd, _atime, _mtime) { return; }
9650function __fakeWatcher() {
9651 const w = { close() {}, unref() { return w; }, ref() { return w; }, on() { return w; }, once() { return w; }, removeListener() { return w; }, removeAllListeners() { return w; } };
9652 return w;
9653}
9654export function watch(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
9655export function watchFile(_path, _optsOrListener, _listener) { return __fakeWatcher(); }
9656export function unwatchFile(_path, _listener) { return; }
9657function __queueMicrotaskPolyfill(fn) {
9658 if (typeof queueMicrotask === "function") {
9659 queueMicrotask(fn);
9660 return;
9661 }
9662 Promise.resolve().then(fn);
9663}
9664export function createReadStream(path, opts) {
9665 const options = opts && typeof opts === "object" ? opts : {};
9666 const encoding = typeof options.encoding === "string" ? options.encoding : null;
9667 const highWaterMark =
9668 Number.isInteger(options.highWaterMark) && options.highWaterMark > 0
9669 ? options.highWaterMark
9670 : 64 * 1024;
9671
9672 const stream = new Readable({ encoding: encoding || undefined, autoDestroy: false });
9673 stream.path = __pi_vfs.normalizePath(path);
9674
9675 __queueMicrotaskPolyfill(() => {
9676 try {
9677 const bytes = readFileSync(path, "buffer");
9678 const source =
9679 bytes instanceof Uint8Array
9680 ? bytes
9681 : (typeof Buffer !== "undefined" && Buffer.from
9682 ? Buffer.from(bytes)
9683 : __pi_vfs.toBytes(bytes));
9684
9685 if (source.byteLength === 0) {
9686 stream.push(null);
9687 return;
9688 }
9689
9690 let offset = 0;
9691 while (offset < source.byteLength) {
9692 const nextOffset = Math.min(source.byteLength, offset + highWaterMark);
9693 const slice = source.subarray(offset, nextOffset);
9694 if (encoding && typeof Buffer !== "undefined" && Buffer.from) {
9695 stream.push(Buffer.from(slice).toString(encoding));
9696 } else {
9697 stream.push(slice);
9698 }
9699 offset = nextOffset;
9700 }
9701 stream.push(null);
9702 } catch (err) {
9703 stream.emit("error", err instanceof Error ? err : new Error(String(err)));
9704 }
9705 });
9706
9707 return stream;
9708}
9709export function createWriteStream(path, opts) {
9710 const options = opts && typeof opts === "object" ? opts : {};
9711 const encoding = typeof options.encoding === "string" ? options.encoding : "utf8";
9712 const flags = typeof options.flags === "string" ? options.flags : "w";
9713 const appendMode = flags.startsWith("a");
9714 const bufferedChunks = [];
9715
9716 const stream = new Writable({
9717 autoDestroy: false,
9718 write(chunk, chunkEncoding, callback) {
9719 try {
9720 const normalizedEncoding =
9721 typeof chunkEncoding === "string" && chunkEncoding
9722 ? chunkEncoding
9723 : encoding;
9724 const bytes = __pi_vfs.toBytes(chunk, normalizedEncoding);
9725 bufferedChunks.push(bytes);
9726 this.bytesWritten += bytes.byteLength;
9727 callback(null);
9728 } catch (err) {
9729 callback(err instanceof Error ? err : new Error(String(err)));
9730 }
9731 },
9732 final(callback) {
9733 try {
9734 if (appendMode) {
9735 for (const bytes of bufferedChunks) {
9736 appendFileSync(path, bytes);
9737 }
9738 } else {
9739 const totalSize = bufferedChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0);
9740 const merged = new Uint8Array(totalSize);
9741 let offset = 0;
9742 for (const bytes of bufferedChunks) {
9743 merged.set(bytes, offset);
9744 offset += bytes.byteLength;
9745 }
9746 writeFileSync(path, merged);
9747 }
9748 callback(null);
9749 } catch (err) {
9750 callback(err instanceof Error ? err : new Error(String(err)));
9751 }
9752 },
9753 });
9754 stream.path = __pi_vfs.normalizePath(path);
9755 stream.bytesWritten = 0;
9756 stream.cork = () => stream;
9757 stream.uncork = () => stream;
9758 return stream;
9759}
9760export function readFile(path, optOrCb, cb) {
9761 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9762 const encoding = typeof optOrCb === 'function' ? undefined : optOrCb;
9763 if (typeof callback === 'function') {
9764 try { callback(null, readFileSync(path, encoding)); }
9765 catch (err) { callback(err); }
9766 }
9767}
9768export function writeFile(path, data, optOrCb, cb) {
9769 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9770 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9771 if (typeof callback === 'function') {
9772 try { writeFileSync(path, data, opts); callback(null); }
9773 catch (err) { callback(err); }
9774 }
9775}
9776export function stat(path, optOrCb, cb) {
9777 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9778 if (typeof callback === 'function') {
9779 try { callback(null, statSync(path)); }
9780 catch (err) { callback(err); }
9781 }
9782}
9783export function readdir(path, optOrCb, cb) {
9784 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9785 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9786 if (typeof callback === 'function') {
9787 try { callback(null, readdirSync(path, opts)); }
9788 catch (err) { callback(err); }
9789 }
9790}
9791export function mkdir(path, optOrCb, cb) {
9792 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9793 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9794 if (typeof callback === 'function') {
9795 try { callback(null, mkdirSync(path, opts)); }
9796 catch (err) { callback(err); }
9797 }
9798}
9799export function unlink(path, cb) {
9800 if (typeof cb === 'function') {
9801 try { unlinkSync(path); cb(null); }
9802 catch (err) { cb(err); }
9803 }
9804}
9805export function readlink(path, optOrCb, cb) {
9806 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9807 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9808 if (typeof callback === 'function') {
9809 try { callback(null, readlinkSync(path, opts)); }
9810 catch (err) { callback(err); }
9811 }
9812}
9813export function symlink(target, path, typeOrCb, cb) {
9814 const callback = typeof typeOrCb === 'function' ? typeOrCb : cb;
9815 const type = typeof typeOrCb === 'function' ? undefined : typeOrCb;
9816 if (typeof callback === 'function') {
9817 try { symlinkSync(target, path, type); callback(null); }
9818 catch (err) { callback(err); }
9819 }
9820}
9821export function lstat(path, optOrCb, cb) {
9822 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9823 if (typeof callback === 'function') {
9824 try { callback(null, lstatSync(path)); }
9825 catch (err) { callback(err); }
9826 }
9827}
9828export function rmdir(path, optOrCb, cb) {
9829 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9830 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9831 if (typeof callback === 'function') {
9832 try { rmdirSync(path, opts); callback(null); }
9833 catch (err) { callback(err); }
9834 }
9835}
9836export function rm(path, optOrCb, cb) {
9837 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9838 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9839 if (typeof callback === 'function') {
9840 try { rmSync(path, opts); callback(null); }
9841 catch (err) { callback(err); }
9842 }
9843}
9844export function rename(oldPath, newPath, cb) {
9845 if (typeof cb === 'function') {
9846 try { renameSync(oldPath, newPath); cb(null); }
9847 catch (err) { cb(err); }
9848 }
9849}
9850export function copyFile(src, dest, flagsOrCb, cb) {
9851 const callback = typeof flagsOrCb === 'function' ? flagsOrCb : cb;
9852 if (typeof callback === 'function') {
9853 try { copyFileSync(src, dest); callback(null); }
9854 catch (err) { callback(err); }
9855 }
9856}
9857export function appendFile(path, data, optOrCb, cb) {
9858 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9859 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9860 if (typeof callback === 'function') {
9861 try { appendFileSync(path, data, opts); callback(null); }
9862 catch (err) { callback(err); }
9863 }
9864}
9865export function chmod(path, mode, cb) {
9866 if (typeof cb === 'function') {
9867 try { chmodSync(path, mode); cb(null); }
9868 catch (err) { cb(err); }
9869 }
9870}
9871export function chown(path, uid, gid, cb) {
9872 if (typeof cb === 'function') {
9873 try { chownSync(path, uid, gid); cb(null); }
9874 catch (err) { cb(err); }
9875 }
9876}
9877export function realpath(path, optOrCb, cb) {
9878 const callback = typeof optOrCb === 'function' ? optOrCb : cb;
9879 const opts = typeof optOrCb === 'function' ? undefined : optOrCb;
9880 if (typeof callback === 'function') {
9881 try { callback(null, realpathSync(path, opts)); }
9882 catch (err) { callback(err); }
9883 }
9884}
9885export function access(_path, modeOrCb, cb) {
9886 const callback = typeof modeOrCb === 'function' ? modeOrCb : cb;
9887 if (typeof callback === 'function') {
9888 try {
9889 accessSync(_path);
9890 callback(null);
9891 } catch (err) {
9892 callback(err);
9893 }
9894 }
9895}
9896export const promises = {
9897 access: async (path, _mode) => accessSync(path),
9898 mkdir: async (path, opts) => mkdirSync(path, opts),
9899 mkdtemp: async (prefix, _opts) => {
9900 return mkdtempSync(prefix, _opts);
9901 },
9902 readFile: async (path, opts) => readFileSync(path, opts),
9903 writeFile: async (path, data, opts) => writeFileSync(path, data, opts),
9904 unlink: async (path) => unlinkSync(path),
9905 readlink: async (path, opts) => readlinkSync(path, opts),
9906 symlink: async (target, path, type) => symlinkSync(target, path, type),
9907 rmdir: async (path, opts) => rmdirSync(path, opts),
9908 stat: async (path) => statSync(path),
9909 lstat: async (path) => lstatSync(path),
9910 realpath: async (path, _opts) => realpathSync(path, _opts),
9911 readdir: async (path, opts) => readdirSync(path, opts),
9912 rm: async (path, opts) => rmSync(path, opts),
9913 rename: async (oldPath, newPath) => renameSync(oldPath, newPath),
9914 copyFile: async (src, dest, mode) => copyFileSync(src, dest, mode),
9915 appendFile: async (path, data, opts) => appendFileSync(path, data, opts),
9916 chmod: async (_path, _mode) => {},
9917};
9918export default { constants, existsSync, readFileSync, appendFileSync, writeFileSync, readdirSync, statSync, lstatSync, mkdtempSync, realpathSync, unlinkSync, rmdirSync, rmSync, copyFileSync, renameSync, mkdirSync, accessSync, chmodSync, chownSync, readlinkSync, symlinkSync, openSync, closeSync, readSync, writeSync, fstatSync, ftruncateSync, futimesSync, watch, watchFile, unwatchFile, createReadStream, createWriteStream, readFile, writeFile, stat, lstat, readdir, mkdir, unlink, readlink, symlink, rmdir, rm, rename, copyFile, appendFile, chmod, chown, realpath, access, promises };
9919"#
9920 .trim()
9921 .to_string(),
9922 );
9923
9924 modules.insert(
9925 "node:fs/promises".to_string(),
9926 r"
9927import fs from 'node:fs';
9928
9929export async function access(path, mode) { return fs.promises.access(path, mode); }
9930export async function mkdir(path, opts) { return fs.promises.mkdir(path, opts); }
9931export async function mkdtemp(prefix, opts) { return fs.promises.mkdtemp(prefix, opts); }
9932export async function readFile(path, opts) { return fs.promises.readFile(path, opts); }
9933export async function writeFile(path, data, opts) { return fs.promises.writeFile(path, data, opts); }
9934export async function unlink(path) { return fs.promises.unlink(path); }
9935export async function readlink(path, opts) { return fs.promises.readlink(path, opts); }
9936export async function symlink(target, path, type) { return fs.promises.symlink(target, path, type); }
9937export async function rmdir(path, opts) { return fs.promises.rmdir(path, opts); }
9938export async function stat(path) { return fs.promises.stat(path); }
9939export async function realpath(path, opts) { return fs.promises.realpath(path, opts); }
9940export async function readdir(path, opts) { return fs.promises.readdir(path, opts); }
9941export async function rm(path, opts) { return fs.promises.rm(path, opts); }
9942export async function lstat(path) { return fs.promises.lstat(path); }
9943export async function copyFile(src, dest) { return fs.promises.copyFile(src, dest); }
9944export async function rename(oldPath, newPath) { return fs.promises.rename(oldPath, newPath); }
9945export async function chmod(path, mode) { return; }
9946export async function chown(path, uid, gid) { return; }
9947export async function utimes(path, atime, mtime) { return; }
9948export async function appendFile(path, data, opts) { return fs.promises.appendFile(path, data, opts); }
9949export async function open(path, flags, mode) { return { close: async () => {} }; }
9950export async function truncate(path, len) { return; }
9951export default { access, mkdir, mkdtemp, readFile, writeFile, unlink, readlink, symlink, rmdir, stat, lstat, realpath, readdir, rm, copyFile, rename, chmod, chown, utimes, appendFile, open, truncate };
9952"
9953 .trim()
9954 .to_string(),
9955 );
9956
9957 modules.insert(
9958 "node:http".to_string(),
9959 crate::http_shim::NODE_HTTP_JS.trim().to_string(),
9960 );
9961
9962 modules.insert(
9963 "node:https".to_string(),
9964 crate::http_shim::NODE_HTTPS_JS.trim().to_string(),
9965 );
9966
9967 modules.insert(
9968 "node:http2".to_string(),
9969 r#"
9970import EventEmitter from "node:events";
9971
9972export const constants = {
9973 HTTP2_HEADER_STATUS: ":status",
9974 HTTP2_HEADER_METHOD: ":method",
9975 HTTP2_HEADER_PATH: ":path",
9976 HTTP2_HEADER_AUTHORITY: ":authority",
9977 HTTP2_HEADER_SCHEME: ":scheme",
9978 HTTP2_HEADER_PROTOCOL: ":protocol",
9979 HTTP2_HEADER_CONTENT_TYPE: "content-type",
9980 NGHTTP2_CANCEL: 8,
9981};
9982
9983function __makeStream() {
9984 const stream = new EventEmitter();
9985 stream.end = (_data, _encoding, cb) => {
9986 if (typeof cb === "function") cb();
9987 stream.emit("finish");
9988 };
9989 stream.close = () => stream.emit("close");
9990 stream.destroy = (err) => {
9991 if (err) stream.emit("error", err);
9992 stream.emit("close");
9993 };
9994 stream.respond = () => {};
9995 stream.setEncoding = () => stream;
9996 stream.setTimeout = (_ms, cb) => {
9997 if (typeof cb === "function") cb();
9998 return stream;
9999 };
10000 return stream;
10001}
10002
10003function __makeSession() {
10004 const session = new EventEmitter();
10005 session.closed = false;
10006 session.connecting = false;
10007 session.request = (_headers, _opts) => __makeStream();
10008 session.close = () => {
10009 session.closed = true;
10010 session.emit("close");
10011 };
10012 session.destroy = (err) => {
10013 session.closed = true;
10014 if (err) session.emit("error", err);
10015 session.emit("close");
10016 };
10017 session.ref = () => session;
10018 session.unref = () => session;
10019 return session;
10020}
10021
10022export function connect(_authority, _options, listener) {
10023 const session = __makeSession();
10024 if (typeof listener === "function") {
10025 try {
10026 listener(session);
10027 } catch (_err) {}
10028 }
10029 return session;
10030}
10031
10032export class ClientHttp2Session extends EventEmitter {}
10033export class ClientHttp2Stream extends EventEmitter {}
10034
10035export default { connect, constants, ClientHttp2Session, ClientHttp2Stream };
10036"#
10037 .trim()
10038 .to_string(),
10039 );
10040
10041 modules.insert(
10042 "node:util".to_string(),
10043 r#"
10044export function inspect(value, opts) {
10045 const depth = (opts && typeof opts.depth === 'number') ? opts.depth : 2;
10046 const seen = new Set();
10047 function fmt(v, d) {
10048 if (v === null) return 'null';
10049 if (v === undefined) return 'undefined';
10050 const t = typeof v;
10051 if (t === 'string') return d > 0 ? "'" + v + "'" : v;
10052 if (t === 'number' || t === 'boolean' || t === 'bigint') return String(v);
10053 if (t === 'symbol') return v.toString();
10054 if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';
10055 if (v instanceof Date) return v.toISOString();
10056 if (v instanceof RegExp) return v.toString();
10057 if (v instanceof Error) return v.stack || v.message || String(v);
10058 if (seen.has(v)) return '[Circular]';
10059 seen.add(v);
10060 if (d > depth) { seen.delete(v); return Array.isArray(v) ? '[Array]' : '[Object]'; }
10061 if (Array.isArray(v)) {
10062 const items = v.map(x => fmt(x, d + 1));
10063 seen.delete(v);
10064 return '[ ' + items.join(', ') + ' ]';
10065 }
10066 const keys = Object.keys(v);
10067 if (keys.length === 0) { seen.delete(v); return '{}'; }
10068 const pairs = keys.map(k => k + ': ' + fmt(v[k], d + 1));
10069 seen.delete(v);
10070 return '{ ' + pairs.join(', ') + ' }';
10071 }
10072 return fmt(value, 0);
10073}
10074
10075export function promisify(fn) {
10076 return (...args) => new Promise((resolve, reject) => {
10077 try {
10078 fn(...args, (err, result) => {
10079 if (err) reject(err);
10080 else resolve(result);
10081 });
10082 } catch (e) {
10083 reject(e);
10084 }
10085 });
10086}
10087
10088export function stripVTControlCharacters(str) {
10089 // eslint-disable-next-line no-control-regex
10090 return (str || '').replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1B\][^\x07]*\x07/g, '');
10091}
10092
10093export function deprecate(fn, msg) {
10094 let warned = false;
10095 return function(...args) {
10096 if (!warned) { warned = true; if (typeof console !== 'undefined') console.error('DeprecationWarning: ' + (msg || '')); }
10097 return fn.apply(this, args);
10098 };
10099}
10100export function inherits(ctor, superCtor) {
10101 if (!ctor || !superCtor) return ctor;
10102 const ctorProto = ctor && ctor.prototype;
10103 const superProto = superCtor && superCtor.prototype;
10104 if (!ctorProto || !superProto || typeof ctorProto !== 'object' || typeof superProto !== 'object') {
10105 try { ctor.super_ = superCtor; } catch (_) {}
10106 return ctor;
10107 }
10108 try {
10109 Object.setPrototypeOf(ctorProto, superProto);
10110 ctor.super_ = superCtor;
10111 } catch (_) {
10112 try { ctor.super_ = superCtor; } catch (_ignored) {}
10113 }
10114 return ctor;
10115}
10116export function debuglog(section) {
10117 const env = (typeof process !== 'undefined' && process.env && process.env.NODE_DEBUG) || '';
10118 const enabled = env.split(',').some(s => s.trim().toLowerCase() === (section || '').toLowerCase());
10119 if (!enabled) return () => {};
10120 return (...args) => { if (typeof console !== 'undefined') console.error(section.toUpperCase() + ': ' + args.map(String).join(' ')); };
10121}
10122export function format(f, ...args) {
10123 if (typeof f !== 'string') return [f, ...args].map(v => typeof v === 'string' ? v : inspect(v)).join(' ');
10124 let i = 0;
10125 let result = f.replace(/%[sdifjoO%]/g, (m) => {
10126 if (m === '%%') return '%';
10127 if (i >= args.length) return m;
10128 const a = args[i++];
10129 switch (m) {
10130 case '%s': return String(a);
10131 case '%d': case '%f': return Number(a).toString();
10132 case '%i': return parseInt(a, 10).toString();
10133 case '%j': try { return JSON.stringify(a); } catch { return '[Circular]'; }
10134 case '%o': case '%O': return inspect(a);
10135 default: return m;
10136 }
10137 });
10138 while (i < args.length) result += ' ' + (typeof args[i] === 'string' ? args[i] : inspect(args[i])), i++;
10139 return result;
10140}
10141export function callbackify(fn) {
10142 return function(...args) {
10143 const cb = args.pop();
10144 fn(...args).then(r => cb(null, r), e => cb(e));
10145 };
10146}
10147export const types = {
10148 isAsyncFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'AsyncFunction',
10149 isPromise: (v) => v instanceof Promise,
10150 isDate: (v) => v instanceof Date,
10151 isRegExp: (v) => v instanceof RegExp,
10152 isNativeError: (v) => v instanceof Error,
10153 isSet: (v) => v instanceof Set,
10154 isMap: (v) => v instanceof Map,
10155 isTypedArray: (v) => ArrayBuffer.isView(v) && !(v instanceof DataView),
10156 isArrayBuffer: (v) => v instanceof ArrayBuffer,
10157 isArrayBufferView: (v) => ArrayBuffer.isView(v),
10158 isDataView: (v) => v instanceof DataView,
10159 isGeneratorFunction: (fn) => typeof fn === 'function' && fn.constructor && fn.constructor.name === 'GeneratorFunction',
10160 isGeneratorObject: (v) => v && typeof v.next === 'function' && typeof v.throw === 'function',
10161 isBooleanObject: (v) => typeof v === 'object' && v instanceof Boolean,
10162 isNumberObject: (v) => typeof v === 'object' && v instanceof Number,
10163 isStringObject: (v) => typeof v === 'object' && v instanceof String,
10164 isSymbolObject: () => false,
10165 isWeakMap: (v) => v instanceof WeakMap,
10166 isWeakSet: (v) => v instanceof WeakSet,
10167};
10168export const TextEncoder = globalThis.TextEncoder;
10169export const TextDecoder = globalThis.TextDecoder;
10170
10171export default { inspect, promisify, stripVTControlCharacters, deprecate, inherits, debuglog, format, callbackify, types, TextEncoder, TextDecoder };
10172"#
10173 .trim()
10174 .to_string(),
10175 );
10176
10177 modules.insert(
10178 "node:crypto".to_string(),
10179 crate::crypto_shim::NODE_CRYPTO_JS.trim().to_string(),
10180 );
10181
10182 modules.insert(
10183 "node:readline".to_string(),
10184 r"
10185// Stub readline module - interactive prompts are not available in PiJS
10186
10187export function createInterface(_opts) {
10188 return {
10189 question: (_query, callback) => {
10190 if (typeof callback === 'function') callback('');
10191 },
10192 close: () => {},
10193 on: () => {},
10194 once: () => {},
10195 };
10196}
10197
10198export const promises = {
10199 createInterface: (_opts) => ({
10200 question: async (_query) => '',
10201 close: () => {},
10202 [Symbol.asyncIterator]: async function* () {},
10203 }),
10204};
10205
10206export default { createInterface, promises };
10207"
10208 .trim()
10209 .to_string(),
10210 );
10211
10212 modules.insert(
10213 "node:url".to_string(),
10214 r"
10215export function fileURLToPath(url) {
10216 const u = String(url ?? '');
10217 if (u.startsWith('file://')) {
10218 let p = decodeURIComponent(u.slice(7));
10219 // file:///C:/... → C:/... (strip leading / before Windows drive letter)
10220 if (p.length >= 3 && p[0] === '/' && p[2] === ':') { p = p.slice(1); }
10221 return p;
10222 }
10223 return u;
10224}
10225export function pathToFileURL(path) {
10226 return new URL('file://' + encodeURI(String(path ?? '')));
10227}
10228
10229// Use built-in URL if available (QuickJS may have it), else provide polyfill
10230const _URL = globalThis.URL || (() => {
10231 class URLPolyfill {
10232 constructor(input, base) {
10233 let u = String(input ?? '');
10234 if (base !== undefined) {
10235 const b = String(base);
10236 if (u.startsWith('/')) {
10237 const m = b.match(/^([^:]+:\/\/[^\/]+)/);
10238 u = m ? m[1] + u : b + u;
10239 } else if (!/^[a-z][a-z0-9+.-]*:/i.test(u)) {
10240 u = b.replace(/[^\/]*$/, '') + u;
10241 }
10242 }
10243 this.href = u;
10244 const protoEnd = u.indexOf(':');
10245 this.protocol = protoEnd >= 0 ? u.slice(0, protoEnd + 1) : '';
10246 let rest = protoEnd >= 0 ? u.slice(protoEnd + 1) : u;
10247 this.username = ''; this.password = '';
10248 if (rest.startsWith('//')) {
10249 rest = rest.slice(2);
10250 const pathStart = rest.indexOf('/');
10251 const authority = pathStart >= 0 ? rest.slice(0, pathStart) : rest;
10252 rest = pathStart >= 0 ? rest.slice(pathStart) : '/';
10253 const atIdx = authority.indexOf('@');
10254 let hostPart = authority;
10255 if (atIdx >= 0) {
10256 const userInfo = authority.slice(0, atIdx);
10257 hostPart = authority.slice(atIdx + 1);
10258 const colonIdx = userInfo.indexOf(':');
10259 if (colonIdx >= 0) {
10260 this.username = userInfo.slice(0, colonIdx);
10261 this.password = userInfo.slice(colonIdx + 1);
10262 } else {
10263 this.username = userInfo;
10264 }
10265 }
10266 const portIdx = hostPart.lastIndexOf(':');
10267 if (portIdx >= 0 && /^\d+$/.test(hostPart.slice(portIdx + 1))) {
10268 this.hostname = hostPart.slice(0, portIdx);
10269 this.port = hostPart.slice(portIdx + 1);
10270 } else {
10271 this.hostname = hostPart;
10272 this.port = '';
10273 }
10274 this.host = this.port ? this.hostname + ':' + this.port : this.hostname;
10275 this.origin = this.protocol + '//' + this.host;
10276 } else {
10277 this.hostname = ''; this.host = ''; this.port = '';
10278 this.origin = 'null';
10279 }
10280 const hashIdx = rest.indexOf('#');
10281 if (hashIdx >= 0) {
10282 this.hash = rest.slice(hashIdx);
10283 rest = rest.slice(0, hashIdx);
10284 } else {
10285 this.hash = '';
10286 }
10287 const qIdx = rest.indexOf('?');
10288 if (qIdx >= 0) {
10289 this.search = rest.slice(qIdx);
10290 this.pathname = rest.slice(0, qIdx) || '/';
10291 } else {
10292 this.search = '';
10293 this.pathname = rest || '/';
10294 }
10295 this.searchParams = new _URLSearchParams(this.search.slice(1));
10296 }
10297 toString() { return this.href; }
10298 toJSON() { return this.href; }
10299 }
10300 return URLPolyfill;
10301})();
10302
10303// Always use our polyfill — QuickJS built-in URLSearchParams may not support string init
10304const _URLSearchParams = class URLSearchParamsPolyfill {
10305 constructor(init) {
10306 this._entries = [];
10307 if (typeof init === 'string') {
10308 const s = init.startsWith('?') ? init.slice(1) : init;
10309 if (s) {
10310 for (const pair of s.split('&')) {
10311 const eqIdx = pair.indexOf('=');
10312 if (eqIdx >= 0) {
10313 this._entries.push([decodeURIComponent(pair.slice(0, eqIdx)), decodeURIComponent(pair.slice(eqIdx + 1))]);
10314 } else {
10315 this._entries.push([decodeURIComponent(pair), '']);
10316 }
10317 }
10318 }
10319 }
10320 }
10321 get(key) {
10322 for (const [k, v] of this._entries) { if (k === key) return v; }
10323 return null;
10324 }
10325 set(key, val) {
10326 let found = false;
10327 this._entries = this._entries.filter(([k]) => {
10328 if (k === key && !found) { found = true; return true; }
10329 return k !== key;
10330 });
10331 if (found) {
10332 for (let i = 0; i < this._entries.length; i++) {
10333 if (this._entries[i][0] === key) { this._entries[i][1] = String(val); break; }
10334 }
10335 } else {
10336 this._entries.push([key, String(val)]);
10337 }
10338 }
10339 has(key) { return this._entries.some(([k]) => k === key); }
10340 delete(key) { this._entries = this._entries.filter(([k]) => k !== key); }
10341 append(key, val) { this._entries.push([key, String(val)]); }
10342 getAll(key) { return this._entries.filter(([k]) => k === key).map(([, v]) => v); }
10343 keys() { return this._entries.map(([k]) => k)[Symbol.iterator](); }
10344 values() { return this._entries.map(([, v]) => v)[Symbol.iterator](); }
10345 entries() { return this._entries.slice()[Symbol.iterator](); }
10346 forEach(fn, thisArg) { for (const [k, v] of this._entries) fn.call(thisArg, v, k, this); }
10347 toString() {
10348 return this._entries.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
10349 }
10350 [Symbol.iterator]() { return this.entries(); }
10351 get size() { return this._entries.length; }
10352};
10353
10354export { _URL as URL, _URLSearchParams as URLSearchParams };
10355export function format(urlObj) {
10356 if (typeof urlObj === 'string') return urlObj;
10357 return urlObj && typeof urlObj.href === 'string' ? urlObj.href : String(urlObj);
10358}
10359export function parse(urlStr) {
10360 try { return new _URL(urlStr); } catch (_) { return null; }
10361}
10362export function resolve(from, to) {
10363 try { return new _URL(to, from).href; } catch (_) { return to; }
10364}
10365export default { URL: _URL, URLSearchParams: _URLSearchParams, fileURLToPath, pathToFileURL, format, parse, resolve };
10366"
10367 .trim()
10368 .to_string(),
10369 );
10370
10371 modules.insert(
10372 "node:net".to_string(),
10373 r"
10374// Stub net module - socket operations are not available in PiJS
10375
10376export function createConnection(_opts, _callback) {
10377 throw new Error('node:net.createConnection is not available in PiJS');
10378}
10379
10380export function createServer(_opts, _callback) {
10381 throw new Error('node:net.createServer is not available in PiJS');
10382}
10383
10384export function connect(_opts, _callback) {
10385 throw new Error('node:net.connect is not available in PiJS');
10386}
10387
10388export function isIP(input) {
10389 const value = String(input ?? '');
10390 if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) return 4;
10391 if (/^[0-9a-fA-F:]+$/.test(value) && value.includes(':')) return 6;
10392 return 0;
10393}
10394
10395export function isIPv4(input) { return isIP(input) === 4; }
10396export function isIPv6(input) { return isIP(input) === 6; }
10397
10398export class Socket {
10399 constructor() {
10400 throw new Error('node:net.Socket is not available in PiJS');
10401 }
10402}
10403
10404export class Server {
10405 constructor() {
10406 throw new Error('node:net.Server is not available in PiJS');
10407 }
10408}
10409
10410export default { createConnection, createServer, connect, isIP, isIPv4, isIPv6, Socket, Server };
10411"
10412 .trim()
10413 .to_string(),
10414 );
10415
10416 modules.insert(
10418 "node:events".to_string(),
10419 r"
10420class EventEmitter {
10421 constructor() {
10422 this._events = Object.create(null);
10423 this._maxListeners = 10;
10424 }
10425
10426 on(event, listener) {
10427 if (!this._events[event]) this._events[event] = [];
10428 this._events[event].push(listener);
10429 return this;
10430 }
10431
10432 addListener(event, listener) { return this.on(event, listener); }
10433
10434 once(event, listener) {
10435 const wrapper = (...args) => {
10436 this.removeListener(event, wrapper);
10437 listener.apply(this, args);
10438 };
10439 wrapper._original = listener;
10440 return this.on(event, wrapper);
10441 }
10442
10443 off(event, listener) { return this.removeListener(event, listener); }
10444
10445 removeListener(event, listener) {
10446 const list = this._events[event];
10447 if (!list) return this;
10448 this._events[event] = list.filter(
10449 fn => fn !== listener && fn._original !== listener
10450 );
10451 if (this._events[event].length === 0) delete this._events[event];
10452 return this;
10453 }
10454
10455 removeAllListeners(event) {
10456 if (event === undefined) {
10457 this._events = Object.create(null);
10458 } else {
10459 delete this._events[event];
10460 }
10461 return this;
10462 }
10463
10464 emit(event, ...args) {
10465 const list = this._events[event];
10466 if (!list || list.length === 0) return false;
10467 for (const fn of list.slice()) {
10468 try { fn.apply(this, args); } catch (e) {
10469 if (event !== 'error') this.emit('error', e);
10470 }
10471 }
10472 return true;
10473 }
10474
10475 listeners(event) {
10476 const list = this._events[event];
10477 if (!list) return [];
10478 return list.map(fn => fn._original || fn);
10479 }
10480
10481 listenerCount(event) {
10482 const list = this._events[event];
10483 return list ? list.length : 0;
10484 }
10485
10486 eventNames() { return Object.keys(this._events); }
10487
10488 setMaxListeners(n) { this._maxListeners = n; return this; }
10489 getMaxListeners() { return this._maxListeners; }
10490
10491 prependListener(event, listener) {
10492 if (!this._events[event]) this._events[event] = [];
10493 this._events[event].unshift(listener);
10494 return this;
10495 }
10496
10497 prependOnceListener(event, listener) {
10498 const wrapper = (...args) => {
10499 this.removeListener(event, wrapper);
10500 listener.apply(this, args);
10501 };
10502 wrapper._original = listener;
10503 return this.prependListener(event, wrapper);
10504 }
10505
10506 rawListeners(event) {
10507 return this._events[event] ? this._events[event].slice() : [];
10508 }
10509}
10510
10511EventEmitter.EventEmitter = EventEmitter;
10512EventEmitter.defaultMaxListeners = 10;
10513
10514export { EventEmitter };
10515export default EventEmitter;
10516"
10517 .trim()
10518 .to_string(),
10519 );
10520
10521 modules.insert(
10523 "node:buffer".to_string(),
10524 crate::buffer_shim::NODE_BUFFER_JS.trim().to_string(),
10525 );
10526
10527 modules.insert(
10529 "node:assert".to_string(),
10530 r"
10531function assert(value, message) {
10532 if (!value) throw new Error(message || 'Assertion failed');
10533}
10534assert.ok = assert;
10535assert.equal = (a, b, msg) => { if (a != b) throw new Error(msg || `${a} != ${b}`); };
10536assert.strictEqual = (a, b, msg) => { if (a !== b) throw new Error(msg || `${a} !== ${b}`); };
10537assert.notEqual = (a, b, msg) => { if (a == b) throw new Error(msg || `${a} == ${b}`); };
10538assert.notStrictEqual = (a, b, msg) => { if (a === b) throw new Error(msg || `${a} === ${b}`); };
10539assert.deepEqual = assert.deepStrictEqual = (a, b, msg) => {
10540 if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(msg || 'Deep equality failed');
10541};
10542assert.throws = (fn, _expected, msg) => {
10543 let threw = false;
10544 try { fn(); } catch (_) { threw = true; }
10545 if (!threw) throw new Error(msg || 'Expected function to throw');
10546};
10547assert.doesNotThrow = (fn, _expected, msg) => {
10548 try { fn(); } catch (e) { throw new Error(msg || `Got unwanted exception: ${e}`); }
10549};
10550assert.fail = (msg) => { throw new Error(msg || 'assert.fail()'); };
10551
10552export default assert;
10553export { assert };
10554"
10555 .trim()
10556 .to_string(),
10557 );
10558
10559 modules.insert(
10561 "node:stream".to_string(),
10562 r#"
10563import EventEmitter from "node:events";
10564
10565function __streamToError(err) {
10566 return err instanceof Error ? err : new Error(String(err ?? "stream error"));
10567}
10568
10569function __streamQueueMicrotask(fn) {
10570 if (typeof queueMicrotask === "function") {
10571 queueMicrotask(fn);
10572 return;
10573 }
10574 Promise.resolve().then(fn);
10575}
10576
10577function __normalizeChunk(chunk, encoding) {
10578 if (chunk === null || chunk === undefined) return chunk;
10579 if (typeof chunk === "string") return chunk;
10580 if (typeof Buffer !== "undefined" && Buffer.isBuffer && Buffer.isBuffer(chunk)) {
10581 return encoding ? chunk.toString(encoding) : chunk;
10582 }
10583 if (chunk instanceof Uint8Array) {
10584 return encoding && typeof Buffer !== "undefined" && Buffer.from
10585 ? Buffer.from(chunk).toString(encoding)
10586 : chunk;
10587 }
10588 if (chunk instanceof ArrayBuffer) {
10589 const view = new Uint8Array(chunk);
10590 return encoding && typeof Buffer !== "undefined" && Buffer.from
10591 ? Buffer.from(view).toString(encoding)
10592 : view;
10593 }
10594 if (ArrayBuffer.isView(chunk)) {
10595 const view = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
10596 return encoding && typeof Buffer !== "undefined" && Buffer.from
10597 ? Buffer.from(view).toString(encoding)
10598 : view;
10599 }
10600 return encoding ? String(chunk) : chunk;
10601}
10602
10603class Stream extends EventEmitter {
10604 constructor() {
10605 super();
10606 this.destroyed = false;
10607 }
10608
10609 destroy(err) {
10610 if (this.destroyed) return this;
10611 this.destroyed = true;
10612 if (err) this.emit("error", __streamToError(err));
10613 this.emit("close");
10614 return this;
10615 }
10616}
10617
10618class Readable extends Stream {
10619 constructor(opts = {}) {
10620 super();
10621 this._readableState = { flowing: null, ended: false, encoding: opts.encoding || null };
10622 this.readable = true;
10623 this._queue = [];
10624 this._pipeCleanup = new Map();
10625 this._autoDestroy = opts.autoDestroy !== false;
10626 }
10627
10628 push(chunk) {
10629 if (chunk === null) {
10630 if (this._readableState.ended) return false;
10631 this._readableState.ended = true;
10632 __streamQueueMicrotask(() => {
10633 this.emit("end");
10634 if (this._autoDestroy) this.emit("close");
10635 });
10636 return false;
10637 }
10638 const normalized = __normalizeChunk(chunk, this._readableState.encoding);
10639 this._queue.push(normalized);
10640 this.emit("data", normalized);
10641 return true;
10642 }
10643
10644 read(_size) {
10645 return this._queue.length > 0 ? this._queue.shift() : null;
10646 }
10647
10648 pipe(dest) {
10649 if (!dest || typeof dest.write !== "function") {
10650 throw new Error("stream.pipe destination must implement write()");
10651 }
10652
10653 const onData = (chunk) => {
10654 const writable = dest.write(chunk);
10655 if (writable === false && typeof this.pause === "function") {
10656 this.pause();
10657 }
10658 };
10659 const onDrain = () => {
10660 if (typeof this.resume === "function") this.resume();
10661 };
10662 const onEnd = () => {
10663 if (typeof dest.end === "function") dest.end();
10664 cleanup();
10665 };
10666 const onError = (err) => {
10667 cleanup();
10668 if (typeof dest.destroy === "function") {
10669 dest.destroy(err);
10670 } else if (typeof dest.emit === "function") {
10671 dest.emit("error", err);
10672 }
10673 };
10674 const cleanup = () => {
10675 this.removeListener("data", onData);
10676 this.removeListener("end", onEnd);
10677 this.removeListener("error", onError);
10678 if (typeof dest.removeListener === "function") {
10679 dest.removeListener("drain", onDrain);
10680 }
10681 this._pipeCleanup.delete(dest);
10682 };
10683
10684 this.on("data", onData);
10685 this.on("end", onEnd);
10686 this.on("error", onError);
10687 if (typeof dest.on === "function") {
10688 dest.on("drain", onDrain);
10689 }
10690 this._pipeCleanup.set(dest, cleanup);
10691 return dest;
10692 }
10693
10694 unpipe(dest) {
10695 if (dest) {
10696 const cleanup = this._pipeCleanup.get(dest);
10697 if (cleanup) cleanup();
10698 return this;
10699 }
10700 for (const cleanup of this._pipeCleanup.values()) {
10701 cleanup();
10702 }
10703 this._pipeCleanup.clear();
10704 return this;
10705 }
10706
10707 resume() {
10708 this._readableState.flowing = true;
10709 return this;
10710 }
10711
10712 pause() {
10713 this._readableState.flowing = false;
10714 return this;
10715 }
10716
10717 [Symbol.asyncIterator]() {
10718 const stream = this;
10719 const queue = [];
10720 const waiters = [];
10721 let done = false;
10722 let failure = null;
10723
10724 const settleDone = () => {
10725 done = true;
10726 while (waiters.length > 0) {
10727 waiters.shift().resolve({ value: undefined, done: true });
10728 }
10729 };
10730 const settleError = (err) => {
10731 failure = __streamToError(err);
10732 while (waiters.length > 0) {
10733 waiters.shift().reject(failure);
10734 }
10735 };
10736 const onData = (value) => {
10737 if (waiters.length > 0) {
10738 waiters.shift().resolve({ value, done: false });
10739 } else {
10740 queue.push(value);
10741 }
10742 };
10743 const onEnd = () => settleDone();
10744 const onError = (err) => settleError(err);
10745 const cleanup = () => {
10746 stream.removeListener("data", onData);
10747 stream.removeListener("end", onEnd);
10748 stream.removeListener("error", onError);
10749 };
10750
10751 stream.on("data", onData);
10752 stream.on("end", onEnd);
10753 stream.on("error", onError);
10754
10755 return {
10756 async next() {
10757 if (queue.length > 0) return { value: queue.shift(), done: false };
10758 if (failure) throw failure;
10759 if (done) return { value: undefined, done: true };
10760 return await new Promise((resolve, reject) => waiters.push({ resolve, reject }));
10761 },
10762 async return() {
10763 cleanup();
10764 settleDone();
10765 return { value: undefined, done: true };
10766 },
10767 [Symbol.asyncIterator]() { return this; },
10768 };
10769 }
10770
10771 static from(iterable, opts = {}) {
10772 const readable = new Readable(opts);
10773 (async () => {
10774 try {
10775 for await (const chunk of iterable) {
10776 readable.push(chunk);
10777 }
10778 readable.push(null);
10779 } catch (err) {
10780 readable.emit("error", __streamToError(err));
10781 }
10782 })();
10783 return readable;
10784 }
10785
10786 static fromWeb(webReadable, opts = {}) {
10787 if (!webReadable || typeof webReadable.getReader !== "function") {
10788 throw new Error("Readable.fromWeb expects a Web ReadableStream");
10789 }
10790 const reader = webReadable.getReader();
10791 const readable = new Readable(opts);
10792 (async () => {
10793 try {
10794 while (true) {
10795 const { done, value } = await reader.read();
10796 if (done) break;
10797 readable.push(value);
10798 }
10799 readable.push(null);
10800 } catch (err) {
10801 readable.emit("error", __streamToError(err));
10802 } finally {
10803 try { reader.releaseLock(); } catch (_) {}
10804 }
10805 })();
10806 return readable;
10807 }
10808
10809 static toWeb(nodeReadable) {
10810 if (typeof ReadableStream !== "function") {
10811 throw new Error("Readable.toWeb requires global ReadableStream");
10812 }
10813 if (!nodeReadable || typeof nodeReadable.on !== "function") {
10814 throw new Error("Readable.toWeb expects a Node Readable stream");
10815 }
10816 return new ReadableStream({
10817 start(controller) {
10818 const onData = (chunk) => controller.enqueue(chunk);
10819 const onEnd = () => {
10820 cleanup();
10821 controller.close();
10822 };
10823 const onError = (err) => {
10824 cleanup();
10825 controller.error(__streamToError(err));
10826 };
10827 const cleanup = () => {
10828 nodeReadable.removeListener?.("data", onData);
10829 nodeReadable.removeListener?.("end", onEnd);
10830 nodeReadable.removeListener?.("error", onError);
10831 };
10832 nodeReadable.on("data", onData);
10833 nodeReadable.on("end", onEnd);
10834 nodeReadable.on("error", onError);
10835 if (typeof nodeReadable.resume === "function") nodeReadable.resume();
10836 },
10837 cancel(reason) {
10838 if (typeof nodeReadable.destroy === "function") {
10839 nodeReadable.destroy(__streamToError(reason ?? "stream cancelled"));
10840 }
10841 },
10842 });
10843 }
10844}
10845
10846class Writable extends Stream {
10847 constructor(opts = {}) {
10848 super();
10849 this._writableState = { ended: false, finished: false };
10850 this.writable = true;
10851 this._autoDestroy = opts.autoDestroy !== false;
10852 this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
10853 this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
10854 }
10855
10856 _write(chunk, encoding, callback) {
10857 if (this._writeImpl) {
10858 this._writeImpl(chunk, encoding, callback);
10859 return;
10860 }
10861 callback(null);
10862 }
10863
10864 write(chunk, encoding, callback) {
10865 let cb = callback;
10866 let enc = encoding;
10867 if (typeof encoding === "function") {
10868 cb = encoding;
10869 enc = undefined;
10870 }
10871 if (this._writableState.ended) {
10872 const err = new Error("write after end");
10873 if (typeof cb === "function") cb(err);
10874 this.emit("error", err);
10875 return false;
10876 }
10877
10878 try {
10879 this._write(chunk, enc, (err) => {
10880 if (err) {
10881 const normalized = __streamToError(err);
10882 if (typeof cb === "function") cb(normalized);
10883 this.emit("error", normalized);
10884 return;
10885 }
10886 if (typeof cb === "function") cb(null);
10887 this.emit("drain");
10888 });
10889 } catch (err) {
10890 const normalized = __streamToError(err);
10891 if (typeof cb === "function") cb(normalized);
10892 this.emit("error", normalized);
10893 return false;
10894 }
10895 return true;
10896 }
10897
10898 _finish(callback) {
10899 if (this._finalImpl) {
10900 try {
10901 this._finalImpl(callback);
10902 } catch (err) {
10903 callback(__streamToError(err));
10904 }
10905 return;
10906 }
10907 callback(null);
10908 }
10909
10910 end(chunk, encoding, callback) {
10911 let cb = callback;
10912 let enc = encoding;
10913 if (typeof encoding === "function") {
10914 cb = encoding;
10915 enc = undefined;
10916 }
10917
10918 const finalize = () => {
10919 if (this._writableState.ended) {
10920 if (typeof cb === "function") cb(null);
10921 return;
10922 }
10923 this._writableState.ended = true;
10924 this._finish((err) => {
10925 if (err) {
10926 const normalized = __streamToError(err);
10927 if (typeof cb === "function") cb(normalized);
10928 this.emit("error", normalized);
10929 return;
10930 }
10931 this._writableState.finished = true;
10932 this.emit("finish");
10933 if (this._autoDestroy) this.emit("close");
10934 if (typeof cb === "function") cb(null);
10935 });
10936 };
10937
10938 if (chunk !== undefined && chunk !== null) {
10939 this.write(chunk, enc, (err) => {
10940 if (err) {
10941 if (typeof cb === "function") cb(err);
10942 return;
10943 }
10944 finalize();
10945 });
10946 return this;
10947 }
10948
10949 finalize();
10950 return this;
10951 }
10952
10953 static fromWeb(webWritable, opts = {}) {
10954 if (!webWritable || typeof webWritable.getWriter !== "function") {
10955 throw new Error("Writable.fromWeb expects a Web WritableStream");
10956 }
10957 const writer = webWritable.getWriter();
10958 return new Writable({
10959 ...opts,
10960 write(chunk, _encoding, callback) {
10961 Promise.resolve(writer.write(chunk))
10962 .then(() => callback(null))
10963 .catch((err) => callback(__streamToError(err)));
10964 },
10965 final(callback) {
10966 Promise.resolve(writer.close())
10967 .then(() => {
10968 try { writer.releaseLock(); } catch (_) {}
10969 callback(null);
10970 })
10971 .catch((err) => callback(__streamToError(err)));
10972 },
10973 });
10974 }
10975
10976 static toWeb(nodeWritable) {
10977 if (typeof WritableStream !== "function") {
10978 throw new Error("Writable.toWeb requires global WritableStream");
10979 }
10980 if (!nodeWritable || typeof nodeWritable.write !== "function") {
10981 throw new Error("Writable.toWeb expects a Node Writable stream");
10982 }
10983 return new WritableStream({
10984 write(chunk) {
10985 return new Promise((resolve, reject) => {
10986 try {
10987 const ok = nodeWritable.write(chunk, (err) => {
10988 if (err) reject(__streamToError(err));
10989 else resolve();
10990 });
10991 if (ok === true) resolve();
10992 } catch (err) {
10993 reject(__streamToError(err));
10994 }
10995 });
10996 },
10997 close() {
10998 return new Promise((resolve, reject) => {
10999 try {
11000 nodeWritable.end((err) => {
11001 if (err) reject(__streamToError(err));
11002 else resolve();
11003 });
11004 } catch (err) {
11005 reject(__streamToError(err));
11006 }
11007 });
11008 },
11009 abort(reason) {
11010 if (typeof nodeWritable.destroy === "function") {
11011 nodeWritable.destroy(__streamToError(reason ?? "stream aborted"));
11012 }
11013 },
11014 });
11015 }
11016}
11017
11018class Duplex extends Readable {
11019 constructor(opts = {}) {
11020 super(opts);
11021 this._writableState = { ended: false, finished: false };
11022 this.writable = true;
11023 this._autoDestroy = opts.autoDestroy !== false;
11024 this._writeImpl = typeof opts.write === "function" ? opts.write.bind(this) : null;
11025 this._finalImpl = typeof opts.final === "function" ? opts.final.bind(this) : null;
11026 }
11027
11028 _write(chunk, encoding, callback) {
11029 if (this._writeImpl) {
11030 this._writeImpl(chunk, encoding, callback);
11031 return;
11032 }
11033 callback(null);
11034 }
11035
11036 _finish(callback) {
11037 if (this._finalImpl) {
11038 try {
11039 this._finalImpl(callback);
11040 } catch (err) {
11041 callback(__streamToError(err));
11042 }
11043 return;
11044 }
11045 callback(null);
11046 }
11047
11048 write(chunk, encoding, callback) {
11049 return Writable.prototype.write.call(this, chunk, encoding, callback);
11050 }
11051
11052 end(chunk, encoding, callback) {
11053 return Writable.prototype.end.call(this, chunk, encoding, callback);
11054 }
11055}
11056
11057class Transform extends Duplex {
11058 constructor(opts = {}) {
11059 super(opts);
11060 this._transformImpl = typeof opts.transform === "function" ? opts.transform.bind(this) : null;
11061 }
11062
11063 _transform(chunk, encoding, callback) {
11064 if (this._transformImpl) {
11065 this._transformImpl(chunk, encoding, callback);
11066 return;
11067 }
11068 callback(null, chunk);
11069 }
11070
11071 write(chunk, encoding, callback) {
11072 let cb = callback;
11073 let enc = encoding;
11074 if (typeof encoding === "function") {
11075 cb = encoding;
11076 enc = undefined;
11077 }
11078 try {
11079 this._transform(chunk, enc, (err, data) => {
11080 if (err) {
11081 const normalized = __streamToError(err);
11082 if (typeof cb === "function") cb(normalized);
11083 this.emit("error", normalized);
11084 return;
11085 }
11086 if (data !== undefined && data !== null) {
11087 this.push(data);
11088 }
11089 if (typeof cb === "function") cb(null);
11090 });
11091 } catch (err) {
11092 const normalized = __streamToError(err);
11093 if (typeof cb === "function") cb(normalized);
11094 this.emit("error", normalized);
11095 return false;
11096 }
11097 return true;
11098 }
11099
11100 end(chunk, encoding, callback) {
11101 let cb = callback;
11102 let enc = encoding;
11103 if (typeof encoding === "function") {
11104 cb = encoding;
11105 enc = undefined;
11106 }
11107 const finalize = () => {
11108 this.push(null);
11109 this.emit("finish");
11110 this.emit("close");
11111 if (typeof cb === "function") cb(null);
11112 };
11113 if (chunk !== undefined && chunk !== null) {
11114 this.write(chunk, enc, (err) => {
11115 if (err) {
11116 if (typeof cb === "function") cb(err);
11117 return;
11118 }
11119 finalize();
11120 });
11121 return this;
11122 }
11123 finalize();
11124 return this;
11125 }
11126}
11127
11128class PassThrough extends Transform {
11129 _transform(chunk, _encoding, callback) { callback(null, chunk); }
11130}
11131
11132function finished(stream, callback) {
11133 if (!stream || typeof stream.on !== "function") {
11134 const err = new Error("finished expects a stream-like object");
11135 if (typeof callback === "function") callback(err);
11136 return Promise.reject(err);
11137 }
11138 return new Promise((resolve, reject) => {
11139 let settled = false;
11140 const cleanup = () => {
11141 stream.removeListener?.("finish", onDone);
11142 stream.removeListener?.("end", onDone);
11143 stream.removeListener?.("close", onDone);
11144 stream.removeListener?.("error", onError);
11145 };
11146 const settle = (fn, value) => {
11147 if (settled) return;
11148 settled = true;
11149 cleanup();
11150 fn(value);
11151 };
11152 const onDone = () => {
11153 if (typeof callback === "function") callback(null, stream);
11154 settle(resolve, stream);
11155 };
11156 const onError = (err) => {
11157 const normalized = __streamToError(err);
11158 if (typeof callback === "function") callback(normalized);
11159 settle(reject, normalized);
11160 };
11161 stream.on("finish", onDone);
11162 stream.on("end", onDone);
11163 stream.on("close", onDone);
11164 stream.on("error", onError);
11165 });
11166}
11167
11168function pipeline(...args) {
11169 const callback = typeof args[args.length - 1] === "function" ? args.pop() : null;
11170 const streams = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
11171 if (!Array.isArray(streams) || streams.length < 2) {
11172 const err = new Error("pipeline requires at least two streams");
11173 if (callback) callback(err);
11174 throw err;
11175 }
11176
11177 for (let i = 0; i < streams.length - 1; i += 1) {
11178 streams[i].pipe(streams[i + 1]);
11179 }
11180 const last = streams[streams.length - 1];
11181 const done = (err) => {
11182 if (callback) callback(err || null, last);
11183 };
11184 last.on?.("finish", () => done(null));
11185 last.on?.("end", () => done(null));
11186 last.on?.("error", (err) => done(__streamToError(err)));
11187 return last;
11188}
11189
11190const promises = {
11191 pipeline: (...args) =>
11192 new Promise((resolve, reject) => {
11193 try {
11194 pipeline(...args, (err, stream) => {
11195 if (err) reject(err);
11196 else resolve(stream);
11197 });
11198 } catch (err) {
11199 reject(__streamToError(err));
11200 }
11201 }),
11202 finished: (stream) => finished(stream),
11203};
11204
11205export { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
11206export default { Stream, Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished, promises };
11207"#
11208 .trim()
11209 .to_string(),
11210 );
11211
11212 modules.insert(
11214 "node:stream/promises".to_string(),
11215 r"
11216import { Readable, Writable } from 'node:stream';
11217
11218function __streamToError(err) {
11219 return err instanceof Error ? err : new Error(String(err ?? 'stream error'));
11220}
11221
11222function __isReadableLike(stream) {
11223 return !!stream && typeof stream.pipe === 'function' && typeof stream.on === 'function';
11224}
11225
11226function __isWritableLike(stream) {
11227 return !!stream && typeof stream.write === 'function' && typeof stream.on === 'function';
11228}
11229
11230export async function pipeline(...streams) {
11231 if (streams.length === 1 && Array.isArray(streams[0])) {
11232 streams = streams[0];
11233 }
11234 if (streams.length < 2) {
11235 throw new Error('pipeline requires at least two streams');
11236 }
11237
11238 if (!__isReadableLike(streams[0]) && streams[0] && (typeof streams[0][Symbol.asyncIterator] === 'function' || typeof streams[0][Symbol.iterator] === 'function')) {
11239 streams = [Readable.from(streams[0]), ...streams.slice(1)];
11240 }
11241
11242 return await new Promise((resolve, reject) => {
11243 let settled = false;
11244 const cleanups = [];
11245 const cleanup = () => {
11246 while (cleanups.length > 0) {
11247 try { cleanups.pop()(); } catch (_) {}
11248 }
11249 };
11250 const settleResolve = (value) => {
11251 if (settled) return;
11252 settled = true;
11253 cleanup();
11254 resolve(value);
11255 };
11256 const settleReject = (err) => {
11257 if (settled) return;
11258 settled = true;
11259 cleanup();
11260 reject(__streamToError(err));
11261 };
11262 const addListener = (target, event, handler) => {
11263 if (!target || typeof target.on !== 'function') return;
11264 target.on(event, handler);
11265 cleanups.push(() => {
11266 if (typeof target.removeListener === 'function') {
11267 target.removeListener(event, handler);
11268 }
11269 });
11270 };
11271
11272 for (let i = 0; i < streams.length - 1; i += 1) {
11273 const source = streams[i];
11274 const dest = streams[i + 1];
11275 if (!__isReadableLike(source)) {
11276 settleReject(new Error(`pipeline source at index ${i} is not readable`));
11277 return;
11278 }
11279 if (!__isWritableLike(dest)) {
11280 settleReject(new Error(`pipeline destination at index ${i + 1} is not writable`));
11281 return;
11282 }
11283 try {
11284 source.pipe(dest);
11285 } catch (err) {
11286 settleReject(err);
11287 return;
11288 }
11289 }
11290
11291 const last = streams[streams.length - 1];
11292 for (const stream of streams) {
11293 addListener(stream, 'error', settleReject);
11294 }
11295 addListener(last, 'finish', () => settleResolve(last));
11296 addListener(last, 'end', () => settleResolve(last));
11297 addListener(last, 'close', () => settleResolve(last));
11298
11299 const first = streams[0];
11300 if (first && typeof first.resume === 'function') {
11301 try { first.resume(); } catch (_) {}
11302 }
11303 });
11304}
11305
11306export async function finished(stream) {
11307 if (!stream || typeof stream.on !== 'function') {
11308 throw new Error('finished expects a stream-like object');
11309 }
11310 return await new Promise((resolve, reject) => {
11311 let settled = false;
11312 const cleanup = () => {
11313 if (typeof stream.removeListener !== 'function') return;
11314 stream.removeListener('finish', onDone);
11315 stream.removeListener('end', onDone);
11316 stream.removeListener('close', onDone);
11317 stream.removeListener('error', onError);
11318 };
11319 const onDone = () => {
11320 if (settled) return;
11321 settled = true;
11322 cleanup();
11323 resolve(stream);
11324 };
11325 const onError = (err) => {
11326 if (settled) return;
11327 settled = true;
11328 cleanup();
11329 reject(__streamToError(err));
11330 };
11331 stream.on('finish', onDone);
11332 stream.on('end', onDone);
11333 stream.on('close', onDone);
11334 stream.on('error', onError);
11335 });
11336}
11337export default { pipeline, finished };
11338"
11339 .trim()
11340 .to_string(),
11341 );
11342
11343 modules.insert(
11345 "node:stream/web".to_string(),
11346 r"
11347const _ReadableStream = globalThis.ReadableStream;
11348const _WritableStream = globalThis.WritableStream;
11349const _TransformStream = globalThis.TransformStream;
11350const _TextEncoderStream = globalThis.TextEncoderStream;
11351const _TextDecoderStream = globalThis.TextDecoderStream;
11352const _CompressionStream = globalThis.CompressionStream;
11353const _DecompressionStream = globalThis.DecompressionStream;
11354const _ByteLengthQueuingStrategy = globalThis.ByteLengthQueuingStrategy;
11355const _CountQueuingStrategy = globalThis.CountQueuingStrategy;
11356
11357export const ReadableStream = _ReadableStream;
11358export const WritableStream = _WritableStream;
11359export const TransformStream = _TransformStream;
11360export const TextEncoderStream = _TextEncoderStream;
11361export const TextDecoderStream = _TextDecoderStream;
11362export const CompressionStream = _CompressionStream;
11363export const DecompressionStream = _DecompressionStream;
11364export const ByteLengthQueuingStrategy = _ByteLengthQueuingStrategy;
11365export const CountQueuingStrategy = _CountQueuingStrategy;
11366
11367export default {
11368 ReadableStream,
11369 WritableStream,
11370 TransformStream,
11371 TextEncoderStream,
11372 TextDecoderStream,
11373 CompressionStream,
11374 DecompressionStream,
11375 ByteLengthQueuingStrategy,
11376 CountQueuingStrategy,
11377};
11378"
11379 .trim()
11380 .to_string(),
11381 );
11382
11383 modules.insert(
11385 "node:string_decoder".to_string(),
11386 r"
11387export class StringDecoder {
11388 constructor(encoding) { this.encoding = encoding || 'utf8'; }
11389 write(buf) { return typeof buf === 'string' ? buf : String(buf ?? ''); }
11390 end(buf) { return buf ? this.write(buf) : ''; }
11391}
11392export default { StringDecoder };
11393"
11394 .trim()
11395 .to_string(),
11396 );
11397
11398 modules.insert(
11400 "node:querystring".to_string(),
11401 r"
11402export function parse(qs, sep, eq) {
11403 const s = String(qs ?? '');
11404 const sepStr = sep || '&';
11405 const eqStr = eq || '=';
11406 const result = {};
11407 if (!s) return result;
11408 for (const pair of s.split(sepStr)) {
11409 const idx = pair.indexOf(eqStr);
11410 const key = idx === -1 ? decodeURIComponent(pair) : decodeURIComponent(pair.slice(0, idx));
11411 const val = idx === -1 ? '' : decodeURIComponent(pair.slice(idx + eqStr.length));
11412 if (Object.prototype.hasOwnProperty.call(result, key)) {
11413 if (Array.isArray(result[key])) result[key].push(val);
11414 else result[key] = [result[key], val];
11415 } else {
11416 result[key] = val;
11417 }
11418 }
11419 return result;
11420}
11421export function stringify(obj, sep, eq) {
11422 const sepStr = sep || '&';
11423 const eqStr = eq || '=';
11424 if (!obj || typeof obj !== 'object') return '';
11425 return Object.entries(obj).map(([k, v]) => {
11426 if (Array.isArray(v)) return v.map(i => encodeURIComponent(k) + eqStr + encodeURIComponent(i)).join(sepStr);
11427 return encodeURIComponent(k) + eqStr + encodeURIComponent(v ?? '');
11428 }).join(sepStr);
11429}
11430export const decode = parse;
11431export const encode = stringify;
11432export function escape(str) { return encodeURIComponent(str); }
11433export function unescape(str) { return decodeURIComponent(str); }
11434export default { parse, stringify, decode, encode, escape, unescape };
11435"
11436 .trim()
11437 .to_string(),
11438 );
11439
11440 modules.insert(
11442 "node:constants".to_string(),
11443 r"
11444const _constants = {
11445 EOL: '\n',
11446 F_OK: 0,
11447 R_OK: 4,
11448 W_OK: 2,
11449 X_OK: 1,
11450 UV_UDP_REUSEADDR: 4,
11451 SSL_OP_NO_SSLv2: 0,
11452 SSL_OP_NO_SSLv3: 0,
11453 SSL_OP_NO_TLSv1: 0,
11454 SSL_OP_NO_TLSv1_1: 0,
11455};
11456
11457const constants = new Proxy(_constants, {
11458 get(target, prop) {
11459 if (prop in target) return target[prop];
11460 return 0;
11461 },
11462});
11463
11464export default constants;
11465export { constants };
11466"
11467 .trim()
11468 .to_string(),
11469 );
11470
11471 modules.insert(
11473 "node:tty".to_string(),
11474 r"
11475import EventEmitter from 'node:events';
11476
11477export function isatty(_fd) { return false; }
11478
11479export class ReadStream extends EventEmitter {
11480 constructor(_fd) {
11481 super();
11482 this.isTTY = false;
11483 this.columns = 80;
11484 this.rows = 24;
11485 }
11486 setRawMode(_mode) { return this; }
11487}
11488
11489export class WriteStream extends EventEmitter {
11490 constructor(_fd) {
11491 super();
11492 this.isTTY = false;
11493 this.columns = 80;
11494 this.rows = 24;
11495 }
11496 getColorDepth() { return 1; }
11497 hasColors() { return false; }
11498 getWindowSize() { return [this.columns, this.rows]; }
11499}
11500
11501export default { isatty, ReadStream, WriteStream };
11502"
11503 .trim()
11504 .to_string(),
11505 );
11506
11507 modules.insert(
11509 "node:tls".to_string(),
11510 r"
11511import EventEmitter from 'node:events';
11512
11513export const DEFAULT_MIN_VERSION = 'TLSv1.2';
11514export const DEFAULT_MAX_VERSION = 'TLSv1.3';
11515
11516export class TLSSocket extends EventEmitter {
11517 constructor(_socket, _options) {
11518 super();
11519 this.authorized = false;
11520 this.encrypted = true;
11521 }
11522}
11523
11524export function connect(_portOrOptions, _host, _options, _callback) {
11525 throw new Error('node:tls.connect is not available in PiJS');
11526}
11527
11528export function createServer(_options, _secureConnectionListener) {
11529 throw new Error('node:tls.createServer is not available in PiJS');
11530}
11531
11532export default { connect, createServer, TLSSocket, DEFAULT_MIN_VERSION, DEFAULT_MAX_VERSION };
11533"
11534 .trim()
11535 .to_string(),
11536 );
11537
11538 modules.insert(
11540 "node:zlib".to_string(),
11541 r"
11542const constants = {
11543 Z_NO_COMPRESSION: 0,
11544 Z_BEST_SPEED: 1,
11545 Z_BEST_COMPRESSION: 9,
11546 Z_DEFAULT_COMPRESSION: -1,
11547};
11548
11549function unsupported(name) {
11550 throw new Error(`node:zlib.${name} is not available in PiJS`);
11551}
11552
11553export function gzip(_buffer, callback) {
11554 if (typeof callback === 'function') callback(new Error('node:zlib.gzip is not available in PiJS'));
11555}
11556export function gunzip(_buffer, callback) {
11557 if (typeof callback === 'function') callback(new Error('node:zlib.gunzip is not available in PiJS'));
11558}
11559
11560export function createGzip() { unsupported('createGzip'); }
11561export function createGunzip() { unsupported('createGunzip'); }
11562export function createDeflate() { unsupported('createDeflate'); }
11563export function createInflate() { unsupported('createInflate'); }
11564export function createBrotliCompress() { unsupported('createBrotliCompress'); }
11565export function createBrotliDecompress() { unsupported('createBrotliDecompress'); }
11566
11567export const promises = {
11568 gzip: async () => { unsupported('promises.gzip'); },
11569 gunzip: async () => { unsupported('promises.gunzip'); },
11570};
11571
11572export default {
11573 constants,
11574 gzip,
11575 gunzip,
11576 createGzip,
11577 createGunzip,
11578 createDeflate,
11579 createInflate,
11580 createBrotliCompress,
11581 createBrotliDecompress,
11582 promises,
11583};
11584"
11585 .trim()
11586 .to_string(),
11587 );
11588
11589 modules.insert(
11591 "node:perf_hooks".to_string(),
11592 r"
11593const perf =
11594 globalThis.performance ||
11595 {
11596 now: () => Date.now(),
11597 mark: () => {},
11598 measure: () => {},
11599 clearMarks: () => {},
11600 clearMeasures: () => {},
11601 getEntries: () => [],
11602 getEntriesByType: () => [],
11603 getEntriesByName: () => [],
11604 };
11605
11606export const performance = perf;
11607export const constants = {};
11608export class PerformanceObserver {
11609 constructor(_callback) {}
11610 observe(_opts) {}
11611 disconnect() {}
11612}
11613
11614export default { performance, constants, PerformanceObserver };
11615"
11616 .trim()
11617 .to_string(),
11618 );
11619
11620 modules.insert(
11622 "node:vm".to_string(),
11623 r"
11624function unsupported(name) {
11625 throw new Error(`node:vm.${name} is not available in PiJS`);
11626}
11627
11628export function runInContext() { unsupported('runInContext'); }
11629export function runInNewContext() { unsupported('runInNewContext'); }
11630export function runInThisContext() { unsupported('runInThisContext'); }
11631export function createContext(_sandbox) { return _sandbox || {}; }
11632
11633export class Script {
11634 constructor(_code, _options) { unsupported('Script'); }
11635}
11636
11637export default { runInContext, runInNewContext, runInThisContext, createContext, Script };
11638"
11639 .trim()
11640 .to_string(),
11641 );
11642
11643 modules.insert(
11645 "node:v8".to_string(),
11646 r"
11647function __toBuffer(str) {
11648 if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') {
11649 return Buffer.from(str, 'utf8');
11650 }
11651 if (typeof TextEncoder !== 'undefined') {
11652 return new TextEncoder().encode(str);
11653 }
11654 return str;
11655}
11656
11657function __fromBuffer(buf) {
11658 if (buf == null) return '';
11659 if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(buf)) {
11660 return buf.toString('utf8');
11661 }
11662 if (buf instanceof Uint8Array && typeof TextDecoder !== 'undefined') {
11663 return new TextDecoder().decode(buf);
11664 }
11665 return String(buf);
11666}
11667
11668export function serialize(value) {
11669 return __toBuffer(JSON.stringify(value));
11670}
11671
11672export function deserialize(value) {
11673 return JSON.parse(__fromBuffer(value));
11674}
11675
11676export default { serialize, deserialize };
11677"
11678 .trim()
11679 .to_string(),
11680 );
11681
11682 modules.insert(
11684 "node:worker_threads".to_string(),
11685 r"
11686export const isMainThread = true;
11687export const threadId = 0;
11688export const workerData = null;
11689export const parentPort = null;
11690
11691export class Worker {
11692 constructor(_filename, _options) {
11693 throw new Error('node:worker_threads.Worker is not available in PiJS');
11694 }
11695}
11696
11697export default { isMainThread, threadId, workerData, parentPort, Worker };
11698"
11699 .trim()
11700 .to_string(),
11701 );
11702
11703 modules.insert(
11705 "node:process".to_string(),
11706 r"
11707const p = globalThis.process || {};
11708export const env = p.env || {};
11709export const argv = p.argv || [];
11710export const cwd = typeof p.cwd === 'function' ? p.cwd : () => '/';
11711export const chdir = typeof p.chdir === 'function' ? p.chdir : () => { throw new Error('ENOSYS'); };
11712export const platform = p.platform || 'linux';
11713export const arch = p.arch || 'x64';
11714export const version = p.version || 'v20.0.0';
11715export const versions = p.versions || {};
11716export const pid = p.pid || 1;
11717export const ppid = p.ppid || 0;
11718export const title = p.title || 'pi';
11719export const execPath = p.execPath || '/usr/bin/pi';
11720export const execArgv = p.execArgv || [];
11721export const stdout = p.stdout || { write() {} };
11722export const stderr = p.stderr || { write() {} };
11723export const stdin = p.stdin || {};
11724export const nextTick = p.nextTick || ((fn, ...a) => Promise.resolve().then(() => fn(...a)));
11725export const hrtime = p.hrtime || Object.assign(() => [0, 0], { bigint: () => BigInt(0) });
11726export const exit = p.exit || (() => {});
11727export const kill = p.kill || (() => {});
11728export const on = p.on || (() => p);
11729export const off = p.off || (() => p);
11730export const once = p.once || (() => p);
11731export const addListener = p.addListener || (() => p);
11732export const removeListener = p.removeListener || (() => p);
11733export const removeAllListeners = p.removeAllListeners || (() => p);
11734export const listeners = p.listeners || (() => []);
11735export const emit = p.emit || (() => false);
11736export const emitWarning = p.emitWarning || (() => {});
11737export const uptime = p.uptime || (() => 0);
11738export const memoryUsage = p.memoryUsage || (() => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0 }));
11739export const cpuUsage = p.cpuUsage || (() => ({ user: 0, system: 0 }));
11740export const release = p.release || { name: 'node' };
11741export default p;
11742"
11743 .trim()
11744 .to_string(),
11745 );
11746
11747 modules.insert(
11754 "@mariozechner/clipboard".to_string(),
11755 r"
11756export async function getText() { return ''; }
11757export async function setText(_text) {}
11758export default { getText, setText };
11759"
11760 .trim()
11761 .to_string(),
11762 );
11763
11764 modules.insert(
11765 "node-pty".to_string(),
11766 r"
11767let _pid = 1000;
11768export function spawn(shell, args, options) {
11769 const pid = _pid++;
11770 const handlers = {};
11771 return {
11772 pid,
11773 onData(cb) { handlers.data = cb; },
11774 onExit(cb) { if (cb) setTimeout(() => cb({ exitCode: 1, signal: undefined }), 0); },
11775 write(d) {},
11776 resize(c, r) {},
11777 kill(s) {},
11778 };
11779}
11780export default { spawn };
11781"
11782 .trim()
11783 .to_string(),
11784 );
11785
11786 modules.insert(
11787 "chokidar".to_string(),
11788 r"
11789function makeWatcher() {
11790 const w = {
11791 on(ev, cb) { return w; },
11792 once(ev, cb) { return w; },
11793 close() { return Promise.resolve(); },
11794 add(p) { return w; },
11795 unwatch(p) { return w; },
11796 getWatched() { return {}; },
11797 };
11798 return w;
11799}
11800export function watch(paths, options) { return makeWatcher(); }
11801export default { watch };
11802"
11803 .trim()
11804 .to_string(),
11805 );
11806
11807 modules.insert(
11808 "jsdom".to_string(),
11809 r"
11810class Element {
11811 constructor(tag, html) { this.tagName = tag; this._html = html || ''; this.childNodes = []; }
11812 get innerHTML() { return this._html; }
11813 set innerHTML(v) { this._html = v; }
11814 get textContent() { return this._html.replace(/<[^>]*>/g, ''); }
11815 get outerHTML() { return `<${this.tagName}>${this._html}</${this.tagName}>`; }
11816 get parentNode() { return null; }
11817 querySelectorAll() { return []; }
11818 querySelector() { return null; }
11819 getElementsByTagName() { return []; }
11820 getElementById() { return null; }
11821 remove() {}
11822 getAttribute() { return null; }
11823 setAttribute() {}
11824 cloneNode() { return new Element(this.tagName, this._html); }
11825}
11826export class JSDOM {
11827 constructor(html, opts) {
11828 const doc = new Element('html', html || '');
11829 doc.body = new Element('body', html || '');
11830 doc.title = '';
11831 doc.querySelectorAll = () => [];
11832 doc.querySelector = () => null;
11833 doc.getElementsByTagName = () => [];
11834 doc.getElementById = () => null;
11835 doc.createElement = (t) => new Element(t, '');
11836 doc.documentElement = doc;
11837 this.window = { document: doc, location: { href: (opts && opts.url) || '' } };
11838 }
11839}
11840"
11841 .trim()
11842 .to_string(),
11843 );
11844
11845 modules.insert(
11846 "@mozilla/readability".to_string(),
11847 r"
11848export class Readability {
11849 constructor(doc, opts) { this._doc = doc; }
11850 parse() {
11851 const text = (this._doc && this._doc.body && this._doc.body.textContent) || '';
11852 return { title: '', content: text, textContent: text, length: text.length, excerpt: '', byline: '', dir: '', siteName: '', lang: '' };
11853 }
11854}
11855"
11856 .trim()
11857 .to_string(),
11858 );
11859
11860 modules.insert(
11861 "beautiful-mermaid".to_string(),
11862 r"
11863export function renderMermaidAscii(source) {
11864 const firstLine = (source || '').split('\n')[0] || 'diagram';
11865 return '[mermaid: ' + firstLine.trim() + ']';
11866}
11867"
11868 .trim()
11869 .to_string(),
11870 );
11871
11872 modules.insert(
11873 "@aliou/pi-utils-settings".to_string(),
11874 r"
11875export class ConfigLoader {
11876 constructor(name, defaultConfig, options) {
11877 this._name = name;
11878 this._default = defaultConfig || {};
11879 this._opts = options || {};
11880 this._data = structuredClone(this._default);
11881 }
11882 async load() { return this._data; }
11883 save(d) { this._data = d; }
11884 get() { return this._data; }
11885 getConfig() { return this._data; }
11886 set(k, v) { this._data[k] = v; }
11887}
11888export class ArrayEditor {
11889 constructor(arr) { this._arr = arr || []; }
11890 add(item) { this._arr.push(item); return this; }
11891 remove(idx) { this._arr.splice(idx, 1); return this; }
11892 toArray() { return this._arr; }
11893}
11894export function registerSettingsCommand(pi, opts) {}
11895export function getNestedValue(obj, path) {
11896 const keys = (path || '').split('.');
11897 let cur = obj;
11898 for (const k of keys) { if (cur == null) return undefined; cur = cur[k]; }
11899 return cur;
11900}
11901export function setNestedValue(obj, path, value) {
11902 const keys = (path || '').split('.');
11903 let cur = obj;
11904 for (let i = 0; i < keys.length - 1; i++) {
11905 if (cur[keys[i]] == null) cur[keys[i]] = {};
11906 cur = cur[keys[i]];
11907 }
11908 cur[keys[keys.length - 1]] = value;
11909}
11910"
11911 .trim()
11912 .to_string(),
11913 );
11914
11915 modules.insert(
11916 "@aliou/sh".to_string(),
11917 r#"
11918export function parse(cmd) { return [{ type: 'command', value: cmd }]; }
11919export function tokenize(cmd) { return (cmd || '').split(/\s+/); }
11920export function quote(s) { return "'" + (s || '').replace(/'/g, "'\\''") + "'"; }
11921export class ParseError extends Error { constructor(msg) { super(msg); this.name = 'ParseError'; } }
11922"#
11923 .trim()
11924 .to_string(),
11925 );
11926
11927 modules.insert(
11928 "@marckrenn/pi-sub-shared".to_string(),
11929 r#"
11930export const PROVIDERS = ["anthropic", "openai", "google", "aws", "azure"];
11931export const MODEL_MULTIPLIERS = {};
11932const _meta = (name) => ({
11933 name, displayName: name.charAt(0).toUpperCase() + name.slice(1),
11934 detection: { envVars: [], configPaths: [] },
11935 status: { operational: true },
11936});
11937export const PROVIDER_METADATA = Object.fromEntries(PROVIDERS.map(p => [p, _meta(p)]));
11938export const PROVIDER_DISPLAY_NAMES = Object.fromEntries(
11939 PROVIDERS.map(p => [p, p.charAt(0).toUpperCase() + p.slice(1)])
11940);
11941export function getDefaultCoreSettings() {
11942 return { providers: {}, behavior: { autoSwitch: false } };
11943}
11944"#
11945 .trim()
11946 .to_string(),
11947 );
11948
11949 modules.insert(
11950 "turndown".to_string(),
11951 r"
11952class TurndownService {
11953 constructor(opts) { this._opts = opts || {}; }
11954 turndown(html) { return (html || '').replace(/<[^>]*>/g, ''); }
11955 addRule(name, rule) { return this; }
11956 use(plugin) { return this; }
11957 remove(filter) { return this; }
11958}
11959export default TurndownService;
11960"
11961 .trim()
11962 .to_string(),
11963 );
11964
11965 modules.insert(
11966 "@xterm/headless".to_string(),
11967 r"
11968export class Terminal {
11969 constructor(opts) { this._opts = opts || {}; this.cols = opts?.cols || 80; this.rows = opts?.rows || 24; this.buffer = { active: { cursorX: 0, cursorY: 0, length: 0, getLine: () => null } }; }
11970 write(data) {}
11971 writeln(data) {}
11972 resize(cols, rows) { this.cols = cols; this.rows = rows; }
11973 dispose() {}
11974 onData(cb) { return { dispose() {} }; }
11975 onLineFeed(cb) { return { dispose() {} }; }
11976}
11977export default { Terminal };
11978"
11979 .trim()
11980 .to_string(),
11981 );
11982
11983 modules.insert(
11984 "@opentelemetry/api".to_string(),
11985 r"
11986export const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 };
11987const noopSpan = {
11988 setAttribute() { return this; },
11989 setAttributes() { return this; },
11990 addEvent() { return this; },
11991 setStatus() { return this; },
11992 end() {},
11993 isRecording() { return false; },
11994 recordException() {},
11995 spanContext() { return { traceId: '', spanId: '', traceFlags: 0 }; },
11996};
11997const noopTracer = {
11998 startSpan() { return noopSpan; },
11999 startActiveSpan(name, optsOrFn, fn) {
12000 const cb = typeof optsOrFn === 'function' ? optsOrFn : fn;
12001 return cb ? cb(noopSpan) : noopSpan;
12002 },
12003};
12004export const trace = {
12005 getTracer() { return noopTracer; },
12006 getActiveSpan() { return noopSpan; },
12007 setSpan(ctx) { return ctx; },
12008};
12009export const context = {
12010 active() { return {}; },
12011 with(ctx, fn) { return fn(); },
12012};
12013"
12014 .trim()
12015 .to_string(),
12016 );
12017
12018 modules.insert(
12019 "@juanibiapina/pi-extension-settings".to_string(),
12020 r"
12021export function getSetting(pi, key, defaultValue) { return defaultValue; }
12022export function setSetting(pi, key, value) {}
12023export function getSettings(pi) { return {}; }
12024"
12025 .trim()
12026 .to_string(),
12027 );
12028
12029 modules.insert(
12030 "@xterm/addon-serialize".to_string(),
12031 r"
12032export class SerializeAddon {
12033 activate(terminal) {}
12034 serialize(opts) { return ''; }
12035 dispose() {}
12036}
12037"
12038 .trim()
12039 .to_string(),
12040 );
12041
12042 modules.insert(
12043 "turndown-plugin-gfm".to_string(),
12044 r"
12045export function gfm(service) {}
12046export function tables(service) {}
12047export function strikethrough(service) {}
12048export function taskListItems(service) {}
12049"
12050 .trim()
12051 .to_string(),
12052 );
12053
12054 modules.insert(
12055 "@opentelemetry/exporter-trace-otlp-http".to_string(),
12056 r"
12057export class OTLPTraceExporter {
12058 constructor(opts) { this._opts = opts || {}; }
12059 export(spans, cb) { if (cb) cb({ code: 0 }); }
12060 shutdown() { return Promise.resolve(); }
12061}
12062"
12063 .trim()
12064 .to_string(),
12065 );
12066
12067 modules.insert(
12068 "@opentelemetry/resources".to_string(),
12069 r"
12070export class Resource {
12071 constructor(attrs) { this.attributes = attrs || {}; }
12072 merge(other) { return new Resource({ ...this.attributes, ...(other?.attributes || {}) }); }
12073}
12074export function resourceFromAttributes(attrs) { return new Resource(attrs); }
12075"
12076 .trim()
12077 .to_string(),
12078 );
12079
12080 modules.insert(
12081 "@opentelemetry/sdk-trace-base".to_string(),
12082 r"
12083const noopSpan = { setAttribute() { return this; }, end() {}, isRecording() { return false; }, spanContext() { return {}; } };
12084export class BasicTracerProvider {
12085 constructor(opts) { this._opts = opts || {}; }
12086 addSpanProcessor(p) {}
12087 register() {}
12088 getTracer() { return { startSpan() { return noopSpan; }, startActiveSpan(n, fn) { return fn(noopSpan); } }; }
12089 shutdown() { return Promise.resolve(); }
12090}
12091export class SimpleSpanProcessor {
12092 constructor(exporter) {}
12093 onStart() {}
12094 onEnd() {}
12095 shutdown() { return Promise.resolve(); }
12096 forceFlush() { return Promise.resolve(); }
12097}
12098export class BatchSpanProcessor extends SimpleSpanProcessor {}
12099"
12100 .trim()
12101 .to_string(),
12102 );
12103
12104 modules.insert(
12105 "@opentelemetry/semantic-conventions".to_string(),
12106 r"
12107export const SemanticResourceAttributes = {
12108 SERVICE_NAME: 'service.name',
12109 SERVICE_VERSION: 'service.version',
12110 DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
12111};
12112export const SEMRESATTRS_SERVICE_NAME = 'service.name';
12113export const SEMRESATTRS_SERVICE_VERSION = 'service.version';
12114"
12115 .trim()
12116 .to_string(),
12117 );
12118
12119 {
12122 let openclaw_plugin_sdk = r#"
12123export function definePlugin(spec = {}) { return spec; }
12124export function createPlugin(spec = {}) { return spec; }
12125export function tool(spec = {}) { return { ...spec, type: "tool" }; }
12126export function command(spec = {}) { return { ...spec, type: "command" }; }
12127export function provider(spec = {}) { return { ...spec, type: "provider" }; }
12128export const DEFAULT_ACCOUNT_ID = "default";
12129const __schema = {
12130 parse(value) { return value; },
12131 safeParse(value) { return { success: true, data: value }; },
12132 optional() { return this; },
12133 nullable() { return this; },
12134 default() { return this; },
12135 array() { return this; },
12136 transform() { return this; },
12137 refine() { return this; },
12138};
12139export const emptyPluginConfigSchema = __schema;
12140export function createReplyPrefixContext() { return {}; }
12141export function stringEnum(values = []) { return values[0] ?? ""; }
12142export function getChatChannelMeta() { return {}; }
12143export function addWildcardAllowFrom() { return []; }
12144export function listFeishuAccountIds() { return []; }
12145export function normalizeAccountId(value) { return String(value ?? ""); }
12146export function jsonResult(value) {
12147 return {
12148 content: [{ type: "text", text: JSON.stringify(value ?? null) }],
12149 details: { value },
12150 };
12151}
12152export function stripAnsi(value) {
12153 return String(value ?? "").replace(/\u001b\[[0-9;]*m/g, "");
12154}
12155export function recordInboundSession() { return undefined; }
12156export class OpenClawPlugin {
12157 constructor(spec = {}) { this.spec = spec; }
12158 async activate(pi) {
12159 const plugin = this.spec || {};
12160 if (Array.isArray(plugin.tools)) {
12161 for (const t of plugin.tools) {
12162 if (!t || !t.name) continue;
12163 const execute = typeof t.execute === "function" ? t.execute : async () => ({ content: [] });
12164 pi.registerTool?.({ ...t, execute });
12165 }
12166 }
12167 if (Array.isArray(plugin.commands)) {
12168 for (const c of plugin.commands) {
12169 if (!c || !c.name) continue;
12170 const handler = typeof c.handler === "function" ? c.handler : async () => ({});
12171 pi.registerCommand?.(c.name, { ...c, handler });
12172 }
12173 }
12174 if (typeof plugin.activate === "function") {
12175 await plugin.activate(pi);
12176 }
12177 }
12178}
12179export async function registerOpenClaw(pi, plugin) {
12180 if (typeof plugin === "function") {
12181 return await plugin(pi);
12182 }
12183 if (plugin && typeof plugin.default === "function") {
12184 return await plugin.default(pi);
12185 }
12186 if (plugin && typeof plugin.activate === "function") {
12187 return await plugin.activate(pi);
12188 }
12189 return undefined;
12190}
12191export default {
12192 definePlugin,
12193 createPlugin,
12194 tool,
12195 command,
12196 provider,
12197 DEFAULT_ACCOUNT_ID,
12198 emptyPluginConfigSchema,
12199 createReplyPrefixContext,
12200 stringEnum,
12201 getChatChannelMeta,
12202 addWildcardAllowFrom,
12203 listFeishuAccountIds,
12204 normalizeAccountId,
12205 jsonResult,
12206 stripAnsi,
12207 recordInboundSession,
12208 registerOpenClaw,
12209 OpenClawPlugin,
12210};
12211"#
12212 .trim()
12213 .to_string();
12214
12215 modules.insert(
12216 "openclaw/plugin-sdk".to_string(),
12217 openclaw_plugin_sdk.clone(),
12218 );
12219 modules.insert(
12220 "openclaw/plugin-sdk/index.js".to_string(),
12221 openclaw_plugin_sdk.clone(),
12222 );
12223 modules.insert(
12224 "clawdbot/plugin-sdk".to_string(),
12225 openclaw_plugin_sdk.clone(),
12226 );
12227 modules.insert(
12228 "clawdbot/plugin-sdk/index.js".to_string(),
12229 openclaw_plugin_sdk,
12230 );
12231 }
12232
12233 modules.insert(
12234 "zod".to_string(),
12235 r"
12236const __schema = {
12237 parse(value) { return value; },
12238 safeParse(value) { return { success: true, data: value }; },
12239 optional() { return this; },
12240 nullable() { return this; },
12241 nullish() { return this; },
12242 default() { return this; },
12243 array() { return this; },
12244 transform() { return this; },
12245 refine() { return this; },
12246 describe() { return this; },
12247 min() { return this; },
12248 max() { return this; },
12249 length() { return this; },
12250 regex() { return this; },
12251 url() { return this; },
12252 email() { return this; },
12253 uuid() { return this; },
12254 int() { return this; },
12255 positive() { return this; },
12256 nonnegative() { return this; },
12257 nonempty() { return this; },
12258};
12259function makeSchema() { return Object.create(__schema); }
12260export const z = {
12261 string() { return makeSchema(); },
12262 number() { return makeSchema(); },
12263 boolean() { return makeSchema(); },
12264 object() { return makeSchema(); },
12265 array() { return makeSchema(); },
12266 enum() { return makeSchema(); },
12267 literal() { return makeSchema(); },
12268 union() { return makeSchema(); },
12269 intersection() { return makeSchema(); },
12270 record() { return makeSchema(); },
12271 any() { return makeSchema(); },
12272 unknown() { return makeSchema(); },
12273 null() { return makeSchema(); },
12274 undefined() { return makeSchema(); },
12275 optional(inner) { return inner ?? makeSchema(); },
12276 nullable(inner) { return inner ?? makeSchema(); },
12277};
12278export default z;
12279"
12280 .trim()
12281 .to_string(),
12282 );
12283
12284 modules.insert(
12285 "yaml".to_string(),
12286 r##"
12287export function parse(input) {
12288 const text = String(input ?? "").trim();
12289 if (!text) return {};
12290 const out = {};
12291 for (const rawLine of text.split(/\r?\n/)) {
12292 const line = rawLine.trim();
12293 if (!line || line.startsWith("#")) continue;
12294 const idx = line.indexOf(":");
12295 if (idx === -1) continue;
12296 const key = line.slice(0, idx).trim();
12297 const value = line.slice(idx + 1).trim();
12298 if (key) out[key] = value;
12299 }
12300 return out;
12301}
12302export function stringify(value) {
12303 if (!value || typeof value !== "object") return "";
12304 const lines = Object.entries(value).map(([k, v]) => `${k}: ${v ?? ""}`);
12305 return lines.length ? `${lines.join("\n")}\n` : "";
12306}
12307export default { parse, stringify };
12308"##
12309 .trim()
12310 .to_string(),
12311 );
12312
12313 modules.insert(
12314 "better-sqlite3".to_string(),
12315 r#"
12316class Statement {
12317 all() { return []; }
12318 get() { return undefined; }
12319 run() { return { changes: 0, lastInsertRowid: 0 }; }
12320}
12321
12322function BetterSqlite3(filename, options = {}) {
12323 if (!(this instanceof BetterSqlite3)) return new BetterSqlite3(filename, options);
12324 this.filename = String(filename ?? "");
12325 this.options = options;
12326}
12327
12328BetterSqlite3.prototype.prepare = function(_sql) { return new Statement(); };
12329BetterSqlite3.prototype.exec = function(_sql) { return this; };
12330BetterSqlite3.prototype.pragma = function(_sql) { return []; };
12331BetterSqlite3.prototype.transaction = function(fn) {
12332 const wrapped = (...args) => (typeof fn === "function" ? fn(...args) : undefined);
12333 wrapped.immediate = wrapped;
12334 wrapped.deferred = wrapped;
12335 wrapped.exclusive = wrapped;
12336 return wrapped;
12337};
12338BetterSqlite3.prototype.close = function() {};
12339
12340BetterSqlite3.Statement = Statement;
12341BetterSqlite3.Database = BetterSqlite3;
12342
12343export { Statement };
12344export default BetterSqlite3;
12345"#
12346 .trim()
12347 .to_string(),
12348 );
12349
12350 modules.insert(
12351 "@mariozechner/pi-agent-core".to_string(),
12352 r#"
12353export const ThinkingLevel = {
12354 low: "low",
12355 medium: "medium",
12356 high: "high",
12357};
12358export class AgentTool {}
12359export default { ThinkingLevel, AgentTool };
12360"#
12361 .trim()
12362 .to_string(),
12363 );
12364
12365 modules.insert(
12366 "@mariozechner/pi-agent-core/index.js".to_string(),
12367 r#"
12368export const ThinkingLevel = {
12369 low: "low",
12370 medium: "medium",
12371 high: "high",
12372};
12373export class AgentTool {}
12374export default { ThinkingLevel, AgentTool };
12375"#
12376 .trim()
12377 .to_string(),
12378 );
12379
12380 modules.insert(
12381 "openai".to_string(),
12382 r#"
12383class OpenAI {
12384 constructor(config = {}) { this.config = config; }
12385 get chat() {
12386 return { completions: { create: async () => ({ choices: [{ message: { content: "" } }] }) } };
12387 }
12388}
12389export default OpenAI;
12390export { OpenAI };
12391"#
12392 .trim()
12393 .to_string(),
12394 );
12395
12396 modules.insert(
12397 "adm-zip".to_string(),
12398 r#"
12399class AdmZip {
12400 constructor(path) { this.path = path; this.entries = []; }
12401 getEntries() { return this.entries; }
12402 readAsText() { return ""; }
12403 extractAllTo() {}
12404 addFile() {}
12405 writeZip() {}
12406}
12407export default AdmZip;
12408"#
12409 .trim()
12410 .to_string(),
12411 );
12412
12413 modules.insert(
12414 "linkedom".to_string(),
12415 r#"
12416export function parseHTML(html) {
12417 const doc = {
12418 documentElement: { outerHTML: html || "" },
12419 querySelector: () => null,
12420 querySelectorAll: () => [],
12421 createElement: (tag) => ({ tagName: tag, textContent: "", innerHTML: "", children: [], appendChild() {} }),
12422 body: { textContent: "", innerHTML: "", children: [] },
12423 title: "",
12424 };
12425 return { document: doc, window: { document: doc } };
12426}
12427"#
12428 .trim()
12429 .to_string(),
12430 );
12431
12432 modules.insert(
12433 "@sourcegraph/scip-typescript".to_string(),
12434 r"
12435export const scip = { Index: class {} };
12436export default { scip };
12437"
12438 .trim()
12439 .to_string(),
12440 );
12441
12442 modules.insert(
12443 "p-limit".to_string(),
12444 r"
12445export default function pLimit(concurrency) {
12446 const queue = [];
12447 let active = 0;
12448 const next = () => {
12449 active--;
12450 if (queue.length > 0) queue.shift()();
12451 };
12452 const run = async (fn, resolve, ...args) => {
12453 active++;
12454 const result = (async () => fn(...args))();
12455 resolve(result);
12456 try { await result; } catch {}
12457 next();
12458 };
12459 const enqueue = (fn, resolve, ...args) => {
12460 queue.push(run.bind(null, fn, resolve, ...args));
12461 (async () => { if (active < concurrency && queue.length > 0) queue.shift()(); })();
12462 };
12463 const generator = (fn, ...args) => new Promise(resolve => enqueue(fn, resolve, ...args));
12464 Object.defineProperties(generator, {
12465 activeCount: { get: () => active },
12466 pendingCount: { get: () => queue.length },
12467 clearQueue: { value: () => { queue.length = 0; } },
12468 });
12469 return generator;
12470}
12471"
12472 .trim()
12473 .to_string(),
12474 );
12475
12476 modules.insert(
12478 "@sourcegraph/scip-typescript/dist/src/scip.js".to_string(),
12479 r"
12480export const scip = { Index: class {} };
12481export default { scip };
12482"
12483 .trim()
12484 .to_string(),
12485 );
12486
12487 modules.insert(
12488 "unpdf".to_string(),
12489 r#"
12490export async function getDocumentProxy(data) {
12491 return { numPages: 0, getPage: async () => ({ getTextContent: async () => ({ items: [] }) }) };
12492}
12493export async function extractText(data) { return { totalPages: 0, text: "" }; }
12494export async function renderPageAsImage() { return new Uint8Array(); }
12495"#
12496 .trim()
12497 .to_string(),
12498 );
12499
12500 modules.insert(
12501 "@sourcegraph/scip-python".to_string(),
12502 r"
12503export class PythonIndexer { async index() { return []; } }
12504export default { PythonIndexer };
12505"
12506 .trim()
12507 .to_string(),
12508 );
12509
12510 modules.insert(
12511 "@sourcegraph/scip-python/index.js".to_string(),
12512 r"
12513export class PythonIndexer { async index() { return []; } }
12514export default { PythonIndexer };
12515"
12516 .trim()
12517 .to_string(),
12518 );
12519
12520 modules
12521}
12522
12523fn default_virtual_modules_shared() -> Arc<HashMap<String, String>> {
12524 static DEFAULT_VIRTUAL_MODULES: std::sync::OnceLock<Arc<HashMap<String, String>>> =
12525 std::sync::OnceLock::new();
12526 Arc::clone(DEFAULT_VIRTUAL_MODULES.get_or_init(|| Arc::new(default_virtual_modules())))
12527}
12528
12529#[must_use]
12534pub fn available_virtual_module_names() -> std::collections::BTreeSet<String> {
12535 default_virtual_modules_shared().keys().cloned().collect()
12536}
12537
12538const UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS: u64 = 32;
12544
12545pub struct PiJsRuntime<C: SchedulerClock = WallClock> {
12580 runtime: AsyncRuntime,
12581 context: AsyncContext,
12582 scheduler: Rc<RefCell<Scheduler<C>>>,
12583 hostcall_queue: HostcallQueue,
12584 trace_seq: Arc<AtomicU64>,
12585 hostcall_tracker: Rc<RefCell<HostcallTracker>>,
12586 hostcalls_total: Arc<AtomicU64>,
12587 hostcalls_timed_out: Arc<AtomicU64>,
12588 last_memory_used_bytes: Arc<AtomicU64>,
12589 peak_memory_used_bytes: Arc<AtomicU64>,
12590 tick_counter: Arc<AtomicU64>,
12591 interrupt_budget: Rc<InterruptBudget>,
12592 config: PiJsRuntimeConfig,
12593 allowed_read_roots: Arc<std::sync::Mutex<Vec<PathBuf>>>,
12596 repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>>,
12599 module_state: Rc<RefCell<PiJsModuleState>>,
12603 policy: Option<ExtensionPolicy>,
12605}
12606
12607#[derive(Debug, Clone, Default, serde::Deserialize)]
12608#[serde(rename_all = "camelCase")]
12609struct JsRuntimeRegistrySnapshot {
12610 extensions: u64,
12611 tools: u64,
12612 commands: u64,
12613 hooks: u64,
12614 event_bus_hooks: u64,
12615 providers: u64,
12616 shortcuts: u64,
12617 message_renderers: u64,
12618 pending_tasks: u64,
12619 pending_hostcalls: u64,
12620 pending_timers: u64,
12621 pending_event_listener_lists: u64,
12622 provider_streams: u64,
12623}
12624
12625#[derive(Debug, Clone, serde::Deserialize)]
12626struct JsRuntimeResetPayload {
12627 before: JsRuntimeRegistrySnapshot,
12628 after: JsRuntimeRegistrySnapshot,
12629 clean: bool,
12630}
12631
12632#[derive(Debug, Clone, Default)]
12633pub struct PiJsWarmResetReport {
12634 pub reused: bool,
12635 pub reason_code: Option<String>,
12636 pub rust_pending_hostcalls: u64,
12637 pub rust_pending_hostcall_queue: u64,
12638 pub rust_scheduler_pending: bool,
12639 pub pending_tasks_before: u64,
12640 pub pending_hostcalls_before: u64,
12641 pub pending_timers_before: u64,
12642 pub residual_entries_after: u64,
12643 pub dynamic_module_invalidations: u64,
12644 pub module_cache_hits: u64,
12645 pub module_cache_misses: u64,
12646 pub module_cache_invalidations: u64,
12647 pub module_cache_entries: u64,
12648}
12649
12650#[allow(clippy::future_not_send)]
12651impl PiJsRuntime<WallClock> {
12652 #[allow(clippy::future_not_send)]
12654 pub async fn new() -> Result<Self> {
12655 Self::with_clock(WallClock).await
12656 }
12657}
12658
12659#[allow(clippy::future_not_send)]
12660impl<C: SchedulerClock + 'static> PiJsRuntime<C> {
12661 #[allow(clippy::future_not_send)]
12663 pub async fn with_clock(clock: C) -> Result<Self> {
12664 Self::with_clock_and_config(clock, PiJsRuntimeConfig::default()).await
12665 }
12666
12667 #[allow(clippy::future_not_send)]
12669 pub async fn with_clock_and_config(clock: C, config: PiJsRuntimeConfig) -> Result<Self> {
12670 Self::with_clock_and_config_with_policy(clock, config, None).await
12671 }
12672
12673 #[allow(clippy::future_not_send, clippy::too_many_lines)]
12675 pub async fn with_clock_and_config_with_policy(
12676 clock: C,
12677 mut config: PiJsRuntimeConfig,
12678 policy: Option<ExtensionPolicy>,
12679 ) -> Result<Self> {
12680 #[cfg(target_arch = "x86_64")]
12682 config
12683 .env
12684 .entry("PI_TARGET_ARCH".to_string())
12685 .or_insert_with(|| "x64".to_string());
12686 #[cfg(target_arch = "aarch64")]
12687 config
12688 .env
12689 .entry("PI_TARGET_ARCH".to_string())
12690 .or_insert_with(|| "arm64".to_string());
12691 #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
12692 config
12693 .env
12694 .entry("PI_TARGET_ARCH".to_string())
12695 .or_insert_with(|| "x64".to_string());
12696
12697 {
12700 let platform = match std::env::consts::OS {
12701 "macos" => "darwin",
12702 "windows" => "win32",
12703 other => other,
12704 };
12705 config
12706 .env
12707 .entry("PI_PLATFORM".to_string())
12708 .or_insert_with(|| platform.to_string());
12709 }
12710
12711 let runtime = AsyncRuntime::new().map_err(|err| map_js_error(&err))?;
12712 if let Some(limit) = config.limits.memory_limit_bytes {
12713 runtime.set_memory_limit(limit).await;
12714 }
12715 if let Some(limit) = config.limits.max_stack_bytes {
12716 runtime.set_max_stack_size(limit).await;
12717 }
12718
12719 let interrupt_budget = Rc::new(InterruptBudget::new(config.limits.interrupt_budget));
12720 if config.limits.interrupt_budget.is_some() {
12721 let budget = Rc::clone(&interrupt_budget);
12722 runtime
12723 .set_interrupt_handler(Some(Box::new(move || budget.on_interrupt())))
12724 .await;
12725 }
12726
12727 let repair_events: Arc<std::sync::Mutex<Vec<ExtensionRepairEvent>>> =
12728 Arc::new(std::sync::Mutex::new(Vec::new()));
12729 let module_state = Rc::new(RefCell::new(
12730 PiJsModuleState::new()
12731 .with_repair_mode(config.repair_mode)
12732 .with_repair_events(Arc::clone(&repair_events))
12733 .with_disk_cache_dir(config.disk_cache_dir.clone()),
12734 ));
12735 runtime
12736 .set_loader(
12737 PiJsResolver {
12738 state: Rc::clone(&module_state),
12739 },
12740 PiJsLoader {
12741 state: Rc::clone(&module_state),
12742 },
12743 )
12744 .await;
12745
12746 let context = AsyncContext::full(&runtime)
12747 .await
12748 .map_err(|err| map_js_error(&err))?;
12749
12750 let scheduler = Rc::new(RefCell::new(Scheduler::with_clock(clock)));
12751 let fast_queue_capacity = if config.limits.hostcall_fast_queue_capacity == 0 {
12752 HOSTCALL_FAST_RING_CAPACITY
12753 } else {
12754 config.limits.hostcall_fast_queue_capacity
12755 };
12756 let overflow_queue_capacity = if config.limits.hostcall_overflow_queue_capacity == 0 {
12757 HOSTCALL_OVERFLOW_CAPACITY
12758 } else {
12759 config.limits.hostcall_overflow_queue_capacity
12760 };
12761 let hostcall_queue: HostcallQueue = Rc::new(RefCell::new(
12762 HostcallRequestQueue::with_capacities(fast_queue_capacity, overflow_queue_capacity),
12763 ));
12764 let hostcall_tracker = Rc::new(RefCell::new(HostcallTracker::default()));
12765 let hostcalls_total = Arc::new(AtomicU64::new(0));
12766 let hostcalls_timed_out = Arc::new(AtomicU64::new(0));
12767 let last_memory_used_bytes = Arc::new(AtomicU64::new(0));
12768 let peak_memory_used_bytes = Arc::new(AtomicU64::new(0));
12769 let tick_counter = Arc::new(AtomicU64::new(0));
12770 let trace_seq = Arc::new(AtomicU64::new(1));
12771
12772 let instance = Self {
12773 runtime,
12774 context,
12775 scheduler,
12776 hostcall_queue,
12777 trace_seq,
12778 hostcall_tracker,
12779 hostcalls_total,
12780 hostcalls_timed_out,
12781 last_memory_used_bytes,
12782 peak_memory_used_bytes,
12783 tick_counter,
12784 interrupt_budget,
12785 config,
12786 allowed_read_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
12787 repair_events,
12788 module_state,
12789 policy,
12790 };
12791
12792 instance.install_pi_bridge().await?;
12793 Ok(instance)
12794 }
12795
12796 async fn map_quickjs_error(&self, err: &rquickjs::Error) -> Error {
12797 if self.interrupt_budget.did_trip() {
12798 self.interrupt_budget.clear_trip();
12799 return Error::extension("PiJS execution budget exceeded".to_string());
12800 }
12801 if matches!(err, rquickjs::Error::Exception) {
12802 let detail = self
12803 .context
12804 .with(|ctx| {
12805 let caught = ctx.catch();
12806 Ok::<String, rquickjs::Error>(format_quickjs_exception(&ctx, caught))
12807 })
12808 .await
12809 .ok();
12810 if let Some(detail) = detail {
12811 let detail = detail.trim();
12812 if !detail.is_empty() && detail != "undefined" {
12813 return Error::extension(format!("QuickJS exception: {detail}"));
12814 }
12815 }
12816 }
12817 map_js_error(err)
12818 }
12819
12820 fn map_quickjs_job_error<E: std::fmt::Display>(&self, err: E) -> Error {
12821 if self.interrupt_budget.did_trip() {
12822 self.interrupt_budget.clear_trip();
12823 return Error::extension("PiJS execution budget exceeded".to_string());
12824 }
12825 Error::extension(format!("QuickJS job: {err}"))
12826 }
12827
12828 fn should_sample_memory_usage(&self) -> bool {
12829 if self.config.limits.memory_limit_bytes.is_some() {
12830 return true;
12831 }
12832
12833 let tick = self.tick_counter.fetch_add(1, AtomicOrdering::SeqCst) + 1;
12834 tick == 1 || (tick % UNBOUNDED_MEMORY_USAGE_SAMPLE_EVERY_TICKS == 0)
12835 }
12836
12837 fn module_cache_snapshot(&self) -> (u64, u64, u64, u64, u64) {
12838 let state = self.module_state.borrow();
12839 let entries = u64::try_from(state.compiled_sources.len()).unwrap_or(u64::MAX);
12840 (
12841 state.module_cache_counters.hits,
12842 state.module_cache_counters.misses,
12843 state.module_cache_counters.invalidations,
12844 entries,
12845 state.module_cache_counters.disk_hits,
12846 )
12847 }
12848
12849 #[allow(clippy::future_not_send, clippy::too_many_lines)]
12850 pub async fn reset_for_warm_reload(&self) -> Result<PiJsWarmResetReport> {
12851 let rust_pending_hostcalls =
12852 u64::try_from(self.hostcall_tracker.borrow().pending_count()).unwrap_or(u64::MAX);
12853 let rust_pending_hostcall_queue =
12854 u64::try_from(self.hostcall_queue.borrow().len()).unwrap_or(u64::MAX);
12855 let rust_scheduler_pending = self.scheduler.borrow().has_pending();
12856
12857 let mut report = PiJsWarmResetReport {
12858 rust_pending_hostcalls,
12859 rust_pending_hostcall_queue,
12860 rust_scheduler_pending,
12861 ..PiJsWarmResetReport::default()
12862 };
12863
12864 if rust_pending_hostcalls > 0 || rust_pending_hostcall_queue > 0 || rust_scheduler_pending {
12865 report.reason_code = Some("pending_rust_work".to_string());
12866 return Ok(report);
12867 }
12868
12869 let reset_payload_value = match self
12870 .context
12871 .with(|ctx| {
12872 let global = ctx.globals();
12873 let reset_fn: Function<'_> = global.get("__pi_reset_extension_runtime_state")?;
12874 let value: Value<'_> = reset_fn.call(())?;
12875 js_to_json(&value)
12876 })
12877 .await
12878 {
12879 Ok(value) => value,
12880 Err(err) => return Err(self.map_quickjs_error(&err).await),
12881 };
12882
12883 let reset_payload: JsRuntimeResetPayload = serde_json::from_value(reset_payload_value)
12884 .map_err(|err| {
12885 Error::extension(format!("PiJS warm reset payload decode failed: {err}"))
12886 })?;
12887
12888 report.pending_tasks_before = reset_payload.before.pending_tasks;
12889 report.pending_hostcalls_before = reset_payload.before.pending_hostcalls;
12890 report.pending_timers_before = reset_payload.before.pending_timers;
12891
12892 let residual_after = reset_payload.after.extensions
12893 + reset_payload.after.tools
12894 + reset_payload.after.commands
12895 + reset_payload.after.hooks
12896 + reset_payload.after.event_bus_hooks
12897 + reset_payload.after.providers
12898 + reset_payload.after.shortcuts
12899 + reset_payload.after.message_renderers
12900 + reset_payload.after.pending_tasks
12901 + reset_payload.after.pending_hostcalls
12902 + reset_payload.after.pending_timers
12903 + reset_payload.after.pending_event_listener_lists
12904 + reset_payload.after.provider_streams;
12905 report.residual_entries_after = residual_after;
12906
12907 self.hostcall_queue.borrow_mut().clear();
12908 *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
12909
12910 if let Ok(mut roots) = self.allowed_read_roots.lock() {
12911 roots.clear();
12912 }
12913
12914 let mut dynamic_invalidations = 0_u64;
12915 {
12916 let mut state = self.module_state.borrow_mut();
12917 let dynamic_specs: Vec<String> =
12918 state.dynamic_virtual_modules.keys().cloned().collect();
12919 state.dynamic_virtual_modules.clear();
12920 state.dynamic_virtual_named_exports.clear();
12921 state.extension_roots.clear();
12922 state.extension_root_tiers.clear();
12923 state.extension_root_scopes.clear();
12924
12925 for spec in dynamic_specs {
12926 if state.compiled_sources.remove(&spec).is_some() {
12927 dynamic_invalidations = dynamic_invalidations.saturating_add(1);
12928 }
12929 }
12930 if dynamic_invalidations > 0 {
12931 state.module_cache_counters.invalidations = state
12932 .module_cache_counters
12933 .invalidations
12934 .saturating_add(dynamic_invalidations);
12935 }
12936 }
12937 report.dynamic_module_invalidations = dynamic_invalidations;
12938
12939 let (cache_hits, cache_misses, cache_invalidations, cache_entries, _disk_hits) =
12940 self.module_cache_snapshot();
12941 report.module_cache_hits = cache_hits;
12942 report.module_cache_misses = cache_misses;
12943 report.module_cache_invalidations = cache_invalidations;
12944 report.module_cache_entries = cache_entries;
12945
12946 if report.pending_tasks_before > 0
12947 || report.pending_hostcalls_before > 0
12948 || report.pending_timers_before > 0
12949 {
12950 report.reason_code = Some("pending_js_work".to_string());
12951 return Ok(report);
12952 }
12953
12954 if !reset_payload.clean || residual_after > 0 {
12955 report.reason_code = Some("reset_residual_state".to_string());
12956 return Ok(report);
12957 }
12958
12959 report.reused = true;
12960 Ok(report)
12961 }
12962
12963 pub async fn eval(&self, source: &str) -> Result<()> {
12965 self.interrupt_budget.reset();
12966 match self.context.with(|ctx| ctx.eval::<(), _>(source)).await {
12967 Ok(()) => {}
12968 Err(err) => return Err(self.map_quickjs_error(&err).await),
12969 }
12970 self.drain_jobs().await?;
12972 Ok(())
12973 }
12974
12975 pub async fn call_global_void(&self, name: &str) -> Result<()> {
12980 self.interrupt_budget.reset();
12981 match self
12982 .context
12983 .with(|ctx| {
12984 let global = ctx.globals();
12985 let function: Function<'_> = global.get(name)?;
12986 function.call::<(), ()>(())?;
12987 Ok::<(), rquickjs::Error>(())
12988 })
12989 .await
12990 {
12991 Ok(()) => {}
12992 Err(err) => return Err(self.map_quickjs_error(&err).await),
12993 }
12994 self.drain_jobs().await?;
12995 Ok(())
12996 }
12997
12998 pub const fn repair_mode(&self) -> RepairMode {
13002 self.config.repair_mode
13003 }
13004
13005 pub const fn auto_repair_enabled(&self) -> bool {
13007 self.config.repair_mode.should_apply()
13008 }
13009
13010 pub fn record_repair(&self, event: ExtensionRepairEvent) {
13014 tracing::info!(
13015 event = "pijs.repair",
13016 extension_id = %event.extension_id,
13017 pattern = %event.pattern,
13018 success = event.success,
13019 repair_action = %event.repair_action,
13020 "auto-repair applied"
13021 );
13022 if let Ok(mut events) = self.repair_events.lock() {
13023 events.push(event);
13024 }
13025 }
13026
13027 pub fn drain_repair_events(&self) -> Vec<ExtensionRepairEvent> {
13031 self.repair_events
13032 .lock()
13033 .map(|mut v| std::mem::take(&mut *v))
13034 .unwrap_or_default()
13035 }
13036
13037 pub fn repair_count(&self) -> u64 {
13039 self.repair_events.lock().map_or(0, |v| v.len() as u64)
13040 }
13041
13042 pub fn reset_transient_state(&self) {
13050 let mut state = self.module_state.borrow_mut();
13051 state.extension_roots.clear();
13052 state.extension_root_tiers.clear();
13053 state.extension_root_scopes.clear();
13054 state.dynamic_virtual_modules.clear();
13055 state.dynamic_virtual_named_exports.clear();
13056 state.module_cache_counters = ModuleCacheCounters::default();
13057 drop(state);
13061
13062 self.hostcall_queue.borrow_mut().clear();
13064 *self.hostcall_tracker.borrow_mut() = HostcallTracker::default();
13065 if let Ok(mut events) = self.repair_events.lock() {
13067 events.clear();
13068 }
13069 self.hostcalls_total
13071 .store(0, std::sync::atomic::Ordering::SeqCst);
13072 self.hostcalls_timed_out
13073 .store(0, std::sync::atomic::Ordering::SeqCst);
13074 self.tick_counter
13075 .store(0, std::sync::atomic::Ordering::SeqCst);
13076 }
13077
13078 pub async fn eval_file(&self, path: &std::path::Path) -> Result<()> {
13080 self.interrupt_budget.reset();
13081 match self.context.with(|ctx| ctx.eval_file::<(), _>(path)).await {
13082 Ok(()) => {}
13083 Err(err) => return Err(self.map_quickjs_error(&err).await),
13084 }
13085 self.drain_jobs().await?;
13086 Ok(())
13087 }
13088
13089 pub(crate) async fn with_ctx<F, R>(&self, f: F) -> Result<R>
13094 where
13095 F: for<'js> FnOnce(Ctx<'js>) -> rquickjs::Result<R> + rquickjs::markers::ParallelSend,
13096 R: rquickjs::markers::ParallelSend,
13097 {
13098 self.interrupt_budget.reset();
13099 match self.context.with(f).await {
13100 Ok(value) => Ok(value),
13101 Err(err) => Err(self.map_quickjs_error(&err).await),
13102 }
13103 }
13104
13105 pub async fn read_global_json(&self, name: &str) -> Result<serde_json::Value> {
13110 self.interrupt_budget.reset();
13111 let value = match self
13112 .context
13113 .with(|ctx| {
13114 let global = ctx.globals();
13115 let value: Value<'_> = global.get(name)?;
13116 js_to_json(&value)
13117 })
13118 .await
13119 {
13120 Ok(value) => value,
13121 Err(err) => return Err(self.map_quickjs_error(&err).await),
13122 };
13123 Ok(value)
13124 }
13125
13126 pub fn drain_hostcall_requests(&self) -> VecDeque<HostcallRequest> {
13131 self.hostcall_queue.borrow_mut().drain_all()
13132 }
13133
13134 pub async fn drain_microtasks(&self) -> Result<usize> {
13136 self.drain_jobs().await
13137 }
13138
13139 pub fn next_timer_deadline_ms(&self) -> Option<u64> {
13141 self.scheduler.borrow().next_timer_deadline()
13142 }
13143
13144 pub fn pending_hostcall_count(&self) -> usize {
13146 self.hostcall_tracker.borrow().pending_count()
13147 }
13148
13149 pub fn hostcall_queue_telemetry(&self) -> HostcallQueueTelemetry {
13151 self.hostcall_queue.borrow().snapshot()
13152 }
13153
13154 pub fn hostcall_queue_wait_ms(&self, call_id: &str) -> Option<u64> {
13156 let now_ms = self.scheduler.borrow().now_ms();
13157 self.hostcall_tracker
13158 .borrow()
13159 .queue_wait_ms(call_id, now_ms)
13160 }
13161
13162 pub fn is_hostcall_pending(&self, call_id: &str) -> bool {
13167 self.hostcall_tracker.borrow().is_pending(call_id)
13168 }
13169
13170 pub async fn get_registered_tools(&self) -> Result<Vec<ExtensionToolDef>> {
13172 self.interrupt_budget.reset();
13173 let value = match self
13174 .context
13175 .with(|ctx| {
13176 let global = ctx.globals();
13177 let getter: Function<'_> = global.get("__pi_get_registered_tools")?;
13178 let tools: Value<'_> = getter.call(())?;
13179 js_to_json(&tools)
13180 })
13181 .await
13182 {
13183 Ok(value) => value,
13184 Err(err) => return Err(self.map_quickjs_error(&err).await),
13185 };
13186
13187 serde_json::from_value(value).map_err(|err| Error::Json(Box::new(err)))
13188 }
13189
13190 pub async fn get_global_json(&self, name: &str) -> Result<serde_json::Value> {
13195 self.interrupt_budget.reset();
13196 match self
13197 .context
13198 .with(|ctx| {
13199 let global = ctx.globals();
13200 let value: Value<'_> = global.get(name)?;
13201 js_to_json(&value)
13202 })
13203 .await
13204 {
13205 Ok(value) => Ok(value),
13206 Err(err) => Err(self.map_quickjs_error(&err).await),
13207 }
13208 }
13209
13210 pub fn complete_hostcall(&self, call_id: impl Into<String>, outcome: HostcallOutcome) {
13212 self.scheduler
13213 .borrow_mut()
13214 .enqueue_hostcall_complete(call_id.into(), outcome);
13215 }
13216
13217 pub fn complete_hostcalls_batch<I>(&self, completions: I)
13219 where
13220 I: IntoIterator<Item = (String, HostcallOutcome)>,
13221 {
13222 self.scheduler
13223 .borrow_mut()
13224 .enqueue_hostcall_completions(completions);
13225 }
13226
13227 pub fn enqueue_event(&self, event_id: impl Into<String>, payload: serde_json::Value) {
13229 self.scheduler
13230 .borrow_mut()
13231 .enqueue_event(event_id.into(), payload);
13232 }
13233
13234 pub fn set_timeout(&self, delay_ms: u64) -> u64 {
13238 self.scheduler.borrow_mut().set_timeout(delay_ms)
13239 }
13240
13241 pub fn clear_timeout(&self, timer_id: u64) -> bool {
13243 self.scheduler.borrow_mut().clear_timeout(timer_id)
13244 }
13245
13246 pub fn now_ms(&self) -> u64 {
13248 self.scheduler.borrow().now_ms()
13249 }
13250
13251 pub fn has_pending(&self) -> bool {
13253 self.scheduler.borrow().has_pending() || self.pending_hostcall_count() > 0
13254 }
13255
13256 pub async fn tick(&self) -> Result<PiJsTickStats> {
13265 let macrotask = self.scheduler.borrow_mut().tick();
13267
13268 let mut stats = PiJsTickStats::default();
13269
13270 if let Some(task) = macrotask {
13271 stats.ran_macrotask = true;
13272 self.interrupt_budget.reset();
13273
13274 let result = self
13276 .context
13277 .with(|ctx| {
13278 self.handle_macrotask(&ctx, &task)?;
13279 Ok::<_, rquickjs::Error>(())
13280 })
13281 .await;
13282 if let Err(err) = result {
13283 return Err(self.map_quickjs_error(&err).await);
13284 }
13285
13286 stats.jobs_drained = self.drain_jobs().await?;
13288 }
13289
13290 stats.pending_hostcalls = self.hostcall_tracker.borrow().pending_count();
13291 stats.hostcalls_total = self
13292 .hostcalls_total
13293 .load(std::sync::atomic::Ordering::SeqCst);
13294 stats.hostcalls_timed_out = self
13295 .hostcalls_timed_out
13296 .load(std::sync::atomic::Ordering::SeqCst);
13297
13298 if self.should_sample_memory_usage() {
13299 let usage = self.runtime.memory_usage().await;
13300 stats.memory_used_bytes = u64::try_from(usage.memory_used_size).unwrap_or(0);
13301 self.last_memory_used_bytes
13302 .store(stats.memory_used_bytes, std::sync::atomic::Ordering::SeqCst);
13303
13304 let mut peak = self
13305 .peak_memory_used_bytes
13306 .load(std::sync::atomic::Ordering::SeqCst);
13307 if stats.memory_used_bytes > peak {
13308 peak = stats.memory_used_bytes;
13309 self.peak_memory_used_bytes
13310 .store(peak, std::sync::atomic::Ordering::SeqCst);
13311 }
13312 stats.peak_memory_used_bytes = peak;
13313 } else {
13314 stats.memory_used_bytes = self
13315 .last_memory_used_bytes
13316 .load(std::sync::atomic::Ordering::SeqCst);
13317 stats.peak_memory_used_bytes = self
13318 .peak_memory_used_bytes
13319 .load(std::sync::atomic::Ordering::SeqCst);
13320 }
13321 stats.repairs_total = self.repair_count();
13322 let (cache_hits, cache_misses, cache_invalidations, cache_entries, disk_hits) =
13323 self.module_cache_snapshot();
13324 stats.module_cache_hits = cache_hits;
13325 stats.module_cache_misses = cache_misses;
13326 stats.module_cache_invalidations = cache_invalidations;
13327 stats.module_cache_entries = cache_entries;
13328 stats.module_disk_cache_hits = disk_hits;
13329
13330 if let Some(limit) = self.config.limits.memory_limit_bytes {
13331 let limit = u64::try_from(limit).unwrap_or(u64::MAX);
13332 if stats.memory_used_bytes > limit {
13333 return Err(Error::extension(format!(
13334 "PiJS memory budget exceeded (used {} bytes, limit {} bytes)",
13335 stats.memory_used_bytes, limit
13336 )));
13337 }
13338 }
13339
13340 Ok(stats)
13341 }
13342
13343 async fn drain_jobs(&self) -> Result<usize> {
13345 let mut count = 0;
13346 loop {
13347 if count >= MAX_JOBS_PER_TICK {
13348 return Err(Error::extension(format!(
13349 "PiJS microtask limit exceeded ({MAX_JOBS_PER_TICK})"
13350 )));
13351 }
13352 let ran = match self.runtime.execute_pending_job().await {
13353 Ok(ran) => ran,
13354 Err(err) => return Err(self.map_quickjs_job_error(err)),
13355 };
13356 if !ran {
13357 break;
13358 }
13359 count += 1;
13360 }
13361 Ok(count)
13362 }
13363
13364 fn handle_macrotask(
13366 &self,
13367 ctx: &Ctx<'_>,
13368 task: &crate::scheduler::Macrotask,
13369 ) -> rquickjs::Result<()> {
13370 use crate::scheduler::MacrotaskKind as SMK;
13371
13372 match &task.kind {
13373 SMK::HostcallComplete { call_id, outcome } => {
13374 let is_nonfinal_stream = matches!(
13375 outcome,
13376 HostcallOutcome::StreamChunk {
13377 is_final: false,
13378 ..
13379 }
13380 );
13381
13382 if is_nonfinal_stream {
13383 if !self.hostcall_tracker.borrow().is_pending(call_id) {
13385 tracing::debug!(
13386 event = "pijs.macrotask.stream_chunk.ignored",
13387 call_id = %call_id,
13388 "Ignoring stream chunk (not pending)"
13389 );
13390 return Ok(());
13391 }
13392 } else {
13393 let completion = self.hostcall_tracker.borrow_mut().on_complete(call_id);
13395 let timer_id = match completion {
13396 HostcallCompletion::Delivered { timer_id } => timer_id,
13397 HostcallCompletion::Unknown => {
13398 tracing::debug!(
13399 event = "pijs.macrotask.hostcall_complete.ignored",
13400 call_id = %call_id,
13401 "Ignoring hostcall completion (not pending)"
13402 );
13403 return Ok(());
13404 }
13405 };
13406
13407 if let Some(timer_id) = timer_id {
13408 let _ = self.scheduler.borrow_mut().clear_timeout(timer_id);
13409 }
13410 }
13411
13412 tracing::debug!(
13413 event = "pijs.macrotask.hostcall_complete",
13414 call_id = %call_id,
13415 seq = task.seq.value(),
13416 "Delivering hostcall completion"
13417 );
13418 Self::deliver_hostcall_completion(ctx, call_id, outcome)?;
13419 }
13420 SMK::TimerFired { timer_id } => {
13421 if let Some(call_id) = self
13422 .hostcall_tracker
13423 .borrow_mut()
13424 .take_timed_out_call(*timer_id)
13425 {
13426 self.hostcalls_timed_out
13427 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
13428 tracing::warn!(
13429 event = "pijs.hostcall.timeout",
13430 call_id = %call_id,
13431 timer_id = timer_id,
13432 "Hostcall timed out"
13433 );
13434
13435 let outcome = HostcallOutcome::Error {
13436 code: "timeout".to_string(),
13437 message: "Hostcall timed out".to_string(),
13438 };
13439 Self::deliver_hostcall_completion(ctx, &call_id, &outcome)?;
13440 return Ok(());
13441 }
13442
13443 tracing::debug!(
13444 event = "pijs.macrotask.timer_fired",
13445 timer_id = timer_id,
13446 seq = task.seq.value(),
13447 "Timer fired"
13448 );
13449 Self::deliver_timer_fire(ctx, *timer_id)?;
13451 }
13452 SMK::InboundEvent { event_id, payload } => {
13453 tracing::debug!(
13454 event = "pijs.macrotask.inbound_event",
13455 event_id = %event_id,
13456 seq = task.seq.value(),
13457 "Delivering inbound event"
13458 );
13459 Self::deliver_inbound_event(ctx, event_id, payload)?;
13460 }
13461 }
13462 Ok(())
13463 }
13464
13465 fn deliver_hostcall_completion(
13467 ctx: &Ctx<'_>,
13468 call_id: &str,
13469 outcome: &HostcallOutcome,
13470 ) -> rquickjs::Result<()> {
13471 let global = ctx.globals();
13472 let complete_fn: Function<'_> = global.get("__pi_complete_hostcall")?;
13473 let js_outcome = match outcome {
13474 HostcallOutcome::Success(value) => {
13475 let obj = Object::new(ctx.clone())?;
13476 obj.set("ok", true)?;
13477 obj.set("value", json_to_js(ctx, value)?)?;
13478 obj
13479 }
13480 HostcallOutcome::Error { code, message } => {
13481 let obj = Object::new(ctx.clone())?;
13482 obj.set("ok", false)?;
13483 obj.set("code", code.clone())?;
13484 obj.set("message", message.clone())?;
13485 obj
13486 }
13487 HostcallOutcome::StreamChunk {
13488 chunk,
13489 sequence,
13490 is_final,
13491 } => {
13492 let obj = Object::new(ctx.clone())?;
13493 obj.set("ok", true)?;
13494 obj.set("stream", true)?;
13495 obj.set("sequence", *sequence)?;
13496 obj.set("isFinal", *is_final)?;
13497 obj.set("chunk", json_to_js(ctx, chunk)?)?;
13498 obj
13499 }
13500 };
13501 complete_fn.call::<_, ()>((call_id, js_outcome))?;
13502 Ok(())
13503 }
13504
13505 fn deliver_timer_fire(ctx: &Ctx<'_>, timer_id: u64) -> rquickjs::Result<()> {
13507 let global = ctx.globals();
13508 let fire_fn: Function<'_> = global.get("__pi_fire_timer")?;
13509 fire_fn.call::<_, ()>((timer_id,))?;
13510 Ok(())
13511 }
13512
13513 fn deliver_inbound_event(
13515 ctx: &Ctx<'_>,
13516 event_id: &str,
13517 payload: &serde_json::Value,
13518 ) -> rquickjs::Result<()> {
13519 let global = ctx.globals();
13520 let dispatch_fn: Function<'_> = global.get("__pi_dispatch_event")?;
13521 let js_payload = json_to_js(ctx, payload)?;
13522 dispatch_fn.call::<_, ()>((event_id, js_payload))?;
13523 Ok(())
13524 }
13525
13526 fn next_trace_id(&self) -> u64 {
13528 self.trace_seq.fetch_add(1, AtomicOrdering::SeqCst)
13529 }
13530
13531 pub fn add_allowed_read_root(&self, root: PathBuf) {
13542 if let Ok(mut roots) = self.allowed_read_roots.lock() {
13543 if !roots.contains(&root) {
13544 roots.push(root);
13545 }
13546 }
13547 }
13548
13549 pub fn add_extension_root(&self, root: PathBuf) {
13553 self.add_extension_root_with_id(root, None);
13554 }
13555
13556 pub fn add_extension_root_with_id(&self, root: PathBuf, extension_id: Option<&str>) {
13562 self.add_allowed_read_root(root.clone());
13563 let mut state = self.module_state.borrow_mut();
13564 if !state.extension_roots.contains(&root) {
13565 state.extension_roots.push(root.clone());
13566 }
13567
13568 let tier = extension_id.map_or_else(
13569 || root_path_hint_tier(&root),
13570 |id| classify_proxy_stub_source_tier(id, &root),
13571 );
13572 state.extension_root_tiers.insert(root.clone(), tier);
13573
13574 if let Some(scope) = read_extension_package_scope(&root) {
13575 state.extension_root_scopes.insert(root, scope);
13576 }
13577 }
13578
13579 #[allow(clippy::too_many_lines)]
13580 async fn install_pi_bridge(&self) -> Result<()> {
13581 let hostcall_queue = self.hostcall_queue.clone();
13582 let scheduler = Rc::clone(&self.scheduler);
13583 let hostcall_tracker = Rc::clone(&self.hostcall_tracker);
13584 let hostcalls_total = Arc::clone(&self.hostcalls_total);
13585 let trace_seq = Arc::clone(&self.trace_seq);
13586 let default_hostcall_timeout_ms = self.config.limits.hostcall_timeout_ms;
13587 let process_cwd = self.config.cwd.clone();
13588 let process_args = self.config.args.clone();
13589 let env = self.config.env.clone();
13590 let deny_env = self.config.deny_env;
13591 let repair_mode = self.config.repair_mode;
13592 let repair_events = Arc::clone(&self.repair_events);
13593 let allow_unsafe_sync_exec = self.config.allow_unsafe_sync_exec;
13594 let allowed_read_roots = Arc::clone(&self.allowed_read_roots);
13595 let policy = self.policy.clone();
13596
13597 self.context
13598 .with(|ctx| {
13599 let global = ctx.globals();
13600
13601 global.set(
13606 "__pi_tool_native",
13607 Func::from({
13608 let queue = hostcall_queue.clone();
13609 let tracker = hostcall_tracker.clone();
13610 let scheduler = Rc::clone(&scheduler);
13611 let hostcalls_total = Arc::clone(&hostcalls_total);
13612 let trace_seq = Arc::clone(&trace_seq);
13613 move |ctx: Ctx<'_>,
13614 name: String,
13615 input: Value<'_>|
13616 -> rquickjs::Result<String> {
13617 let payload = js_to_json(&input)?;
13618 let call_id = format!("call-{}", generate_call_id());
13619 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13620 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13621 let enqueued_at_ms = scheduler.borrow().now_ms();
13622 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13623 let timer_id =
13624 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13625 tracker
13626 .borrow_mut()
13627 .register(call_id.clone(), timer_id, enqueued_at_ms);
13628 let extension_id: Option<String> = ctx
13629 .globals()
13630 .get::<_, Option<String>>("__pi_current_extension_id")
13631 .ok()
13632 .flatten()
13633 .map(|value| value.trim().to_string())
13634 .filter(|value| !value.is_empty());
13635 let request = HostcallRequest {
13636 call_id: call_id.clone(),
13637 kind: HostcallKind::Tool { name },
13638 payload,
13639 trace_id,
13640 extension_id,
13641 };
13642 enqueue_hostcall_request_with_backpressure(
13643 &queue, &tracker, &scheduler, request,
13644 );
13645 Ok(call_id)
13646 }
13647 }),
13648 )?;
13649
13650 global.set(
13652 "__pi_exec_native",
13653 Func::from({
13654 let queue = hostcall_queue.clone();
13655 let tracker = hostcall_tracker.clone();
13656 let scheduler = Rc::clone(&scheduler);
13657 let hostcalls_total = Arc::clone(&hostcalls_total);
13658 let trace_seq = Arc::clone(&trace_seq);
13659 move |ctx: Ctx<'_>,
13660 cmd: String,
13661 args: Value<'_>,
13662 options: Opt<Value<'_>>|
13663 -> rquickjs::Result<String> {
13664 let mut options_json = match options.0.as_ref() {
13665 None => serde_json::json!({}),
13666 Some(value) if value.is_null() => serde_json::json!({}),
13667 Some(value) => js_to_json(value)?,
13668 };
13669 if let Some(default_timeout_ms) =
13670 default_hostcall_timeout_ms.filter(|ms| *ms > 0)
13671 {
13672 match &mut options_json {
13673 serde_json::Value::Object(map) => {
13674 let has_timeout = map.contains_key("timeout")
13675 || map.contains_key("timeoutMs")
13676 || map.contains_key("timeout_ms");
13677 if !has_timeout {
13678 map.insert(
13679 "timeoutMs".to_string(),
13680 serde_json::Value::from(default_timeout_ms),
13681 );
13682 }
13683 }
13684 _ => {
13685 options_json =
13686 serde_json::json!({ "timeoutMs": default_timeout_ms });
13687 }
13688 }
13689 }
13690 let payload = serde_json::json!({
13691 "args": js_to_json(&args)?,
13692 "options": options_json,
13693 });
13694 let call_id = format!("call-{}", generate_call_id());
13695 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13696 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13697 let enqueued_at_ms = scheduler.borrow().now_ms();
13698 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13699 let timer_id =
13700 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13701 tracker
13702 .borrow_mut()
13703 .register(call_id.clone(), timer_id, enqueued_at_ms);
13704 let extension_id: Option<String> = ctx
13705 .globals()
13706 .get::<_, Option<String>>("__pi_current_extension_id")
13707 .ok()
13708 .flatten()
13709 .map(|value| value.trim().to_string())
13710 .filter(|value| !value.is_empty());
13711 let request = HostcallRequest {
13712 call_id: call_id.clone(),
13713 kind: HostcallKind::Exec { cmd },
13714 payload,
13715 trace_id,
13716 extension_id,
13717 };
13718 enqueue_hostcall_request_with_backpressure(
13719 &queue, &tracker, &scheduler, request,
13720 );
13721 Ok(call_id)
13722 }
13723 }),
13724 )?;
13725
13726 global.set(
13728 "__pi_http_native",
13729 Func::from({
13730 let queue = hostcall_queue.clone();
13731 let tracker = hostcall_tracker.clone();
13732 let scheduler = Rc::clone(&scheduler);
13733 let hostcalls_total = Arc::clone(&hostcalls_total);
13734 let trace_seq = Arc::clone(&trace_seq);
13735 move |ctx: Ctx<'_>, req: Value<'_>| -> rquickjs::Result<String> {
13736 let payload = js_to_json(&req)?;
13737 let call_id = format!("call-{}", generate_call_id());
13738 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13739 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13740 let enqueued_at_ms = scheduler.borrow().now_ms();
13741 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13742 let timer_id =
13743 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13744 tracker
13745 .borrow_mut()
13746 .register(call_id.clone(), timer_id, enqueued_at_ms);
13747 let extension_id: Option<String> = ctx
13748 .globals()
13749 .get::<_, Option<String>>("__pi_current_extension_id")
13750 .ok()
13751 .flatten()
13752 .map(|value| value.trim().to_string())
13753 .filter(|value| !value.is_empty());
13754 let request = HostcallRequest {
13755 call_id: call_id.clone(),
13756 kind: HostcallKind::Http,
13757 payload,
13758 trace_id,
13759 extension_id,
13760 };
13761 enqueue_hostcall_request_with_backpressure(
13762 &queue, &tracker, &scheduler, request,
13763 );
13764 Ok(call_id)
13765 }
13766 }),
13767 )?;
13768
13769 global.set(
13771 "__pi_session_native",
13772 Func::from({
13773 let queue = hostcall_queue.clone();
13774 let tracker = hostcall_tracker.clone();
13775 let scheduler = Rc::clone(&scheduler);
13776 let hostcalls_total = Arc::clone(&hostcalls_total);
13777 let trace_seq = Arc::clone(&trace_seq);
13778 move |ctx: Ctx<'_>,
13779 op: String,
13780 args: Value<'_>|
13781 -> rquickjs::Result<String> {
13782 let payload = js_to_json(&args)?;
13783 let call_id = format!("call-{}", generate_call_id());
13784 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13785 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13786 let enqueued_at_ms = scheduler.borrow().now_ms();
13787 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13788 let timer_id =
13789 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13790 tracker
13791 .borrow_mut()
13792 .register(call_id.clone(), timer_id, enqueued_at_ms);
13793 let extension_id: Option<String> = ctx
13794 .globals()
13795 .get::<_, Option<String>>("__pi_current_extension_id")
13796 .ok()
13797 .flatten()
13798 .map(|value| value.trim().to_string())
13799 .filter(|value| !value.is_empty());
13800 let request = HostcallRequest {
13801 call_id: call_id.clone(),
13802 kind: HostcallKind::Session { op },
13803 payload,
13804 trace_id,
13805 extension_id,
13806 };
13807 enqueue_hostcall_request_with_backpressure(
13808 &queue, &tracker, &scheduler, request,
13809 );
13810 Ok(call_id)
13811 }
13812 }),
13813 )?;
13814
13815 global.set(
13817 "__pi_ui_native",
13818 Func::from({
13819 let queue = hostcall_queue.clone();
13820 let tracker = hostcall_tracker.clone();
13821 let scheduler = Rc::clone(&scheduler);
13822 let hostcalls_total = Arc::clone(&hostcalls_total);
13823 let trace_seq = Arc::clone(&trace_seq);
13824 move |ctx: Ctx<'_>,
13825 op: String,
13826 args: Value<'_>|
13827 -> rquickjs::Result<String> {
13828 let payload = js_to_json(&args)?;
13829 let call_id = format!("call-{}", generate_call_id());
13830 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13831 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13832 let enqueued_at_ms = scheduler.borrow().now_ms();
13833 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13834 let timer_id =
13835 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13836 tracker
13837 .borrow_mut()
13838 .register(call_id.clone(), timer_id, enqueued_at_ms);
13839 let extension_id: Option<String> = ctx
13840 .globals()
13841 .get::<_, Option<String>>("__pi_current_extension_id")
13842 .ok()
13843 .flatten()
13844 .map(|value| value.trim().to_string())
13845 .filter(|value| !value.is_empty());
13846 let request = HostcallRequest {
13847 call_id: call_id.clone(),
13848 kind: HostcallKind::Ui { op },
13849 payload,
13850 trace_id,
13851 extension_id,
13852 };
13853 enqueue_hostcall_request_with_backpressure(
13854 &queue, &tracker, &scheduler, request,
13855 );
13856 Ok(call_id)
13857 }
13858 }),
13859 )?;
13860
13861 global.set(
13863 "__pi_events_native",
13864 Func::from({
13865 let queue = hostcall_queue.clone();
13866 let tracker = hostcall_tracker.clone();
13867 let scheduler = Rc::clone(&scheduler);
13868 let hostcalls_total = Arc::clone(&hostcalls_total);
13869 let trace_seq = Arc::clone(&trace_seq);
13870 move |ctx: Ctx<'_>,
13871 op: String,
13872 args: Value<'_>|
13873 -> rquickjs::Result<String> {
13874 let payload = js_to_json(&args)?;
13875 let call_id = format!("call-{}", generate_call_id());
13876 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13877 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13878 let enqueued_at_ms = scheduler.borrow().now_ms();
13879 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13880 let timer_id =
13881 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13882 tracker
13883 .borrow_mut()
13884 .register(call_id.clone(), timer_id, enqueued_at_ms);
13885 let extension_id: Option<String> = ctx
13886 .globals()
13887 .get::<_, Option<String>>("__pi_current_extension_id")
13888 .ok()
13889 .flatten()
13890 .map(|value| value.trim().to_string())
13891 .filter(|value| !value.is_empty());
13892 let request = HostcallRequest {
13893 call_id: call_id.clone(),
13894 kind: HostcallKind::Events { op },
13895 payload,
13896 trace_id,
13897 extension_id,
13898 };
13899 enqueue_hostcall_request_with_backpressure(
13900 &queue, &tracker, &scheduler, request,
13901 );
13902 Ok(call_id)
13903 }
13904 }),
13905 )?;
13906
13907 global.set(
13909 "__pi_log_native",
13910 Func::from({
13911 let queue = hostcall_queue.clone();
13912 let tracker = hostcall_tracker.clone();
13913 let scheduler = Rc::clone(&scheduler);
13914 let hostcalls_total = Arc::clone(&hostcalls_total);
13915 let trace_seq = Arc::clone(&trace_seq);
13916 move |ctx: Ctx<'_>, entry: Value<'_>| -> rquickjs::Result<String> {
13917 let payload = js_to_json(&entry)?;
13918 let call_id = format!("call-{}", generate_call_id());
13919 hostcalls_total.fetch_add(1, AtomicOrdering::SeqCst);
13920 let trace_id = trace_seq.fetch_add(1, AtomicOrdering::SeqCst);
13921 let enqueued_at_ms = scheduler.borrow().now_ms();
13922 let timeout_ms = default_hostcall_timeout_ms.filter(|ms| *ms > 0);
13923 let timer_id =
13924 timeout_ms.map(|ms| scheduler.borrow_mut().set_timeout(ms));
13925 tracker
13926 .borrow_mut()
13927 .register(call_id.clone(), timer_id, enqueued_at_ms);
13928 let extension_id: Option<String> = ctx
13929 .globals()
13930 .get::<_, Option<String>>("__pi_current_extension_id")
13931 .ok()
13932 .flatten()
13933 .map(|value| value.trim().to_string())
13934 .filter(|value| !value.is_empty());
13935 let request = HostcallRequest {
13936 call_id: call_id.clone(),
13937 kind: HostcallKind::Log,
13938 payload,
13939 trace_id,
13940 extension_id,
13941 };
13942 enqueue_hostcall_request_with_backpressure(
13943 &queue, &tracker, &scheduler, request,
13944 );
13945 Ok(call_id)
13946 }
13947 }),
13948 )?;
13949
13950 global.set(
13952 "__pi_set_timeout_native",
13953 Func::from({
13954 let scheduler = Rc::clone(&scheduler);
13955 move |_ctx: Ctx<'_>, delay_ms: u64| -> rquickjs::Result<u64> {
13956 Ok(scheduler.borrow_mut().set_timeout(delay_ms))
13957 }
13958 }),
13959 )?;
13960
13961 global.set(
13963 "__pi_clear_timeout_native",
13964 Func::from({
13965 let scheduler = Rc::clone(&scheduler);
13966 move |_ctx: Ctx<'_>, timer_id: u64| -> rquickjs::Result<bool> {
13967 Ok(scheduler.borrow_mut().clear_timeout(timer_id))
13968 }
13969 }),
13970 )?;
13971
13972 global.set(
13974 "__pi_now_ms_native",
13975 Func::from({
13976 let scheduler = Rc::clone(&scheduler);
13977 move |_ctx: Ctx<'_>| -> rquickjs::Result<u64> {
13978 Ok(scheduler.borrow().now_ms())
13979 }
13980 }),
13981 )?;
13982
13983 global.set(
13985 "__pi_process_cwd_native",
13986 Func::from({
13987 let process_cwd = process_cwd.clone();
13988 move |_ctx: Ctx<'_>| -> rquickjs::Result<String> { Ok(process_cwd.clone()) }
13989 }),
13990 )?;
13991
13992 global.set(
13994 "__pi_process_args_native",
13995 Func::from({
13996 let process_args = process_args.clone();
13997 move |_ctx: Ctx<'_>| -> rquickjs::Result<Vec<String>> {
13998 Ok(process_args.clone())
13999 }
14000 }),
14001 )?;
14002
14003 global.set(
14005 "__pi_process_exit_native",
14006 Func::from({
14007 let queue = hostcall_queue.clone();
14008 let tracker = hostcall_tracker.clone();
14009 let scheduler = Rc::clone(&scheduler);
14010 move |_ctx: Ctx<'_>, code: i32| -> rquickjs::Result<()> {
14011 tracing::info!(
14012 event = "pijs.process.exit",
14013 code,
14014 "process.exit requested"
14015 );
14016 let call_id = format!("call-{}", generate_call_id());
14017 let enqueued_at_ms = scheduler.borrow().now_ms();
14018 tracker
14019 .borrow_mut()
14020 .register(call_id.clone(), None, enqueued_at_ms);
14021 let request = HostcallRequest {
14022 call_id,
14023 kind: HostcallKind::Events {
14024 op: "exit".to_string(),
14025 },
14026 payload: serde_json::json!({ "code": code }),
14027 trace_id: 0,
14028 extension_id: None,
14029 };
14030 enqueue_hostcall_request_with_backpressure(
14031 &queue, &tracker, &scheduler, request,
14032 );
14033 Ok(())
14034 }
14035 }),
14036 )?;
14037
14038 global.set(
14040 "__pi_process_execpath_native",
14041 Func::from(move |_ctx: Ctx<'_>| -> rquickjs::Result<String> {
14042 Ok(std::env::current_exe().map_or_else(
14043 |_| "/usr/bin/pi".to_string(),
14044 |p| p.to_string_lossy().into_owned(),
14045 ))
14046 }),
14047 )?;
14048
14049 global.set(
14051 "__pi_env_get_native",
14052 Func::from({
14053 let env = env.clone();
14054 let policy_for_env = policy.clone();
14055 move |_ctx: Ctx<'_>, key: String| -> rquickjs::Result<Option<String>> {
14056 if let Some(value) = compat_env_fallback_value(&key, &env) {
14061 tracing::debug!(
14062 event = "pijs.env.get.compat",
14063 key = %key,
14064 "env compat fallback"
14065 );
14066 return Ok(Some(value));
14067 }
14068 if deny_env {
14069 tracing::debug!(event = "pijs.env.get.denied", key = %key, "env capability denied");
14070 return Ok(None);
14071 }
14072 let allowed = policy_for_env.as_ref().map_or_else(
14076 || is_env_var_allowed(&key),
14077 |policy| !policy.secret_broker.is_secret(&key),
14078 );
14079 tracing::debug!(
14080 event = "pijs.env.get",
14081 key = %key,
14082 allowed,
14083 "env get"
14084 );
14085 if !allowed {
14086 return Ok(None);
14087 }
14088 Ok(env.get(&key).cloned())
14089 }
14090 }),
14091 )?;
14092
14093 global.set(
14095 "__pi_crypto_sha256_hex_native",
14096 Func::from(
14097 move |_ctx: Ctx<'_>, text: String| -> rquickjs::Result<String> {
14098 tracing::debug!(
14099 event = "pijs.crypto.sha256_hex",
14100 input_len = text.len(),
14101 "crypto sha256"
14102 );
14103 let mut hasher = Sha256::new();
14104 hasher.update(text.as_bytes());
14105 let digest = hasher.finalize();
14106 Ok(hex_lower(&digest))
14107 },
14108 ),
14109 )?;
14110
14111 global.set(
14115 "__pi_crypto_random_bytes_native",
14116 Func::from(
14117 move |_ctx: Ctx<'_>, len: usize| -> rquickjs::Result<Vec<u8>> {
14118 tracing::debug!(
14119 event = "pijs.crypto.random_bytes",
14120 len,
14121 "crypto random bytes"
14122 );
14123 Ok(random_bytes(len))
14124 },
14125 ),
14126 )?;
14127
14128 global.set(
14130 "__pi_base64_encode_native",
14131 Func::from(
14132 move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
14133 let mut bytes = Vec::with_capacity(input.len());
14134 for ch in input.chars() {
14135 let code = ch as u32;
14136 let byte = u8::try_from(code).map_err(|_| {
14137 rquickjs::Error::new_into_js_message(
14138 "base64",
14139 "encode",
14140 "Input contains non-latin1 characters",
14141 )
14142 })?;
14143 bytes.push(byte);
14144 }
14145 Ok(BASE64_STANDARD.encode(bytes))
14146 },
14147 ),
14148 )?;
14149
14150 global.set(
14152 "__pi_base64_decode_native",
14153 Func::from(
14154 move |_ctx: Ctx<'_>, input: String| -> rquickjs::Result<String> {
14155 let bytes = BASE64_STANDARD.decode(input).map_err(|err| {
14156 rquickjs::Error::new_into_js_message(
14157 "base64",
14158 "decode",
14159 format!("Invalid base64: {err}"),
14160 )
14161 })?;
14162
14163 let mut out = String::with_capacity(bytes.len());
14164 for byte in bytes {
14165 out.push(byte as char);
14166 }
14167 Ok(out)
14168 },
14169 ),
14170 )?;
14171
14172 global.set(
14176 "__pi_console_output_native",
14177 Func::from(
14178 move |_ctx: Ctx<'_>,
14179 level: String,
14180 message: String|
14181 -> rquickjs::Result<()> {
14182 match level.as_str() {
14183 "error" => tracing::error!(
14184 target: "pijs.console",
14185 "{message}"
14186 ),
14187 "warn" => tracing::warn!(
14188 target: "pijs.console",
14189 "{message}"
14190 ),
14191 "debug" => tracing::debug!(
14192 target: "pijs.console",
14193 "{message}"
14194 ),
14195 "trace" => tracing::trace!(
14196 target: "pijs.console",
14197 "{message}"
14198 ),
14199 _ => tracing::info!(
14201 target: "pijs.console",
14202 "{message}"
14203 ),
14204 }
14205 Ok(())
14206 },
14207 ),
14208 )?;
14209
14210 global.set(
14216 "__pi_host_read_file_sync",
14217 Func::from({
14218 let process_cwd = process_cwd.clone();
14219 let allowed_read_roots = Arc::clone(&allowed_read_roots);
14220 let configured_repair_mode = repair_mode;
14221 let repair_events = Arc::clone(&repair_events);
14222 move |path: String| -> rquickjs::Result<String> {
14223 const MAX_SYNC_READ_SIZE: u64 = 64 * 1024 * 1024; let workspace_root =
14226 crate::extensions::safe_canonicalize(Path::new(&process_cwd));
14227
14228 let requested = PathBuf::from(&path);
14229 let requested_abs = if requested.is_absolute() {
14230 requested
14231 } else {
14232 workspace_root.join(requested)
14233 };
14234
14235 #[cfg(target_os = "linux")]
14236 {
14237 use std::io::Read;
14238 use std::os::fd::AsRawFd;
14239
14240 let file = match std::fs::File::open(&requested_abs) {
14244 Ok(file) => file,
14245 Err(err)
14246 if err.kind() == std::io::ErrorKind::NotFound
14247 && configured_repair_mode.should_apply() =>
14248 {
14249 let checked_path = std::fs::canonicalize(&requested_abs)
14254 .map(crate::extensions::strip_unc_prefix)
14255 .or_else(|canonicalize_err| {
14256 if canonicalize_err.kind()
14257 == std::io::ErrorKind::NotFound
14258 {
14259 let mut ancestor = requested_abs.as_path();
14263 loop {
14264 ancestor = match ancestor.parent() {
14265 Some(p) if !p.as_os_str().is_empty() => p,
14266 _ => break,
14267 };
14268 if let Ok(canonical_ancestor) =
14269 std::fs::canonicalize(ancestor).map(
14270 crate::extensions::strip_unc_prefix,
14271 )
14272 {
14273 if canonical_ancestor
14274 .starts_with(&workspace_root)
14275 {
14276 return Ok(requested_abs.clone());
14277 }
14278 if let Ok(roots) =
14279 allowed_read_roots.lock()
14280 {
14281 for root in roots.iter() {
14282 if canonical_ancestor
14283 .starts_with(root)
14284 {
14285 return Ok(
14286 requested_abs.clone()
14287 );
14288 }
14289 }
14290 }
14291 break;
14292 }
14293 }
14294 }
14295 Err(canonicalize_err)
14296 })
14297 .map_err(|canonicalize_err| {
14298 rquickjs::Error::new_loading_message(
14299 &path,
14300 format!("host read open: {canonicalize_err}"),
14301 )
14302 })?;
14303
14304 let in_ext_root =
14305 allowed_read_roots.lock().is_ok_and(|roots| {
14306 roots.iter().any(|root| checked_path.starts_with(root))
14307 });
14308
14309 if in_ext_root {
14310 let ext = checked_path
14311 .extension()
14312 .and_then(|e| e.to_str())
14313 .unwrap_or("");
14314 let fallback = match ext {
14315 "html" | "htm" => {
14316 "<!DOCTYPE html><html><body></body></html>"
14317 }
14318 "css" => "/* auto-repair: empty stylesheet */",
14319 "js" | "mjs" => "// auto-repair: empty script",
14320 "md" | "txt" | "toml" | "yaml" | "yml" => "",
14321 _ => {
14324 return Err(rquickjs::Error::new_loading_message(
14325 &path,
14326 format!("host read open: {err}"),
14327 ));
14328 }
14329 };
14330
14331 tracing::info!(
14332 event = "pijs.repair.missing_asset",
14333 path = %path,
14334 ext = %ext,
14335 "returning empty fallback for missing asset"
14336 );
14337
14338 if let Ok(mut events) = repair_events.lock() {
14339 events.push(ExtensionRepairEvent {
14340 extension_id: String::new(),
14341 pattern: RepairPattern::MissingAsset,
14342 original_error: format!(
14343 "ENOENT: {}",
14344 checked_path.display()
14345 ),
14346 repair_action: format!(
14347 "returned empty {ext} fallback"
14348 ),
14349 success: true,
14350 timestamp_ms: 0,
14351 });
14352 }
14353
14354 return Ok(fallback.to_string());
14355 }
14356
14357 return Err(rquickjs::Error::new_loading_message(
14358 &path,
14359 format!("host read open: {err}"),
14360 ));
14361 }
14362 Err(err) => {
14363 return Err(rquickjs::Error::new_loading_message(
14364 &path,
14365 format!("host read open: {err}"),
14366 ));
14367 }
14368 };
14369
14370 let secure_path_buf = std::fs::read_link(format!(
14371 "/proc/self/fd/{}",
14372 file.as_raw_fd()
14373 ))
14374 .map_err(|err| {
14375 rquickjs::Error::new_loading_message(
14376 &path,
14377 format!("host read verify: {err}"),
14378 )
14379 })?;
14380 let secure_path =
14381 crate::extensions::strip_unc_prefix(secure_path_buf);
14382
14383 let in_ext_root = allowed_read_roots.lock().is_ok_and(|roots| {
14384 roots.iter().any(|root| secure_path.starts_with(root))
14385 });
14386 let allowed =
14387 secure_path.starts_with(&workspace_root) || in_ext_root;
14388
14389 if !allowed {
14390 return Err(rquickjs::Error::new_loading_message(
14391 &path,
14392 "host read denied: path outside extension root".to_string(),
14393 ));
14394 }
14395
14396 let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
14397 let mut buffer = Vec::new();
14398 reader.read_to_end(&mut buffer).map_err(|err| {
14399 rquickjs::Error::new_loading_message(
14400 &path,
14401 format!("host read content: {err}"),
14402 )
14403 })?;
14404
14405 if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
14406 return Err(rquickjs::Error::new_loading_message(
14407 &path,
14408 format!(
14409 "host read failed: file exceeds {MAX_SYNC_READ_SIZE} bytes"
14410 ),
14411 ));
14412 }
14413
14414 String::from_utf8(buffer).map_err(|err| {
14415 rquickjs::Error::new_loading_message(
14416 &path,
14417 format!("host read utf8: {err}"),
14418 )
14419 })
14420 }
14421
14422 #[cfg(not(target_os = "linux"))]
14423 {
14424 let checked_path = std::fs::canonicalize(&requested_abs)
14425 .map(crate::extensions::strip_unc_prefix)
14426 .or_else(|err| {
14427 if err.kind() == std::io::ErrorKind::NotFound {
14428 let mut ancestor = requested_abs.as_path();
14433 loop {
14434 ancestor = match ancestor.parent() {
14435 Some(p) if !p.as_os_str().is_empty() => p,
14436 _ => break,
14437 };
14438 if let Ok(canonical_ancestor) =
14439 std::fs::canonicalize(ancestor)
14440 .map(crate::extensions::strip_unc_prefix)
14441 {
14442 if canonical_ancestor
14443 .starts_with(&workspace_root)
14444 {
14445 return Ok(requested_abs.clone());
14446 }
14447 if let Ok(roots) = allowed_read_roots.lock() {
14448 for root in roots.iter() {
14449 if canonical_ancestor.starts_with(root)
14450 {
14451 return Ok(requested_abs.clone());
14452 }
14453 }
14454 }
14455 break;
14456 }
14457 }
14458 }
14459 Err(err)
14460 })
14461 .map_err(|err| {
14462 rquickjs::Error::new_loading_message(
14463 &path,
14464 format!("host read: {err}"),
14465 )
14466 })?;
14467
14468 let in_ext_root = allowed_read_roots.lock().is_ok_and(|roots| {
14471 roots.iter().any(|root| checked_path.starts_with(root))
14472 });
14473 let allowed =
14474 checked_path.starts_with(&workspace_root) || in_ext_root;
14475 if !allowed {
14476 return Err(rquickjs::Error::new_loading_message(
14477 &path,
14478 "host read denied: path outside extension root".to_string(),
14479 ));
14480 }
14481
14482 use std::io::Read;
14483 let file = std::fs::File::open(&checked_path).map_err(|err| {
14484 match err.kind() {
14488 std::io::ErrorKind::NotFound if in_ext_root && configured_repair_mode.should_apply() => {
14489 rquickjs::Error::new_loading_message(
14494 &path,
14495 format!("host read: {err}"),
14496 )
14497 }
14498 _ => rquickjs::Error::new_loading_message(
14499 &path,
14500 format!("host read: {err}"),
14501 )
14502 }
14503 })?;
14504
14505 let mut reader = file.take(MAX_SYNC_READ_SIZE + 1);
14506 let mut buffer = Vec::new();
14507 reader.read_to_end(&mut buffer).map_err(|err| {
14508 rquickjs::Error::new_loading_message(
14509 &path,
14510 format!("host read content: {err}"),
14511 )
14512 })?;
14513
14514 if buffer.len() as u64 > MAX_SYNC_READ_SIZE {
14515 return Err(rquickjs::Error::new_loading_message(
14516 &path,
14517 format!("host read failed: file exceeds {} bytes", MAX_SYNC_READ_SIZE),
14518 ));
14519 }
14520
14521 String::from_utf8(buffer).map_err(|err| {
14522 rquickjs::Error::new_loading_message(
14523 &path,
14524 format!("host read utf8: {err}"),
14525 )
14526 })
14527 }
14528 }
14529 }),
14530 )?;
14531
14532 global.set(
14536 "__pi_exec_sync_native",
14537 Func::from({
14538 let process_cwd = process_cwd.clone();
14539 let policy = self.policy.clone();
14540 move |ctx: Ctx<'_>,
14541 cmd: String,
14542 args_json: String,
14543 cwd: Opt<String>,
14544 timeout_ms: Opt<f64>,
14545 max_buffer: Opt<f64>|
14546 -> rquickjs::Result<String> {
14547 use std::io::Read as _;
14548 use std::process::{Command, Stdio};
14549 use std::sync::atomic::AtomicBool;
14550 use std::time::{Duration, Instant};
14551
14552 tracing::debug!(
14553 event = "pijs.exec_sync",
14554 cmd = %cmd,
14555 "exec_sync"
14556 );
14557
14558 let args: Vec<String> =
14559 serde_json::from_str(&args_json).unwrap_or_default();
14560
14561 let mut denied_reason = if allow_unsafe_sync_exec {
14562 None
14563 } else {
14564 Some("sync child_process APIs are disabled by default".to_string())
14565 };
14566
14567 if denied_reason.is_none() {
14569 if let Some(policy) = &policy {
14570 let extension_id: Option<String> = ctx
14571 .globals()
14572 .get::<_, Option<String>>("__pi_current_extension_id")
14573 .ok()
14574 .flatten()
14575 .map(|value| value.trim().to_string())
14576 .filter(|value| !value.is_empty());
14577
14578 if check_exec_capability(policy, extension_id.as_deref()) {
14579 match evaluate_exec_mediation(&policy.exec_mediation, &cmd, &args) {
14580 ExecMediationResult::Deny { reason, .. } => {
14581 denied_reason = Some(format!(
14582 "command blocked by exec mediation: {reason}"
14583 ));
14584 }
14585 ExecMediationResult::AllowWithAudit {
14586 class,
14587 reason,
14588 } => {
14589 tracing::info!(
14590 event = "pijs.exec_sync.mediation_audit",
14591 cmd = %cmd,
14592 class = class.label(),
14593 reason = %reason,
14594 "sync child_process command allowed with exec mediation audit"
14595 );
14596 }
14597 ExecMediationResult::Allow => {}
14598 }
14599 } else {
14600 denied_reason = Some("extension lacks 'exec' capability".to_string());
14601 }
14602 }
14603 }
14604
14605 if let Some(reason) = denied_reason {
14606 tracing::warn!(
14607 event = "pijs.exec_sync.denied",
14608 cmd = %cmd,
14609 reason = %reason,
14610 "sync child_process execution denied by security policy"
14611 );
14612 let denied = serde_json::json!({
14613 "stdout": "",
14614 "stderr": "",
14615 "status": null,
14616 "error": format!("Execution denied by policy ({reason})"),
14617 "killed": false,
14618 "pid": 0,
14619 "code": "denied",
14620 });
14621 return Ok(denied.to_string());
14622 }
14623
14624 let working_dir = cwd
14625 .0
14626 .filter(|s| !s.is_empty())
14627 .unwrap_or_else(|| process_cwd.clone());
14628
14629 let timeout = timeout_ms
14630 .0
14631 .filter(|ms| ms.is_finite() && *ms > 0.0)
14632 .map(|ms| Duration::from_secs_f64(ms / 1000.0));
14633
14634 let limit_bytes = max_buffer
14636 .0
14637 .filter(|b| b.is_finite() && *b > 0.0)
14638 .and_then(|b| b.trunc().to_string().parse::<usize>().ok())
14639 .unwrap_or(10 * 1024 * 1024);
14640
14641 let result: std::result::Result<serde_json::Value, String> = (|| {
14642 let mut command = Command::new(&cmd);
14643 command
14644 .args(&args)
14645 .current_dir(&working_dir)
14646 .stdin(Stdio::null())
14647 .stdout(Stdio::piped())
14648 .stderr(Stdio::piped());
14649
14650 let mut child = command.spawn().map_err(|e| e.to_string())?;
14651 let pid = child.id();
14652
14653 let mut stdout_pipe =
14654 child.stdout.take().ok_or("Missing stdout pipe")?;
14655 let mut stderr_pipe =
14656 child.stderr.take().ok_or("Missing stderr pipe")?;
14657
14658 let limit_exceeded = Arc::new(AtomicBool::new(false));
14659 let limit_exceeded_stdout = limit_exceeded.clone();
14660 let limit_exceeded_stderr = limit_exceeded.clone();
14661
14662 let stdout_handle = std::thread::spawn(
14663 move || -> (Vec<u8>, Option<String>) {
14664 let mut buf = Vec::new();
14665 let mut chunk = [0u8; 8192];
14666 loop {
14667 let n = match stdout_pipe.read(&mut chunk) {
14668 Ok(n) => n,
14669 Err(e) => return (buf, Some(e.to_string())),
14670 };
14671 if n == 0 { break; }
14672 if buf.len() + n > limit_bytes {
14673 limit_exceeded_stdout.store(true, AtomicOrdering::Relaxed);
14674 return (buf, Some("ENOBUFS: stdout maxBuffer length exceeded".to_string()));
14675 }
14676 buf.extend_from_slice(&chunk[..n]);
14677 }
14678 (buf, None)
14679 },
14680 );
14681 let stderr_handle = std::thread::spawn(
14682 move || -> (Vec<u8>, Option<String>) {
14683 let mut buf = Vec::new();
14684 let mut chunk = [0u8; 8192];
14685 loop {
14686 let n = match stderr_pipe.read(&mut chunk) {
14687 Ok(n) => n,
14688 Err(e) => return (buf, Some(e.to_string())),
14689 };
14690 if n == 0 { break; }
14691 if buf.len() + n > limit_bytes {
14692 limit_exceeded_stderr.store(true, AtomicOrdering::Relaxed);
14693 return (buf, Some("ENOBUFS: stderr maxBuffer length exceeded".to_string()));
14694 }
14695 buf.extend_from_slice(&chunk[..n]);
14696 }
14697 (buf, None)
14698 },
14699 );
14700
14701 let start = Instant::now();
14702 let mut killed = false;
14703 let status = loop {
14704 if let Some(st) = child.try_wait().map_err(|e| e.to_string())? {
14705 break st;
14706 }
14707 if limit_exceeded.load(AtomicOrdering::Relaxed) {
14708 killed = true;
14709 crate::tools::kill_process_tree(Some(pid));
14710 let _ = child.kill();
14711 }
14712 if let Some(t) = timeout {
14713 if start.elapsed() >= t {
14714 killed = true;
14715 crate::tools::kill_process_tree(Some(pid));
14716 let _ = child.kill();
14717 break child.wait().map_err(|e| e.to_string())?;
14718 }
14719 }
14720 std::thread::sleep(Duration::from_millis(5));
14721 };
14722
14723 let (stdout_bytes, stdout_err) = stdout_handle
14724 .join()
14725 .map_err(|_| "stdout reader thread panicked".to_string())?;
14726 let (stderr_bytes, stderr_err) = stderr_handle
14727 .join()
14728 .map_err(|_| "stderr reader thread panicked".to_string())?;
14729
14730 let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
14731 let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
14732 let code = status.code();
14733 let error = stdout_err.or(stderr_err);
14734
14735 Ok(serde_json::json!({
14736 "stdout": stdout,
14737 "stderr": stderr,
14738 "status": code,
14739 "killed": killed,
14740 "pid": pid,
14741 "error": error
14742 }))
14743 })(
14744 );
14745
14746 let json = match result {
14747 Ok(v) => v,
14748 Err(e) => serde_json::json!({
14749 "stdout": "",
14750 "stderr": "",
14751 "status": null,
14752 "error": e,
14753 "killed": false,
14754 "pid": 0,
14755 }),
14756 };
14757 Ok(json.to_string())
14758 }
14759 }),
14760 )?;
14761
14762 crate::crypto_shim::register_crypto_hostcalls(&global)?;
14764
14765 #[cfg(feature = "wasm-host")]
14767 {
14768 let wasm_state = std::rc::Rc::new(std::cell::RefCell::new(
14769 crate::pi_wasm::WasmBridgeState::new(),
14770 ));
14771 crate::pi_wasm::inject_wasm_globals(&ctx, &wasm_state)?;
14772 }
14773
14774 match ctx.eval::<(), _>(PI_BRIDGE_JS) {
14776 Ok(()) => {}
14777 Err(rquickjs::Error::Exception) => {
14778 let detail = format_quickjs_exception(&ctx, ctx.catch());
14779 return Err(rquickjs::Error::new_into_js_message(
14780 "PI_BRIDGE_JS",
14781 "eval",
14782 detail,
14783 ));
14784 }
14785 Err(err) => return Err(err),
14786 }
14787
14788 Ok(())
14789 })
14790 .await
14791 .map_err(|err| map_js_error(&err))?;
14792
14793 Ok(())
14794 }
14795}
14796
14797fn generate_call_id() -> u64 {
14799 use std::sync::atomic::{AtomicU64, Ordering};
14800 static COUNTER: AtomicU64 = AtomicU64::new(1);
14801 COUNTER.fetch_add(1, Ordering::Relaxed)
14802}
14803
14804fn hex_lower(bytes: &[u8]) -> String {
14805 const HEX: [char; 16] = [
14806 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
14807 ];
14808
14809 let mut output = String::with_capacity(bytes.len() * 2);
14810 for &byte in bytes {
14811 output.push(HEX[usize::from(byte >> 4)]);
14812 output.push(HEX[usize::from(byte & 0x0f)]);
14813 }
14814 output
14815}
14816
14817fn random_bytes(len: usize) -> Vec<u8> {
14818 let mut out = Vec::with_capacity(len);
14819 while out.len() < len {
14820 let bytes = Uuid::new_v4().into_bytes();
14821 let remaining = len - out.len();
14822 out.extend_from_slice(&bytes[..remaining.min(bytes.len())]);
14823 }
14824 out
14825}
14826
14827const PI_BRIDGE_JS: &str = r"
14832// ============================================================================
14833// Console global — must come first so all other bridge code can use it.
14834// ============================================================================
14835if (typeof globalThis.console === 'undefined') {
14836 const __fmt = (...args) => args.map(a => {
14837 if (a === null) return 'null';
14838 if (a === undefined) return 'undefined';
14839 if (typeof a === 'object') {
14840 try { return JSON.stringify(a); } catch (_) { return String(a); }
14841 }
14842 return String(a);
14843 }).join(' ');
14844
14845 globalThis.console = {
14846 log: (...args) => { __pi_console_output_native('log', __fmt(...args)); },
14847 info: (...args) => { __pi_console_output_native('info', __fmt(...args)); },
14848 warn: (...args) => { __pi_console_output_native('warn', __fmt(...args)); },
14849 error: (...args) => { __pi_console_output_native('error', __fmt(...args)); },
14850 debug: (...args) => { __pi_console_output_native('debug', __fmt(...args)); },
14851 trace: (...args) => { __pi_console_output_native('trace', __fmt(...args)); },
14852 dir: (...args) => { __pi_console_output_native('log', __fmt(...args)); },
14853 time: () => {},
14854 timeEnd: () => {},
14855 timeLog: () => {},
14856 assert: (cond, ...args) => {
14857 if (!cond) __pi_console_output_native('error', 'Assertion failed: ' + __fmt(...args));
14858 },
14859 count: () => {},
14860 countReset: () => {},
14861 group: () => {},
14862 groupEnd: () => {},
14863 table: (...args) => { __pi_console_output_native('log', __fmt(...args)); },
14864 clear: () => {},
14865 };
14866}
14867
14868// ============================================================================
14869// Intl polyfill — minimal stubs for extensions that use Intl APIs.
14870// QuickJS does not ship with Intl support; these cover the most common uses.
14871// ============================================================================
14872if (typeof globalThis.Intl === 'undefined') {
14873 const __intlPad = (n, w) => String(n).padStart(w || 2, '0');
14874
14875 class NumberFormat {
14876 constructor(locale, opts) {
14877 this._locale = locale || 'en-US';
14878 this._opts = opts || {};
14879 }
14880 format(n) {
14881 const o = this._opts;
14882 if (o.style === 'currency') {
14883 const c = o.currency || 'USD';
14884 const v = Number(n).toFixed(o.maximumFractionDigits ?? 2);
14885 return c + ' ' + v;
14886 }
14887 if (o.notation === 'compact') {
14888 const abs = Math.abs(n);
14889 if (abs >= 1e9) return (n / 1e9).toFixed(1) + 'B';
14890 if (abs >= 1e6) return (n / 1e6).toFixed(1) + 'M';
14891 if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'K';
14892 return String(n);
14893 }
14894 if (o.style === 'percent') return (Number(n) * 100).toFixed(0) + '%';
14895 return String(n);
14896 }
14897 resolvedOptions() { return { ...this._opts, locale: this._locale }; }
14898 }
14899
14900 const __months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
14901 class DateTimeFormat {
14902 constructor(locale, opts) {
14903 this._locale = locale || 'en-US';
14904 this._opts = opts || {};
14905 }
14906 format(d) {
14907 const dt = d instanceof Date ? d : new Date(d ?? Date.now());
14908 const o = this._opts;
14909 const parts = [];
14910 if (o.month === 'short') parts.push(__months[dt.getMonth()]);
14911 else if (o.month === 'numeric' || o.month === '2-digit') parts.push(__intlPad(dt.getMonth() + 1));
14912 if (o.day === 'numeric' || o.day === '2-digit') parts.push(String(dt.getDate()));
14913 if (o.year === 'numeric') parts.push(String(dt.getFullYear()));
14914 if (parts.length === 0) {
14915 return __intlPad(dt.getMonth()+1) + '/' + __intlPad(dt.getDate()) + '/' + dt.getFullYear();
14916 }
14917 if (o.hour !== undefined) {
14918 parts.push(__intlPad(dt.getHours()) + ':' + __intlPad(dt.getMinutes()));
14919 }
14920 return parts.join(' ');
14921 }
14922 resolvedOptions() { return { ...this._opts, locale: this._locale, timeZone: 'UTC' }; }
14923 }
14924
14925 class Collator {
14926 constructor(locale, opts) {
14927 this._locale = locale || 'en';
14928 this._opts = opts || {};
14929 }
14930 compare(a, b) {
14931 const sa = String(a ?? '');
14932 const sb = String(b ?? '');
14933 if (this._opts.sensitivity === 'base') {
14934 return sa.toLowerCase().localeCompare(sb.toLowerCase());
14935 }
14936 return sa.localeCompare(sb);
14937 }
14938 resolvedOptions() { return { ...this._opts, locale: this._locale }; }
14939 }
14940
14941 class Segmenter {
14942 constructor(locale, opts) {
14943 this._locale = locale || 'en';
14944 this._opts = opts || {};
14945 }
14946 segment(str) {
14947 const s = String(str ?? '');
14948 const segments = [];
14949 // Approximate grapheme segmentation: split by codepoints
14950 for (const ch of s) {
14951 segments.push({ segment: ch, index: segments.length, input: s });
14952 }
14953 segments[Symbol.iterator] = function*() { for (const seg of segments) yield seg; };
14954 return segments;
14955 }
14956 }
14957
14958 class RelativeTimeFormat {
14959 constructor(locale, opts) {
14960 this._locale = locale || 'en';
14961 this._opts = opts || {};
14962 }
14963 format(value, unit) {
14964 const v = Number(value);
14965 const u = String(unit);
14966 const abs = Math.abs(v);
14967 const plural = abs !== 1 ? 's' : '';
14968 if (this._opts.numeric === 'auto') {
14969 if (v === -1 && u === 'day') return 'yesterday';
14970 if (v === 1 && u === 'day') return 'tomorrow';
14971 }
14972 if (v < 0) return abs + ' ' + u + plural + ' ago';
14973 return 'in ' + abs + ' ' + u + plural;
14974 }
14975 }
14976
14977 globalThis.Intl = {
14978 NumberFormat,
14979 DateTimeFormat,
14980 Collator,
14981 Segmenter,
14982 RelativeTimeFormat,
14983 };
14984}
14985
14986// Pending hostcalls: call_id -> { resolve, reject }
14987const __pi_pending_hostcalls = new Map();
14988
14989// Timer callbacks: timer_id -> callback
14990const __pi_timer_callbacks = new Map();
14991
14992// Event listeners: event_id -> [callback, ...]
14993const __pi_event_listeners = new Map();
14994
14995// ============================================================================
14996// Extension Registry (registration + hooks)
14997// ============================================================================
14998
14999var __pi_current_extension_id = null;
15000
15001// extension_id -> { id, name, version, apiVersion, tools: Map, commands: Map, hooks: Map }
15002const __pi_extensions = new Map();
15003
15004// Fast indexes
15005const __pi_tool_index = new Map(); // tool_name -> { extensionId, spec, execute }
15006const __pi_command_index = new Map(); // command_name -> { extensionId, name, description, handler }
15007const __pi_hook_index = new Map(); // event_name -> [{ extensionId, handler }, ...]
15008const __pi_event_bus_index = new Map(); // event_name -> [{ extensionId, handler }, ...] (pi.events.on)
15009const __pi_provider_index = new Map(); // provider_id -> { extensionId, spec }
15010const __pi_shortcut_index = new Map(); // key_id -> { extensionId, key, description, handler }
15011const __pi_message_renderer_index = new Map(); // customType -> { extensionId, customType, renderer }
15012
15013// Async task tracking for Rust-driven calls (tool exec, command exec, event dispatch).
15014// task_id -> { status: 'pending'|'resolved'|'rejected', value?, error? }
15015const __pi_tasks = new Map();
15016
15017function __pi_serialize_error(err) {
15018 if (!err) {
15019 return { message: 'Unknown error' };
15020 }
15021 if (typeof err === 'string') {
15022 return { message: err };
15023 }
15024 const out = { message: String(err.message || err) };
15025 if (err.code) out.code = String(err.code);
15026 if (err.stack) out.stack = String(err.stack);
15027 return out;
15028}
15029
15030function __pi_task_start(task_id, promise) {
15031 const id = String(task_id || '').trim();
15032 if (!id) {
15033 throw new Error('task_id is required');
15034 }
15035 __pi_tasks.set(id, { status: 'pending' });
15036 Promise.resolve(promise).then(
15037 (value) => {
15038 __pi_tasks.set(id, { status: 'resolved', value: value });
15039 },
15040 (err) => {
15041 __pi_tasks.set(id, { status: 'rejected', error: __pi_serialize_error(err) });
15042 }
15043 );
15044 return id;
15045}
15046
15047function __pi_task_poll(task_id) {
15048 const id = String(task_id || '').trim();
15049 return __pi_tasks.get(id) || null;
15050}
15051
15052function __pi_task_take(task_id) {
15053 const id = String(task_id || '').trim();
15054 const state = __pi_tasks.get(id) || null;
15055 if (state && state.status !== 'pending') {
15056 __pi_tasks.delete(id);
15057 }
15058 return state;
15059}
15060
15061function __pi_runtime_registry_snapshot() {
15062 return {
15063 extensions: __pi_extensions.size,
15064 tools: __pi_tool_index.size,
15065 commands: __pi_command_index.size,
15066 hooks: __pi_hook_index.size,
15067 eventBusHooks: __pi_event_bus_index.size,
15068 providers: __pi_provider_index.size,
15069 shortcuts: __pi_shortcut_index.size,
15070 messageRenderers: __pi_message_renderer_index.size,
15071 pendingTasks: __pi_tasks.size,
15072 pendingHostcalls: __pi_pending_hostcalls.size,
15073 pendingTimers: __pi_timer_callbacks.size,
15074 pendingEventListenerLists: __pi_event_listeners.size,
15075 providerStreams:
15076 typeof __pi_provider_streams !== 'undefined' &&
15077 __pi_provider_streams &&
15078 typeof __pi_provider_streams.size === 'number'
15079 ? __pi_provider_streams.size
15080 : 0,
15081 };
15082}
15083
15084function __pi_reset_extension_runtime_state() {
15085 const before = __pi_runtime_registry_snapshot();
15086
15087 if (
15088 typeof __pi_provider_streams !== 'undefined' &&
15089 __pi_provider_streams &&
15090 typeof __pi_provider_streams.values === 'function'
15091 ) {
15092 for (const stream of __pi_provider_streams.values()) {
15093 try {
15094 if (stream && stream.controller && typeof stream.controller.abort === 'function') {
15095 stream.controller.abort();
15096 }
15097 } catch (_) {}
15098 try {
15099 if (
15100 stream &&
15101 stream.iterator &&
15102 typeof stream.iterator.return === 'function'
15103 ) {
15104 stream.iterator.return();
15105 }
15106 } catch (_) {}
15107 }
15108 if (typeof __pi_provider_streams.clear === 'function') {
15109 __pi_provider_streams.clear();
15110 }
15111 }
15112 if (typeof __pi_provider_stream_seq === 'number') {
15113 __pi_provider_stream_seq = 0;
15114 }
15115
15116 __pi_current_extension_id = null;
15117 __pi_extensions.clear();
15118 __pi_tool_index.clear();
15119 __pi_command_index.clear();
15120 __pi_hook_index.clear();
15121 __pi_event_bus_index.clear();
15122 __pi_provider_index.clear();
15123 __pi_shortcut_index.clear();
15124 __pi_message_renderer_index.clear();
15125 __pi_tasks.clear();
15126 __pi_pending_hostcalls.clear();
15127 __pi_timer_callbacks.clear();
15128 __pi_event_listeners.clear();
15129
15130 const after = __pi_runtime_registry_snapshot();
15131 const clean =
15132 after.extensions === 0 &&
15133 after.tools === 0 &&
15134 after.commands === 0 &&
15135 after.hooks === 0 &&
15136 after.eventBusHooks === 0 &&
15137 after.providers === 0 &&
15138 after.shortcuts === 0 &&
15139 after.messageRenderers === 0 &&
15140 after.pendingTasks === 0 &&
15141 after.pendingHostcalls === 0 &&
15142 after.pendingTimers === 0 &&
15143 after.pendingEventListenerLists === 0 &&
15144 after.providerStreams === 0;
15145
15146 return { before, after, clean };
15147}
15148
15149function __pi_get_or_create_extension(extension_id, meta) {
15150 const id = String(extension_id || '').trim();
15151 if (!id) {
15152 throw new Error('extension_id is required');
15153 }
15154
15155 if (!__pi_extensions.has(id)) {
15156 __pi_extensions.set(id, {
15157 id: id,
15158 name: (meta && meta.name) ? String(meta.name) : id,
15159 version: (meta && meta.version) ? String(meta.version) : '0.0.0',
15160 apiVersion: (meta && meta.apiVersion) ? String(meta.apiVersion) : '1.0',
15161 tools: new Map(),
15162 commands: new Map(),
15163 hooks: new Map(),
15164 eventBusHooks: new Map(),
15165 providers: new Map(),
15166 shortcuts: new Map(),
15167 flags: new Map(),
15168 flagValues: new Map(),
15169 messageRenderers: new Map(),
15170 activeTools: null,
15171 });
15172 }
15173
15174 return __pi_extensions.get(id);
15175}
15176
15177function __pi_begin_extension(extension_id, meta) {
15178 const ext = __pi_get_or_create_extension(extension_id, meta);
15179 __pi_current_extension_id = ext.id;
15180}
15181
15182function __pi_end_extension() {
15183 __pi_current_extension_id = null;
15184}
15185
15186function __pi_current_extension_or_throw() {
15187 if (!__pi_current_extension_id) {
15188 throw new Error('No active extension. Did you forget to call __pi_begin_extension?');
15189 }
15190 const ext = __pi_extensions.get(__pi_current_extension_id);
15191 if (!ext) {
15192 throw new Error('Internal error: active extension not found');
15193 }
15194 return ext;
15195}
15196
15197async function __pi_with_extension_async(extension_id, fn) {
15198 const prev = __pi_current_extension_id;
15199 __pi_current_extension_id = String(extension_id || '').trim();
15200 try {
15201 return await fn();
15202 } finally {
15203 __pi_current_extension_id = prev;
15204 }
15205}
15206
15207// Pattern 5 (bd-k5q5.8.6): log export shape normalization repairs.
15208// This is a lightweight JS-side event emitter; the Rust repair_events
15209// collector is not called from here to keep the bridge minimal.
15210function __pi_emit_repair_event(pattern, ext_id, entry, error, action) {
15211 if (typeof globalThis.__pi_host_log_event === 'function') {
15212 try {
15213 globalThis.__pi_host_log_event('pijs.repair.' + pattern, JSON.stringify({
15214 extension_id: ext_id, entry, error, action
15215 }));
15216 } catch (_) { /* best-effort */ }
15217 }
15218}
15219
15220async function __pi_load_extension(extension_id, entry_specifier, meta) {
15221 const id = String(extension_id || '').trim();
15222 const entry = String(entry_specifier || '').trim();
15223 if (!id) {
15224 throw new Error('load_extension: extension_id is required');
15225 }
15226 if (!entry) {
15227 throw new Error('load_extension: entry_specifier is required');
15228 }
15229
15230 const prev = __pi_current_extension_id;
15231 __pi_begin_extension(id, meta);
15232 try {
15233 const mod = await import(entry);
15234 let init = mod && mod.default;
15235
15236 // Pattern 5 (bd-k5q5.8.6): export shape normalization.
15237 // Try alternative activation function shapes before failing.
15238 if (typeof init !== 'function') {
15239 // 5a: double-wrapped default (CJS→ESM artifact)
15240 if (init && typeof init === 'object' && typeof init.default === 'function') {
15241 init = init.default;
15242 __pi_emit_repair_event('export_shape', id, entry,
15243 'double-wrapped default export', 'unwrapped mod.default.default');
15244 }
15245 // 5b: named 'activate' export
15246 else if (typeof mod.activate === 'function') {
15247 init = mod.activate;
15248 __pi_emit_repair_event('export_shape', id, entry,
15249 'no default export function', 'used named export mod.activate');
15250 }
15251 // 5c: nested CJS default with activate method
15252 else if (init && typeof init === 'object' && typeof init.activate === 'function') {
15253 init = init.activate;
15254 __pi_emit_repair_event('export_shape', id, entry,
15255 'default is object with activate method', 'used mod.default.activate');
15256 }
15257 }
15258
15259 if (typeof init !== 'function') {
15260 const namedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
15261 for (const key of namedFallbacks) {
15262 if (typeof mod?.[key] === 'function') {
15263 init = mod[key];
15264 __pi_emit_repair_event('export_shape', id, entry,
15265 'no default export function', `used named export mod.${key}`);
15266 break;
15267 }
15268 }
15269 }
15270
15271 if (typeof init !== 'function' && init && typeof init === 'object') {
15272 const nestedFallbacks = ['init', 'initialize', 'setup', 'register', 'plugin', 'main'];
15273 for (const key of nestedFallbacks) {
15274 if (typeof init?.[key] === 'function') {
15275 init = init[key];
15276 __pi_emit_repair_event('export_shape', id, entry,
15277 'default is object with init-like export', `used mod.default.${key}`);
15278 break;
15279 }
15280 }
15281 }
15282
15283 if (typeof init !== 'function') {
15284 for (const [key, value] of Object.entries(mod || {})) {
15285 if (typeof value === 'function') {
15286 init = value;
15287 __pi_emit_repair_event('export_shape', id, entry,
15288 'no default export function', `used first function export mod.${key}`);
15289 break;
15290 }
15291 }
15292 }
15293
15294 if (typeof init !== 'function') {
15295 throw new Error('load_extension: entry module must default-export a function');
15296 }
15297 await init(pi);
15298 return true;
15299 } finally {
15300 __pi_current_extension_id = prev;
15301 }
15302}
15303
15304function __pi_register_tool(spec) {
15305 const ext = __pi_current_extension_or_throw();
15306 if (!spec || typeof spec !== 'object') {
15307 throw new Error('registerTool: spec must be an object');
15308 }
15309 const name = String(spec.name || '').trim();
15310 if (!name) {
15311 throw new Error('registerTool: spec.name is required');
15312 }
15313 if (typeof spec.execute !== 'function') {
15314 throw new Error('registerTool: spec.execute must be a function');
15315 }
15316
15317 const toolSpec = {
15318 name: name,
15319 description: spec.description ? String(spec.description) : '',
15320 parameters: spec.parameters || { type: 'object', properties: {} },
15321 };
15322 if (typeof spec.label === 'string') {
15323 toolSpec.label = spec.label;
15324 }
15325
15326 if (__pi_tool_index.has(name)) {
15327 const existing = __pi_tool_index.get(name);
15328 if (existing && existing.extensionId !== ext.id) {
15329 throw new Error(`registerTool: tool name collision: ${name}`);
15330 }
15331 }
15332
15333 const record = { extensionId: ext.id, spec: toolSpec, execute: spec.execute };
15334 ext.tools.set(name, record);
15335 __pi_tool_index.set(name, record);
15336}
15337
15338function __pi_get_registered_tools() {
15339 const names = Array.from(__pi_tool_index.keys()).map((v) => String(v));
15340 names.sort();
15341 const out = [];
15342 for (const name of names) {
15343 const record = __pi_tool_index.get(name);
15344 if (!record || !record.spec) continue;
15345 out.push(record.spec);
15346 }
15347 return out;
15348}
15349
15350function __pi_register_command(name, spec) {
15351 const ext = __pi_current_extension_or_throw();
15352 const cmd = String(name || '').trim().replace(/^\//, '');
15353 if (!cmd) {
15354 throw new Error('registerCommand: name is required');
15355 }
15356 if (!spec || typeof spec !== 'object') {
15357 throw new Error('registerCommand: spec must be an object');
15358 }
15359 // Accept both spec.handler and spec.fn (PiCommand compat)
15360 const handler = typeof spec.handler === 'function' ? spec.handler
15361 : typeof spec.fn === 'function' ? spec.fn
15362 : undefined;
15363 if (!handler) {
15364 throw new Error('registerCommand: spec.handler must be a function');
15365 }
15366
15367 const cmdSpec = {
15368 name: cmd,
15369 description: spec.description ? String(spec.description) : '',
15370 };
15371
15372 if (__pi_command_index.has(cmd)) {
15373 const existing = __pi_command_index.get(cmd);
15374 if (existing && existing.extensionId !== ext.id) {
15375 throw new Error(`registerCommand: command name collision: ${cmd}`);
15376 }
15377 }
15378
15379 const record = {
15380 extensionId: ext.id,
15381 name: cmd,
15382 description: cmdSpec.description,
15383 handler: handler,
15384 spec: cmdSpec,
15385 };
15386 ext.commands.set(cmd, record);
15387 __pi_command_index.set(cmd, record);
15388}
15389
15390function __pi_register_provider(provider_id, spec) {
15391 const ext = __pi_current_extension_or_throw();
15392 const id = String(provider_id || '').trim();
15393 if (!id) {
15394 throw new Error('registerProvider: id is required');
15395 }
15396 if (!spec || typeof spec !== 'object') {
15397 throw new Error('registerProvider: spec must be an object');
15398 }
15399
15400 const models = Array.isArray(spec.models) ? spec.models.map((m) => {
15401 const out = {
15402 id: m && m.id ? String(m.id) : '',
15403 name: m && m.name ? String(m.name) : '',
15404 };
15405 if (m && m.api) out.api = String(m.api);
15406 if (m && m.reasoning !== undefined) out.reasoning = !!m.reasoning;
15407 if (m && Array.isArray(m.input)) out.input = m.input.slice();
15408 if (m && m.cost) out.cost = m.cost;
15409 if (m && m.contextWindow !== undefined) out.contextWindow = m.contextWindow;
15410 if (m && m.maxTokens !== undefined) out.maxTokens = m.maxTokens;
15411 return out;
15412 }) : [];
15413
15414 const hasStreamSimple = typeof spec.streamSimple === 'function';
15415 if (spec.streamSimple !== undefined && spec.streamSimple !== null && !hasStreamSimple) {
15416 throw new Error('registerProvider: spec.streamSimple must be a function');
15417 }
15418
15419 const providerSpec = {
15420 id: id,
15421 baseUrl: spec.baseUrl ? String(spec.baseUrl) : '',
15422 apiKey: spec.apiKey ? String(spec.apiKey) : '',
15423 api: spec.api ? String(spec.api) : '',
15424 models: models,
15425 hasStreamSimple: hasStreamSimple,
15426 };
15427
15428 if (hasStreamSimple && !providerSpec.api) {
15429 throw new Error('registerProvider: api is required when registering streamSimple');
15430 }
15431
15432 if (__pi_provider_index.has(id)) {
15433 const existing = __pi_provider_index.get(id);
15434 if (existing && existing.extensionId !== ext.id) {
15435 throw new Error(`registerProvider: provider id collision: ${id}`);
15436 }
15437 }
15438
15439 const record = {
15440 extensionId: ext.id,
15441 spec: providerSpec,
15442 streamSimple: hasStreamSimple ? spec.streamSimple : null,
15443 };
15444 ext.providers.set(id, record);
15445 __pi_provider_index.set(id, record);
15446}
15447
15448// ============================================================================
15449// Provider Streaming (streamSimple bridge)
15450// ============================================================================
15451
15452let __pi_provider_stream_seq = 0;
15453const __pi_provider_streams = new Map(); // stream_id -> { iterator, controller }
15454
15455function __pi_make_abort_controller() {
15456 const listeners = new Set();
15457 const signal = {
15458 aborted: false,
15459 addEventListener: (type, cb) => {
15460 if (type !== 'abort') return;
15461 if (typeof cb === 'function') listeners.add(cb);
15462 },
15463 removeEventListener: (type, cb) => {
15464 if (type !== 'abort') return;
15465 listeners.delete(cb);
15466 },
15467 };
15468 return {
15469 signal,
15470 abort: () => {
15471 if (signal.aborted) return;
15472 signal.aborted = true;
15473 for (const cb of listeners) {
15474 try {
15475 cb();
15476 } catch (_) {}
15477 }
15478 },
15479 };
15480}
15481
15482async function __pi_provider_stream_simple_start(provider_id, model, context, options) {
15483 const id = String(provider_id || '').trim();
15484 if (!id) {
15485 throw new Error('providerStreamSimple.start: provider_id is required');
15486 }
15487 const record = __pi_provider_index.get(id);
15488 if (!record) {
15489 throw new Error('providerStreamSimple.start: unknown provider: ' + id);
15490 }
15491 if (!record.streamSimple || typeof record.streamSimple !== 'function') {
15492 throw new Error('providerStreamSimple.start: provider has no streamSimple handler: ' + id);
15493 }
15494
15495 const controller = __pi_make_abort_controller();
15496 const mergedOptions = Object.assign({}, options || {}, { signal: controller.signal });
15497
15498 const stream = record.streamSimple(model, context, mergedOptions);
15499 const iterator = stream && stream[Symbol.asyncIterator] ? stream[Symbol.asyncIterator]() : stream;
15500 if (!iterator || typeof iterator.next !== 'function') {
15501 throw new Error('providerStreamSimple.start: streamSimple must return an async iterator');
15502 }
15503
15504 const stream_id = 'provider-stream-' + String(++__pi_provider_stream_seq);
15505 __pi_provider_streams.set(stream_id, { iterator, controller });
15506 return stream_id;
15507}
15508
15509async function __pi_provider_stream_simple_next(stream_id) {
15510 const id = String(stream_id || '').trim();
15511 const record = __pi_provider_streams.get(id);
15512 if (!record) {
15513 return { done: true, value: null };
15514 }
15515
15516 const result = await record.iterator.next();
15517 if (!result || result.done) {
15518 __pi_provider_streams.delete(id);
15519 return { done: true, value: null };
15520 }
15521
15522 return { done: false, value: result.value };
15523}
15524
15525async function __pi_provider_stream_simple_cancel(stream_id) {
15526 const id = String(stream_id || '').trim();
15527 const record = __pi_provider_streams.get(id);
15528 if (!record) {
15529 return false;
15530 }
15531
15532 try {
15533 record.controller.abort();
15534 } catch (_) {}
15535
15536 try {
15537 if (record.iterator && typeof record.iterator.return === 'function') {
15538 await record.iterator.return();
15539 }
15540 } catch (_) {}
15541
15542 __pi_provider_streams.delete(id);
15543 return true;
15544}
15545
15546const __pi_reserved_keys = new Set(['ctrl+c', 'ctrl+d', 'ctrl+l', 'ctrl+z']);
15547
15548function __pi_key_to_string(key) {
15549 // Convert Key object from @mariozechner/pi-tui to string format
15550 if (typeof key === 'string') {
15551 return key.toLowerCase();
15552 }
15553 if (key && typeof key === 'object') {
15554 const kind = key.kind;
15555 const k = key.key || '';
15556 if (kind === 'ctrlAlt') {
15557 return 'ctrl+alt+' + k.toLowerCase();
15558 }
15559 if (kind === 'ctrlShift') {
15560 return 'ctrl+shift+' + k.toLowerCase();
15561 }
15562 if (kind === 'ctrl') {
15563 return 'ctrl+' + k.toLowerCase();
15564 }
15565 if (kind === 'alt') {
15566 return 'alt+' + k.toLowerCase();
15567 }
15568 if (kind === 'shift') {
15569 return 'shift+' + k.toLowerCase();
15570 }
15571 // Fallback for unknown object format
15572 if (k) {
15573 return k.toLowerCase();
15574 }
15575 }
15576 return '<unknown>';
15577}
15578
15579function __pi_register_shortcut(key, spec) {
15580 const ext = __pi_current_extension_or_throw();
15581 if (!spec || typeof spec !== 'object') {
15582 throw new Error('registerShortcut: spec must be an object');
15583 }
15584 if (typeof spec.handler !== 'function') {
15585 throw new Error('registerShortcut: spec.handler must be a function');
15586 }
15587
15588 const keyId = __pi_key_to_string(key);
15589 if (__pi_reserved_keys.has(keyId)) {
15590 throw new Error('registerShortcut: key ' + keyId + ' is reserved and cannot be overridden');
15591 }
15592
15593 const record = {
15594 key: key,
15595 keyId: keyId,
15596 description: spec.description ? String(spec.description) : '',
15597 handler: spec.handler,
15598 extensionId: ext.id,
15599 spec: { shortcut: keyId, key: key, key_id: keyId, description: spec.description ? String(spec.description) : '' },
15600 };
15601 ext.shortcuts.set(keyId, record);
15602 __pi_shortcut_index.set(keyId, record);
15603}
15604
15605function __pi_register_message_renderer(customType, renderer) {
15606 const ext = __pi_current_extension_or_throw();
15607 const typeId = String(customType || '').trim();
15608 if (!typeId) {
15609 throw new Error('registerMessageRenderer: customType is required');
15610 }
15611 if (typeof renderer !== 'function') {
15612 throw new Error('registerMessageRenderer: renderer must be a function');
15613 }
15614
15615 const record = {
15616 customType: typeId,
15617 renderer: renderer,
15618 extensionId: ext.id,
15619 };
15620 ext.messageRenderers.set(typeId, record);
15621 __pi_message_renderer_index.set(typeId, record);
15622}
15623
15624 function __pi_register_hook(event_name, handler) {
15625 const ext = __pi_current_extension_or_throw();
15626 const eventName = String(event_name || '').trim();
15627 if (!eventName) {
15628 throw new Error('on: event name is required');
15629 }
15630 if (typeof handler !== 'function') {
15631 throw new Error('on: handler must be a function');
15632 }
15633
15634 if (!ext.hooks.has(eventName)) {
15635 ext.hooks.set(eventName, []);
15636 }
15637 ext.hooks.get(eventName).push(handler);
15638
15639 if (!__pi_hook_index.has(eventName)) {
15640 __pi_hook_index.set(eventName, []);
15641 }
15642 const indexed = { extensionId: ext.id, handler: handler };
15643 __pi_hook_index.get(eventName).push(indexed);
15644
15645 let removed = false;
15646 return function unsubscribe() {
15647 if (removed) return;
15648 removed = true;
15649
15650 const local = ext.hooks.get(eventName);
15651 if (Array.isArray(local)) {
15652 const idx = local.indexOf(handler);
15653 if (idx !== -1) local.splice(idx, 1);
15654 if (local.length === 0) ext.hooks.delete(eventName);
15655 }
15656
15657 const global = __pi_hook_index.get(eventName);
15658 if (Array.isArray(global)) {
15659 const idx = global.indexOf(indexed);
15660 if (idx !== -1) global.splice(idx, 1);
15661 if (global.length === 0) __pi_hook_index.delete(eventName);
15662 }
15663 };
15664 }
15665
15666 function __pi_register_event_bus_hook(event_name, handler) {
15667 const ext = __pi_current_extension_or_throw();
15668 const eventName = String(event_name || '').trim();
15669 if (!eventName) {
15670 throw new Error('events.on: event name is required');
15671 }
15672 if (typeof handler !== 'function') {
15673 throw new Error('events.on: handler must be a function');
15674 }
15675
15676 if (!ext.eventBusHooks.has(eventName)) {
15677 ext.eventBusHooks.set(eventName, []);
15678 }
15679 ext.eventBusHooks.get(eventName).push(handler);
15680
15681 if (!__pi_event_bus_index.has(eventName)) {
15682 __pi_event_bus_index.set(eventName, []);
15683 }
15684 const indexed = { extensionId: ext.id, handler: handler };
15685 __pi_event_bus_index.get(eventName).push(indexed);
15686
15687 let removed = false;
15688 return function unsubscribe() {
15689 if (removed) return;
15690 removed = true;
15691
15692 const local = ext.eventBusHooks.get(eventName);
15693 if (Array.isArray(local)) {
15694 const idx = local.indexOf(handler);
15695 if (idx !== -1) local.splice(idx, 1);
15696 if (local.length === 0) ext.eventBusHooks.delete(eventName);
15697 }
15698
15699 const global = __pi_event_bus_index.get(eventName);
15700 if (Array.isArray(global)) {
15701 const idx = global.indexOf(indexed);
15702 if (idx !== -1) global.splice(idx, 1);
15703 if (global.length === 0) __pi_event_bus_index.delete(eventName);
15704 }
15705 };
15706 }
15707
15708function __pi_register_flag(flag_name, spec) {
15709 const ext = __pi_current_extension_or_throw();
15710 const name = String(flag_name || '').trim().replace(/^\//, '');
15711 if (!name) {
15712 throw new Error('registerFlag: name is required');
15713 }
15714 if (!spec || typeof spec !== 'object') {
15715 throw new Error('registerFlag: spec must be an object');
15716 }
15717 ext.flags.set(name, spec);
15718}
15719
15720function __pi_set_flag_value(extension_id, flag_name, value) {
15721 const extId = String(extension_id || '').trim();
15722 const name = String(flag_name || '').trim().replace(/^\//, '');
15723 if (!extId || !name) return false;
15724 const ext = __pi_extensions.get(extId);
15725 if (!ext) return false;
15726 ext.flagValues.set(name, value);
15727 return true;
15728}
15729
15730function __pi_get_flag(flag_name) {
15731 const ext = __pi_current_extension_or_throw();
15732 const name = String(flag_name || '').trim().replace(/^\//, '');
15733 if (!name) return undefined;
15734 if (ext.flagValues.has(name)) {
15735 return ext.flagValues.get(name);
15736 }
15737 const spec = ext.flags.get(name);
15738 return spec ? spec.default : undefined;
15739}
15740
15741function __pi_set_active_tools(tools) {
15742 const ext = __pi_current_extension_or_throw();
15743 if (!Array.isArray(tools)) {
15744 throw new Error('setActiveTools: tools must be an array');
15745 }
15746 ext.activeTools = tools.map((t) => String(t));
15747 // Best-effort notify host; ignore completion.
15748 try {
15749 pi.events('setActiveTools', { extensionId: ext.id, tools: ext.activeTools }).catch(() => {});
15750 } catch (_) {}
15751}
15752
15753function __pi_get_active_tools() {
15754 const ext = __pi_current_extension_or_throw();
15755 if (!Array.isArray(ext.activeTools)) return undefined;
15756 return ext.activeTools.slice();
15757}
15758
15759function __pi_get_model() {
15760 return pi.events('getModel', {});
15761}
15762
15763function __pi_set_model(provider, modelId) {
15764 const p = provider != null ? String(provider) : null;
15765 const m = modelId != null ? String(modelId) : null;
15766 return pi.events('setModel', { provider: p, modelId: m });
15767}
15768
15769function __pi_get_thinking_level() {
15770 return pi.events('getThinkingLevel', {});
15771}
15772
15773function __pi_set_thinking_level(level) {
15774 const l = level != null ? String(level).trim() : null;
15775 return pi.events('setThinkingLevel', { thinkingLevel: l });
15776}
15777
15778function __pi_get_session_name() {
15779 return pi.session('get_name', {});
15780}
15781
15782function __pi_set_session_name(name) {
15783 const n = name != null ? String(name) : '';
15784 return pi.session('set_name', { name: n });
15785}
15786
15787function __pi_set_label(entryId, label) {
15788 const eid = String(entryId || '').trim();
15789 if (!eid) {
15790 throw new Error('setLabel: entryId is required');
15791 }
15792 const l = label != null ? String(label).trim() : null;
15793 return pi.session('set_label', { targetId: eid, label: l || undefined });
15794}
15795
15796function __pi_append_entry(custom_type, data) {
15797 const ext = __pi_current_extension_or_throw();
15798 const customType = String(custom_type || '').trim();
15799 if (!customType) {
15800 throw new Error('appendEntry: customType is required');
15801 }
15802 try {
15803 pi.events('appendEntry', {
15804 extensionId: ext.id,
15805 customType: customType,
15806 data: data === undefined ? null : data,
15807 }).catch(() => {});
15808 } catch (_) {}
15809}
15810
15811function __pi_send_message(message, options) {
15812 const ext = __pi_current_extension_or_throw();
15813 if (!message || typeof message !== 'object') {
15814 throw new Error('sendMessage: message must be an object');
15815 }
15816 const opts = options && typeof options === 'object' ? options : {};
15817 try {
15818 pi.events('sendMessage', { extensionId: ext.id, message: message, options: opts }).catch(() => {});
15819 } catch (_) {}
15820}
15821
15822function __pi_send_user_message(text, options) {
15823 const ext = __pi_current_extension_or_throw();
15824 const msg = String(text === undefined || text === null ? '' : text).trim();
15825 if (!msg) return;
15826 const opts = options && typeof options === 'object' ? options : {};
15827 try {
15828 pi.events('sendUserMessage', { extensionId: ext.id, text: msg, options: opts }).catch(() => {});
15829 } catch (_) {}
15830}
15831
15832function __pi_snapshot_extensions() {
15833 const out = [];
15834 for (const [id, ext] of __pi_extensions.entries()) {
15835 const tools = [];
15836 for (const tool of ext.tools.values()) {
15837 tools.push(tool.spec);
15838 }
15839
15840 const commands = [];
15841 for (const cmd of ext.commands.values()) {
15842 commands.push(cmd.spec);
15843 }
15844
15845 const providers = [];
15846 for (const provider of ext.providers.values()) {
15847 providers.push(provider.spec);
15848 }
15849
15850 const event_hooks = [];
15851 for (const key of ext.hooks.keys()) {
15852 event_hooks.push(String(key));
15853 }
15854
15855 const shortcuts = [];
15856 for (const shortcut of ext.shortcuts.values()) {
15857 shortcuts.push(shortcut.spec);
15858 }
15859
15860 const message_renderers = [];
15861 for (const renderer of ext.messageRenderers.values()) {
15862 message_renderers.push(renderer.customType);
15863 }
15864
15865 const flags = [];
15866 for (const [flagName, flagSpec] of ext.flags.entries()) {
15867 flags.push({
15868 name: flagName,
15869 description: flagSpec.description ? String(flagSpec.description) : '',
15870 type: flagSpec.type ? String(flagSpec.type) : 'string',
15871 default: flagSpec.default !== undefined ? flagSpec.default : null,
15872 });
15873 }
15874
15875 out.push({
15876 id: id,
15877 name: ext.name,
15878 version: ext.version,
15879 api_version: ext.apiVersion,
15880 tools: tools,
15881 slash_commands: commands,
15882 providers: providers,
15883 shortcuts: shortcuts,
15884 message_renderers: message_renderers,
15885 flags: flags,
15886 event_hooks: event_hooks,
15887 active_tools: Array.isArray(ext.activeTools) ? ext.activeTools.slice() : null,
15888 });
15889 }
15890 return out;
15891}
15892
15893function __pi_make_extension_theme() {
15894 return Object.create(__pi_extension_theme_template);
15895}
15896
15897const __pi_extension_theme_template = {
15898 // Minimal theme shim. Legacy emits ANSI; conformance harness should normalize ANSI away.
15899 fg: (_style, text) => String(text === undefined || text === null ? '' : text),
15900 bold: (text) => String(text === undefined || text === null ? '' : text),
15901 strikethrough: (text) => String(text === undefined || text === null ? '' : text),
15902};
15903
15904function __pi_build_extension_ui_template(hasUI) {
15905 return {
15906 select: (title, options) => {
15907 if (!hasUI) return Promise.resolve(undefined);
15908 const list = Array.isArray(options) ? options : [];
15909 const mapped = list.map((v) => String(v));
15910 return pi.ui('select', { title: String(title === undefined || title === null ? '' : title), options: mapped });
15911 },
15912 confirm: (title, message) => {
15913 if (!hasUI) return Promise.resolve(false);
15914 return pi.ui('confirm', {
15915 title: String(title === undefined || title === null ? '' : title),
15916 message: String(message === undefined || message === null ? '' : message),
15917 });
15918 },
15919 input: (title, placeholder, def) => {
15920 if (!hasUI) return Promise.resolve(undefined);
15921 // Legacy extensions typically call input(title, placeholder?, default?)
15922 let payloadDefault = def;
15923 let payloadPlaceholder = placeholder;
15924 if (def === undefined && typeof placeholder === 'string') {
15925 payloadDefault = placeholder;
15926 payloadPlaceholder = undefined;
15927 }
15928 return pi.ui('input', {
15929 title: String(title === undefined || title === null ? '' : title),
15930 placeholder: payloadPlaceholder,
15931 default: payloadDefault,
15932 });
15933 },
15934 editor: (title, def, language) => {
15935 if (!hasUI) return Promise.resolve(undefined);
15936 // Legacy extensions typically call editor(title, defaultText)
15937 return pi.ui('editor', {
15938 title: String(title === undefined || title === null ? '' : title),
15939 language: language,
15940 default: def,
15941 });
15942 },
15943 notify: (message, level) => {
15944 const notifyType = level ? String(level) : undefined;
15945 const payload = {
15946 message: String(message === undefined || message === null ? '' : message),
15947 };
15948 if (notifyType) {
15949 payload.level = notifyType;
15950 payload.notifyType = notifyType; // legacy field
15951 }
15952 void pi.ui('notify', payload).catch(() => {});
15953 },
15954 setStatus: (statusKey, statusText) => {
15955 const key = String(statusKey === undefined || statusKey === null ? '' : statusKey);
15956 const text = String(statusText === undefined || statusText === null ? '' : statusText);
15957 void pi.ui('setStatus', {
15958 statusKey: key,
15959 statusText: text,
15960 text: text, // compat: some UI surfaces only consume `text`
15961 }).catch(() => {});
15962 },
15963 setWidget: (widgetKey, lines) => {
15964 if (!hasUI) return;
15965 const payload = { widgetKey: String(widgetKey === undefined || widgetKey === null ? '' : widgetKey) };
15966 if (Array.isArray(lines)) {
15967 payload.lines = lines.map((v) => String(v));
15968 payload.widgetLines = payload.lines; // compat with pi-mono RPC naming
15969 payload.content = payload.lines.join('\n'); // compat: some UI surfaces expect a single string
15970 }
15971 void pi.ui('setWidget', payload).catch(() => {});
15972 },
15973 setTitle: (title) => {
15974 void pi.ui('setTitle', {
15975 title: String(title === undefined || title === null ? '' : title),
15976 }).catch(() => {});
15977 },
15978 setEditorText: (text) => {
15979 void pi.ui('set_editor_text', {
15980 text: String(text === undefined || text === null ? '' : text),
15981 }).catch(() => {});
15982 },
15983 custom: (_component, options) => {
15984 if (!hasUI) return Promise.resolve(undefined);
15985 const payload = options && typeof options === 'object' ? options : {};
15986 return pi.ui('custom', payload);
15987 },
15988 };
15989}
15990
15991const __pi_extension_ui_templates = {
15992 with_ui: __pi_build_extension_ui_template(true),
15993 without_ui: __pi_build_extension_ui_template(false),
15994};
15995
15996function __pi_make_extension_ui(hasUI) {
15997 const template = hasUI ? __pi_extension_ui_templates.with_ui : __pi_extension_ui_templates.without_ui;
15998 const ui = Object.create(template);
15999 ui.theme = __pi_make_extension_theme();
16000 return ui;
16001}
16002
16003function __pi_make_extension_ctx(ctx_payload) {
16004 const hasUI = !!(ctx_payload && (ctx_payload.hasUI || ctx_payload.has_ui));
16005 const cwd = ctx_payload && (ctx_payload.cwd || ctx_payload.CWD) ? String(ctx_payload.cwd || ctx_payload.CWD) : '';
16006
16007 const entriesRaw =
16008 (ctx_payload && (ctx_payload.sessionEntries || ctx_payload.session_entries || ctx_payload.entries)) || [];
16009 const branchRaw =
16010 (ctx_payload && (ctx_payload.sessionBranch || ctx_payload.session_branch || ctx_payload.branch)) || entriesRaw;
16011
16012 const entries = Array.isArray(entriesRaw) ? entriesRaw : [];
16013 const branch = Array.isArray(branchRaw) ? branchRaw : entries;
16014
16015 const leafEntry =
16016 (ctx_payload &&
16017 (ctx_payload.sessionLeafEntry ||
16018 ctx_payload.session_leaf_entry ||
16019 ctx_payload.leafEntry ||
16020 ctx_payload.leaf_entry)) ||
16021 null;
16022
16023 const modelRegistryValues =
16024 (ctx_payload && (ctx_payload.modelRegistry || ctx_payload.model_registry || ctx_payload.model_registry_values)) ||
16025 {};
16026
16027 const sessionManager = {
16028 getEntries: () => entries,
16029 getBranch: () => branch,
16030 getLeafEntry: () => leafEntry,
16031 };
16032
16033 return {
16034 hasUI: hasUI,
16035 cwd: cwd,
16036 ui: __pi_make_extension_ui(hasUI),
16037 sessionManager: sessionManager,
16038 modelRegistry: {
16039 getApiKeyForProvider: async (provider) => {
16040 const key = String(provider || '').trim();
16041 if (!key) return undefined;
16042 const value = modelRegistryValues[key];
16043 if (value === undefined || value === null) return undefined;
16044 return String(value);
16045 },
16046 },
16047 };
16048}
16049
16050 async function __pi_dispatch_event_inner(eventName, event_payload, ctx) {
16051 const handlers = [
16052 ...(__pi_hook_index.get(eventName) || []),
16053 ...(__pi_event_bus_index.get(eventName) || []),
16054 ];
16055 if (handlers.length === 0) {
16056 return undefined;
16057 }
16058
16059 if (eventName === 'input') {
16060 const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
16061 const originalText = typeof base.text === 'string' ? base.text : String(base.text ?? '');
16062 const originalImages = Array.isArray(base.images) ? base.images : undefined;
16063 const source = base.source !== undefined ? base.source : 'extension';
16064
16065 let currentText = originalText;
16066 let currentImages = originalImages;
16067
16068 for (const entry of handlers) {
16069 const handler = entry && entry.handler;
16070 if (typeof handler !== 'function') continue;
16071 const event = { type: 'input', text: currentText, images: currentImages, source: source };
16072 let result = undefined;
16073 try {
16074 result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
16075 } catch (e) {
16076 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
16077 continue;
16078 }
16079 if (result && typeof result === 'object') {
16080 if (result.action === 'handled') return result;
16081 if (result.action === 'transform' && typeof result.text === 'string') {
16082 currentText = result.text;
16083 if (result.images !== undefined) currentImages = result.images;
16084 }
16085 }
16086 }
16087
16088 if (currentText !== originalText || currentImages !== originalImages) {
16089 return { action: 'transform', text: currentText, images: currentImages };
16090 }
16091 return { action: 'continue' };
16092 }
16093
16094 if (eventName === 'before_agent_start') {
16095 const base = event_payload && typeof event_payload === 'object' ? event_payload : {};
16096 const prompt = typeof base.prompt === 'string' ? base.prompt : '';
16097 const images = Array.isArray(base.images) ? base.images : undefined;
16098 let currentSystemPrompt = typeof base.systemPrompt === 'string' ? base.systemPrompt : '';
16099 let modified = false;
16100 const messages = [];
16101
16102 for (const entry of handlers) {
16103 const handler = entry && entry.handler;
16104 if (typeof handler !== 'function') continue;
16105 const event = { type: 'before_agent_start', prompt, images, systemPrompt: currentSystemPrompt };
16106 let result = undefined;
16107 try {
16108 result = await __pi_with_extension_async(entry.extensionId, () => handler(event, ctx));
16109 } catch (e) {
16110 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
16111 continue;
16112 }
16113 if (result && typeof result === 'object') {
16114 if (result.message !== undefined) messages.push(result.message);
16115 if (result.systemPrompt !== undefined) {
16116 currentSystemPrompt = String(result.systemPrompt);
16117 modified = true;
16118 }
16119 }
16120 }
16121
16122 if (messages.length > 0 || modified) {
16123 return { messages: messages.length > 0 ? messages : undefined, systemPrompt: modified ? currentSystemPrompt : undefined };
16124 }
16125 return undefined;
16126 }
16127
16128 let last = undefined;
16129 for (const entry of handlers) {
16130 const handler = entry && entry.handler;
16131 if (typeof handler !== 'function') continue;
16132 let value = undefined;
16133 try {
16134 value = await __pi_with_extension_async(entry.extensionId, () => handler(event_payload, ctx));
16135 } catch (e) {
16136 try { globalThis.console && globalThis.console.error && globalThis.console.error('Event handler error:', eventName, entry.extensionId, e); } catch (_e) {}
16137 continue;
16138 }
16139 if (value === undefined) continue;
16140
16141 // First-result semantics (legacy parity)
16142 if (eventName === 'user_bash') {
16143 return value;
16144 }
16145
16146 last = value;
16147
16148 // Early-stop semantics (legacy parity)
16149 if (eventName === 'tool_call' && value && typeof value === 'object' && value.block) {
16150 return value;
16151 }
16152 if (eventName.startsWith('session_before_') && value && typeof value === 'object' && value.cancel) {
16153 return value;
16154 }
16155 }
16156 return last;
16157}
16158
16159 async function __pi_dispatch_extension_event(event_name, event_payload, ctx_payload) {
16160 const eventName = String(event_name || '').trim();
16161 if (!eventName) {
16162 throw new Error('dispatch_event: event name is required');
16163 }
16164 const ctx = __pi_make_extension_ctx(ctx_payload);
16165 return __pi_dispatch_event_inner(eventName, event_payload, ctx);
16166 }
16167
16168 async function __pi_dispatch_extension_events_batch(events_json, ctx_payload) {
16169 const ctx = __pi_make_extension_ctx(ctx_payload);
16170 const results = [];
16171 for (const entry of events_json) {
16172 const eventName = String(entry.event_name || '').trim();
16173 if (!eventName) continue;
16174 try {
16175 const value = await __pi_dispatch_event_inner(eventName, entry.event_payload, ctx);
16176 results.push({ event: eventName, ok: true, value: value });
16177 } catch (e) {
16178 results.push({ event: eventName, ok: false, error: String(e) });
16179 }
16180 }
16181 return results;
16182 }
16183
16184async function __pi_execute_tool(tool_name, tool_call_id, input, ctx_payload) {
16185 const name = String(tool_name || '').trim();
16186 const record = __pi_tool_index.get(name);
16187 if (!record) {
16188 throw new Error(`Unknown tool: ${name}`);
16189 }
16190
16191 const ctx = __pi_make_extension_ctx(ctx_payload);
16192 return __pi_with_extension_async(record.extensionId, () =>
16193 record.execute(tool_call_id, input, undefined, undefined, ctx)
16194 );
16195}
16196
16197async function __pi_execute_command(command_name, args, ctx_payload) {
16198 const name = String(command_name || '').trim().replace(/^\//, '');
16199 const record = __pi_command_index.get(name);
16200 if (!record) {
16201 throw new Error(`Unknown command: ${name}`);
16202 }
16203
16204 const ctx = __pi_make_extension_ctx(ctx_payload);
16205 return __pi_with_extension_async(record.extensionId, () => record.handler(args, ctx));
16206}
16207
16208async function __pi_execute_shortcut(key_id, ctx_payload) {
16209 const id = String(key_id || '').trim().toLowerCase();
16210 const record = __pi_shortcut_index.get(id);
16211 if (!record) {
16212 throw new Error('Unknown shortcut: ' + id);
16213 }
16214
16215 const ctx = __pi_make_extension_ctx(ctx_payload);
16216 return __pi_with_extension_async(record.extensionId, () => record.handler(ctx));
16217}
16218
16219// Hostcall stream class (async iterator for streaming hostcall results)
16220class __pi_HostcallStream {
16221 constructor(callId) {
16222 this.callId = callId;
16223 this.buffer = [];
16224 this.waitResolve = null;
16225 this.done = false;
16226 }
16227 pushChunk(chunk, isFinal) {
16228 if (isFinal) this.done = true;
16229 if (this.waitResolve) {
16230 const resolve = this.waitResolve;
16231 this.waitResolve = null;
16232 if (isFinal && chunk === null) {
16233 resolve({ value: undefined, done: true });
16234 } else {
16235 resolve({ value: chunk, done: false });
16236 }
16237 } else {
16238 this.buffer.push({ chunk, isFinal });
16239 }
16240 }
16241 pushError(error) {
16242 this.done = true;
16243 if (this.waitResolve) {
16244 const rej = this.waitResolve;
16245 this.waitResolve = null;
16246 rej({ __error: error });
16247 } else {
16248 this.buffer.push({ __error: error });
16249 }
16250 }
16251 next() {
16252 if (this.buffer.length > 0) {
16253 const entry = this.buffer.shift();
16254 if (entry.__error) return Promise.reject(entry.__error);
16255 if (entry.isFinal && entry.chunk === null) return Promise.resolve({ value: undefined, done: true });
16256 return Promise.resolve({ value: entry.chunk, done: false });
16257 }
16258 if (this.done) return Promise.resolve({ value: undefined, done: true });
16259 return new Promise((resolve, reject) => {
16260 this.waitResolve = (result) => {
16261 if (result && result.__error) reject(result.__error);
16262 else resolve(result);
16263 };
16264 });
16265 }
16266 return() {
16267 this.done = true;
16268 this.buffer = [];
16269 this.waitResolve = null;
16270 return Promise.resolve({ value: undefined, done: true });
16271 }
16272 [Symbol.asyncIterator]() { return this; }
16273}
16274
16275// Complete a hostcall (called from Rust)
16276function __pi_complete_hostcall_impl(call_id, outcome) {
16277 const pending = __pi_pending_hostcalls.get(call_id);
16278 if (!pending) return;
16279
16280 if (outcome.stream) {
16281 const seq = Number(outcome.sequence);
16282 if (!Number.isFinite(seq)) {
16283 const error = new Error('Invalid stream sequence');
16284 error.code = 'STREAM_SEQUENCE';
16285 if (pending.stream) pending.stream.pushError(error);
16286 else if (pending.reject) pending.reject(error);
16287 __pi_pending_hostcalls.delete(call_id);
16288 return;
16289 }
16290 if (pending.lastSeq === undefined) {
16291 if (seq !== 0) {
16292 const error = new Error('Stream sequence must start at 0');
16293 error.code = 'STREAM_SEQUENCE';
16294 if (pending.stream) pending.stream.pushError(error);
16295 else if (pending.reject) pending.reject(error);
16296 __pi_pending_hostcalls.delete(call_id);
16297 return;
16298 }
16299 } else if (seq <= pending.lastSeq) {
16300 const error = new Error('Stream sequence out of order');
16301 error.code = 'STREAM_SEQUENCE';
16302 if (pending.stream) pending.stream.pushError(error);
16303 else if (pending.reject) pending.reject(error);
16304 __pi_pending_hostcalls.delete(call_id);
16305 return;
16306 }
16307 pending.lastSeq = seq;
16308
16309 if (pending.stream) {
16310 pending.stream.pushChunk(outcome.chunk, outcome.isFinal);
16311 } else if (pending.onChunk) {
16312 const chunk = outcome.chunk;
16313 const isFinal = outcome.isFinal;
16314 Promise.resolve().then(() => {
16315 try {
16316 pending.onChunk(chunk, isFinal);
16317 } catch (e) {
16318 console.error('Hostcall onChunk error:', e);
16319 }
16320 });
16321 }
16322 if (outcome.isFinal) {
16323 __pi_pending_hostcalls.delete(call_id);
16324 if (pending.resolve) pending.resolve(outcome.chunk);
16325 }
16326 return;
16327 }
16328
16329 if (!outcome.ok && pending.stream) {
16330 const error = new Error(outcome.message);
16331 error.code = outcome.code;
16332 pending.stream.pushError(error);
16333 __pi_pending_hostcalls.delete(call_id);
16334 return;
16335 }
16336
16337 __pi_pending_hostcalls.delete(call_id);
16338 if (outcome.ok) {
16339 pending.resolve(outcome.value);
16340 } else {
16341 const error = new Error(outcome.message);
16342 error.code = outcome.code;
16343 pending.reject(error);
16344 }
16345}
16346
16347function __pi_complete_hostcall(call_id, outcome) {
16348 const pending = __pi_pending_hostcalls.get(call_id);
16349 if (pending && pending.extensionId) {
16350 const prev = __pi_current_extension_id;
16351 __pi_current_extension_id = pending.extensionId;
16352 try {
16353 return __pi_complete_hostcall_impl(call_id, outcome);
16354 } finally {
16355 Promise.resolve().then(() => { __pi_current_extension_id = prev; });
16356 }
16357 }
16358 return __pi_complete_hostcall_impl(call_id, outcome);
16359}
16360
16361// Fire a timer callback (called from Rust)
16362function __pi_fire_timer(timer_id) {
16363 const callback = __pi_timer_callbacks.get(timer_id);
16364 if (callback) {
16365 __pi_timer_callbacks.delete(timer_id);
16366 try {
16367 callback();
16368 } catch (e) {
16369 console.error('Timer callback error:', e);
16370 }
16371 }
16372}
16373
16374// Dispatch an inbound event (called from Rust)
16375function __pi_dispatch_event(event_id, payload) {
16376 const listeners = __pi_event_listeners.get(event_id);
16377 if (listeners) {
16378 for (const listener of listeners) {
16379 try {
16380 listener(payload);
16381 } catch (e) {
16382 console.error('Event listener error:', e);
16383 }
16384 }
16385 }
16386}
16387
16388// Register a timer callback (used by setTimeout)
16389function __pi_register_timer(timer_id, callback) {
16390 __pi_timer_callbacks.set(timer_id, callback);
16391}
16392
16393// Unregister a timer callback (used by clearTimeout)
16394function __pi_unregister_timer(timer_id) {
16395 __pi_timer_callbacks.delete(timer_id);
16396}
16397
16398// Add an event listener
16399function __pi_add_event_listener(event_id, callback) {
16400 if (!__pi_event_listeners.has(event_id)) {
16401 __pi_event_listeners.set(event_id, []);
16402 }
16403 __pi_event_listeners.get(event_id).push(callback);
16404}
16405
16406// Remove an event listener
16407function __pi_remove_event_listener(event_id, callback) {
16408 const listeners = __pi_event_listeners.get(event_id);
16409 if (listeners) {
16410 const index = listeners.indexOf(callback);
16411 if (index !== -1) {
16412 listeners.splice(index, 1);
16413 }
16414 }
16415}
16416
16417// Helper to create a Promise-returning hostcall wrapper
16418function __pi_make_hostcall(nativeFn) {
16419 return function(...args) {
16420 return new Promise((resolve, reject) => {
16421 const call_id = nativeFn(...args);
16422 __pi_pending_hostcalls.set(call_id, {
16423 resolve,
16424 reject,
16425 extensionId: __pi_current_extension_id
16426 });
16427 });
16428 };
16429}
16430
16431function __pi_make_streaming_hostcall(nativeFn, ...args) {
16432 const call_id = nativeFn(...args);
16433 const stream = new __pi_HostcallStream(call_id);
16434 __pi_pending_hostcalls.set(call_id, {
16435 stream,
16436 resolve: () => {},
16437 reject: () => {},
16438 extensionId: __pi_current_extension_id
16439 });
16440 return stream;
16441}
16442
16443function __pi_env_get(key) {
16444 const value = __pi_env_get_native(key);
16445 if (value === null || value === undefined) {
16446 return undefined;
16447 }
16448 return value;
16449}
16450
16451function __pi_path_join(...parts) {
16452 let out = '';
16453 for (const part of parts) {
16454 if (!part) continue;
16455 if (out === '' || out.endsWith('/')) {
16456 out += part;
16457 } else {
16458 out += '/' + part;
16459 }
16460 }
16461 return __pi_path_normalize(out);
16462}
16463
16464function __pi_path_basename(path) {
16465 if (!path) return '';
16466 let p = path;
16467 while (p.length > 1 && p.endsWith('/')) {
16468 p = p.slice(0, -1);
16469 }
16470 const idx = p.lastIndexOf('/');
16471 return idx === -1 ? p : p.slice(idx + 1);
16472}
16473
16474function __pi_path_normalize(path) {
16475 if (!path) return '';
16476 const isAbs = path.startsWith('/');
16477 const parts = path.split('/').filter(p => p.length > 0);
16478 const stack = [];
16479 for (const part of parts) {
16480 if (part === '.') continue;
16481 if (part === '..') {
16482 if (stack.length > 0 && stack[stack.length - 1] !== '..') {
16483 stack.pop();
16484 } else if (!isAbs) {
16485 stack.push('..');
16486 }
16487 continue;
16488 }
16489 stack.push(part);
16490 }
16491 const joined = stack.join('/');
16492 return isAbs ? '/' + joined : joined || (isAbs ? '/' : '');
16493}
16494
16495function __pi_sleep(ms) {
16496 return new Promise((resolve) => setTimeout(resolve, ms));
16497}
16498
16499// Create the pi global object with Promise-returning methods
16500const __pi_exec_hostcall = __pi_make_hostcall(__pi_exec_native);
16501 const pi = {
16502 // pi.tool(name, input) - invoke a tool
16503 tool: __pi_make_hostcall(__pi_tool_native),
16504
16505 // pi.exec(cmd, args, options) - execute a shell command
16506 exec: (cmd, args, options = {}) => {
16507 if (options && options.stream) {
16508 const onChunk =
16509 options && typeof options === 'object'
16510 ? (options.onChunk || options.on_chunk)
16511 : undefined;
16512 if (typeof onChunk === 'function') {
16513 const opts = Object.assign({}, options);
16514 delete opts.onChunk;
16515 delete opts.on_chunk;
16516 const call_id = __pi_exec_native(cmd, args, opts);
16517 return new Promise((resolve, reject) => {
16518 __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
16519 });
16520 }
16521 return __pi_make_streaming_hostcall(__pi_exec_native, cmd, args, options);
16522 }
16523 return __pi_exec_hostcall(cmd, args, options);
16524 },
16525
16526 // pi.http(request) - make an HTTP request
16527 http: (request) => {
16528 if (request && request.stream) {
16529 const onChunk =
16530 request && typeof request === 'object'
16531 ? (request.onChunk || request.on_chunk)
16532 : undefined;
16533 if (typeof onChunk === 'function') {
16534 const req = Object.assign({}, request);
16535 delete req.onChunk;
16536 delete req.on_chunk;
16537 const call_id = __pi_http_native(req);
16538 return new Promise((resolve, reject) => {
16539 __pi_pending_hostcalls.set(call_id, { onChunk, resolve, reject, extensionId: __pi_current_extension_id });
16540 });
16541 }
16542 return __pi_make_streaming_hostcall(__pi_http_native, request);
16543 }
16544 return __pi_make_hostcall(__pi_http_native)(request);
16545 },
16546
16547 // pi.session(op, args) - session operations
16548 session: __pi_make_hostcall(__pi_session_native),
16549
16550 // pi.ui(op, args) - UI operations
16551 ui: __pi_make_hostcall(__pi_ui_native),
16552
16553 // pi.events(op, args) - event operations
16554 events: __pi_make_hostcall(__pi_events_native),
16555
16556 // pi.log(entry) - structured log emission
16557 log: __pi_make_hostcall(__pi_log_native),
16558
16559 // Extension API (legacy-compatible subset)
16560 registerTool: __pi_register_tool,
16561 registerCommand: __pi_register_command,
16562 registerProvider: __pi_register_provider,
16563 registerShortcut: __pi_register_shortcut,
16564 registerMessageRenderer: __pi_register_message_renderer,
16565 on: __pi_register_hook,
16566 registerFlag: __pi_register_flag,
16567 getFlag: __pi_get_flag,
16568 setActiveTools: __pi_set_active_tools,
16569 getActiveTools: __pi_get_active_tools,
16570 getModel: __pi_get_model,
16571 setModel: __pi_set_model,
16572 getThinkingLevel: __pi_get_thinking_level,
16573 setThinkingLevel: __pi_set_thinking_level,
16574 appendEntry: __pi_append_entry,
16575 sendMessage: __pi_send_message,
16576 sendUserMessage: __pi_send_user_message,
16577 getSessionName: __pi_get_session_name,
16578 setSessionName: __pi_set_session_name,
16579 setLabel: __pi_set_label,
16580 };
16581
16582 // Convenience API: pi.events.emit/on (inter-extension bus).
16583 // Keep pi.events callable for legacy hostcall operations.
16584 pi.events.emit = (event, data, options = undefined) => {
16585 const name = String(event || '').trim();
16586 if (!name) {
16587 throw new Error('events.emit: event name is required');
16588 }
16589 const payload = { event: name, data: (data === undefined ? null : data) };
16590 if (options && typeof options === 'object') {
16591 if (options.ctx !== undefined) payload.ctx = options.ctx;
16592 if (options.timeout_ms !== undefined) payload.timeout_ms = options.timeout_ms;
16593 if (options.timeoutMs !== undefined) payload.timeoutMs = options.timeoutMs;
16594 if (options.timeout !== undefined) payload.timeout = options.timeout;
16595 }
16596 return pi.events('emit', payload);
16597 };
16598 pi.events.on = (event, handler) => __pi_register_event_bus_hook(event, handler);
16599
16600 pi.env = {
16601 get: __pi_env_get,
16602 };
16603
16604pi.process = {
16605 cwd: __pi_process_cwd_native(),
16606 args: __pi_process_args_native(),
16607};
16608
16609const __pi_det_cwd = __pi_env_get('PI_DETERMINISTIC_CWD');
16610if (__pi_det_cwd) {
16611 try { pi.process.cwd = __pi_det_cwd; } catch (_) {}
16612}
16613
16614try { Object.freeze(pi.process.args); } catch (_) {}
16615try { Object.freeze(pi.process); } catch (_) {}
16616
16617pi.path = {
16618 join: __pi_path_join,
16619 basename: __pi_path_basename,
16620 normalize: __pi_path_normalize,
16621};
16622
16623function __pi_crypto_bytes_to_array(raw) {
16624 if (raw == null) return [];
16625 if (Array.isArray(raw)) {
16626 return raw.map((value) => Number(value) & 0xff);
16627 }
16628 if (raw instanceof Uint8Array) {
16629 return Array.from(raw, (value) => Number(value) & 0xff);
16630 }
16631 if (raw instanceof ArrayBuffer) {
16632 return Array.from(new Uint8Array(raw), (value) => Number(value) & 0xff);
16633 }
16634 if (typeof raw === 'string') {
16635 // Depending on bridge coercion, bytes may arrive as:
16636 // 1) hex text (2 chars per byte), or 2) latin1-style binary string.
16637 const isHex = raw.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(raw);
16638 if (isHex) {
16639 const out = [];
16640 for (let i = 0; i + 1 < raw.length; i += 2) {
16641 const byte = Number.parseInt(raw.slice(i, i + 2), 16);
16642 out.push(Number.isFinite(byte) ? (byte & 0xff) : 0);
16643 }
16644 return out;
16645 }
16646 const out = new Array(raw.length);
16647 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i) & 0xff;
16648 return out;
16649 }
16650 if (typeof raw.length === 'number') {
16651 const len = Number(raw.length) || 0;
16652 const out = new Array(len);
16653 for (let i = 0; i < len; i++) out[i] = Number(raw[i] || 0) & 0xff;
16654 return out;
16655 }
16656 return [];
16657}
16658
16659pi.crypto = {
16660 sha256Hex: __pi_crypto_sha256_hex_native,
16661 randomBytes: function(n) {
16662 return __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(n));
16663 },
16664};
16665
16666pi.time = {
16667 nowMs: __pi_now_ms_native,
16668 sleep: __pi_sleep,
16669};
16670
16671// Make pi available globally
16672globalThis.pi = pi;
16673
16674const __pi_det_time_raw = __pi_env_get('PI_DETERMINISTIC_TIME_MS');
16675const __pi_det_time_step_raw = __pi_env_get('PI_DETERMINISTIC_TIME_STEP_MS');
16676const __pi_det_random_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM');
16677const __pi_det_random_seed_raw = __pi_env_get('PI_DETERMINISTIC_RANDOM_SEED');
16678
16679if (__pi_det_time_raw !== undefined) {
16680 const __pi_det_base = Number(__pi_det_time_raw);
16681 if (Number.isFinite(__pi_det_base)) {
16682 const __pi_det_step = (() => {
16683 if (__pi_det_time_step_raw === undefined) return 1;
16684 const value = Number(__pi_det_time_step_raw);
16685 return Number.isFinite(value) ? value : 1;
16686 })();
16687 let __pi_det_tick = 0;
16688 const __pi_det_now = () => {
16689 const value = __pi_det_base + (__pi_det_step * __pi_det_tick);
16690 __pi_det_tick += 1;
16691 return value;
16692 };
16693
16694 if (pi && pi.time) {
16695 pi.time.nowMs = () => __pi_det_now();
16696 }
16697
16698 const __pi_OriginalDate = Date;
16699 class PiDeterministicDate extends __pi_OriginalDate {
16700 constructor(...args) {
16701 if (args.length === 0) {
16702 super(__pi_det_now());
16703 } else {
16704 super(...args);
16705 }
16706 }
16707 static now() {
16708 return __pi_det_now();
16709 }
16710 }
16711 PiDeterministicDate.UTC = __pi_OriginalDate.UTC;
16712 PiDeterministicDate.parse = __pi_OriginalDate.parse;
16713 globalThis.Date = PiDeterministicDate;
16714 }
16715}
16716
16717if (__pi_det_random_raw !== undefined) {
16718 const __pi_det_random_val = Number(__pi_det_random_raw);
16719 if (Number.isFinite(__pi_det_random_val)) {
16720 Math.random = () => __pi_det_random_val;
16721 }
16722} else if (__pi_det_random_seed_raw !== undefined) {
16723 let __pi_det_state = Number(__pi_det_random_seed_raw);
16724 if (Number.isFinite(__pi_det_state)) {
16725 __pi_det_state = __pi_det_state >>> 0;
16726 Math.random = () => {
16727 __pi_det_state = (__pi_det_state * 1664525 + 1013904223) >>> 0;
16728 return __pi_det_state / 4294967296;
16729 };
16730 }
16731}
16732
16733// ============================================================================
16734// Minimal Web/Node polyfills for legacy extensions (best-effort)
16735// ============================================================================
16736
16737if (typeof globalThis.btoa !== 'function') {
16738 globalThis.btoa = (s) => {
16739 const bin = String(s === undefined || s === null ? '' : s);
16740 return __pi_base64_encode_native(bin);
16741 };
16742}
16743
16744if (typeof globalThis.atob !== 'function') {
16745 globalThis.atob = (s) => {
16746 const b64 = String(s === undefined || s === null ? '' : s);
16747 return __pi_base64_decode_native(b64);
16748 };
16749}
16750
16751if (typeof globalThis.TextEncoder === 'undefined') {
16752 class TextEncoder {
16753 encode(input) {
16754 const s = String(input === undefined || input === null ? '' : input);
16755 const bytes = [];
16756 for (let i = 0; i < s.length; i++) {
16757 let code = s.charCodeAt(i);
16758 if (code < 0x80) {
16759 bytes.push(code);
16760 continue;
16761 }
16762 if (code < 0x800) {
16763 bytes.push(0xc0 | (code >> 6));
16764 bytes.push(0x80 | (code & 0x3f));
16765 continue;
16766 }
16767 if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
16768 const next = s.charCodeAt(i + 1);
16769 if (next >= 0xdc00 && next <= 0xdfff) {
16770 const cp = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
16771 bytes.push(0xf0 | (cp >> 18));
16772 bytes.push(0x80 | ((cp >> 12) & 0x3f));
16773 bytes.push(0x80 | ((cp >> 6) & 0x3f));
16774 bytes.push(0x80 | (cp & 0x3f));
16775 i++;
16776 continue;
16777 }
16778 }
16779 bytes.push(0xe0 | (code >> 12));
16780 bytes.push(0x80 | ((code >> 6) & 0x3f));
16781 bytes.push(0x80 | (code & 0x3f));
16782 }
16783 return new Uint8Array(bytes);
16784 }
16785 }
16786 globalThis.TextEncoder = TextEncoder;
16787}
16788
16789if (typeof globalThis.TextDecoder === 'undefined') {
16790 class TextDecoder {
16791 constructor(encoding = 'utf-8') {
16792 this.encoding = encoding;
16793 }
16794
16795 decode(input, _opts) {
16796 if (input === undefined || input === null) return '';
16797 if (typeof input === 'string') return input;
16798
16799 let bytes;
16800 if (input instanceof ArrayBuffer) {
16801 bytes = new Uint8Array(input);
16802 } else if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
16803 bytes = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
16804 } else if (Array.isArray(input)) {
16805 bytes = new Uint8Array(input);
16806 } else if (typeof input.length === 'number') {
16807 bytes = new Uint8Array(input);
16808 } else {
16809 return '';
16810 }
16811
16812 let out = '';
16813 for (let i = 0; i < bytes.length; ) {
16814 const b0 = bytes[i++];
16815 if (b0 < 0x80) {
16816 out += String.fromCharCode(b0);
16817 continue;
16818 }
16819 if ((b0 & 0xe0) === 0xc0) {
16820 const b1 = bytes[i++] & 0x3f;
16821 out += String.fromCharCode(((b0 & 0x1f) << 6) | b1);
16822 continue;
16823 }
16824 if ((b0 & 0xf0) === 0xe0) {
16825 const b1 = bytes[i++] & 0x3f;
16826 const b2 = bytes[i++] & 0x3f;
16827 out += String.fromCharCode(((b0 & 0x0f) << 12) | (b1 << 6) | b2);
16828 continue;
16829 }
16830 if ((b0 & 0xf8) === 0xf0) {
16831 const b1 = bytes[i++] & 0x3f;
16832 const b2 = bytes[i++] & 0x3f;
16833 const b3 = bytes[i++] & 0x3f;
16834 let cp = ((b0 & 0x07) << 18) | (b1 << 12) | (b2 << 6) | b3;
16835 cp -= 0x10000;
16836 out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
16837 continue;
16838 }
16839 }
16840 return out;
16841 }
16842 }
16843
16844 globalThis.TextDecoder = TextDecoder;
16845}
16846
16847// structuredClone — deep clone using JSON round-trip
16848if (typeof globalThis.structuredClone === 'undefined') {
16849 globalThis.structuredClone = (value) => JSON.parse(JSON.stringify(value));
16850}
16851
16852// queueMicrotask — schedule a microtask
16853if (typeof globalThis.queueMicrotask === 'undefined') {
16854 globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
16855}
16856
16857// performance.now() — high-resolution timer
16858if (typeof globalThis.performance === 'undefined') {
16859 const start = Date.now();
16860 globalThis.performance = { now: () => Date.now() - start, timeOrigin: start };
16861}
16862
16863if (typeof globalThis.URLSearchParams === 'undefined') {
16864 class URLSearchParams {
16865 constructor(init) {
16866 this._pairs = [];
16867 if (typeof init === 'string') {
16868 const s = init.replace(/^\?/, '');
16869 if (s.length > 0) {
16870 for (const part of s.split('&')) {
16871 const idx = part.indexOf('=');
16872 if (idx === -1) {
16873 this.append(decodeURIComponent(part), '');
16874 } else {
16875 const k = part.slice(0, idx);
16876 const v = part.slice(idx + 1);
16877 this.append(decodeURIComponent(k), decodeURIComponent(v));
16878 }
16879 }
16880 }
16881 } else if (Array.isArray(init)) {
16882 for (const entry of init) {
16883 if (!entry) continue;
16884 this.append(entry[0], entry[1]);
16885 }
16886 } else if (init && typeof init === 'object') {
16887 for (const k of Object.keys(init)) {
16888 this.append(k, init[k]);
16889 }
16890 }
16891 }
16892
16893 append(key, value) {
16894 this._pairs.push([String(key), String(value)]);
16895 }
16896
16897 toString() {
16898 const out = [];
16899 for (const [k, v] of this._pairs) {
16900 out.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
16901 }
16902 return out.join('&');
16903 }
16904 }
16905
16906 globalThis.URLSearchParams = URLSearchParams;
16907}
16908
16909if (typeof globalThis.URL === 'undefined') {
16910 class URL {
16911 constructor(input, base) {
16912 const s = base ? new URL(base).href.replace(/\/[^/]*$/, '/') + String(input ?? '') : String(input ?? '');
16913 const m = s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/);
16914 if (m) {
16915 this.protocol = m[1] + ':';
16916 const auth = m[2];
16917 const atIdx = auth.lastIndexOf('@');
16918 if (atIdx !== -1) {
16919 const userinfo = auth.slice(0, atIdx);
16920 const ci = userinfo.indexOf(':');
16921 this.username = ci === -1 ? userinfo : userinfo.slice(0, ci);
16922 this._pw = ci === -1 ? String() : userinfo.slice(ci + 1);
16923 this.host = auth.slice(atIdx + 1);
16924 } else {
16925 this.username = '';
16926 this._pw = String();
16927 this.host = auth;
16928 }
16929 const hi = this.host.indexOf(':');
16930 this.hostname = hi === -1 ? this.host : this.host.slice(0, hi);
16931 this.port = hi === -1 ? '' : this.host.slice(hi + 1);
16932 this.pathname = m[3] || '/';
16933 this.search = m[4] || '';
16934 this.hash = m[5] || '';
16935 } else {
16936 this.protocol = '';
16937 this.username = '';
16938 this._pw = String();
16939 this.host = '';
16940 this.hostname = '';
16941 this.port = '';
16942 this.pathname = s;
16943 this.search = '';
16944 this.hash = '';
16945 }
16946 this.searchParams = new globalThis.URLSearchParams(this.search.replace(/^\?/, ''));
16947 this.origin = this.protocol ? `${this.protocol}//${this.host}` : '';
16948 this.href = this.toString();
16949 }
16950 get password() {
16951 return this._pw;
16952 }
16953 set password(value) {
16954 this._pw = value == null ? String() : String(value);
16955 }
16956 toString() {
16957 const auth = this.username ? `${this.username}${this.password ? ':' + this.password : ''}@` : '';
16958 return this.protocol ? `${this.protocol}//${auth}${this.host}${this.pathname}${this.search}${this.hash}` : this.pathname;
16959 }
16960 toJSON() { return this.toString(); }
16961 }
16962 globalThis.URL = URL;
16963}
16964
16965if (typeof globalThis.Buffer === 'undefined') {
16966 class Buffer extends Uint8Array {
16967 static from(input, encoding) {
16968 if (typeof input === 'string') {
16969 const enc = String(encoding || '').toLowerCase();
16970 if (enc === 'base64') {
16971 const bin = __pi_base64_decode_native(input);
16972 const out = new Buffer(bin.length);
16973 for (let i = 0; i < bin.length; i++) {
16974 out[i] = bin.charCodeAt(i) & 0xff;
16975 }
16976 return out;
16977 }
16978 if (enc === 'hex') {
16979 const hex = input.replace(/[^0-9a-fA-F]/g, '');
16980 const out = new Buffer(hex.length >> 1);
16981 for (let i = 0; i < out.length; i++) {
16982 out[i] = parseInt(hex.substr(i * 2, 2), 16);
16983 }
16984 return out;
16985 }
16986 const encoded = new TextEncoder().encode(input);
16987 const out = new Buffer(encoded.length);
16988 out.set(encoded);
16989 return out;
16990 }
16991 if (input instanceof ArrayBuffer) {
16992 const out = new Buffer(input.byteLength);
16993 out.set(new Uint8Array(input));
16994 return out;
16995 }
16996 if (ArrayBuffer.isView && ArrayBuffer.isView(input)) {
16997 const out = new Buffer(input.byteLength);
16998 out.set(new Uint8Array(input.buffer, input.byteOffset, input.byteLength));
16999 return out;
17000 }
17001 if (Array.isArray(input)) {
17002 const out = new Buffer(input.length);
17003 for (let i = 0; i < input.length; i++) out[i] = input[i] & 0xff;
17004 return out;
17005 }
17006 throw new Error('Buffer.from: unsupported input');
17007 }
17008 static alloc(size, fill) {
17009 const buf = new Buffer(size);
17010 if (fill !== undefined) buf.fill(typeof fill === 'number' ? fill : 0);
17011 return buf;
17012 }
17013 static allocUnsafe(size) { return new Buffer(size); }
17014 static isBuffer(obj) { return obj instanceof Buffer; }
17015 static isEncoding(enc) {
17016 return ['utf8','utf-8','ascii','latin1','binary','base64','hex','ucs2','ucs-2','utf16le','utf-16le'].includes(String(enc).toLowerCase());
17017 }
17018 static byteLength(str, encoding) {
17019 if (typeof str !== 'string') return str.length || 0;
17020 const enc = String(encoding || 'utf8').toLowerCase();
17021 if (enc === 'base64') return Math.ceil(str.length * 3 / 4);
17022 if (enc === 'hex') return str.length >> 1;
17023 return new TextEncoder().encode(str).length;
17024 }
17025 static concat(list, totalLength) {
17026 if (!Array.isArray(list) || list.length === 0) return Buffer.alloc(0);
17027 const total = totalLength !== undefined ? totalLength : list.reduce((s, b) => s + b.length, 0);
17028 const out = Buffer.alloc(total);
17029 let offset = 0;
17030 for (const buf of list) {
17031 if (offset >= total) break;
17032 const src = buf instanceof Uint8Array ? buf : Buffer.from(buf);
17033 const copyLen = Math.min(src.length, total - offset);
17034 out.set(src.subarray(0, copyLen), offset);
17035 offset += copyLen;
17036 }
17037 return out;
17038 }
17039 static compare(a, b) {
17040 const len = Math.min(a.length, b.length);
17041 for (let i = 0; i < len; i++) {
17042 if (a[i] < b[i]) return -1;
17043 if (a[i] > b[i]) return 1;
17044 }
17045 if (a.length < b.length) return -1;
17046 if (a.length > b.length) return 1;
17047 return 0;
17048 }
17049 toString(encoding, start, end) {
17050 const s = start || 0;
17051 const e = end !== undefined ? end : this.length;
17052 const view = this.subarray(s, e);
17053 const enc = String(encoding || 'utf8').toLowerCase();
17054 if (enc === 'base64') {
17055 let binary = '';
17056 for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]);
17057 return __pi_base64_encode_native(binary);
17058 }
17059 if (enc === 'hex') {
17060 let hex = '';
17061 for (let i = 0; i < view.length; i++) hex += (view[i] < 16 ? '0' : '') + view[i].toString(16);
17062 return hex;
17063 }
17064 return new TextDecoder().decode(view);
17065 }
17066 toJSON() {
17067 return { type: 'Buffer', data: Array.from(this) };
17068 }
17069 equals(other) {
17070 if (this.length !== other.length) return false;
17071 for (let i = 0; i < this.length; i++) {
17072 if (this[i] !== other[i]) return false;
17073 }
17074 return true;
17075 }
17076 compare(other) { return Buffer.compare(this, other); }
17077 copy(target, targetStart, sourceStart, sourceEnd) {
17078 const ts = targetStart || 0;
17079 const ss = sourceStart || 0;
17080 const se = sourceEnd !== undefined ? sourceEnd : this.length;
17081 const src = this.subarray(ss, se);
17082 const copyLen = Math.min(src.length, target.length - ts);
17083 target.set(src.subarray(0, copyLen), ts);
17084 return copyLen;
17085 }
17086 slice(start, end) {
17087 const sliced = super.slice(start, end);
17088 const buf = new Buffer(sliced.length);
17089 buf.set(sliced);
17090 return buf;
17091 }
17092 indexOf(value, byteOffset, encoding) {
17093 const offset = byteOffset || 0;
17094 if (typeof value === 'number') {
17095 for (let i = offset; i < this.length; i++) {
17096 if (this[i] === (value & 0xff)) return i;
17097 }
17098 return -1;
17099 }
17100 const needle = typeof value === 'string' ? Buffer.from(value, encoding) : value;
17101 outer: for (let i = offset; i <= this.length - needle.length; i++) {
17102 for (let j = 0; j < needle.length; j++) {
17103 if (this[i + j] !== needle[j]) continue outer;
17104 }
17105 return i;
17106 }
17107 return -1;
17108 }
17109 includes(value, byteOffset, encoding) {
17110 return this.indexOf(value, byteOffset, encoding) !== -1;
17111 }
17112 write(string, offset, length, encoding) {
17113 const o = offset || 0;
17114 const enc = encoding || 'utf8';
17115 const bytes = Buffer.from(string, enc);
17116 const len = length !== undefined ? Math.min(length, bytes.length) : bytes.length;
17117 const copyLen = Math.min(len, this.length - o);
17118 this.set(bytes.subarray(0, copyLen), o);
17119 return copyLen;
17120 }
17121 fill(value, offset, end, encoding) {
17122 const s = offset || 0;
17123 const e = end !== undefined ? end : this.length;
17124 const v = typeof value === 'number' ? (value & 0xff) : 0;
17125 for (let i = s; i < e; i++) this[i] = v;
17126 return this;
17127 }
17128 readUInt8(offset) { return this[offset || 0]; }
17129 readUInt16BE(offset) { const o = offset || 0; return (this[o] << 8) | this[o + 1]; }
17130 readUInt16LE(offset) { const o = offset || 0; return this[o] | (this[o + 1] << 8); }
17131 readUInt32BE(offset) { const o = offset || 0; return ((this[o] << 24) | (this[o+1] << 16) | (this[o+2] << 8) | this[o+3]) >>> 0; }
17132 readUInt32LE(offset) { const o = offset || 0; return (this[o] | (this[o+1] << 8) | (this[o+2] << 16) | (this[o+3] << 24)) >>> 0; }
17133 readInt8(offset) { const v = this[offset || 0]; return v > 127 ? v - 256 : v; }
17134 writeUInt8(value, offset) { this[offset || 0] = value & 0xff; return (offset || 0) + 1; }
17135 writeUInt16BE(value, offset) { const o = offset || 0; this[o] = (value >> 8) & 0xff; this[o+1] = value & 0xff; return o + 2; }
17136 writeUInt16LE(value, offset) { const o = offset || 0; this[o] = value & 0xff; this[o+1] = (value >> 8) & 0xff; return o + 2; }
17137 writeUInt32BE(value, offset) { const o = offset || 0; this[o]=(value>>>24)&0xff; this[o+1]=(value>>>16)&0xff; this[o+2]=(value>>>8)&0xff; this[o+3]=value&0xff; return o+4; }
17138 writeUInt32LE(value, offset) { const o = offset || 0; this[o]=value&0xff; this[o+1]=(value>>>8)&0xff; this[o+2]=(value>>>16)&0xff; this[o+3]=(value>>>24)&0xff; return o+4; }
17139 }
17140 globalThis.Buffer = Buffer;
17141}
17142
17143if (typeof globalThis.crypto === 'undefined') {
17144 globalThis.crypto = {};
17145}
17146
17147if (typeof globalThis.crypto.getRandomValues !== 'function') {
17148 globalThis.crypto.getRandomValues = (arr) => {
17149 const len = Number(arr && arr.length ? arr.length : 0);
17150 const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(len));
17151 for (let i = 0; i < len; i++) {
17152 arr[i] = bytes[i] || 0;
17153 }
17154 return arr;
17155 };
17156}
17157
17158if (!globalThis.crypto.subtle) {
17159 globalThis.crypto.subtle = {};
17160}
17161
17162if (typeof globalThis.crypto.subtle.digest !== 'function') {
17163 globalThis.crypto.subtle.digest = async (algorithm, data) => {
17164 const name = typeof algorithm === 'string' ? algorithm : (algorithm && algorithm.name ? algorithm.name : '');
17165 const upper = String(name).toUpperCase();
17166 if (upper !== 'SHA-256') {
17167 throw new Error('crypto.subtle.digest: only SHA-256 is supported');
17168 }
17169 const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
17170 let text = '';
17171 for (let i = 0; i < bytes.length; i++) {
17172 text += String.fromCharCode(bytes[i]);
17173 }
17174 const hex = __pi_crypto_sha256_hex_native(text);
17175 const out = new Uint8Array(hex.length / 2);
17176 for (let i = 0; i < out.length; i++) {
17177 out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
17178 }
17179 return out.buffer;
17180 };
17181}
17182
17183if (typeof globalThis.crypto.randomUUID !== 'function') {
17184 globalThis.crypto.randomUUID = () => {
17185 const bytes = __pi_crypto_bytes_to_array(__pi_crypto_random_bytes_native(16));
17186 while (bytes.length < 16) bytes.push(0);
17187 bytes[6] = (bytes[6] & 0x0f) | 0x40;
17188 bytes[8] = (bytes[8] & 0x3f) | 0x80;
17189 const hex = Array.from(bytes, (b) => (b & 0xff).toString(16).padStart(2, '0')).join('');
17190 return (
17191 hex.slice(0, 8) +
17192 '-' +
17193 hex.slice(8, 12) +
17194 '-' +
17195 hex.slice(12, 16) +
17196 '-' +
17197 hex.slice(16, 20) +
17198 '-' +
17199 hex.slice(20)
17200 );
17201 };
17202}
17203
17204if (typeof globalThis.process === 'undefined') {
17205 const rawPlatform =
17206 __pi_env_get_native('PI_PLATFORM') ||
17207 __pi_env_get_native('OSTYPE') ||
17208 __pi_env_get_native('OS') ||
17209 'linux';
17210 // Normalize to Node.js conventions: strip version suffix from OSTYPE
17211 // (e.g. darwin24.0 -> darwin, linux-gnu -> linux, msys -> win32)
17212 const platform = (() => {
17213 const s = String(rawPlatform).replace(/[0-9].*$/, '').split('-')[0].toLowerCase();
17214 if (s === 'darwin') return 'darwin';
17215 if (s === 'msys' || s === 'cygwin' || s === 'windows_nt') return 'win32';
17216 return s || 'linux';
17217 })();
17218 const detHome = __pi_env_get_native('PI_DETERMINISTIC_HOME');
17219 const detCwd = __pi_env_get_native('PI_DETERMINISTIC_CWD');
17220
17221 const envProxy = new Proxy(
17222 {},
17223 {
17224 get(_target, prop) {
17225 if (typeof prop !== 'string') return undefined;
17226 if (prop === 'HOME' && detHome) return detHome;
17227 const value = __pi_env_get_native(prop);
17228 return value === null || value === undefined ? undefined : value;
17229 },
17230 set(_target, prop, _value) {
17231 // Read-only in PiJS — silently ignore writes
17232 return typeof prop === 'string';
17233 },
17234 deleteProperty(_target, prop) {
17235 // Read-only — silently ignore deletes
17236 return typeof prop === 'string';
17237 },
17238 has(_target, prop) {
17239 if (typeof prop !== 'string') return false;
17240 if (prop === 'HOME' && detHome) return true;
17241 const value = __pi_env_get_native(prop);
17242 return value !== null && value !== undefined;
17243 },
17244 ownKeys() {
17245 // Cannot enumerate real env — return empty
17246 return [];
17247 },
17248 getOwnPropertyDescriptor(_target, prop) {
17249 if (typeof prop !== 'string') return undefined;
17250 const value = __pi_env_get_native(prop);
17251 if (value === null || value === undefined) return undefined;
17252 return { value, writable: false, enumerable: true, configurable: true };
17253 },
17254 },
17255 );
17256
17257 // stdout/stderr that route through console output
17258 function makeWritable(level) {
17259 return {
17260 write(chunk) {
17261 if (typeof __pi_console_output_native === 'function') {
17262 __pi_console_output_native(level, String(chunk));
17263 }
17264 return true;
17265 },
17266 end() { return this; },
17267 on() { return this; },
17268 once() { return this; },
17269 pipe() { return this; },
17270 isTTY: false,
17271 };
17272 }
17273
17274 // Event listener registry
17275 const __evtMap = Object.create(null);
17276 function __on(event, fn) {
17277 if (!__evtMap[event]) __evtMap[event] = [];
17278 __evtMap[event].push(fn);
17279 return globalThis.process;
17280 }
17281 function __off(event, fn) {
17282 const arr = __evtMap[event];
17283 if (!arr) return globalThis.process;
17284 const idx = arr.indexOf(fn);
17285 if (idx >= 0) arr.splice(idx, 1);
17286 return globalThis.process;
17287 }
17288
17289 const startMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17290
17291 globalThis.process = {
17292 env: envProxy,
17293 argv: __pi_process_args_native(),
17294 cwd: () => detCwd || __pi_process_cwd_native(),
17295 platform: String(platform).split('-')[0],
17296 arch: __pi_env_get_native('PI_TARGET_ARCH') || 'x64',
17297 version: 'v20.0.0',
17298 versions: { node: '20.0.0', v8: '0.0.0', modules: '0' },
17299 pid: 1,
17300 ppid: 0,
17301 title: 'pi',
17302 execPath: (typeof __pi_process_execpath_native === 'function')
17303 ? __pi_process_execpath_native()
17304 : '/usr/bin/pi',
17305 execArgv: [],
17306 stdout: makeWritable('log'),
17307 stderr: makeWritable('error'),
17308 stdin: { on() { return this; }, once() { return this; }, read() {}, resume() { return this; }, pause() { return this; } },
17309 nextTick: (fn, ...args) => { Promise.resolve().then(() => fn(...args)); },
17310 hrtime: Object.assign((prev) => {
17311 const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17312 const secs = Math.floor(nowMs / 1000);
17313 const nanos = Math.floor((nowMs % 1000) * 1e6);
17314 if (Array.isArray(prev) && prev.length >= 2) {
17315 let ds = secs - prev[0];
17316 let dn = nanos - prev[1];
17317 if (dn < 0) { ds -= 1; dn += 1e9; }
17318 return [ds, dn];
17319 }
17320 return [secs, nanos];
17321 }, {
17322 bigint: () => {
17323 const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17324 return BigInt(Math.floor(nowMs * 1e6));
17325 },
17326 }),
17327 kill: (pid, sig) => {
17328 const impl = globalThis.__pi_process_kill_impl;
17329 if (typeof impl === 'function') {
17330 return impl(pid, sig);
17331 }
17332 const err = new Error('process.kill is not available in PiJS');
17333 err.code = 'ENOSYS';
17334 throw err;
17335 },
17336 exit: (code) => {
17337 const exitCode = code === undefined ? 0 : Number(code);
17338 // Fire exit listeners
17339 const listeners = __evtMap['exit'];
17340 if (listeners) {
17341 for (const fn of listeners.slice()) {
17342 try { fn(exitCode); } catch (_) {}
17343 }
17344 }
17345 // Signal native side
17346 if (typeof __pi_process_exit_native === 'function') {
17347 __pi_process_exit_native(exitCode);
17348 }
17349 const err = new Error('process.exit(' + exitCode + ')');
17350 err.code = 'ERR_PROCESS_EXIT';
17351 err.exitCode = exitCode;
17352 throw err;
17353 },
17354 chdir: (_dir) => {
17355 const err = new Error('process.chdir is not supported in PiJS');
17356 err.code = 'ENOSYS';
17357 throw err;
17358 },
17359 uptime: () => {
17360 const nowMs = (typeof __pi_now_ms_native === 'function') ? __pi_now_ms_native() : 0;
17361 return Math.floor((nowMs - startMs) / 1000);
17362 },
17363 memoryUsage: () => ({
17364 rss: 0, heapTotal: 0, heapUsed: 0, external: 0, arrayBuffers: 0,
17365 }),
17366 cpuUsage: (_prev) => ({ user: 0, system: 0 }),
17367 emitWarning: (msg) => {
17368 if (typeof __pi_console_output_native === 'function') {
17369 __pi_console_output_native('warn', 'Warning: ' + msg);
17370 }
17371 },
17372 release: { name: 'node', lts: 'PiJS' },
17373 config: { variables: {} },
17374 features: {},
17375 on: __on,
17376 addListener: __on,
17377 off: __off,
17378 removeListener: __off,
17379 once(event, fn) {
17380 const wrapped = (...args) => {
17381 __off(event, wrapped);
17382 fn(...args);
17383 };
17384 wrapped._original = fn;
17385 __on(event, wrapped);
17386 return globalThis.process;
17387 },
17388 removeAllListeners(event) {
17389 if (event) { delete __evtMap[event]; }
17390 else { for (const k in __evtMap) delete __evtMap[k]; }
17391 return globalThis.process;
17392 },
17393 listeners(event) {
17394 return (__evtMap[event] || []).slice();
17395 },
17396 emit(event, ...args) {
17397 const listeners = __evtMap[event];
17398 if (!listeners || listeners.length === 0) return false;
17399 for (const fn of listeners.slice()) {
17400 try { fn(...args); } catch (_) {}
17401 }
17402 return true;
17403 },
17404 };
17405
17406 try { Object.freeze(envProxy); } catch (_) {}
17407 try { Object.freeze(globalThis.process.argv); } catch (_) {}
17408 // Do NOT freeze globalThis.process — extensions may need to monkey-patch it
17409}
17410
17411// Node.js global alias compatibility.
17412if (typeof globalThis.global === 'undefined') {
17413 globalThis.global = globalThis;
17414}
17415
17416if (typeof globalThis.Bun === 'undefined') {
17417 const __pi_bun_require = (specifier) => {
17418 try {
17419 if (typeof require === 'function') {
17420 return require(specifier);
17421 }
17422 } catch (_) {}
17423 return null;
17424 };
17425
17426 const __pi_bun_fs = () => __pi_bun_require('node:fs');
17427 const __pi_bun_import_fs = () => import('node:fs');
17428 const __pi_bun_child_process = () => __pi_bun_require('node:child_process');
17429
17430 const __pi_bun_to_uint8 = (value) => {
17431 if (value instanceof Uint8Array) {
17432 return value;
17433 }
17434 if (value instanceof ArrayBuffer) {
17435 return new Uint8Array(value);
17436 }
17437 if (ArrayBuffer.isView && ArrayBuffer.isView(value)) {
17438 return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
17439 }
17440 if (typeof value === 'string') {
17441 return new TextEncoder().encode(value);
17442 }
17443 if (value === undefined || value === null) {
17444 return new Uint8Array();
17445 }
17446 return new TextEncoder().encode(String(value));
17447 };
17448
17449 const __pi_bun_make_text_stream = (fetchText) => ({
17450 async text() {
17451 return fetchText();
17452 },
17453 async arrayBuffer() {
17454 const text = await fetchText();
17455 const bytes = new TextEncoder().encode(String(text ?? ''));
17456 return bytes.buffer;
17457 },
17458 });
17459
17460 const Bun = {};
17461
17462 Bun.argv = Array.isArray(globalThis.process && globalThis.process.argv)
17463 ? globalThis.process.argv.slice()
17464 : [];
17465
17466 Bun.file = (path) => {
17467 const targetPath = String(path ?? '');
17468 return {
17469 path: targetPath,
17470 name: targetPath,
17471 async exists() {
17472 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17473 return Boolean(fs && typeof fs.existsSync === 'function' && fs.existsSync(targetPath));
17474 },
17475 async text() {
17476 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17477 if (!fs || typeof fs.readFileSync !== 'function') {
17478 throw new Error('Bun.file.text: node:fs is unavailable');
17479 }
17480 return String(fs.readFileSync(targetPath, 'utf8'));
17481 },
17482 async arrayBuffer() {
17483 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17484 if (!fs || typeof fs.readFileSync !== 'function') {
17485 throw new Error('Bun.file.arrayBuffer: node:fs is unavailable');
17486 }
17487 const bytes = __pi_bun_to_uint8(fs.readFileSync(targetPath));
17488 return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
17489 },
17490 async json() {
17491 return JSON.parse(await this.text());
17492 },
17493 };
17494 };
17495
17496 Bun.write = async (destination, data) => {
17497 const targetPath =
17498 destination && typeof destination === 'object' && typeof destination.path === 'string'
17499 ? destination.path
17500 : String(destination ?? '');
17501 if (!targetPath) {
17502 throw new Error('Bun.write: destination path is required');
17503 }
17504 const fs = __pi_bun_fs() || (await __pi_bun_import_fs());
17505 if (!fs || typeof fs.writeFileSync !== 'function') {
17506 throw new Error('Bun.write: node:fs is unavailable');
17507 }
17508
17509 let payload = data;
17510 if (payload && typeof payload === 'object' && typeof payload.text === 'function') {
17511 payload = payload.text();
17512 }
17513 if (payload && typeof payload === 'object' && typeof payload.arrayBuffer === 'function') {
17514 payload = payload.arrayBuffer();
17515 }
17516 if (payload && typeof payload.then === 'function') {
17517 payload = await payload;
17518 }
17519
17520 const bytes = __pi_bun_to_uint8(payload);
17521 fs.writeFileSync(targetPath, bytes);
17522 return bytes.byteLength;
17523 };
17524
17525 Bun.which = (command) => {
17526 const name = String(command ?? '').trim();
17527 if (!name) return null;
17528 const cwd =
17529 globalThis.process && typeof globalThis.process.cwd === 'function'
17530 ? globalThis.process.cwd()
17531 : '/';
17532 const raw = __pi_exec_sync_native('which', JSON.stringify([name]), cwd, 2000, undefined);
17533 try {
17534 const parsed = JSON.parse(raw || '{}');
17535 if (Number(parsed && parsed.code) !== 0) return null;
17536 const out = String((parsed && parsed.stdout) || '').trim();
17537 return out ? out.split('\n')[0] : null;
17538 } catch (_) {
17539 return null;
17540 }
17541 };
17542
17543 Bun.spawn = (commandOrArgv, rawOptions = {}) => {
17544 const options = rawOptions && typeof rawOptions === 'object' ? rawOptions : {};
17545
17546 let command = '';
17547 let args = [];
17548 if (Array.isArray(commandOrArgv)) {
17549 if (commandOrArgv.length === 0) {
17550 throw new Error('Bun.spawn: command is required');
17551 }
17552 command = String(commandOrArgv[0] ?? '');
17553 args = commandOrArgv.slice(1).map((arg) => String(arg ?? ''));
17554 } else {
17555 command = String(commandOrArgv ?? '');
17556 if (Array.isArray(options.args)) {
17557 args = options.args.map((arg) => String(arg ?? ''));
17558 }
17559 }
17560
17561 if (!command.trim()) {
17562 throw new Error('Bun.spawn: command is required');
17563 }
17564
17565 const spawnOptions = {
17566 shell: false,
17567 stdio: [
17568 options.stdin === 'pipe' ? 'pipe' : 'ignore',
17569 options.stdout === 'ignore' ? 'ignore' : 'pipe',
17570 options.stderr === 'ignore' ? 'ignore' : 'pipe',
17571 ],
17572 };
17573 if (typeof options.cwd === 'string' && options.cwd.trim().length > 0) {
17574 spawnOptions.cwd = options.cwd;
17575 }
17576 if (
17577 typeof options.timeout === 'number' &&
17578 Number.isFinite(options.timeout) &&
17579 options.timeout >= 0
17580 ) {
17581 spawnOptions.timeout = Math.floor(options.timeout);
17582 }
17583
17584 const childProcess = __pi_bun_child_process();
17585 if (childProcess && typeof childProcess.spawn === 'function') {
17586 const child = childProcess.spawn(command, args, spawnOptions);
17587 let stdoutText = '';
17588 let stderrText = '';
17589
17590 if (child && child.stdout && typeof child.stdout.on === 'function') {
17591 child.stdout.on('data', (chunk) => {
17592 stdoutText += String(chunk ?? '');
17593 });
17594 }
17595 if (child && child.stderr && typeof child.stderr.on === 'function') {
17596 child.stderr.on('data', (chunk) => {
17597 stderrText += String(chunk ?? '');
17598 });
17599 }
17600
17601 const exited = new Promise((resolve, reject) => {
17602 let settled = false;
17603 child.on('error', (err) => {
17604 if (settled) return;
17605 settled = true;
17606 reject(err instanceof Error ? err : new Error(String(err)));
17607 });
17608 child.on('close', (code) => {
17609 if (settled) return;
17610 settled = true;
17611 resolve(typeof code === 'number' ? code : null);
17612 });
17613 });
17614
17615 return {
17616 pid: typeof child.pid === 'number' ? child.pid : 0,
17617 stdin: child.stdin || null,
17618 stdout: __pi_bun_make_text_stream(async () => {
17619 await exited.catch(() => null);
17620 return stdoutText;
17621 }),
17622 stderr: __pi_bun_make_text_stream(async () => {
17623 await exited.catch(() => null);
17624 return stderrText;
17625 }),
17626 exited,
17627 kill(signal) {
17628 try {
17629 return child.kill(signal);
17630 } catch (_) {
17631 return false;
17632 }
17633 },
17634 ref() { return this; },
17635 unref() { return this; },
17636 };
17637 }
17638
17639 // Fallback path if node:child_process is unavailable in context.
17640 const execOptions = {};
17641 if (spawnOptions.cwd !== undefined) execOptions.cwd = spawnOptions.cwd;
17642 if (spawnOptions.timeout !== undefined) execOptions.timeout = spawnOptions.timeout;
17643 const execPromise = pi.exec(command, args, execOptions);
17644 let killed = false;
17645
17646 const exited = execPromise.then(
17647 (result) => (killed ? null : (Number(result && result.code) || 0)),
17648 () => (killed ? null : 1),
17649 );
17650
17651 return {
17652 pid: 0,
17653 stdin: null,
17654 stdout: __pi_bun_make_text_stream(async () => {
17655 try {
17656 const result = await execPromise;
17657 return String((result && result.stdout) || '');
17658 } catch (_) {
17659 return '';
17660 }
17661 }),
17662 stderr: __pi_bun_make_text_stream(async () => {
17663 try {
17664 const result = await execPromise;
17665 return String((result && result.stderr) || '');
17666 } catch (_) {
17667 return '';
17668 }
17669 }),
17670 exited,
17671 kill() {
17672 killed = true;
17673 return true;
17674 },
17675 ref() { return this; },
17676 unref() { return this; },
17677 };
17678 };
17679
17680 globalThis.Bun = Bun;
17681}
17682
17683if (typeof globalThis.setTimeout !== 'function') {
17684 globalThis.setTimeout = (callback, delay, ...args) => {
17685 const ms = Number(delay || 0);
17686 const timer_id = __pi_set_timeout_native(ms <= 0 ? 0 : Math.floor(ms));
17687 const captured_id = __pi_current_extension_id;
17688 __pi_register_timer(timer_id, () => {
17689 const prev = __pi_current_extension_id;
17690 __pi_current_extension_id = captured_id;
17691 try {
17692 callback(...args);
17693 } catch (e) {
17694 console.error('setTimeout callback error:', e);
17695 } finally {
17696 __pi_current_extension_id = prev;
17697 }
17698 });
17699 return timer_id;
17700 };
17701}
17702
17703if (typeof globalThis.clearTimeout !== 'function') {
17704 globalThis.clearTimeout = (timer_id) => {
17705 __pi_unregister_timer(timer_id);
17706 try {
17707 __pi_clear_timeout_native(timer_id);
17708 } catch (_) {}
17709 };
17710}
17711
17712// setInterval polyfill using setTimeout
17713const __pi_intervals = new Map();
17714let __pi_interval_id = 0;
17715
17716if (typeof globalThis.setInterval !== 'function') {
17717 globalThis.setInterval = (callback, delay, ...args) => {
17718 const ms = Math.max(0, Number(delay || 0));
17719 const id = ++__pi_interval_id;
17720 const captured_id = __pi_current_extension_id;
17721 const run = () => {
17722 if (!__pi_intervals.has(id)) return;
17723 const prev = __pi_current_extension_id;
17724 __pi_current_extension_id = captured_id;
17725 try {
17726 callback(...args);
17727 } catch (e) {
17728 console.error('setInterval callback error:', e);
17729 } finally {
17730 __pi_current_extension_id = prev;
17731 }
17732 if (__pi_intervals.has(id)) {
17733 __pi_intervals.set(id, globalThis.setTimeout(run, ms));
17734 }
17735 };
17736 __pi_intervals.set(id, globalThis.setTimeout(run, ms));
17737 return id;
17738 };
17739}
17740
17741if (typeof globalThis.clearInterval !== 'function') {
17742 globalThis.clearInterval = (id) => {
17743 const timerId = __pi_intervals.get(id);
17744 if (timerId !== undefined) {
17745 globalThis.clearTimeout(timerId);
17746 __pi_intervals.delete(id);
17747 }
17748 };
17749}
17750
17751if (typeof globalThis.fetch !== 'function') {
17752 class Headers {
17753 constructor(init) {
17754 this._map = {};
17755 if (init && typeof init === 'object') {
17756 if (Array.isArray(init)) {
17757 for (const pair of init) {
17758 if (pair && pair.length >= 2) this.set(pair[0], pair[1]);
17759 }
17760 } else if (typeof init.forEach === 'function') {
17761 init.forEach((v, k) => this.set(k, v));
17762 } else {
17763 for (const k of Object.keys(init)) {
17764 this.set(k, init[k]);
17765 }
17766 }
17767 }
17768 }
17769
17770 get(name) {
17771 const key = String(name || '').toLowerCase();
17772 return this._map[key] === undefined ? null : this._map[key];
17773 }
17774
17775 set(name, value) {
17776 const key = String(name || '').toLowerCase();
17777 this._map[key] = String(value === undefined || value === null ? '' : value);
17778 }
17779
17780 entries() {
17781 return Object.entries(this._map);
17782 }
17783 }
17784
17785 class Response {
17786 constructor(bodyBytes, init) {
17787 const options = init && typeof init === 'object' ? init : {};
17788 this.status = Number(options.status || 0);
17789 this.ok = this.status >= 200 && this.status < 300;
17790 this.headers = new Headers(options.headers || {});
17791 this._bytes = bodyBytes || new Uint8Array();
17792 this.body = {
17793 getReader: () => {
17794 let done = false;
17795 return {
17796 read: async () => {
17797 if (done) return { done: true, value: undefined };
17798 done = true;
17799 return { done: false, value: this._bytes };
17800 },
17801 cancel: async () => {
17802 done = true;
17803 },
17804 releaseLock: () => {},
17805 };
17806 },
17807 };
17808 }
17809
17810 async text() {
17811 return new TextDecoder().decode(this._bytes);
17812 }
17813
17814 async json() {
17815 return JSON.parse(await this.text());
17816 }
17817
17818 async arrayBuffer() {
17819 const copy = new Uint8Array(this._bytes.length);
17820 copy.set(this._bytes);
17821 return copy.buffer;
17822 }
17823 }
17824
17825 globalThis.Headers = Headers;
17826 globalThis.Response = Response;
17827
17828 if (typeof globalThis.Event === 'undefined') {
17829 class Event {
17830 constructor(type, options) {
17831 const opts = options && typeof options === 'object' ? options : {};
17832 this.type = String(type || '');
17833 this.bubbles = !!opts.bubbles;
17834 this.cancelable = !!opts.cancelable;
17835 this.composed = !!opts.composed;
17836 this.defaultPrevented = false;
17837 this.target = null;
17838 this.currentTarget = null;
17839 this.timeStamp = Date.now();
17840 }
17841 preventDefault() {
17842 if (this.cancelable) this.defaultPrevented = true;
17843 }
17844 stopPropagation() {}
17845 stopImmediatePropagation() {}
17846 }
17847 globalThis.Event = Event;
17848 }
17849
17850 if (typeof globalThis.CustomEvent === 'undefined' && typeof globalThis.Event === 'function') {
17851 class CustomEvent extends globalThis.Event {
17852 constructor(type, options) {
17853 const opts = options && typeof options === 'object' ? options : {};
17854 super(type, opts);
17855 this.detail = opts.detail;
17856 }
17857 }
17858 globalThis.CustomEvent = CustomEvent;
17859 }
17860
17861 if (typeof globalThis.EventTarget === 'undefined') {
17862 class EventTarget {
17863 constructor() {
17864 this.__listeners = Object.create(null);
17865 }
17866 addEventListener(type, listener) {
17867 const key = String(type || '');
17868 if (!key || !listener) return;
17869 if (!this.__listeners[key]) this.__listeners[key] = [];
17870 if (!this.__listeners[key].includes(listener)) this.__listeners[key].push(listener);
17871 }
17872 removeEventListener(type, listener) {
17873 const key = String(type || '');
17874 const list = this.__listeners[key];
17875 if (!list || !listener) return;
17876 this.__listeners[key] = list.filter((fn) => fn !== listener);
17877 }
17878 dispatchEvent(event) {
17879 if (!event || typeof event.type !== 'string') return true;
17880 const key = event.type;
17881 const list = (this.__listeners[key] || []).slice();
17882 try {
17883 event.target = this;
17884 event.currentTarget = this;
17885 } catch (_) {}
17886 for (const listener of list) {
17887 try {
17888 if (typeof listener === 'function') listener.call(this, event);
17889 else if (listener && typeof listener.handleEvent === 'function') listener.handleEvent(event);
17890 } catch (_) {}
17891 }
17892 return !(event && event.defaultPrevented);
17893 }
17894 }
17895 globalThis.EventTarget = EventTarget;
17896 }
17897
17898 if (typeof globalThis.TransformStream === 'undefined') {
17899 class TransformStream {
17900 constructor(_transformer) {
17901 const queue = [];
17902 let closed = false;
17903 this.readable = {
17904 getReader() {
17905 return {
17906 async read() {
17907 if (queue.length > 0) {
17908 return { done: false, value: queue.shift() };
17909 }
17910 return { done: closed, value: undefined };
17911 },
17912 async cancel() {
17913 closed = true;
17914 },
17915 releaseLock() {},
17916 };
17917 },
17918 };
17919 this.writable = {
17920 getWriter() {
17921 return {
17922 async write(chunk) {
17923 queue.push(chunk);
17924 },
17925 async close() {
17926 closed = true;
17927 },
17928 async abort() {
17929 closed = true;
17930 },
17931 releaseLock() {},
17932 };
17933 },
17934 };
17935 }
17936 }
17937 globalThis.TransformStream = TransformStream;
17938 }
17939
17940 // AbortController / AbortSignal polyfill — many npm packages check for these
17941 if (typeof globalThis.AbortController === 'undefined') {
17942 class AbortSignal {
17943 constructor() { this.aborted = false; this._listeners = []; }
17944 get reason() { return this.aborted ? new Error('This operation was aborted') : undefined; }
17945 addEventListener(type, fn) { if (type === 'abort') this._listeners.push(fn); }
17946 removeEventListener(type, fn) { if (type === 'abort') this._listeners = this._listeners.filter(f => f !== fn); }
17947 throwIfAborted() { if (this.aborted) throw this.reason; }
17948 static abort(reason) { const s = new AbortSignal(); s.aborted = true; s._reason = reason; return s; }
17949 static timeout(ms) { const s = new AbortSignal(); setTimeout(() => { s.aborted = true; s._listeners.forEach(fn => fn()); }, ms); return s; }
17950 }
17951 class AbortController {
17952 constructor() { this.signal = new AbortSignal(); }
17953 abort(reason) { this.signal.aborted = true; this.signal._reason = reason; this.signal._listeners.forEach(fn => fn()); }
17954 }
17955 globalThis.AbortController = AbortController;
17956 globalThis.AbortSignal = AbortSignal;
17957 }
17958
17959 globalThis.fetch = async (input, init) => {
17960 const url = typeof input === 'string' ? input : String(input && input.url ? input.url : input);
17961 const options = init && typeof init === 'object' ? init : {};
17962 const method = options.method ? String(options.method) : 'GET';
17963
17964 const headers = {};
17965 if (options.headers && typeof options.headers === 'object') {
17966 if (options.headers instanceof Headers) {
17967 for (const [k, v] of options.headers.entries()) headers[k] = v;
17968 } else if (Array.isArray(options.headers)) {
17969 for (const pair of options.headers) {
17970 if (pair && pair.length >= 2) headers[String(pair[0])] = String(pair[1]);
17971 }
17972 } else {
17973 for (const k of Object.keys(options.headers)) {
17974 headers[k] = String(options.headers[k]);
17975 }
17976 }
17977 }
17978
17979 let body = undefined;
17980 if (options.body !== undefined && options.body !== null) {
17981 body = typeof options.body === 'string' ? options.body : String(options.body);
17982 }
17983
17984 const resp = await pi.http({ url, method, headers, body });
17985 const status = resp && resp.status !== undefined ? Number(resp.status) : 0;
17986 const respHeaders = resp && resp.headers && typeof resp.headers === 'object' ? resp.headers : {};
17987
17988 let bytes = new Uint8Array();
17989 if (resp && resp.body_bytes) {
17990 const bin = __pi_base64_decode_native(String(resp.body_bytes));
17991 const out = new Uint8Array(bin.length);
17992 for (let i = 0; i < bin.length; i++) {
17993 out[i] = bin.charCodeAt(i) & 0xff;
17994 }
17995 bytes = out;
17996 } else if (resp && resp.body !== undefined && resp.body !== null) {
17997 bytes = new TextEncoder().encode(String(resp.body));
17998 }
17999
18000 return new Response(bytes, { status, headers: respHeaders });
18001 };
18002}
18003";
18004
18005#[cfg(test)]
18006#[allow(clippy::future_not_send)]
18007mod tests {
18008 use super::*;
18009 use crate::scheduler::DeterministicClock;
18010
18011 #[allow(clippy::future_not_send)]
18012 async fn get_global_json<C: SchedulerClock + 'static>(
18013 runtime: &PiJsRuntime<C>,
18014 name: &str,
18015 ) -> serde_json::Value {
18016 runtime
18017 .context
18018 .with(|ctx| {
18019 let global = ctx.globals();
18020 let value: Value<'_> = global.get(name)?;
18021 js_to_json(&value)
18022 })
18023 .await
18024 .expect("js context")
18025 }
18026
18027 #[allow(clippy::future_not_send)]
18028 async fn call_global_fn_json<C: SchedulerClock + 'static>(
18029 runtime: &PiJsRuntime<C>,
18030 name: &str,
18031 ) -> serde_json::Value {
18032 runtime
18033 .context
18034 .with(|ctx| {
18035 let global = ctx.globals();
18036 let function: Function<'_> = global.get(name)?;
18037 let value: Value<'_> = function.call(())?;
18038 js_to_json(&value)
18039 })
18040 .await
18041 .expect("js context")
18042 }
18043
18044 #[allow(clippy::future_not_send)]
18045 async fn runtime_with_sync_exec_enabled(
18046 clock: Arc<DeterministicClock>,
18047 ) -> PiJsRuntime<Arc<DeterministicClock>> {
18048 let config = PiJsRuntimeConfig {
18049 allow_unsafe_sync_exec: true,
18050 ..PiJsRuntimeConfig::default()
18051 };
18052 PiJsRuntime::with_clock_and_config_with_policy(clock, config, None)
18053 .await
18054 .expect("create runtime")
18055 }
18056
18057 #[allow(clippy::future_not_send)]
18058 async fn drain_until_idle(
18059 runtime: &PiJsRuntime<Arc<DeterministicClock>>,
18060 clock: &Arc<DeterministicClock>,
18061 ) {
18062 for _ in 0..10_000 {
18063 if !runtime.has_pending() {
18064 break;
18065 }
18066
18067 let stats = runtime.tick().await.expect("tick");
18068 if stats.ran_macrotask {
18069 continue;
18070 }
18071
18072 let next_deadline = runtime.scheduler.borrow().next_timer_deadline();
18073 let Some(next_deadline) = next_deadline else {
18074 break;
18075 };
18076
18077 let now = runtime.now_ms();
18078 assert!(
18079 next_deadline > now,
18080 "expected future timer deadline (deadline={next_deadline}, now={now})"
18081 );
18082 clock.set(next_deadline);
18083 }
18084 }
18085
18086 #[test]
18087 fn extract_static_require_specifiers_skips_literals_and_comments() {
18088 let source = r#"
18089const fs = require("fs");
18090const text = "require('left-pad')";
18091const tpl = `require("ajv/dist/runtime/validation_error").default`;
18092// require("zlib")
18093/* require("tty") */
18094const path = require('path');
18095"#;
18096
18097 let specifiers = extract_static_require_specifiers(source);
18098 assert_eq!(specifiers, vec!["fs".to_string(), "path".to_string()]);
18099 }
18100
18101 #[test]
18102 fn maybe_cjs_to_esm_ignores_codegen_string_requires() {
18103 let source = r#"
18104const fs = require("fs");
18105const generated = `require("ajv/dist/runtime/validation_error").default`;
18106module.exports = { fs, generated };
18107"#;
18108
18109 let rewritten = maybe_cjs_to_esm(source);
18110 assert!(rewritten.contains(r#"from "fs";"#));
18111 assert!(!rewritten.contains(r#"from "ajv/dist/runtime/validation_error";"#));
18112 }
18113
18114 #[test]
18115 fn extract_import_names_handles_default_plus_named_imports() {
18116 let source = r#"
18117import Ajv, {
18118 KeywordDefinition,
18119 type AnySchema,
18120 ValidationError as AjvValidationError,
18121} from "ajv";
18122"#;
18123
18124 let names = extract_import_names(source, "ajv");
18125 assert_eq!(
18126 names,
18127 vec![
18128 "KeywordDefinition".to_string(),
18129 "ValidationError".to_string()
18130 ]
18131 );
18132 }
18133
18134 #[test]
18135 fn extract_builtin_import_names_collects_node_aliases() {
18136 let source = r#"
18137import { isIP } from "net";
18138import { isIPv4 as netIsIpv4 } from "node:net";
18139"#;
18140 let names = extract_builtin_import_names(source, "node:net", "node:net");
18141 assert_eq!(
18142 names.into_iter().collect::<Vec<_>>(),
18143 vec!["isIP".to_string(), "isIPv4".to_string()]
18144 );
18145 }
18146
18147 #[test]
18148 fn builtin_overlay_generation_scopes_exports_per_importing_module() {
18149 let temp_dir = tempfile::tempdir().expect("tempdir");
18150 let base_a = temp_dir.path().join("a.mjs");
18151 let base_b = temp_dir.path().join("b.mjs");
18152 std::fs::write(&base_a, r#"import { isIP } from "net";"#).expect("write a");
18153 std::fs::write(&base_b, r#"import { isIPv6 } from "node:net";"#).expect("write b");
18154
18155 let mut state = PiJsModuleState::new();
18156 let overlay_a = maybe_register_builtin_compat_overlay(
18157 &mut state,
18158 base_a.to_string_lossy().as_ref(),
18159 "net",
18160 "node:net",
18161 )
18162 .expect("overlay key for a");
18163 let overlay_b = maybe_register_builtin_compat_overlay(
18164 &mut state,
18165 base_b.to_string_lossy().as_ref(),
18166 "node:net",
18167 "node:net",
18168 )
18169 .expect("overlay key for b");
18170 assert!(overlay_a.starts_with("pijs-compat://builtin/node:net/"));
18171 assert!(overlay_b.starts_with("pijs-compat://builtin/node:net/"));
18172 assert_ne!(overlay_a, overlay_b);
18173
18174 let exported_names_a = state
18175 .dynamic_virtual_named_exports
18176 .get(&overlay_a)
18177 .expect("export names for a");
18178 assert!(exported_names_a.contains("isIP"));
18179 assert!(!exported_names_a.contains("isIPv6"));
18180
18181 let exported_names_b = state
18182 .dynamic_virtual_named_exports
18183 .get(&overlay_b)
18184 .expect("export names for b");
18185 assert!(exported_names_b.contains("isIPv6"));
18186 assert!(!exported_names_b.contains("isIP"));
18187
18188 let overlay_source_a = state
18189 .dynamic_virtual_modules
18190 .get(&overlay_a)
18191 .expect("overlay source for a");
18192 assert!(overlay_source_a.contains(r#"import * as __pijs_builtin_ns from "node:net";"#));
18193 assert!(overlay_source_a.contains("export const isIP ="));
18194 assert!(!overlay_source_a.contains("export const isIPv6 ="));
18195
18196 let overlay_source_b = state
18197 .dynamic_virtual_modules
18198 .get(&overlay_b)
18199 .expect("overlay source for b");
18200 assert!(overlay_source_b.contains("export const isIPv6 ="));
18201 assert!(!overlay_source_b.contains("export const isIP ="));
18202 }
18203
18204 #[test]
18205 fn hostcall_completions_run_before_due_timers() {
18206 let clock = Arc::new(ManualClock::new(1_000));
18207 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18208
18209 let _timer = loop_state.set_timeout(0);
18210 loop_state.enqueue_hostcall_completion("call-1");
18211
18212 let mut seen = Vec::new();
18213 let result = loop_state.tick(|task| seen.push(task.kind), || false);
18214
18215 assert!(result.ran_macrotask);
18216 assert_eq!(
18217 seen,
18218 vec![MacrotaskKind::HostcallComplete {
18219 call_id: "call-1".to_string()
18220 }]
18221 );
18222 }
18223
18224 #[test]
18225 fn hostcall_request_queue_spills_to_overflow_with_stable_order() {
18226 fn req(id: usize) -> HostcallRequest {
18227 HostcallRequest {
18228 call_id: format!("call-{id}"),
18229 kind: HostcallKind::Log,
18230 payload: serde_json::json!({ "n": id }),
18231 trace_id: u64::try_from(id).unwrap_or(u64::MAX),
18232 extension_id: Some("ext.queue".to_string()),
18233 }
18234 }
18235
18236 let mut queue = HostcallRequestQueue::with_capacities(2, 4);
18237 assert!(matches!(
18238 queue.push_back(req(0)),
18239 HostcallQueueEnqueueResult::FastPath { .. }
18240 ));
18241 assert!(matches!(
18242 queue.push_back(req(1)),
18243 HostcallQueueEnqueueResult::FastPath { .. }
18244 ));
18245 assert!(matches!(
18246 queue.push_back(req(2)),
18247 HostcallQueueEnqueueResult::OverflowPath { .. }
18248 ));
18249
18250 let snapshot = queue.snapshot();
18251 assert_eq!(snapshot.fast_depth, 2);
18252 assert_eq!(snapshot.overflow_depth, 1);
18253 assert_eq!(snapshot.total_depth, 3);
18254 assert_eq!(snapshot.overflow_enqueued_total, 1);
18255
18256 let drained = queue.drain_all();
18257 let drained_ids: Vec<_> = drained.into_iter().map(|item| item.call_id).collect();
18258 assert_eq!(
18259 drained_ids,
18260 vec![
18261 "call-0".to_string(),
18262 "call-1".to_string(),
18263 "call-2".to_string()
18264 ]
18265 );
18266 }
18267
18268 #[test]
18269 fn hostcall_request_queue_rejects_when_overflow_capacity_reached() {
18270 fn req(id: usize) -> HostcallRequest {
18271 HostcallRequest {
18272 call_id: format!("reject-{id}"),
18273 kind: HostcallKind::Log,
18274 payload: serde_json::json!({ "n": id }),
18275 trace_id: u64::try_from(id).unwrap_or(u64::MAX),
18276 extension_id: None,
18277 }
18278 }
18279
18280 let mut queue = HostcallRequestQueue::with_capacities(1, 1);
18281 assert!(matches!(
18282 queue.push_back(req(0)),
18283 HostcallQueueEnqueueResult::FastPath { .. }
18284 ));
18285 assert!(matches!(
18286 queue.push_back(req(1)),
18287 HostcallQueueEnqueueResult::OverflowPath { .. }
18288 ));
18289 let reject = queue.push_back(req(2));
18290 assert!(matches!(
18291 reject,
18292 HostcallQueueEnqueueResult::Rejected { .. }
18293 ));
18294
18295 let snapshot = queue.snapshot();
18296 assert_eq!(snapshot.total_depth, 2);
18297 assert_eq!(snapshot.overflow_depth, 1);
18298 assert_eq!(snapshot.overflow_rejected_total, 1);
18299 }
18300
18301 #[test]
18302 fn timers_order_by_deadline_then_schedule_seq() {
18303 let clock = Arc::new(ManualClock::new(0));
18304 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
18305
18306 let t1 = loop_state.set_timeout(10);
18307 let t2 = loop_state.set_timeout(10);
18308 let t3 = loop_state.set_timeout(5);
18309 clock.set(10);
18310
18311 let mut fired = Vec::new();
18312 for _ in 0..3 {
18313 loop_state.tick(
18314 |task| {
18315 if let MacrotaskKind::TimerFired { timer_id } = task.kind {
18316 fired.push(timer_id);
18317 }
18318 },
18319 || false,
18320 );
18321 }
18322
18323 assert_eq!(fired, vec![t3, t1, t2]);
18324 }
18325
18326 #[test]
18327 fn clear_timeout_prevents_fire() {
18328 let clock = Arc::new(ManualClock::new(0));
18329 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock.clone()));
18330
18331 let timer_id = loop_state.set_timeout(5);
18332 assert!(loop_state.clear_timeout(timer_id));
18333 clock.set(10);
18334
18335 let mut fired = Vec::new();
18336 let result = loop_state.tick(
18337 |task| {
18338 if let MacrotaskKind::TimerFired { timer_id } = task.kind {
18339 fired.push(timer_id);
18340 }
18341 },
18342 || false,
18343 );
18344
18345 assert!(!result.ran_macrotask);
18346 assert!(fired.is_empty());
18347 }
18348
18349 #[test]
18350 fn clear_timeout_nonexistent_returns_false_and_does_not_pollute_cancelled_set() {
18351 let clock = Arc::new(ManualClock::new(0));
18352 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18353
18354 assert!(!loop_state.clear_timeout(42));
18355 assert!(
18356 loop_state.cancelled_timers.is_empty(),
18357 "unknown timer ids should not be retained"
18358 );
18359 }
18360
18361 #[test]
18362 fn clear_timeout_double_cancel_returns_false() {
18363 let clock = Arc::new(ManualClock::new(0));
18364 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18365
18366 let timer_id = loop_state.set_timeout(10);
18367 assert!(loop_state.clear_timeout(timer_id));
18368 assert!(!loop_state.clear_timeout(timer_id));
18369 }
18370
18371 #[test]
18372 fn pi_event_loop_timer_id_saturates_at_u64_max() {
18373 let clock = Arc::new(ManualClock::new(0));
18374 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18375 loop_state.next_timer_id = u64::MAX;
18376
18377 let first = loop_state.set_timeout(10);
18378 let second = loop_state.set_timeout(20);
18379
18380 assert_eq!(first, u64::MAX);
18381 assert_eq!(second, u64::MAX);
18382 }
18383
18384 #[test]
18385 fn audit_ledger_sequence_saturates_at_u64_max() {
18386 let mut ledger = AuditLedger::new();
18387 ledger.next_sequence = u64::MAX;
18388
18389 let first = ledger.append(
18390 1_700_000_000_000,
18391 "ext-a",
18392 AuditEntryKind::Analysis,
18393 "first".to_string(),
18394 Vec::new(),
18395 );
18396 let second = ledger.append(
18397 1_700_000_000_100,
18398 "ext-a",
18399 AuditEntryKind::ProposalGenerated,
18400 "second".to_string(),
18401 Vec::new(),
18402 );
18403
18404 assert_eq!(first, u64::MAX);
18405 assert_eq!(second, u64::MAX);
18406 assert_eq!(ledger.len(), 2);
18407 }
18408
18409 #[test]
18410 fn microtasks_drain_to_fixpoint_after_macrotask() {
18411 let clock = Arc::new(ManualClock::new(0));
18412 let mut loop_state = PiEventLoop::new(ClockHandle::new(clock));
18413
18414 loop_state.enqueue_inbound_event("evt-1");
18415
18416 let mut drain_calls = 0;
18417 let result = loop_state.tick(
18418 |_task| {},
18419 || {
18420 drain_calls += 1;
18421 drain_calls <= 2
18422 },
18423 );
18424
18425 assert!(result.ran_macrotask);
18426 assert_eq!(result.microtasks_drained, 2);
18427 assert_eq!(drain_calls, 3);
18428 }
18429
18430 #[test]
18431 fn compile_module_source_reports_missing_file() {
18432 let temp_dir = tempfile::tempdir().expect("tempdir");
18433 let missing_path = temp_dir.path().join("missing.js");
18434 let err = compile_module_source(
18435 &HashMap::new(),
18436 &HashMap::new(),
18437 missing_path.to_string_lossy().as_ref(),
18438 )
18439 .expect_err("missing module should error");
18440 let message = err.to_string();
18441 assert!(
18442 message.contains("Module is not a file"),
18443 "unexpected error: {message}"
18444 );
18445 }
18446
18447 #[test]
18448 fn compile_module_source_reports_unsupported_extension() {
18449 let temp_dir = tempfile::tempdir().expect("tempdir");
18450 let bad_path = temp_dir.path().join("module.txt");
18451 std::fs::write(&bad_path, "hello").expect("write module.txt");
18452
18453 let err = compile_module_source(
18454 &HashMap::new(),
18455 &HashMap::new(),
18456 bad_path.to_string_lossy().as_ref(),
18457 )
18458 .expect_err("unsupported extension should error");
18459 let message = err.to_string();
18460 assert!(
18461 message.contains("Unsupported module extension"),
18462 "unexpected error: {message}"
18463 );
18464 }
18465
18466 #[test]
18467 fn module_cache_key_changes_when_virtual_module_changes() {
18468 let static_modules = HashMap::new();
18469 let mut dynamic_modules = HashMap::new();
18470 dynamic_modules.insert("pijs://virt".to_string(), "export const x = 1;".to_string());
18471
18472 let key_before = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
18473 .expect("virtual key should exist");
18474
18475 dynamic_modules.insert("pijs://virt".to_string(), "export const x = 2;".to_string());
18476 let key_after = module_cache_key(&static_modules, &dynamic_modules, "pijs://virt")
18477 .expect("virtual key should exist");
18478
18479 assert_ne!(key_before, key_after);
18480 }
18481
18482 #[test]
18483 fn module_cache_key_changes_when_file_size_changes() {
18484 let temp_dir = tempfile::tempdir().expect("tempdir");
18485 let module_path = temp_dir.path().join("module.js");
18486 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18487 let name = module_path.to_string_lossy().to_string();
18488
18489 let key_before =
18490 module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
18491
18492 std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
18493 let key_after =
18494 module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
18495
18496 assert_ne!(key_before, key_after);
18497 }
18498
18499 #[test]
18500 fn load_compiled_module_source_tracks_hit_miss_and_invalidation_counters() {
18501 let temp_dir = tempfile::tempdir().expect("tempdir");
18502 let module_path = temp_dir.path().join("module.js");
18503 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18504 let name = module_path.to_string_lossy().to_string();
18505
18506 let mut state = PiJsModuleState::new();
18507
18508 let _first = load_compiled_module_source(&mut state, &name).expect("first compile");
18509 assert_eq!(state.module_cache_counters.hits, 0);
18510 assert_eq!(state.module_cache_counters.misses, 1);
18511 assert_eq!(state.module_cache_counters.invalidations, 0);
18512 assert_eq!(state.compiled_sources.len(), 1);
18513
18514 let _second = load_compiled_module_source(&mut state, &name).expect("cache hit");
18515 assert_eq!(state.module_cache_counters.hits, 1);
18516 assert_eq!(state.module_cache_counters.misses, 1);
18517 assert_eq!(state.module_cache_counters.invalidations, 0);
18518
18519 std::fs::write(&module_path, "export const xyz = 123456;\n").expect("rewrite module");
18520 let _third = load_compiled_module_source(&mut state, &name).expect("recompile");
18521 assert_eq!(state.module_cache_counters.hits, 1);
18522 assert_eq!(state.module_cache_counters.misses, 2);
18523 assert_eq!(state.module_cache_counters.invalidations, 1);
18524 }
18525
18526 #[test]
18527 fn load_compiled_module_source_uses_disk_cache_between_states() {
18528 let temp_dir = tempfile::tempdir().expect("tempdir");
18529 let cache_dir = temp_dir.path().join("cache");
18530 let module_path = temp_dir.path().join("module.js");
18531 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18532 let name = module_path.to_string_lossy().to_string();
18533
18534 let mut first_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
18535 let first = load_compiled_module_source(&mut first_state, &name).expect("first compile");
18536 assert_eq!(first_state.module_cache_counters.misses, 1);
18537 assert_eq!(first_state.module_cache_counters.disk_hits, 0);
18538
18539 let key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("file key");
18540 let cache_path = disk_cache_path(&cache_dir, &key);
18541 assert!(
18542 cache_path.exists(),
18543 "expected persisted cache at {cache_path:?}"
18544 );
18545
18546 let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
18547 let second =
18548 load_compiled_module_source(&mut second_state, &name).expect("load from disk cache");
18549 assert_eq!(second_state.module_cache_counters.disk_hits, 1);
18550 assert_eq!(second_state.module_cache_counters.misses, 0);
18551 assert_eq!(second_state.module_cache_counters.hits, 0);
18552 assert_eq!(first, second);
18553 }
18554
18555 #[test]
18556 fn load_compiled_module_source_disk_cache_invalidates_when_file_changes() {
18557 let temp_dir = tempfile::tempdir().expect("tempdir");
18558 let cache_dir = temp_dir.path().join("cache");
18559 let module_path = temp_dir.path().join("module.js");
18560 std::fs::write(&module_path, "export const x = 1;\n").expect("write module");
18561 let name = module_path.to_string_lossy().to_string();
18562
18563 let mut prime_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir.clone()));
18564 let first = load_compiled_module_source(&mut prime_state, &name).expect("first compile");
18565 let first_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
18566
18567 std::fs::write(
18568 &module_path,
18569 "export const xyz = 1234567890;\nexport const more = true;\n",
18570 )
18571 .expect("rewrite module");
18572 let second_key = module_cache_key(&HashMap::new(), &HashMap::new(), &name).expect("key");
18573 assert_ne!(first_key, second_key);
18574
18575 let mut second_state = PiJsModuleState::new().with_disk_cache_dir(Some(cache_dir));
18576 let second = load_compiled_module_source(&mut second_state, &name).expect("recompile");
18577 assert_eq!(second_state.module_cache_counters.disk_hits, 0);
18578 assert_eq!(second_state.module_cache_counters.misses, 1);
18579 assert_ne!(first, second);
18580 }
18581
18582 #[test]
18583 fn warm_reset_clears_extension_registry_state() {
18584 futures::executor::block_on(async {
18585 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18586 .await
18587 .expect("create runtime");
18588
18589 runtime
18590 .eval(
18591 r#"
18592 __pi_begin_extension("ext.reset", { name: "ext.reset" });
18593 pi.registerTool({
18594 name: "warm_reset_tool",
18595 execute: async (_callId, _input) => ({ ok: true }),
18596 });
18597 pi.registerCommand("warm_reset_cmd", {
18598 handler: async (_args, _ctx) => ({ ok: true }),
18599 });
18600 pi.on("startup", async () => {});
18601 __pi_end_extension();
18602 "#,
18603 )
18604 .await
18605 .expect("register extension state");
18606
18607 let before = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
18608 assert_eq!(before["extensions"], serde_json::json!(1));
18609 assert_eq!(before["tools"], serde_json::json!(1));
18610 assert_eq!(before["commands"], serde_json::json!(1));
18611
18612 let report = runtime
18613 .reset_for_warm_reload()
18614 .await
18615 .expect("warm reset should run");
18616 assert!(report.reused, "expected warm reuse, got report: {report:?}");
18617 assert!(
18618 report.reason_code.is_none(),
18619 "unexpected warm-reset reason: {:?}",
18620 report.reason_code
18621 );
18622
18623 let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
18624 assert_eq!(after["extensions"], serde_json::json!(0));
18625 assert_eq!(after["tools"], serde_json::json!(0));
18626 assert_eq!(after["commands"], serde_json::json!(0));
18627 assert_eq!(after["hooks"], serde_json::json!(0));
18628 assert_eq!(after["pendingTasks"], serde_json::json!(0));
18629 assert_eq!(after["pendingHostcalls"], serde_json::json!(0));
18630 });
18631 }
18632
18633 #[test]
18634 fn warm_reset_reports_pending_rust_work() {
18635 futures::executor::block_on(async {
18636 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18637 .await
18638 .expect("create runtime");
18639 let _timer = runtime.set_timeout(10);
18640
18641 let report = runtime
18642 .reset_for_warm_reload()
18643 .await
18644 .expect("warm reset should return report");
18645 assert!(!report.reused);
18646 assert_eq!(report.reason_code.as_deref(), Some("pending_rust_work"));
18647 });
18648 }
18649
18650 #[test]
18651 fn warm_reset_reports_pending_js_work() {
18652 futures::executor::block_on(async {
18653 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18654 .await
18655 .expect("create runtime");
18656
18657 runtime
18658 .eval(
18659 r#"
18660 __pi_tasks.set("pending-task", { status: "pending" });
18661 "#,
18662 )
18663 .await
18664 .expect("inject pending JS task");
18665
18666 let report = runtime
18667 .reset_for_warm_reload()
18668 .await
18669 .expect("warm reset should return report");
18670 assert!(!report.reused);
18671 assert_eq!(report.reason_code.as_deref(), Some("pending_js_work"));
18672
18673 let after = call_global_fn_json(&runtime, "__pi_runtime_registry_snapshot").await;
18674 assert_eq!(after["pendingTasks"], serde_json::json!(0));
18675 });
18676 }
18677
18678 #[test]
18679 #[allow(clippy::too_many_lines)]
18680 fn reset_transient_state_preserves_compiled_cache_and_clears_transient_state() {
18681 futures::executor::block_on(async {
18682 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18683 .await
18684 .expect("create runtime");
18685
18686 let cache_key = "pijs://virtual".to_string();
18687 {
18688 let mut state = runtime.module_state.borrow_mut();
18689 let extension_root = PathBuf::from("/tmp/ext-root");
18690 state.extension_roots.push(extension_root.clone());
18691 state
18692 .extension_root_tiers
18693 .insert(extension_root.clone(), ProxyStubSourceTier::Community);
18694 state
18695 .extension_root_scopes
18696 .insert(extension_root, "@scope".to_string());
18697 state
18698 .dynamic_virtual_modules
18699 .insert(cache_key.clone(), "export const v = 1;".to_string());
18700 let mut exports = BTreeSet::new();
18701 exports.insert("v".to_string());
18702 state
18703 .dynamic_virtual_named_exports
18704 .insert(cache_key.clone(), exports);
18705 state.compiled_sources.insert(
18706 cache_key.clone(),
18707 CompiledModuleCacheEntry {
18708 cache_key: Some("cache-v1".to_string()),
18709 source: b"compiled-source".to_vec().into(),
18710 },
18711 );
18712 state.module_cache_counters = ModuleCacheCounters {
18713 hits: 3,
18714 misses: 4,
18715 invalidations: 5,
18716 disk_hits: 6,
18717 };
18718 }
18719
18720 runtime
18721 .hostcall_queue
18722 .borrow_mut()
18723 .push_back(HostcallRequest {
18724 call_id: "call-1".to_string(),
18725 kind: HostcallKind::Tool {
18726 name: "read".to_string(),
18727 },
18728 payload: serde_json::json!({}),
18729 trace_id: 1,
18730 extension_id: Some("ext.reset".to_string()),
18731 });
18732 runtime
18733 .hostcall_tracker
18734 .borrow_mut()
18735 .register("call-1".to_string(), Some(42), 0);
18736 runtime
18737 .hostcalls_total
18738 .store(11, std::sync::atomic::Ordering::SeqCst);
18739 runtime
18740 .hostcalls_timed_out
18741 .store(2, std::sync::atomic::Ordering::SeqCst);
18742 runtime
18743 .tick_counter
18744 .store(7, std::sync::atomic::Ordering::SeqCst);
18745
18746 runtime.reset_transient_state();
18747
18748 {
18749 let state = runtime.module_state.borrow();
18750 assert!(state.extension_roots.is_empty());
18751 assert!(state.extension_root_tiers.is_empty());
18752 assert!(state.extension_root_scopes.is_empty());
18753 assert!(state.dynamic_virtual_modules.is_empty());
18754 assert!(state.dynamic_virtual_named_exports.is_empty());
18755
18756 let cached = state
18757 .compiled_sources
18758 .get(&cache_key)
18759 .expect("compiled source should persist across reset");
18760 assert_eq!(cached.cache_key.as_deref(), Some("cache-v1"));
18761 assert_eq!(cached.source.as_ref(), b"compiled-source");
18762
18763 assert_eq!(state.module_cache_counters.hits, 0);
18764 assert_eq!(state.module_cache_counters.misses, 0);
18765 assert_eq!(state.module_cache_counters.invalidations, 0);
18766 assert_eq!(state.module_cache_counters.disk_hits, 0);
18767 }
18768
18769 assert!(runtime.hostcall_queue.borrow().is_empty());
18770 assert_eq!(runtime.hostcall_tracker.borrow().pending_count(), 0);
18771 assert_eq!(
18772 runtime
18773 .hostcalls_total
18774 .load(std::sync::atomic::Ordering::SeqCst),
18775 0
18776 );
18777 assert_eq!(
18778 runtime
18779 .hostcalls_timed_out
18780 .load(std::sync::atomic::Ordering::SeqCst),
18781 0
18782 );
18783 assert_eq!(
18784 runtime
18785 .tick_counter
18786 .load(std::sync::atomic::Ordering::SeqCst),
18787 0
18788 );
18789 });
18790 }
18791
18792 #[test]
18793 fn warm_isolate_pool_tracks_created_and_reset_counts() {
18794 let cache_dir = tempfile::tempdir().expect("tempdir");
18795 let template = PiJsRuntimeConfig {
18796 cwd: "/tmp/warm-pool".to_string(),
18797 args: vec!["--flag".to_string()],
18798 env: HashMap::from([("PI_POOL".to_string(), "yes".to_string())]),
18799 deny_env: false,
18800 disk_cache_dir: Some(cache_dir.path().join("module-cache")),
18801 ..PiJsRuntimeConfig::default()
18802 };
18803 let expected_disk_cache_dir = template.disk_cache_dir.clone();
18804
18805 let pool = WarmIsolatePool::new(template.clone());
18806 assert_eq!(pool.created_count(), 0);
18807 assert_eq!(pool.reset_count(), 0);
18808
18809 let cfg_a = pool.make_config();
18810 let cfg_b = pool.make_config();
18811 assert_eq!(pool.created_count(), 2);
18812 assert_eq!(cfg_a.cwd, template.cwd);
18813 assert_eq!(cfg_b.cwd, template.cwd);
18814 assert_eq!(cfg_a.args, template.args);
18815 assert_eq!(cfg_a.env.get("PI_POOL"), Some(&"yes".to_string()));
18816 assert_eq!(cfg_a.deny_env, template.deny_env);
18817 assert_eq!(cfg_a.disk_cache_dir, expected_disk_cache_dir);
18818
18819 pool.record_reset();
18820 pool.record_reset();
18821 assert_eq!(pool.reset_count(), 2);
18822 }
18823
18824 #[test]
18825 fn resolver_error_messages_are_classified_deterministically() {
18826 assert_eq!(
18827 unsupported_module_specifier_message("left-pad"),
18828 "Package module specifiers are not supported in PiJS: left-pad"
18829 );
18830 assert_eq!(
18831 unsupported_module_specifier_message("https://example.com/mod.js"),
18832 "Network module imports are not supported in PiJS: https://example.com/mod.js"
18833 );
18834 assert_eq!(
18835 unsupported_module_specifier_message("pi:internal/foo"),
18836 "Unsupported module specifier: pi:internal/foo"
18837 );
18838 }
18839
18840 #[test]
18841 fn resolve_module_path_uses_documented_candidate_order() {
18842 let temp_dir = tempfile::tempdir().expect("tempdir");
18843 let root = temp_dir.path();
18844 let base = root.join("entry.ts");
18845 std::fs::write(&base, "export {};\n").expect("write base");
18846
18847 let pkg_dir = root.join("pkg");
18848 std::fs::create_dir_all(&pkg_dir).expect("mkdir pkg");
18849 let pkg_index_js = pkg_dir.join("index.js");
18850 let pkg_index_ts = pkg_dir.join("index.ts");
18851 std::fs::write(&pkg_index_js, "export const js = true;\n").expect("write index.js");
18852 std::fs::write(&pkg_index_ts, "export const ts = true;\n").expect("write index.ts");
18853
18854 let module_js = root.join("module.js");
18855 let module_ts = root.join("module.ts");
18856 std::fs::write(&module_js, "export const js = true;\n").expect("write module.js");
18857 std::fs::write(&module_ts, "export const ts = true;\n").expect("write module.ts");
18858
18859 let only_json = root.join("only_json.json");
18860 std::fs::write(&only_json, "{\"ok\":true}\n").expect("write only_json.json");
18861
18862 let mode = RepairMode::default();
18863 let roots = vec![];
18864
18865 let resolved_pkg =
18866 resolve_module_path(base.to_string_lossy().as_ref(), "./pkg", mode, &roots)
18867 .expect("resolve ./pkg");
18868 assert_eq!(resolved_pkg, pkg_index_ts);
18869
18870 let resolved_module =
18871 resolve_module_path(base.to_string_lossy().as_ref(), "./module", mode, &roots)
18872 .expect("resolve ./module");
18873 assert_eq!(resolved_module, module_ts);
18874
18875 let resolved_json =
18876 resolve_module_path(base.to_string_lossy().as_ref(), "./only_json", mode, &roots)
18877 .expect("resolve ./only_json");
18878 assert_eq!(resolved_json, only_json);
18879
18880 let file_url = format!("file://{}", module_ts.display());
18881 let resolved_file_url =
18882 resolve_module_path(base.to_string_lossy().as_ref(), &file_url, mode, &roots)
18883 .expect("file://");
18884 assert_eq!(resolved_file_url, module_ts);
18885 }
18886
18887 #[test]
18888 fn resolve_module_path_blocks_file_url_outside_extension_root() {
18889 let temp_dir = tempfile::tempdir().expect("tempdir");
18890 let root = temp_dir.path();
18891 let extension_root = root.join("ext");
18892 std::fs::create_dir_all(&extension_root).expect("mkdir ext");
18893
18894 let base = extension_root.join("index.ts");
18895 std::fs::write(&base, "export {};\n").expect("write base");
18896
18897 let outside = root.join("secret.ts");
18898 std::fs::write(&outside, "export const secret = 1;\n").expect("write outside");
18899
18900 let mode = RepairMode::default();
18901 let roots = vec![extension_root];
18902 let file_url = format!("file://{}", outside.display());
18903 let resolved =
18904 resolve_module_path(base.to_string_lossy().as_ref(), &file_url, mode, &roots);
18905 assert!(
18906 resolved.is_none(),
18907 "file:// import outside extension root should be blocked, got {resolved:?}"
18908 );
18909 }
18910
18911 #[test]
18912 fn resolve_module_path_allows_file_url_inside_extension_root() {
18913 let temp_dir = tempfile::tempdir().expect("tempdir");
18914 let root = temp_dir.path();
18915 let extension_root = root.join("ext");
18916 std::fs::create_dir_all(&extension_root).expect("mkdir ext");
18917
18918 let base = extension_root.join("index.ts");
18919 std::fs::write(&base, "export {};\n").expect("write base");
18920
18921 let inside = extension_root.join("module.ts");
18922 std::fs::write(&inside, "export const ok = 1;\n").expect("write inside");
18923
18924 let mode = RepairMode::default();
18925 let roots = vec![extension_root];
18926 let file_url = format!("file://{}", inside.display());
18927 let resolved =
18928 resolve_module_path(base.to_string_lossy().as_ref(), &file_url, mode, &roots);
18929 assert_eq!(resolved, Some(inside));
18930 }
18931
18932 #[test]
18933 fn pijs_dynamic_import_reports_deterministic_package_error() {
18934 futures::executor::block_on(async {
18935 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
18936 .await
18937 .expect("create runtime");
18938
18939 runtime
18940 .eval(
18941 r"
18942 globalThis.packageImportError = {};
18943 import('left-pad')
18944 .then(() => {
18945 globalThis.packageImportError.done = true;
18946 globalThis.packageImportError.message = '';
18947 })
18948 .catch((err) => {
18949 globalThis.packageImportError.done = true;
18950 globalThis.packageImportError.message = String((err && err.message) || err || '');
18951 });
18952 ",
18953 )
18954 .await
18955 .expect("eval package import");
18956
18957 let result = get_global_json(&runtime, "packageImportError").await;
18958 assert_eq!(result["done"], serde_json::json!(true));
18959 let message = result["message"].as_str().unwrap_or_default();
18960 assert!(
18961 message.contains("Package module specifiers are not supported in PiJS: left-pad"),
18962 "unexpected message: {message}"
18963 );
18964 });
18965 }
18966
18967 #[test]
18968 fn proxy_stub_allowlist_blocks_sensitive_packages() {
18969 assert!(is_proxy_blocklisted_package("node:fs"));
18970 assert!(is_proxy_blocklisted_package("fs"));
18971 assert!(is_proxy_blocklisted_package("child_process"));
18972 assert!(!is_proxy_blocklisted_package("@aliou/pi-utils-settings"));
18973 }
18974
18975 #[test]
18976 fn proxy_stub_allowlist_accepts_curated_scope_and_pi_pattern() {
18977 assert!(is_proxy_allowlisted_package("@sourcegraph/scip-python"));
18978 assert!(is_proxy_allowlisted_package("@aliou/pi-utils-settings"));
18979 assert!(is_proxy_allowlisted_package("@example/pi-helpers"));
18980 assert!(!is_proxy_allowlisted_package("left-pad"));
18981 }
18982
18983 #[test]
18984 fn proxy_stub_allows_same_scope_packages_for_extension() {
18985 let temp_dir = tempfile::tempdir().expect("tempdir");
18986 let root = temp_dir.path().join("community").join("scope-ext");
18987 std::fs::create_dir_all(&root).expect("mkdir root");
18988 std::fs::write(
18989 root.join("package.json"),
18990 r#"{ "name": "@qualisero/my-ext", "version": "1.0.0" }"#,
18991 )
18992 .expect("write package.json");
18993 let base = root.join("index.mjs");
18994 std::fs::write(&base, "export {};\n").expect("write base");
18995
18996 let mut tiers = HashMap::new();
18997 tiers.insert(root.clone(), ProxyStubSourceTier::Community);
18998 let mut scopes = HashMap::new();
18999 scopes.insert(root.clone(), "@qualisero".to_string());
19000
19001 assert!(should_auto_stub_package(
19002 "@qualisero/shared-lib",
19003 base.to_string_lossy().as_ref(),
19004 &[root],
19005 &tiers,
19006 &scopes,
19007 ));
19008 }
19009
19010 #[test]
19011 fn proxy_stub_allows_non_blocklisted_package_for_community_tier() {
19012 let temp_dir = tempfile::tempdir().expect("tempdir");
19013 let root = temp_dir.path().join("community").join("generic-ext");
19014 std::fs::create_dir_all(&root).expect("mkdir root");
19015 let base = root.join("index.mjs");
19016 std::fs::write(&base, "export {};\n").expect("write base");
19017
19018 let mut tiers = HashMap::new();
19019 tiers.insert(root.clone(), ProxyStubSourceTier::Community);
19020
19021 assert!(should_auto_stub_package(
19022 "left-pad",
19023 base.to_string_lossy().as_ref(),
19024 &[root],
19025 &tiers,
19026 &HashMap::new(),
19027 ));
19028 }
19029
19030 #[test]
19031 fn proxy_stub_disallowed_for_official_tier() {
19032 let temp_dir = tempfile::tempdir().expect("tempdir");
19033 let root = temp_dir.path().join("official-pi-mono").join("my-ext");
19034 std::fs::create_dir_all(&root).expect("mkdir root");
19035 let base = root.join("index.mjs");
19036 std::fs::write(&base, "export {};\n").expect("write base");
19037
19038 let mut tiers = HashMap::new();
19039 tiers.insert(root.clone(), ProxyStubSourceTier::Official);
19040
19041 assert!(!should_auto_stub_package(
19042 "left-pad",
19043 base.to_string_lossy().as_ref(),
19044 &[root],
19045 &tiers,
19046 &HashMap::new(),
19047 ));
19048 }
19049
19050 #[test]
19051 fn pijs_dynamic_import_autostrict_allows_missing_npm_proxy_stub() {
19052 const TEST_PKG: &str = "@aliou/pi-missing-proxy-test";
19053 futures::executor::block_on(async {
19054 let temp_dir = tempfile::tempdir().expect("tempdir");
19055 let ext_dir = temp_dir.path().join("community").join("proxy-ext");
19056 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
19057 let entry = ext_dir.join("index.mjs");
19058 std::fs::write(
19059 &entry,
19060 r#"
19061import dep from "@aliou/pi-missing-proxy-test";
19062globalThis.__proxyProbe = {
19063 kind: typeof dep,
19064 chain: typeof dep.foo.bar(),
19065 primitive: String(dep),
19066};
19067export default dep;
19068"#,
19069 )
19070 .expect("write extension module");
19071
19072 let config = PiJsRuntimeConfig {
19073 repair_mode: RepairMode::AutoStrict,
19074 ..PiJsRuntimeConfig::default()
19075 };
19076 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19077 DeterministicClock::new(0),
19078 config,
19079 None,
19080 )
19081 .await
19082 .expect("create runtime");
19083 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext"));
19084
19085 let entry_spec = format!("file://{}", entry.display());
19086 let script = format!(
19087 r#"
19088 globalThis.proxyImport = {{}};
19089 import({entry_spec:?})
19090 .then(() => {{
19091 globalThis.proxyImport.done = true;
19092 globalThis.proxyImport.error = "";
19093 }})
19094 .catch((err) => {{
19095 globalThis.proxyImport.done = true;
19096 globalThis.proxyImport.error = String((err && err.message) || err || "");
19097 }});
19098 "#
19099 );
19100 runtime.eval(&script).await.expect("eval import");
19101
19102 let result = get_global_json(&runtime, "proxyImport").await;
19103 assert_eq!(result["done"], serde_json::json!(true));
19104 assert_eq!(result["error"], serde_json::json!(""));
19105
19106 let probe = get_global_json(&runtime, "__proxyProbe").await;
19107 assert_eq!(probe["kind"], serde_json::json!("function"));
19108 assert_eq!(probe["chain"], serde_json::json!("function"));
19109 assert_eq!(probe["primitive"], serde_json::json!(""));
19110
19111 let events = runtime.drain_repair_events();
19112 assert!(events.iter().any(|event| {
19113 event.pattern == RepairPattern::MissingNpmDep
19114 && event.repair_action.contains(TEST_PKG)
19115 }));
19116 });
19117 }
19118
19119 #[test]
19120 fn pijs_dynamic_import_autosafe_rejects_missing_npm_proxy_stub() {
19121 const TEST_PKG: &str = "@aliou/pi-missing-proxy-test-safe";
19122 futures::executor::block_on(async {
19123 let temp_dir = tempfile::tempdir().expect("tempdir");
19124 let ext_dir = temp_dir.path().join("community").join("proxy-ext-safe");
19125 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
19126 let entry = ext_dir.join("index.mjs");
19127 std::fs::write(
19128 &entry,
19129 r#"import dep from "@aliou/pi-missing-proxy-test-safe"; export default dep;"#,
19130 )
19131 .expect("write extension module");
19132
19133 let config = PiJsRuntimeConfig {
19134 repair_mode: RepairMode::AutoSafe,
19135 ..PiJsRuntimeConfig::default()
19136 };
19137 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19138 DeterministicClock::new(0),
19139 config,
19140 None,
19141 )
19142 .await
19143 .expect("create runtime");
19144 runtime.add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-safe"));
19145
19146 let entry_spec = format!("file://{}", entry.display());
19147 let script = format!(
19148 r#"
19149 globalThis.proxySafeImport = {{}};
19150 import({entry_spec:?})
19151 .then(() => {{
19152 globalThis.proxySafeImport.done = true;
19153 globalThis.proxySafeImport.error = "";
19154 }})
19155 .catch((err) => {{
19156 globalThis.proxySafeImport.done = true;
19157 globalThis.proxySafeImport.error = String((err && err.message) || err || "");
19158 }});
19159 "#
19160 );
19161 runtime.eval(&script).await.expect("eval import");
19162
19163 let result = get_global_json(&runtime, "proxySafeImport").await;
19164 assert_eq!(result["done"], serde_json::json!(true));
19165 let message = result["error"].as_str().unwrap_or_default();
19166 assert!(
19170 message.contains("Package module specifiers are not supported in PiJS"),
19171 "unexpected message: {message}"
19172 );
19173 });
19174 }
19175
19176 #[test]
19177 fn pijs_dynamic_import_existing_virtual_module_does_not_emit_missing_npm_repair() {
19178 futures::executor::block_on(async {
19179 let temp_dir = tempfile::tempdir().expect("tempdir");
19180 let ext_dir = temp_dir.path().join("community").join("proxy-ext-existing");
19181 std::fs::create_dir_all(&ext_dir).expect("mkdir ext");
19182 let entry = ext_dir.join("index.mjs");
19183 std::fs::write(
19184 &entry,
19185 r#"
19186import { ConfigLoader } from "@aliou/pi-utils-settings";
19187globalThis.__existingVirtualProbe = typeof ConfigLoader;
19188export default ConfigLoader;
19189"#,
19190 )
19191 .expect("write extension module");
19192
19193 let config = PiJsRuntimeConfig {
19194 repair_mode: RepairMode::AutoStrict,
19195 ..PiJsRuntimeConfig::default()
19196 };
19197 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19198 DeterministicClock::new(0),
19199 config,
19200 None,
19201 )
19202 .await
19203 .expect("create runtime");
19204 runtime
19205 .add_extension_root_with_id(ext_dir.clone(), Some("community/proxy-ext-existing"));
19206
19207 let entry_spec = format!("file://{}", entry.display());
19208 let script = format!(
19209 r#"
19210 globalThis.proxyExistingImport = {{}};
19211 import({entry_spec:?})
19212 .then(() => {{
19213 globalThis.proxyExistingImport.done = true;
19214 globalThis.proxyExistingImport.error = "";
19215 }})
19216 .catch((err) => {{
19217 globalThis.proxyExistingImport.done = true;
19218 globalThis.proxyExistingImport.error = String((err && err.message) || err || "");
19219 }});
19220 "#
19221 );
19222 runtime.eval(&script).await.expect("eval import");
19223
19224 let result = get_global_json(&runtime, "proxyExistingImport").await;
19225 assert_eq!(result["done"], serde_json::json!(true));
19226 assert_eq!(result["error"], serde_json::json!(""));
19227
19228 let probe = get_global_json(&runtime, "__existingVirtualProbe").await;
19229 assert_eq!(probe, serde_json::json!("function"));
19230
19231 let events = runtime.drain_repair_events();
19232 assert!(
19233 !events
19234 .iter()
19235 .any(|event| event.pattern == RepairPattern::MissingNpmDep),
19236 "existing virtual module should suppress missing_npm_dep repair events"
19237 );
19238 });
19239 }
19240
19241 #[test]
19242 fn pijs_dynamic_import_reports_deterministic_network_error() {
19243 futures::executor::block_on(async {
19244 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19245 .await
19246 .expect("create runtime");
19247
19248 runtime
19249 .eval(
19250 r"
19251 globalThis.networkImportError = {};
19252 import('https://example.com/mod.js')
19253 .then(() => {
19254 globalThis.networkImportError.done = true;
19255 globalThis.networkImportError.message = '';
19256 })
19257 .catch((err) => {
19258 globalThis.networkImportError.done = true;
19259 globalThis.networkImportError.message = String((err && err.message) || err || '');
19260 });
19261 ",
19262 )
19263 .await
19264 .expect("eval network import");
19265
19266 let result = get_global_json(&runtime, "networkImportError").await;
19267 assert_eq!(result["done"], serde_json::json!(true));
19268 let message = result["message"].as_str().unwrap_or_default();
19269 assert!(
19270 message.contains(
19271 "Network module imports are not supported in PiJS: https://example.com/mod.js"
19272 ),
19273 "unexpected message: {message}"
19274 );
19275 });
19276 }
19277
19278 #[test]
19281 fn pijs_runtime_creates_hostcall_request() {
19282 futures::executor::block_on(async {
19283 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19284 .await
19285 .expect("create runtime");
19286
19287 runtime
19289 .eval(r#"pi.tool("read", { path: "test.txt" });"#)
19290 .await
19291 .expect("eval");
19292
19293 let requests = runtime.drain_hostcall_requests();
19295 assert_eq!(requests.len(), 1);
19296 let req = &requests[0];
19297 assert!(matches!(&req.kind, HostcallKind::Tool { name } if name == "read"));
19298 assert_eq!(req.payload["path"], "test.txt");
19299 assert_eq!(req.extension_id.as_deref(), None);
19300 });
19301 }
19302
19303 #[test]
19304 fn pijs_runtime_hostcall_request_captures_extension_id() {
19305 futures::executor::block_on(async {
19306 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19307 .await
19308 .expect("create runtime");
19309
19310 runtime
19311 .eval(
19312 r#"
19313 __pi_begin_extension("ext.test", { name: "Test" });
19314 pi.tool("read", { path: "test.txt" });
19315 __pi_end_extension();
19316 "#,
19317 )
19318 .await
19319 .expect("eval");
19320
19321 let requests = runtime.drain_hostcall_requests();
19322 assert_eq!(requests.len(), 1);
19323 assert_eq!(requests[0].extension_id.as_deref(), Some("ext.test"));
19324 });
19325 }
19326
19327 #[test]
19328 fn pijs_runtime_log_hostcall_request_shape() {
19329 futures::executor::block_on(async {
19330 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19331 .await
19332 .expect("create runtime");
19333
19334 runtime
19335 .eval(
19336 r#"
19337 pi.log({
19338 level: "info",
19339 event: "unit.test",
19340 message: "hello",
19341 correlation: { scenario_id: "scn-1" }
19342 });
19343 "#,
19344 )
19345 .await
19346 .expect("eval");
19347
19348 let requests = runtime.drain_hostcall_requests();
19349 assert_eq!(requests.len(), 1);
19350 let req = &requests[0];
19351 assert!(matches!(&req.kind, HostcallKind::Log));
19352 assert_eq!(req.payload["level"], "info");
19353 assert_eq!(req.payload["event"], "unit.test");
19354 assert_eq!(req.payload["message"], "hello");
19355 });
19356 }
19357
19358 #[test]
19359 fn pijs_runtime_get_registered_tools_empty() {
19360 futures::executor::block_on(async {
19361 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19362 .await
19363 .expect("create runtime");
19364
19365 let tools = runtime.get_registered_tools().await.expect("get tools");
19366 assert!(tools.is_empty());
19367 });
19368 }
19369
19370 #[test]
19371 fn pijs_runtime_get_registered_tools_single_tool() {
19372 futures::executor::block_on(async {
19373 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19374 .await
19375 .expect("create runtime");
19376
19377 runtime
19378 .eval(
19379 r"
19380 __pi_begin_extension('ext.test', { name: 'Test' });
19381 pi.registerTool({
19382 name: 'my_tool',
19383 label: 'My Tool',
19384 description: 'Does stuff',
19385 parameters: { type: 'object', properties: { path: { type: 'string' } } },
19386 execute: async (_callId, _input) => { return { ok: true }; },
19387 });
19388 __pi_end_extension();
19389 ",
19390 )
19391 .await
19392 .expect("eval");
19393
19394 let tools = runtime.get_registered_tools().await.expect("get tools");
19395 assert_eq!(tools.len(), 1);
19396 assert_eq!(
19397 tools[0],
19398 ExtensionToolDef {
19399 name: "my_tool".to_string(),
19400 label: Some("My Tool".to_string()),
19401 description: "Does stuff".to_string(),
19402 parameters: serde_json::json!({
19403 "type": "object",
19404 "properties": {
19405 "path": { "type": "string" }
19406 }
19407 }),
19408 }
19409 );
19410 });
19411 }
19412
19413 #[test]
19414 fn pijs_runtime_get_registered_tools_sorts_by_name() {
19415 futures::executor::block_on(async {
19416 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19417 .await
19418 .expect("create runtime");
19419
19420 runtime
19421 .eval(
19422 r"
19423 __pi_begin_extension('ext.test', { name: 'Test' });
19424 pi.registerTool({ name: 'b', execute: async (_callId, _input) => { return {}; } });
19425 pi.registerTool({ name: 'a', execute: async (_callId, _input) => { return {}; } });
19426 __pi_end_extension();
19427 ",
19428 )
19429 .await
19430 .expect("eval");
19431
19432 let tools = runtime.get_registered_tools().await.expect("get tools");
19433 assert_eq!(
19434 tools
19435 .iter()
19436 .map(|tool| tool.name.as_str())
19437 .collect::<Vec<_>>(),
19438 vec!["a", "b"]
19439 );
19440 });
19441 }
19442
19443 #[test]
19444 fn hostcall_params_hash_is_stable_for_key_ordering() {
19445 let first = serde_json::json!({ "b": 2, "a": 1 });
19446 let second = serde_json::json!({ "a": 1, "b": 2 });
19447
19448 assert_eq!(
19449 hostcall_params_hash("http", &first),
19450 hostcall_params_hash("http", &second)
19451 );
19452 assert_ne!(
19453 hostcall_params_hash("http", &first),
19454 hostcall_params_hash("tool", &first)
19455 );
19456 }
19457
19458 #[test]
19459 #[allow(clippy::too_many_lines)]
19460 fn hostcall_request_params_for_hash_uses_canonical_shapes() {
19461 let cases = vec![
19462 (
19463 HostcallRequest {
19464 call_id: "tool-case".to_string(),
19465 kind: HostcallKind::Tool {
19466 name: "read".to_string(),
19467 },
19468 payload: serde_json::json!({ "path": "README.md" }),
19469 trace_id: 0,
19470 extension_id: None,
19471 },
19472 serde_json::json!({ "name": "read", "input": { "path": "README.md" } }),
19473 ),
19474 (
19475 HostcallRequest {
19476 call_id: "exec-case".to_string(),
19477 kind: HostcallKind::Exec {
19478 cmd: "echo".to_string(),
19479 },
19480 payload: serde_json::json!({
19481 "command": "legacy alias should be dropped",
19482 "args": ["hello"],
19483 "options": { "timeout": 1000 }
19484 }),
19485 trace_id: 0,
19486 extension_id: None,
19487 },
19488 serde_json::json!({
19489 "cmd": "echo",
19490 "args": ["hello"],
19491 "options": { "timeout": 1000 }
19492 }),
19493 ),
19494 (
19495 HostcallRequest {
19496 call_id: "session-object".to_string(),
19497 kind: HostcallKind::Session {
19498 op: "set_model".to_string(),
19499 },
19500 payload: serde_json::json!({
19501 "provider": "openai",
19502 "modelId": "gpt-4o"
19503 }),
19504 trace_id: 0,
19505 extension_id: None,
19506 },
19507 serde_json::json!({
19508 "op": "set_model",
19509 "provider": "openai",
19510 "modelId": "gpt-4o"
19511 }),
19512 ),
19513 (
19514 HostcallRequest {
19515 call_id: "ui-non-object".to_string(),
19516 kind: HostcallKind::Ui {
19517 op: "set_status".to_string(),
19518 },
19519 payload: serde_json::json!("ready"),
19520 trace_id: 0,
19521 extension_id: None,
19522 },
19523 serde_json::json!({ "op": "set_status", "payload": "ready" }),
19524 ),
19525 (
19526 HostcallRequest {
19527 call_id: "events-non-object".to_string(),
19528 kind: HostcallKind::Events {
19529 op: "emit".to_string(),
19530 },
19531 payload: serde_json::json!(42),
19532 trace_id: 0,
19533 extension_id: None,
19534 },
19535 serde_json::json!({ "op": "emit", "payload": 42 }),
19536 ),
19537 (
19538 HostcallRequest {
19539 call_id: "session-null".to_string(),
19540 kind: HostcallKind::Session {
19541 op: "get_state".to_string(),
19542 },
19543 payload: serde_json::Value::Null,
19544 trace_id: 0,
19545 extension_id: None,
19546 },
19547 serde_json::json!({ "op": "get_state" }),
19548 ),
19549 (
19550 HostcallRequest {
19551 call_id: "log-entry".to_string(),
19552 kind: HostcallKind::Log,
19553 payload: serde_json::json!({
19554 "level": "info",
19555 "event": "unit.test",
19556 "message": "hello",
19557 "correlation": { "scenario_id": "scn-1" }
19558 }),
19559 trace_id: 0,
19560 extension_id: None,
19561 },
19562 serde_json::json!({
19563 "level": "info",
19564 "event": "unit.test",
19565 "message": "hello",
19566 "correlation": { "scenario_id": "scn-1" }
19567 }),
19568 ),
19569 ];
19570
19571 for (request, expected) in cases {
19572 assert_eq!(
19573 request.params_for_hash(),
19574 expected,
19575 "canonical params mismatch for {}",
19576 request.call_id
19577 );
19578 }
19579 }
19580
19581 #[test]
19582 fn hostcall_request_params_hash_matches_wasm_contract_for_canonical_requests() {
19583 let requests = vec![
19584 HostcallRequest {
19585 call_id: "hash-session".to_string(),
19586 kind: HostcallKind::Session {
19587 op: "set_model".to_string(),
19588 },
19589 payload: serde_json::json!({
19590 "modelId": "gpt-4o",
19591 "provider": "openai"
19592 }),
19593 trace_id: 0,
19594 extension_id: Some("ext.test".to_string()),
19595 },
19596 HostcallRequest {
19597 call_id: "hash-ui".to_string(),
19598 kind: HostcallKind::Ui {
19599 op: "set_status".to_string(),
19600 },
19601 payload: serde_json::json!("thinking"),
19602 trace_id: 0,
19603 extension_id: Some("ext.test".to_string()),
19604 },
19605 HostcallRequest {
19606 call_id: "hash-log".to_string(),
19607 kind: HostcallKind::Log,
19608 payload: serde_json::json!({
19609 "level": "warn",
19610 "event": "log.test",
19611 "message": "warn line",
19612 "correlation": { "scenario_id": "scn-2" }
19613 }),
19614 trace_id: 0,
19615 extension_id: Some("ext.test".to_string()),
19616 },
19617 ];
19618
19619 for request in requests {
19620 let params = request.params_for_hash();
19621 let js_hash = request.params_hash();
19622
19623 let wasm_contract_hash =
19625 crate::extensions::hostcall_params_hash(request.method(), ¶ms);
19626
19627 assert_eq!(
19628 js_hash, wasm_contract_hash,
19629 "hash parity mismatch for {}",
19630 request.call_id
19631 );
19632 }
19633 }
19634
19635 #[test]
19636 fn hostcall_request_io_uring_capability_and_hint_mappings_are_deterministic() {
19637 let cases = vec![
19638 (
19639 HostcallRequest {
19640 call_id: "io-read".to_string(),
19641 kind: HostcallKind::Tool {
19642 name: "read".to_string(),
19643 },
19644 payload: serde_json::Value::Null,
19645 trace_id: 0,
19646 extension_id: None,
19647 },
19648 HostcallCapabilityClass::Filesystem,
19649 HostcallIoHint::IoHeavy,
19650 ),
19651 (
19652 HostcallRequest {
19653 call_id: "io-bash".to_string(),
19654 kind: HostcallKind::Tool {
19655 name: "bash".to_string(),
19656 },
19657 payload: serde_json::Value::Null,
19658 trace_id: 0,
19659 extension_id: None,
19660 },
19661 HostcallCapabilityClass::Execution,
19662 HostcallIoHint::CpuBound,
19663 ),
19664 (
19665 HostcallRequest {
19666 call_id: "io-http".to_string(),
19667 kind: HostcallKind::Http,
19668 payload: serde_json::Value::Null,
19669 trace_id: 0,
19670 extension_id: None,
19671 },
19672 HostcallCapabilityClass::Network,
19673 HostcallIoHint::IoHeavy,
19674 ),
19675 (
19676 HostcallRequest {
19677 call_id: "io-session".to_string(),
19678 kind: HostcallKind::Session {
19679 op: "get_state".to_string(),
19680 },
19681 payload: serde_json::Value::Null,
19682 trace_id: 0,
19683 extension_id: None,
19684 },
19685 HostcallCapabilityClass::Session,
19686 HostcallIoHint::Unknown,
19687 ),
19688 (
19689 HostcallRequest {
19690 call_id: "io-log".to_string(),
19691 kind: HostcallKind::Log,
19692 payload: serde_json::Value::Null,
19693 trace_id: 0,
19694 extension_id: None,
19695 },
19696 HostcallCapabilityClass::Telemetry,
19697 HostcallIoHint::Unknown,
19698 ),
19699 ];
19700
19701 for (request, expected_capability, expected_hint) in cases {
19702 assert_eq!(
19703 request.io_uring_capability_class(),
19704 expected_capability,
19705 "capability mismatch for {}",
19706 request.call_id
19707 );
19708 assert_eq!(
19709 request.io_uring_io_hint(),
19710 expected_hint,
19711 "io hint mismatch for {}",
19712 request.call_id
19713 );
19714 }
19715 }
19716
19717 #[test]
19718 fn hostcall_request_io_uring_lane_input_preserves_queue_and_force_flags() {
19719 let request = HostcallRequest {
19720 call_id: "io-lane-input".to_string(),
19721 kind: HostcallKind::Tool {
19722 name: "write".to_string(),
19723 },
19724 payload: serde_json::json!({ "path": "notes.txt", "content": "ok" }),
19725 trace_id: 0,
19726 extension_id: Some("ext.test".to_string()),
19727 };
19728
19729 let input = request.io_uring_lane_input(17, true);
19730 assert_eq!(input.capability, HostcallCapabilityClass::Filesystem);
19731 assert_eq!(input.io_hint, HostcallIoHint::IoHeavy);
19732 assert_eq!(input.queue_depth, 17);
19733 assert!(input.force_compat_lane);
19734 }
19735
19736 #[test]
19737 fn pijs_runtime_multiple_hostcalls() {
19738 futures::executor::block_on(async {
19739 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19740 .await
19741 .expect("create runtime");
19742
19743 runtime
19744 .eval(
19745 r#"
19746 pi.tool("read", { path: "a.txt" });
19747 pi.exec("ls", ["-la"]);
19748 pi.http({ url: "https://example.com" });
19749 "#,
19750 )
19751 .await
19752 .expect("eval");
19753
19754 let requests = runtime.drain_hostcall_requests();
19755 let kinds = requests
19756 .iter()
19757 .map(|req| format!("{:?}", req.kind))
19758 .collect::<Vec<_>>();
19759 assert_eq!(requests.len(), 3, "hostcalls: {kinds:?}");
19760
19761 assert!(matches!(&requests[0].kind, HostcallKind::Tool { name } if name == "read"));
19762 assert!(matches!(&requests[1].kind, HostcallKind::Exec { cmd } if cmd == "ls"));
19763 assert!(matches!(&requests[2].kind, HostcallKind::Http));
19764 });
19765 }
19766
19767 #[test]
19768 fn pijs_runtime_hostcall_completion_resolves_promise() {
19769 futures::executor::block_on(async {
19770 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19771 .await
19772 .expect("create runtime");
19773
19774 runtime
19776 .eval(
19777 r#"
19778 globalThis.result = null;
19779 pi.tool("read", { path: "test.txt" }).then(r => {
19780 globalThis.result = r;
19781 });
19782 "#,
19783 )
19784 .await
19785 .expect("eval");
19786
19787 let requests = runtime.drain_hostcall_requests();
19789 assert_eq!(requests.len(), 1);
19790 let call_id = requests[0].call_id.clone();
19791
19792 runtime.complete_hostcall(
19794 call_id,
19795 HostcallOutcome::Success(serde_json::json!({ "content": "hello world" })),
19796 );
19797
19798 let stats = runtime.tick().await.expect("tick");
19800 assert!(stats.ran_macrotask);
19801
19802 runtime
19804 .eval(
19805 r#"
19806 if (globalThis.result === null) {
19807 throw new Error("Promise not resolved");
19808 }
19809 if (globalThis.result.content !== "hello world") {
19810 throw new Error("Wrong result: " + JSON.stringify(globalThis.result));
19811 }
19812 "#,
19813 )
19814 .await
19815 .expect("verify result");
19816 });
19817 }
19818
19819 #[test]
19820 fn pijs_runtime_hostcall_error_rejects_promise() {
19821 futures::executor::block_on(async {
19822 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19823 .await
19824 .expect("create runtime");
19825
19826 runtime
19828 .eval(
19829 r#"
19830 globalThis.error = null;
19831 pi.tool("read", { path: "nonexistent.txt" }).catch(e => {
19832 globalThis.error = { code: e.code, message: e.message };
19833 });
19834 "#,
19835 )
19836 .await
19837 .expect("eval");
19838
19839 let requests = runtime.drain_hostcall_requests();
19840 let call_id = requests[0].call_id.clone();
19841
19842 runtime.complete_hostcall(
19844 call_id,
19845 HostcallOutcome::Error {
19846 code: "ENOENT".to_string(),
19847 message: "File not found".to_string(),
19848 },
19849 );
19850
19851 runtime.tick().await.expect("tick");
19852
19853 runtime
19855 .eval(
19856 r#"
19857 if (globalThis.error === null) {
19858 throw new Error("Promise not rejected");
19859 }
19860 if (globalThis.error.code !== "ENOENT") {
19861 throw new Error("Wrong error code: " + globalThis.error.code);
19862 }
19863 "#,
19864 )
19865 .await
19866 .expect("verify error");
19867 });
19868 }
19869
19870 #[test]
19871 fn pijs_runtime_tick_stats() {
19872 futures::executor::block_on(async {
19873 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
19874 .await
19875 .expect("create runtime");
19876
19877 let stats = runtime.tick().await.expect("tick");
19879 assert!(!stats.ran_macrotask);
19880 assert_eq!(stats.pending_hostcalls, 0);
19881
19882 runtime.eval(r#"pi.tool("test", {});"#).await.expect("eval");
19884
19885 let requests = runtime.drain_hostcall_requests();
19886 assert_eq!(requests.len(), 1);
19887
19888 runtime.complete_hostcall(
19890 requests[0].call_id.clone(),
19891 HostcallOutcome::Success(serde_json::json!(null)),
19892 );
19893
19894 let stats = runtime.tick().await.expect("tick");
19895 assert!(stats.ran_macrotask);
19896 });
19897 }
19898
19899 #[test]
19900 fn pijs_hostcall_timeout_rejects_promise() {
19901 futures::executor::block_on(async {
19902 let clock = Arc::new(DeterministicClock::new(0));
19903 let mut config = PiJsRuntimeConfig::default();
19904 config.limits.hostcall_timeout_ms = Some(50);
19905
19906 let runtime =
19907 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
19908 .await
19909 .expect("create runtime");
19910
19911 runtime
19912 .eval(
19913 r#"
19914 globalThis.done = false;
19915 globalThis.code = null;
19916 pi.tool("read", { path: "test.txt" })
19917 .then(() => { globalThis.done = true; })
19918 .catch((e) => { globalThis.code = e.code; globalThis.done = true; });
19919 "#,
19920 )
19921 .await
19922 .expect("eval");
19923
19924 let requests = runtime.drain_hostcall_requests();
19925 assert_eq!(requests.len(), 1);
19926
19927 clock.set(50);
19928 let stats = runtime.tick().await.expect("tick");
19929 assert!(stats.ran_macrotask);
19930 assert_eq!(stats.hostcalls_timed_out, 1);
19931 assert_eq!(
19932 get_global_json(&runtime, "done").await,
19933 serde_json::json!(true)
19934 );
19935 assert_eq!(
19936 get_global_json(&runtime, "code").await,
19937 serde_json::json!("timeout")
19938 );
19939
19940 runtime.complete_hostcall(
19942 requests[0].call_id.clone(),
19943 HostcallOutcome::Success(serde_json::json!({ "ok": true })),
19944 );
19945 let stats = runtime.tick().await.expect("tick late completion");
19946 assert!(stats.ran_macrotask);
19947 assert_eq!(stats.hostcalls_timed_out, 1);
19948 });
19949 }
19950
19951 #[test]
19952 fn pijs_interrupt_budget_aborts_eval() {
19953 futures::executor::block_on(async {
19954 let mut config = PiJsRuntimeConfig::default();
19955 config.limits.interrupt_budget = Some(0);
19956
19957 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
19958 DeterministicClock::new(0),
19959 config,
19960 None,
19961 )
19962 .await
19963 .expect("create runtime");
19964
19965 let err = runtime
19966 .eval(
19967 r"
19968 let sum = 0;
19969 for (let i = 0; i < 1000000; i++) { sum += i; }
19970 ",
19971 )
19972 .await
19973 .expect_err("expected budget exceed");
19974
19975 assert!(err.to_string().contains("PiJS execution budget exceeded"));
19976 });
19977 }
19978
19979 #[test]
19980 fn pijs_microtasks_drain_before_next_macrotask() {
19981 futures::executor::block_on(async {
19982 let clock = Arc::new(DeterministicClock::new(0));
19983 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
19984 .await
19985 .expect("create runtime");
19986
19987 runtime
19988 .eval(r"globalThis.order = []; globalThis.__pi_done = false;")
19989 .await
19990 .expect("init order");
19991
19992 let timer_id = runtime.set_timeout(10);
19993 runtime
19994 .eval(&format!(
19995 r#"__pi_register_timer({timer_id}, () => {{
19996 globalThis.order.push("timer");
19997 Promise.resolve().then(() => globalThis.order.push("timer-micro"));
19998 }});"#
19999 ))
20000 .await
20001 .expect("register timer");
20002
20003 runtime
20004 .eval(
20005 r#"
20006 pi.tool("read", {}).then(() => {
20007 globalThis.order.push("hostcall");
20008 Promise.resolve().then(() => globalThis.order.push("hostcall-micro"));
20009 });
20010 "#,
20011 )
20012 .await
20013 .expect("enqueue hostcall");
20014
20015 let requests = runtime.drain_hostcall_requests();
20016 let call_id = requests
20017 .into_iter()
20018 .next()
20019 .expect("hostcall request")
20020 .call_id;
20021
20022 runtime.complete_hostcall(call_id, HostcallOutcome::Success(serde_json::json!(null)));
20023
20024 clock.set(10);
20026
20027 runtime.tick().await.expect("tick hostcall");
20029 let after_first = get_global_json(&runtime, "order").await;
20030 assert_eq!(
20031 after_first,
20032 serde_json::json!(["hostcall", "hostcall-micro"])
20033 );
20034
20035 runtime.tick().await.expect("tick timer");
20037 let after_second = get_global_json(&runtime, "order").await;
20038 assert_eq!(
20039 after_second,
20040 serde_json::json!(["hostcall", "hostcall-micro", "timer", "timer-micro"])
20041 );
20042 });
20043 }
20044
20045 #[test]
20046 fn pijs_clear_timeout_prevents_timer_callback() {
20047 futures::executor::block_on(async {
20048 let clock = Arc::new(DeterministicClock::new(0));
20049 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20050 .await
20051 .expect("create runtime");
20052
20053 runtime
20054 .eval(r"globalThis.order = []; ")
20055 .await
20056 .expect("init order");
20057
20058 let timer_id = runtime.set_timeout(10);
20059 runtime
20060 .eval(&format!(
20061 r#"__pi_register_timer({timer_id}, () => globalThis.order.push("timer"));"#
20062 ))
20063 .await
20064 .expect("register timer");
20065
20066 assert!(runtime.clear_timeout(timer_id));
20067 clock.set(10);
20068
20069 let stats = runtime.tick().await.expect("tick");
20070 assert!(!stats.ran_macrotask);
20071
20072 let order = get_global_json(&runtime, "order").await;
20073 assert_eq!(order, serde_json::json!([]));
20074 });
20075 }
20076
20077 #[test]
20078 fn pijs_env_get_honors_allowlist() {
20079 futures::executor::block_on(async {
20080 let clock = Arc::new(DeterministicClock::new(0));
20081 let mut env = HashMap::new();
20082 env.insert("HOME".to_string(), "/virtual/home".to_string());
20083 env.insert("PI_IMAGE_SAVE_MODE".to_string(), "tmp".to_string());
20084 env.insert(
20085 "AWS_SECRET_ACCESS_KEY".to_string(),
20086 "nope-do-not-expose".to_string(),
20087 );
20088 let config = PiJsRuntimeConfig {
20089 cwd: "/virtual/cwd".to_string(),
20090 args: vec!["--flag".to_string()],
20091 env,
20092 limits: PiJsRuntimeLimits::default(),
20093 repair_mode: RepairMode::default(),
20094 allow_unsafe_sync_exec: false,
20095 deny_env: false,
20096 disk_cache_dir: None,
20097 };
20098 let runtime =
20099 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
20100 .await
20101 .expect("create runtime");
20102
20103 runtime
20104 .eval(
20105 r#"
20106 globalThis.home = pi.env.get("HOME");
20107 globalThis.mode = pi.env.get("PI_IMAGE_SAVE_MODE");
20108 globalThis.missing_is_undefined = (pi.env.get("NOPE") === undefined);
20109 globalThis.secret_is_undefined = (pi.env.get("AWS_SECRET_ACCESS_KEY") === undefined);
20110 globalThis.process_secret_is_undefined = (process.env.AWS_SECRET_ACCESS_KEY === undefined);
20111 globalThis.secret_in_env = ("AWS_SECRET_ACCESS_KEY" in process.env);
20112 "#,
20113 )
20114 .await
20115 .expect("eval env");
20116
20117 assert_eq!(
20118 get_global_json(&runtime, "home").await,
20119 serde_json::json!("/virtual/home")
20120 );
20121 assert_eq!(
20122 get_global_json(&runtime, "mode").await,
20123 serde_json::json!("tmp")
20124 );
20125 assert_eq!(
20126 get_global_json(&runtime, "missing_is_undefined").await,
20127 serde_json::json!(true)
20128 );
20129 assert_eq!(
20130 get_global_json(&runtime, "secret_is_undefined").await,
20131 serde_json::json!(true)
20132 );
20133 assert_eq!(
20134 get_global_json(&runtime, "process_secret_is_undefined").await,
20135 serde_json::json!(true)
20136 );
20137 assert_eq!(
20138 get_global_json(&runtime, "secret_in_env").await,
20139 serde_json::json!(false)
20140 );
20141 });
20142 }
20143
20144 #[test]
20145 fn pijs_process_path_crypto_time_apis_smoke() {
20146 futures::executor::block_on(async {
20147 let clock = Arc::new(DeterministicClock::new(123));
20148 let config = PiJsRuntimeConfig {
20149 cwd: "/virtual/cwd".to_string(),
20150 args: vec!["a".to_string(), "b".to_string()],
20151 env: HashMap::new(),
20152 limits: PiJsRuntimeLimits::default(),
20153 repair_mode: RepairMode::default(),
20154 allow_unsafe_sync_exec: false,
20155 deny_env: false,
20156 disk_cache_dir: None,
20157 };
20158 let runtime =
20159 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
20160 .await
20161 .expect("create runtime");
20162
20163 runtime
20164 .eval(
20165 r#"
20166 globalThis.cwd = pi.process.cwd;
20167 globalThis.args = pi.process.args;
20168 globalThis.pi_process_is_frozen = Object.isFrozen(pi.process);
20169 globalThis.pi_args_is_frozen = Object.isFrozen(pi.process.args);
20170 try { pi.process.cwd = "/hacked"; } catch (_) {}
20171 try { pi.process.args.push("c"); } catch (_) {}
20172 globalThis.cwd_after_mut = pi.process.cwd;
20173 globalThis.args_after_mut = pi.process.args;
20174
20175 globalThis.joined = pi.path.join("/a", "b", "..", "c");
20176 globalThis.base = pi.path.basename("/a/b/c.txt");
20177 globalThis.norm = pi.path.normalize("/a/./b//../c/");
20178
20179 globalThis.hash = pi.crypto.sha256Hex("abc");
20180 globalThis.bytes = pi.crypto.randomBytes(32);
20181
20182 globalThis.now = pi.time.nowMs();
20183 globalThis.done = false;
20184 pi.time.sleep(10).then(() => { globalThis.done = true; });
20185 "#,
20186 )
20187 .await
20188 .expect("eval apis");
20189
20190 for (key, expected) in [
20191 ("cwd", serde_json::json!("/virtual/cwd")),
20192 ("args", serde_json::json!(["a", "b"])),
20193 ("pi_process_is_frozen", serde_json::json!(true)),
20194 ("pi_args_is_frozen", serde_json::json!(true)),
20195 ("cwd_after_mut", serde_json::json!("/virtual/cwd")),
20196 ("args_after_mut", serde_json::json!(["a", "b"])),
20197 ("joined", serde_json::json!("/a/c")),
20198 ("base", serde_json::json!("c.txt")),
20199 ("norm", serde_json::json!("/a/c")),
20200 (
20201 "hash",
20202 serde_json::json!(
20203 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
20204 ),
20205 ),
20206 ] {
20207 assert_eq!(get_global_json(&runtime, key).await, expected);
20208 }
20209
20210 let bytes = get_global_json(&runtime, "bytes").await;
20211 let bytes_arr = bytes.as_array().expect("bytes array");
20212 assert_eq!(bytes_arr.len(), 32);
20213 assert!(
20214 bytes_arr
20215 .iter()
20216 .all(|value| value.as_u64().is_some_and(|n| n <= 255)),
20217 "bytes must be numbers in 0..=255: {bytes}"
20218 );
20219
20220 assert_eq!(
20221 get_global_json(&runtime, "now").await,
20222 serde_json::json!(123)
20223 );
20224 assert_eq!(
20225 get_global_json(&runtime, "done").await,
20226 serde_json::json!(false)
20227 );
20228
20229 clock.set(133);
20230 runtime.tick().await.expect("tick sleep");
20231 assert_eq!(
20232 get_global_json(&runtime, "done").await,
20233 serde_json::json!(true)
20234 );
20235 });
20236 }
20237
20238 #[test]
20239 fn pijs_inbound_event_fifo_and_microtask_fixpoint() {
20240 futures::executor::block_on(async {
20241 let clock = Arc::new(DeterministicClock::new(0));
20242 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20243 .await
20244 .expect("create runtime");
20245
20246 runtime
20247 .eval(
20248 r#"
20249 globalThis.order = [];
20250 __pi_add_event_listener("evt", (payload) => {
20251 globalThis.order.push(payload.n);
20252 Promise.resolve().then(() => globalThis.order.push(payload.n + 1000));
20253 });
20254 "#,
20255 )
20256 .await
20257 .expect("install listener");
20258
20259 runtime.enqueue_event("evt", serde_json::json!({ "n": 1 }));
20260 runtime.enqueue_event("evt", serde_json::json!({ "n": 2 }));
20261
20262 runtime.tick().await.expect("tick 1");
20263 let after_first = get_global_json(&runtime, "order").await;
20264 assert_eq!(after_first, serde_json::json!([1, 1001]));
20265
20266 runtime.tick().await.expect("tick 2");
20267 let after_second = get_global_json(&runtime, "order").await;
20268 assert_eq!(after_second, serde_json::json!([1, 1001, 2, 1002]));
20269 });
20270 }
20271
20272 #[derive(Debug, Clone)]
20273 struct XorShift64 {
20274 state: u64,
20275 }
20276
20277 impl XorShift64 {
20278 const fn new(seed: u64) -> Self {
20279 let seed = seed ^ 0x9E37_79B9_7F4A_7C15;
20280 Self { state: seed }
20281 }
20282
20283 fn next_u64(&mut self) -> u64 {
20284 let mut x = self.state;
20285 x ^= x << 13;
20286 x ^= x >> 7;
20287 x ^= x << 17;
20288 self.state = x;
20289 x
20290 }
20291
20292 fn next_range_u64(&mut self, upper_exclusive: u64) -> u64 {
20293 if upper_exclusive == 0 {
20294 return 0;
20295 }
20296 self.next_u64() % upper_exclusive
20297 }
20298
20299 fn next_usize(&mut self, upper_exclusive: usize) -> usize {
20300 let upper = u64::try_from(upper_exclusive).expect("usize fits u64");
20301 let value = self.next_range_u64(upper);
20302 usize::try_from(value).expect("value < upper_exclusive")
20303 }
20304 }
20305
20306 #[allow(clippy::future_not_send)]
20307 async fn run_seeded_runtime_trace(seed: u64) -> serde_json::Value {
20308 let clock = Arc::new(DeterministicClock::new(0));
20309 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20310 .await
20311 .expect("create runtime");
20312
20313 runtime
20314 .eval(
20315 r#"
20316 globalThis.order = [];
20317 __pi_add_event_listener("evt", (payload) => {
20318 globalThis.order.push("event:" + payload.step);
20319 Promise.resolve().then(() => globalThis.order.push("event-micro:" + payload.step));
20320 });
20321 "#,
20322 )
20323 .await
20324 .expect("init");
20325
20326 let mut rng = XorShift64::new(seed);
20327 let mut timers = Vec::new();
20328
20329 for step in 0..64u64 {
20330 match rng.next_range_u64(6) {
20331 0 => {
20332 runtime
20333 .eval(&format!(
20334 r#"
20335 pi.tool("test", {{ step: {step} }}).then(() => {{
20336 globalThis.order.push("hostcall:{step}");
20337 Promise.resolve().then(() => globalThis.order.push("hostcall-micro:{step}"));
20338 }});
20339 "#
20340 ))
20341 .await
20342 .expect("enqueue hostcall");
20343
20344 for request in runtime.drain_hostcall_requests() {
20345 runtime.complete_hostcall(
20346 request.call_id,
20347 HostcallOutcome::Success(serde_json::json!({ "step": step })),
20348 );
20349 }
20350 }
20351 1 => {
20352 let delay_ms = rng.next_range_u64(25);
20353 let timer_id = runtime.set_timeout(delay_ms);
20354 timers.push(timer_id);
20355 runtime
20356 .eval(&format!(
20357 r#"__pi_register_timer({timer_id}, () => {{
20358 globalThis.order.push("timer:{step}");
20359 Promise.resolve().then(() => globalThis.order.push("timer-micro:{step}"));
20360 }});"#
20361 ))
20362 .await
20363 .expect("register timer");
20364 }
20365 2 => {
20366 runtime.enqueue_event("evt", serde_json::json!({ "step": step }));
20367 }
20368 3 => {
20369 if !timers.is_empty() {
20370 let idx = rng.next_usize(timers.len());
20371 let _ = runtime.clear_timeout(timers[idx]);
20372 }
20373 }
20374 4 => {
20375 let delta_ms = rng.next_range_u64(50);
20376 clock.advance(delta_ms);
20377 }
20378 _ => {}
20379 }
20380
20381 for _ in 0..3 {
20383 if !runtime.has_pending() {
20384 break;
20385 }
20386 let _ = runtime.tick().await.expect("tick");
20387 }
20388 }
20389
20390 drain_until_idle(&runtime, &clock).await;
20391 get_global_json(&runtime, "order").await
20392 }
20393
20394 #[test]
20395 fn pijs_seeded_trace_is_deterministic() {
20396 futures::executor::block_on(async {
20397 let a = run_seeded_runtime_trace(0x00C0_FFEE).await;
20398 let b = run_seeded_runtime_trace(0x00C0_FFEE).await;
20399 assert_eq!(a, b);
20400 });
20401 }
20402
20403 #[test]
20404 fn pijs_events_on_returns_unsubscribe_and_removes_handler() {
20405 futures::executor::block_on(async {
20406 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20407 .await
20408 .expect("create runtime");
20409
20410 runtime
20411 .eval(
20412 r#"
20413 globalThis.seen = [];
20414 globalThis.done = false;
20415
20416 __pi_begin_extension("ext.b", { name: "ext.b" });
20417 const off = pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
20418 if (typeof off !== "function") throw new Error("expected unsubscribe function");
20419 __pi_end_extension();
20420
20421 (async () => {
20422 await __pi_dispatch_extension_event("custom_event", { n: 1 }, {});
20423 off();
20424 await __pi_dispatch_extension_event("custom_event", { n: 2 }, {});
20425 globalThis.done = true;
20426 })();
20427 "#,
20428 )
20429 .await
20430 .expect("eval");
20431
20432 assert_eq!(
20433 get_global_json(&runtime, "done").await,
20434 serde_json::Value::Bool(true)
20435 );
20436 assert_eq!(
20437 get_global_json(&runtime, "seen").await,
20438 serde_json::json!([{ "n": 1 }])
20439 );
20440 });
20441 }
20442
20443 #[test]
20444 fn pijs_event_dispatch_continues_after_handler_error() {
20445 futures::executor::block_on(async {
20446 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20447 .await
20448 .expect("create runtime");
20449
20450 runtime
20451 .eval(
20452 r#"
20453 globalThis.seen = [];
20454 globalThis.done = false;
20455
20456 __pi_begin_extension("ext.err", { name: "ext.err" });
20457 pi.events.on("custom_event", (_payload, _ctx) => { throw new Error("boom"); });
20458 __pi_end_extension();
20459
20460 __pi_begin_extension("ext.ok", { name: "ext.ok" });
20461 pi.events.on("custom_event", (payload, _ctx) => { globalThis.seen.push(payload); });
20462 __pi_end_extension();
20463
20464 (async () => {
20465 await __pi_dispatch_extension_event("custom_event", { hello: "world" }, {});
20466 globalThis.done = true;
20467 })();
20468 "#,
20469 )
20470 .await
20471 .expect("eval");
20472
20473 assert_eq!(
20474 get_global_json(&runtime, "done").await,
20475 serde_json::Value::Bool(true)
20476 );
20477 assert_eq!(
20478 get_global_json(&runtime, "seen").await,
20479 serde_json::json!([{ "hello": "world" }])
20480 );
20481 });
20482 }
20483
20484 #[test]
20487 fn pijs_crash_register_throw_host_continues() {
20488 futures::executor::block_on(async {
20489 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20490 .await
20491 .expect("create runtime");
20492
20493 runtime
20495 .eval(
20496 r#"
20497 globalThis.postCrashResult = null;
20498
20499 __pi_begin_extension("ext.crash", { name: "ext.crash" });
20500 // Simulate a throw during registration by registering a handler then
20501 // throwing - the handler should still be partially registered
20502 throw new Error("registration boom");
20503 "#,
20504 )
20505 .await
20506 .ok(); runtime.eval(r"__pi_end_extension();").await.ok();
20510
20511 runtime
20513 .eval(
20514 r#"
20515 __pi_begin_extension("ext.ok", { name: "ext.ok" });
20516 pi.events.on("test_event", (p, _) => { globalThis.postCrashResult = p; });
20517 __pi_end_extension();
20518 "#,
20519 )
20520 .await
20521 .expect("second extension should load");
20522
20523 runtime
20525 .eval(
20526 r#"
20527 (async () => {
20528 await __pi_dispatch_extension_event("test_event", { ok: true }, {});
20529 })();
20530 "#,
20531 )
20532 .await
20533 .expect("dispatch");
20534
20535 assert_eq!(
20536 get_global_json(&runtime, "postCrashResult").await,
20537 serde_json::json!({ "ok": true })
20538 );
20539 });
20540 }
20541
20542 #[test]
20543 fn pijs_crash_handler_throw_other_handlers_run() {
20544 futures::executor::block_on(async {
20545 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20546 .await
20547 .expect("create runtime");
20548
20549 runtime
20550 .eval(
20551 r#"
20552 globalThis.handlerResults = [];
20553 globalThis.dispatchDone = false;
20554
20555 // Extension A: will throw
20556 __pi_begin_extension("ext.a", { name: "ext.a" });
20557 pi.events.on("multi_test", (_p, _c) => {
20558 globalThis.handlerResults.push("a-before-throw");
20559 throw new Error("handler crash");
20560 });
20561 __pi_end_extension();
20562
20563 // Extension B: should still run
20564 __pi_begin_extension("ext.b", { name: "ext.b" });
20565 pi.events.on("multi_test", (_p, _c) => {
20566 globalThis.handlerResults.push("b-ok");
20567 });
20568 __pi_end_extension();
20569
20570 // Extension C: should also still run
20571 __pi_begin_extension("ext.c", { name: "ext.c" });
20572 pi.events.on("multi_test", (_p, _c) => {
20573 globalThis.handlerResults.push("c-ok");
20574 });
20575 __pi_end_extension();
20576
20577 (async () => {
20578 await __pi_dispatch_extension_event("multi_test", {}, {});
20579 globalThis.dispatchDone = true;
20580 })();
20581 "#,
20582 )
20583 .await
20584 .expect("eval");
20585
20586 assert_eq!(
20587 get_global_json(&runtime, "dispatchDone").await,
20588 serde_json::Value::Bool(true)
20589 );
20590
20591 let results = get_global_json(&runtime, "handlerResults").await;
20592 let arr = results.as_array().expect("should be array");
20593 assert!(
20595 arr.iter().any(|v| v == "a-before-throw"),
20596 "Handler A should have run before throwing"
20597 );
20598 assert!(
20600 arr.iter().any(|v| v == "b-ok"),
20601 "Handler B should run after A crashes"
20602 );
20603 assert!(
20604 arr.iter().any(|v| v == "c-ok"),
20605 "Handler C should run after A crashes"
20606 );
20607 });
20608 }
20609
20610 #[test]
20611 fn pijs_crash_invalid_hostcall_returns_error_not_panic() {
20612 futures::executor::block_on(async {
20613 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20614 .await
20615 .expect("create runtime");
20616
20617 runtime
20619 .eval(
20620 r#"
20621 globalThis.invalidResult = null;
20622 globalThis.errCode = null;
20623
20624 __pi_begin_extension("ext.bad", { name: "ext.bad" });
20625 pi.tool("completely_nonexistent_tool_xyz", { junk: true })
20626 .then((r) => { globalThis.invalidResult = r; })
20627 .catch((e) => { globalThis.errCode = e.code || "unknown"; });
20628 __pi_end_extension();
20629 "#,
20630 )
20631 .await
20632 .expect("eval");
20633
20634 let requests = runtime.drain_hostcall_requests();
20636 assert_eq!(requests.len(), 1, "Hostcall should be queued");
20637
20638 runtime
20640 .eval(
20641 r"
20642 globalThis.hostStillAlive = true;
20643 ",
20644 )
20645 .await
20646 .expect("host should still work");
20647
20648 assert_eq!(
20649 get_global_json(&runtime, "hostStillAlive").await,
20650 serde_json::Value::Bool(true)
20651 );
20652 });
20653 }
20654
20655 #[test]
20656 fn pijs_crash_after_crash_new_extensions_load() {
20657 futures::executor::block_on(async {
20658 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20659 .await
20660 .expect("create runtime");
20661
20662 runtime
20664 .eval(
20665 r#"
20666 globalThis.loadOrder = [];
20667
20668 // Extension 1: loads fine
20669 __pi_begin_extension("ext.1", { name: "ext.1" });
20670 globalThis.loadOrder.push("1-loaded");
20671 __pi_end_extension();
20672 "#,
20673 )
20674 .await
20675 .expect("ext 1");
20676
20677 runtime
20679 .eval(
20680 r#"
20681 __pi_begin_extension("ext.2", { name: "ext.2" });
20682 globalThis.loadOrder.push("2-before-crash");
20683 throw new Error("ext 2 crash");
20684 "#,
20685 )
20686 .await
20687 .ok(); runtime.eval(r"__pi_end_extension();").await.ok();
20690
20691 runtime
20693 .eval(
20694 r#"
20695 __pi_begin_extension("ext.3", { name: "ext.3" });
20696 globalThis.loadOrder.push("3-loaded");
20697 __pi_end_extension();
20698 "#,
20699 )
20700 .await
20701 .expect("ext 3 should load after crash");
20702
20703 runtime
20705 .eval(
20706 r#"
20707 __pi_begin_extension("ext.4", { name: "ext.4" });
20708 globalThis.loadOrder.push("4-loaded");
20709 __pi_end_extension();
20710 "#,
20711 )
20712 .await
20713 .expect("ext 4 should load");
20714
20715 let order = get_global_json(&runtime, "loadOrder").await;
20716 let arr = order.as_array().expect("should be array");
20717 assert!(
20718 arr.iter().any(|v| v == "1-loaded"),
20719 "Extension 1 should have loaded"
20720 );
20721 assert!(
20722 arr.iter().any(|v| v == "3-loaded"),
20723 "Extension 3 should load after crash"
20724 );
20725 assert!(
20726 arr.iter().any(|v| v == "4-loaded"),
20727 "Extension 4 should load after crash"
20728 );
20729 });
20730 }
20731
20732 #[test]
20733 fn pijs_crash_no_cross_contamination_between_extensions() {
20734 futures::executor::block_on(async {
20735 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20736 .await
20737 .expect("create runtime");
20738
20739 runtime
20740 .eval(
20741 r#"
20742 globalThis.extAData = null;
20743 globalThis.extBData = null;
20744 globalThis.eventsDone = false;
20745
20746 // Extension A: sets its own state
20747 __pi_begin_extension("ext.isolated.a", { name: "ext.isolated.a" });
20748 pi.events.on("isolation_test", (_p, _c) => {
20749 globalThis.extAData = "from-A";
20750 });
20751 __pi_end_extension();
20752
20753 // Extension B: sets its own state independently
20754 __pi_begin_extension("ext.isolated.b", { name: "ext.isolated.b" });
20755 pi.events.on("isolation_test", (_p, _c) => {
20756 globalThis.extBData = "from-B";
20757 });
20758 __pi_end_extension();
20759
20760 (async () => {
20761 await __pi_dispatch_extension_event("isolation_test", {}, {});
20762 globalThis.eventsDone = true;
20763 })();
20764 "#,
20765 )
20766 .await
20767 .expect("eval");
20768
20769 assert_eq!(
20770 get_global_json(&runtime, "eventsDone").await,
20771 serde_json::Value::Bool(true)
20772 );
20773 assert_eq!(
20775 get_global_json(&runtime, "extAData").await,
20776 serde_json::json!("from-A")
20777 );
20778 assert_eq!(
20779 get_global_json(&runtime, "extBData").await,
20780 serde_json::json!("from-B")
20781 );
20782 });
20783 }
20784
20785 #[test]
20786 fn pijs_crash_interrupt_budget_stops_infinite_loop() {
20787 futures::executor::block_on(async {
20788 let config = PiJsRuntimeConfig {
20789 limits: PiJsRuntimeLimits {
20790 interrupt_budget: Some(1000),
20792 ..Default::default()
20793 },
20794 ..Default::default()
20795 };
20796 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
20797 DeterministicClock::new(0),
20798 config,
20799 None,
20800 )
20801 .await
20802 .expect("create runtime");
20803
20804 let result = runtime
20806 .eval(
20807 r"
20808 let i = 0;
20809 while (true) { i++; }
20810 globalThis.loopResult = i;
20811 ",
20812 )
20813 .await;
20814
20815 assert!(
20817 result.is_err(),
20818 "Infinite loop should be interrupted by budget"
20819 );
20820
20821 let alive_result = runtime.eval(r#"globalThis.postInterrupt = "alive";"#).await;
20823 if alive_result.is_ok() {
20826 assert_eq!(
20827 get_global_json(&runtime, "postInterrupt").await,
20828 serde_json::json!("alive")
20829 );
20830 }
20831 });
20832 }
20833
20834 #[test]
20835 fn pijs_events_emit_queues_events_hostcall() {
20836 futures::executor::block_on(async {
20837 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
20838 .await
20839 .expect("create runtime");
20840
20841 runtime
20842 .eval(
20843 r#"
20844 __pi_begin_extension("ext.test", { name: "Test" });
20845 pi.events.emit("custom_event", { a: 1 });
20846 __pi_end_extension();
20847 "#,
20848 )
20849 .await
20850 .expect("eval");
20851
20852 let requests = runtime.drain_hostcall_requests();
20853 assert_eq!(requests.len(), 1);
20854
20855 let req = &requests[0];
20856 assert_eq!(req.extension_id.as_deref(), Some("ext.test"));
20857 assert!(
20858 matches!(&req.kind, HostcallKind::Events { op } if op == "emit"),
20859 "unexpected hostcall kind: {:?}",
20860 req.kind
20861 );
20862 assert_eq!(
20863 req.payload,
20864 serde_json::json!({ "event": "custom_event", "data": { "a": 1 } })
20865 );
20866 });
20867 }
20868
20869 #[test]
20870 fn pijs_console_global_is_defined_and_callable() {
20871 futures::executor::block_on(async {
20872 let clock = Arc::new(DeterministicClock::new(0));
20873 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20874 .await
20875 .expect("create runtime");
20876
20877 runtime
20879 .eval(
20880 r"
20881 globalThis.console_exists = typeof globalThis.console === 'object';
20882 globalThis.has_log = typeof console.log === 'function';
20883 globalThis.has_warn = typeof console.warn === 'function';
20884 globalThis.has_error = typeof console.error === 'function';
20885 globalThis.has_info = typeof console.info === 'function';
20886 globalThis.has_debug = typeof console.debug === 'function';
20887 globalThis.has_trace = typeof console.trace === 'function';
20888 globalThis.has_dir = typeof console.dir === 'function';
20889 globalThis.has_assert = typeof console.assert === 'function';
20890 globalThis.has_table = typeof console.table === 'function';
20891
20892 // Call each method to ensure they don't throw
20893 console.log('test log', 42, { key: 'value' });
20894 console.warn('test warn');
20895 console.error('test error');
20896 console.info('test info');
20897 console.debug('test debug');
20898 console.trace('test trace');
20899 console.dir({ a: 1 });
20900 console.assert(true, 'should not appear');
20901 console.assert(false, 'assertion failed message');
20902 console.table([1, 2, 3]);
20903 console.time();
20904 console.timeEnd();
20905 console.group();
20906 console.groupEnd();
20907 console.clear();
20908
20909 globalThis.calls_succeeded = true;
20910 ",
20911 )
20912 .await
20913 .expect("eval console tests");
20914
20915 assert_eq!(
20916 get_global_json(&runtime, "console_exists").await,
20917 serde_json::json!(true)
20918 );
20919 assert_eq!(
20920 get_global_json(&runtime, "has_log").await,
20921 serde_json::json!(true)
20922 );
20923 assert_eq!(
20924 get_global_json(&runtime, "has_warn").await,
20925 serde_json::json!(true)
20926 );
20927 assert_eq!(
20928 get_global_json(&runtime, "has_error").await,
20929 serde_json::json!(true)
20930 );
20931 assert_eq!(
20932 get_global_json(&runtime, "has_info").await,
20933 serde_json::json!(true)
20934 );
20935 assert_eq!(
20936 get_global_json(&runtime, "has_debug").await,
20937 serde_json::json!(true)
20938 );
20939 assert_eq!(
20940 get_global_json(&runtime, "has_trace").await,
20941 serde_json::json!(true)
20942 );
20943 assert_eq!(
20944 get_global_json(&runtime, "has_dir").await,
20945 serde_json::json!(true)
20946 );
20947 assert_eq!(
20948 get_global_json(&runtime, "has_assert").await,
20949 serde_json::json!(true)
20950 );
20951 assert_eq!(
20952 get_global_json(&runtime, "has_table").await,
20953 serde_json::json!(true)
20954 );
20955 assert_eq!(
20956 get_global_json(&runtime, "calls_succeeded").await,
20957 serde_json::json!(true)
20958 );
20959 });
20960 }
20961
20962 #[test]
20963 fn pijs_node_events_module_provides_event_emitter() {
20964 futures::executor::block_on(async {
20965 let clock = Arc::new(DeterministicClock::new(0));
20966 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
20967 .await
20968 .expect("create runtime");
20969
20970 runtime
20972 .eval(
20973 r"
20974 globalThis.results = [];
20975 globalThis.testDone = false;
20976
20977 import('node:events').then(({ EventEmitter }) => {
20978 const emitter = new EventEmitter();
20979
20980 emitter.on('data', (val) => globalThis.results.push('data:' + val));
20981 emitter.once('done', () => globalThis.results.push('done'));
20982
20983 emitter.emit('data', 1);
20984 emitter.emit('data', 2);
20985 emitter.emit('done');
20986 emitter.emit('done'); // should not fire again
20987
20988 globalThis.listenerCount = emitter.listenerCount('data');
20989 globalThis.eventNames = emitter.eventNames();
20990 globalThis.testDone = true;
20991 });
20992 ",
20993 )
20994 .await
20995 .expect("eval EventEmitter test");
20996
20997 assert_eq!(
20998 get_global_json(&runtime, "testDone").await,
20999 serde_json::json!(true)
21000 );
21001 assert_eq!(
21002 get_global_json(&runtime, "results").await,
21003 serde_json::json!(["data:1", "data:2", "done"])
21004 );
21005 assert_eq!(
21006 get_global_json(&runtime, "listenerCount").await,
21007 serde_json::json!(1)
21008 );
21009 assert_eq!(
21010 get_global_json(&runtime, "eventNames").await,
21011 serde_json::json!(["data"])
21012 );
21013 });
21014 }
21015
21016 #[test]
21017 fn pijs_bare_module_aliases_resolve_correctly() {
21018 futures::executor::block_on(async {
21019 let clock = Arc::new(DeterministicClock::new(0));
21020 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21021 .await
21022 .expect("create runtime");
21023
21024 runtime
21026 .eval(
21027 r"
21028 globalThis.bare_events_ok = false;
21029 import('events').then((mod) => {
21030 const e = new mod.default();
21031 globalThis.bare_events_ok = typeof e.on === 'function';
21032 });
21033 ",
21034 )
21035 .await
21036 .expect("eval bare events import");
21037
21038 assert_eq!(
21039 get_global_json(&runtime, "bare_events_ok").await,
21040 serde_json::json!(true)
21041 );
21042 });
21043 }
21044
21045 #[test]
21046 fn pijs_path_extended_functions() {
21047 futures::executor::block_on(async {
21048 let clock = Arc::new(DeterministicClock::new(0));
21049 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21050 .await
21051 .expect("create runtime");
21052
21053 runtime
21054 .eval(
21055 r"
21056 globalThis.pathResults = {};
21057 import('node:path').then((path) => {
21058 globalThis.pathResults.isAbsRoot = path.isAbsolute('/foo/bar');
21059 globalThis.pathResults.isAbsRel = path.isAbsolute('foo/bar');
21060 globalThis.pathResults.extJs = path.extname('/a/b/file.js');
21061 globalThis.pathResults.extNone = path.extname('/a/b/noext');
21062 globalThis.pathResults.extDot = path.extname('.hidden');
21063 globalThis.pathResults.norm = path.normalize('/a/b/../c/./d');
21064 globalThis.pathResults.parseBase = path.parse('/home/user/file.txt').base;
21065 globalThis.pathResults.parseExt = path.parse('/home/user/file.txt').ext;
21066 globalThis.pathResults.parseName = path.parse('/home/user/file.txt').name;
21067 globalThis.pathResults.parseDir = path.parse('/home/user/file.txt').dir;
21068 globalThis.pathResults.hasPosix = typeof path.posix === 'object';
21069 globalThis.pathResults.done = true;
21070 });
21071 ",
21072 )
21073 .await
21074 .expect("eval path extended");
21075
21076 let r = get_global_json(&runtime, "pathResults").await;
21077 assert_eq!(r["done"], serde_json::json!(true));
21078 assert_eq!(r["isAbsRoot"], serde_json::json!(true));
21079 assert_eq!(r["isAbsRel"], serde_json::json!(false));
21080 assert_eq!(r["extJs"], serde_json::json!(".js"));
21081 assert_eq!(r["extNone"], serde_json::json!(""));
21082 assert_eq!(r["extDot"], serde_json::json!(""));
21083 assert_eq!(r["norm"], serde_json::json!("/a/c/d"));
21084 assert_eq!(r["parseBase"], serde_json::json!("file.txt"));
21085 assert_eq!(r["parseExt"], serde_json::json!(".txt"));
21086 assert_eq!(r["parseName"], serde_json::json!("file"));
21087 assert_eq!(r["parseDir"], serde_json::json!("/home/user"));
21088 assert_eq!(r["hasPosix"], serde_json::json!(true));
21089 });
21090 }
21091
21092 #[test]
21093 fn pijs_fs_callback_apis() {
21094 futures::executor::block_on(async {
21095 let clock = Arc::new(DeterministicClock::new(0));
21096 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21097 .await
21098 .expect("create runtime");
21099
21100 runtime
21101 .eval(
21102 r"
21103 globalThis.fsResults = {};
21104 import('node:fs').then((fs) => {
21105 fs.writeFileSync('/fake', '');
21106 // readFile callback
21107 fs.readFile('/fake', 'utf8', (err, data) => {
21108 globalThis.fsResults.readFileCallbackCalled = true;
21109 globalThis.fsResults.readFileData = data;
21110 });
21111 // writeFile callback
21112 fs.writeFile('/fake', 'data', (err) => {
21113 globalThis.fsResults.writeFileCallbackCalled = true;
21114 });
21115 // accessSync throws
21116 try {
21117 fs.accessSync('/nonexistent');
21118 globalThis.fsResults.accessSyncThrew = false;
21119 } catch (e) {
21120 globalThis.fsResults.accessSyncThrew = true;
21121 }
21122 // access callback with error
21123 fs.access('/nonexistent', (err) => {
21124 globalThis.fsResults.accessCallbackErr = !!err;
21125 });
21126 globalThis.fsResults.hasLstatSync = typeof fs.lstatSync === 'function';
21127 globalThis.fsResults.done = true;
21128 });
21129 ",
21130 )
21131 .await
21132 .expect("eval fs callbacks");
21133
21134 let r = get_global_json(&runtime, "fsResults").await;
21135 assert_eq!(r["done"], serde_json::json!(true));
21136 assert_eq!(r["readFileCallbackCalled"], serde_json::json!(true));
21137 assert_eq!(r["readFileData"], serde_json::json!(""));
21138 assert_eq!(r["writeFileCallbackCalled"], serde_json::json!(true));
21139 assert_eq!(r["accessSyncThrew"], serde_json::json!(true));
21140 assert_eq!(r["accessCallbackErr"], serde_json::json!(true));
21141 assert_eq!(r["hasLstatSync"], serde_json::json!(true));
21142 });
21143 }
21144
21145 #[test]
21146 fn pijs_fs_sync_roundtrip_and_dirents() {
21147 futures::executor::block_on(async {
21148 let clock = Arc::new(DeterministicClock::new(0));
21149 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21150 .await
21151 .expect("create runtime");
21152
21153 runtime
21154 .eval(
21155 r"
21156 globalThis.fsRoundTrip = {};
21157 import('node:fs').then((fs) => {
21158 fs.mkdirSync('/tmp/demo', { recursive: true });
21159 fs.writeFileSync('/tmp/demo/hello.txt', 'hello world');
21160 fs.writeFileSync('/tmp/demo/raw.bin', Buffer.from([1, 2, 3, 4]));
21161
21162 globalThis.fsRoundTrip.exists = fs.existsSync('/tmp/demo/hello.txt');
21163 globalThis.fsRoundTrip.readText = fs.readFileSync('/tmp/demo/hello.txt', 'utf8');
21164 const raw = fs.readFileSync('/tmp/demo/raw.bin');
21165 globalThis.fsRoundTrip.rawLen = raw.length;
21166
21167 const names = fs.readdirSync('/tmp/demo');
21168 globalThis.fsRoundTrip.names = names;
21169
21170 const dirents = fs.readdirSync('/tmp/demo', { withFileTypes: true });
21171 globalThis.fsRoundTrip.direntHasMethods =
21172 typeof dirents[0].isFile === 'function' &&
21173 typeof dirents[0].isDirectory === 'function';
21174
21175 const dirStat = fs.statSync('/tmp/demo');
21176 const fileStat = fs.statSync('/tmp/demo/hello.txt');
21177 globalThis.fsRoundTrip.isDir = dirStat.isDirectory();
21178 globalThis.fsRoundTrip.isFile = fileStat.isFile();
21179 globalThis.fsRoundTrip.done = true;
21180 });
21181 ",
21182 )
21183 .await
21184 .expect("eval fs sync roundtrip");
21185
21186 let r = get_global_json(&runtime, "fsRoundTrip").await;
21187 assert_eq!(r["done"], serde_json::json!(true));
21188 assert_eq!(r["exists"], serde_json::json!(true));
21189 assert_eq!(r["readText"], serde_json::json!("hello world"));
21190 assert_eq!(r["rawLen"], serde_json::json!(4));
21191 assert_eq!(r["isDir"], serde_json::json!(true));
21192 assert_eq!(r["isFile"], serde_json::json!(true));
21193 assert_eq!(r["direntHasMethods"], serde_json::json!(true));
21194 assert_eq!(r["names"], serde_json::json!(["hello.txt", "raw.bin"]));
21195 });
21196 }
21197
21198 #[test]
21199 fn pijs_create_require_supports_node_builtins() {
21200 futures::executor::block_on(async {
21201 let clock = Arc::new(DeterministicClock::new(0));
21202 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21203 .await
21204 .expect("create runtime");
21205
21206 runtime
21207 .eval(
21208 r"
21209 globalThis.requireResults = {};
21210 import('node:module').then(({ createRequire }) => {
21211 const require = createRequire('/tmp/example.js');
21212 const path = require('path');
21213 const fs = require('node:fs');
21214 const crypto = require('crypto');
21215 const http2 = require('http2');
21216
21217 globalThis.requireResults.pathJoinWorks = path.join('a', 'b') === 'a/b';
21218 globalThis.requireResults.fsReadFileSync = typeof fs.readFileSync === 'function';
21219 globalThis.requireResults.cryptoHasRandomUUID = typeof crypto.randomUUID === 'function';
21220 globalThis.requireResults.http2HasConnect = typeof http2.connect === 'function';
21221 globalThis.requireResults.http2PathHeader = http2.constants.HTTP2_HEADER_PATH;
21222
21223 try {
21224 const missing = require('left-pad');
21225 globalThis.requireResults.missingModuleThrows = false;
21226 globalThis.requireResults.missingModuleIsStub =
21227 typeof missing === 'function' &&
21228 typeof missing.default === 'function' &&
21229 typeof missing.anyNestedProperty === 'function';
21230 } catch (err) {
21231 globalThis.requireResults.missingModuleThrows = true;
21232 globalThis.requireResults.missingModuleIsStub = false;
21233 }
21234 globalThis.requireResults.done = true;
21235 });
21236 ",
21237 )
21238 .await
21239 .expect("eval createRequire test");
21240
21241 let r = get_global_json(&runtime, "requireResults").await;
21242 assert_eq!(r["done"], serde_json::json!(true));
21243 assert_eq!(r["pathJoinWorks"], serde_json::json!(true));
21244 assert_eq!(r["fsReadFileSync"], serde_json::json!(true));
21245 assert_eq!(r["cryptoHasRandomUUID"], serde_json::json!(true));
21246 assert_eq!(r["http2HasConnect"], serde_json::json!(true));
21247 assert_eq!(r["http2PathHeader"], serde_json::json!(":path"));
21248 assert_eq!(r["missingModuleThrows"], serde_json::json!(false));
21249 assert_eq!(r["missingModuleIsStub"], serde_json::json!(true));
21250 });
21251 }
21252
21253 #[test]
21254 fn pijs_fs_promises_delegates_to_node_fs_promises_api() {
21255 futures::executor::block_on(async {
21256 let clock = Arc::new(DeterministicClock::new(0));
21257 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21258 .await
21259 .expect("create runtime");
21260
21261 runtime
21262 .eval(
21263 r"
21264 globalThis.fsPromisesResults = {};
21265 import('node:fs/promises').then(async (fsp) => {
21266 await fsp.mkdir('/tmp/promise-demo', { recursive: true });
21267 await fsp.writeFile('/tmp/promise-demo/value.txt', 'value');
21268 const text = await fsp.readFile('/tmp/promise-demo/value.txt', 'utf8');
21269 const names = await fsp.readdir('/tmp/promise-demo');
21270
21271 globalThis.fsPromisesResults.readText = text;
21272 globalThis.fsPromisesResults.names = names;
21273 globalThis.fsPromisesResults.done = true;
21274 });
21275 ",
21276 )
21277 .await
21278 .expect("eval fs promises test");
21279
21280 let r = get_global_json(&runtime, "fsPromisesResults").await;
21281 assert_eq!(r["done"], serde_json::json!(true));
21282 assert_eq!(r["readText"], serde_json::json!("value"));
21283 assert_eq!(r["names"], serde_json::json!(["value.txt"]));
21284 });
21285 }
21286
21287 #[test]
21288 fn pijs_child_process_spawn_emits_data_and_close() {
21289 futures::executor::block_on(async {
21290 let clock = Arc::new(DeterministicClock::new(0));
21291 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21292 .await
21293 .expect("create runtime");
21294
21295 runtime
21296 .eval(
21297 r"
21298 globalThis.childProcessResult = { events: [] };
21299 import('node:child_process').then(({ spawn }) => {
21300 const child = spawn('pi', ['--version'], {
21301 shell: false,
21302 stdio: ['ignore', 'pipe', 'pipe'],
21303 });
21304 let stdout = '';
21305 let stderr = '';
21306 child.stdout?.on('data', (chunk) => {
21307 stdout += chunk.toString();
21308 globalThis.childProcessResult.events.push('stdout');
21309 });
21310 child.stderr?.on('data', (chunk) => {
21311 stderr += chunk.toString();
21312 globalThis.childProcessResult.events.push('stderr');
21313 });
21314 child.on('error', (err) => {
21315 globalThis.childProcessResult.error =
21316 String((err && err.message) || err || '');
21317 globalThis.childProcessResult.done = true;
21318 });
21319 child.on('exit', (code, signal) => {
21320 globalThis.childProcessResult.events.push('exit');
21321 globalThis.childProcessResult.exitCode = code;
21322 globalThis.childProcessResult.exitSignal = signal;
21323 });
21324 child.on('close', (code) => {
21325 globalThis.childProcessResult.events.push('close');
21326 globalThis.childProcessResult.code = code;
21327 globalThis.childProcessResult.stdout = stdout;
21328 globalThis.childProcessResult.stderr = stderr;
21329 globalThis.childProcessResult.killed = child.killed;
21330 globalThis.childProcessResult.pid = child.pid;
21331 globalThis.childProcessResult.done = true;
21332 });
21333 });
21334 ",
21335 )
21336 .await
21337 .expect("eval child_process spawn script");
21338
21339 let mut requests = runtime.drain_hostcall_requests();
21340 assert_eq!(requests.len(), 1);
21341 let request = requests.pop_front().expect("exec hostcall");
21342 assert!(
21343 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
21344 "unexpected hostcall kind: {:?}",
21345 request.kind
21346 );
21347
21348 runtime.complete_hostcall(
21349 request.call_id,
21350 HostcallOutcome::Success(serde_json::json!({
21351 "stdout": "line-1\n",
21352 "stderr": "warn-1\n",
21353 "code": 0,
21354 "killed": false
21355 })),
21356 );
21357
21358 drain_until_idle(&runtime, &clock).await;
21359 let r = get_global_json(&runtime, "childProcessResult").await;
21360 assert_eq!(r["done"], serde_json::json!(true));
21361 assert_eq!(r["code"], serde_json::json!(0));
21362 assert_eq!(r["exitCode"], serde_json::json!(0));
21363 assert_eq!(r["exitSignal"], serde_json::Value::Null);
21364 assert_eq!(r["stdout"], serde_json::json!("line-1\n"));
21365 assert_eq!(r["stderr"], serde_json::json!("warn-1\n"));
21366 assert_eq!(r["killed"], serde_json::json!(false));
21367 assert_eq!(
21368 r["events"],
21369 serde_json::json!(["stdout", "stderr", "exit", "close"])
21370 );
21371 });
21372 }
21373
21374 #[test]
21375 fn pijs_child_process_spawn_forwards_timeout_option_to_hostcall() {
21376 futures::executor::block_on(async {
21377 let clock = Arc::new(DeterministicClock::new(0));
21378 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21379 .await
21380 .expect("create runtime");
21381
21382 runtime
21383 .eval(
21384 r"
21385 globalThis.childTimeoutResult = {};
21386 import('node:child_process').then(({ spawn }) => {
21387 const child = spawn('pi', ['--version'], {
21388 shell: false,
21389 timeout: 250,
21390 stdio: ['ignore', 'pipe', 'pipe'],
21391 });
21392 child.on('close', (code) => {
21393 globalThis.childTimeoutResult.code = code;
21394 globalThis.childTimeoutResult.killed = child.killed;
21395 globalThis.childTimeoutResult.done = true;
21396 });
21397 });
21398 ",
21399 )
21400 .await
21401 .expect("eval child_process timeout script");
21402
21403 let mut requests = runtime.drain_hostcall_requests();
21404 assert_eq!(requests.len(), 1);
21405 let request = requests.pop_front().expect("exec hostcall");
21406 assert!(
21407 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "pi"),
21408 "unexpected hostcall kind: {:?}",
21409 request.kind
21410 );
21411 assert_eq!(
21412 request.payload["options"]["timeout"].as_i64(),
21413 Some(250),
21414 "spawn timeout should be forwarded to hostcall options"
21415 );
21416
21417 runtime.complete_hostcall(
21418 request.call_id,
21419 HostcallOutcome::Success(serde_json::json!({
21420 "stdout": "",
21421 "stderr": "",
21422 "code": 0,
21423 "killed": true
21424 })),
21425 );
21426
21427 drain_until_idle(&runtime, &clock).await;
21428 let r = get_global_json(&runtime, "childTimeoutResult").await;
21429 assert_eq!(r["done"], serde_json::json!(true));
21430 assert_eq!(r["killed"], serde_json::json!(true));
21431 assert_eq!(r["code"], serde_json::Value::Null);
21432 });
21433 }
21434
21435 #[test]
21436 fn pijs_child_process_exec_returns_child_and_forwards_timeout() {
21437 futures::executor::block_on(async {
21438 let clock = Arc::new(DeterministicClock::new(0));
21439 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21440 .await
21441 .expect("create runtime");
21442
21443 runtime
21444 .eval(
21445 r"
21446 globalThis.execShimResult = {};
21447 import('node:child_process').then(({ exec }) => {
21448 const child = exec('echo hello-exec', { timeout: 321 }, (err, stdout, stderr) => {
21449 globalThis.execShimResult.cbDone = true;
21450 globalThis.execShimResult.cbErr = err ? String((err && err.message) || err) : null;
21451 globalThis.execShimResult.stdout = stdout;
21452 globalThis.execShimResult.stderr = stderr;
21453 });
21454 globalThis.execShimResult.hasPid = typeof child.pid === 'number';
21455 globalThis.execShimResult.hasKill = typeof child.kill === 'function';
21456 child.on('close', () => {
21457 globalThis.execShimResult.closed = true;
21458 });
21459 });
21460 ",
21461 )
21462 .await
21463 .expect("eval child_process exec script");
21464
21465 let mut requests = runtime.drain_hostcall_requests();
21466 assert_eq!(requests.len(), 1);
21467 let request = requests.pop_front().expect("exec hostcall");
21468 assert!(
21469 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "sh"),
21470 "unexpected hostcall kind: {:?}",
21471 request.kind
21472 );
21473 assert_eq!(
21474 request.payload["args"],
21475 serde_json::json!(["-c", "echo hello-exec"])
21476 );
21477 assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(321));
21478
21479 runtime.complete_hostcall(
21480 request.call_id,
21481 HostcallOutcome::Success(serde_json::json!({
21482 "stdout": "hello-exec\n",
21483 "stderr": "",
21484 "code": 0,
21485 "killed": false
21486 })),
21487 );
21488
21489 drain_until_idle(&runtime, &clock).await;
21490 let r = get_global_json(&runtime, "execShimResult").await;
21491 assert_eq!(r["hasPid"], serde_json::json!(true));
21492 assert_eq!(r["hasKill"], serde_json::json!(true));
21493 assert_eq!(r["closed"], serde_json::json!(true));
21494 assert_eq!(r["cbDone"], serde_json::json!(true));
21495 assert_eq!(r["cbErr"], serde_json::Value::Null);
21496 assert_eq!(r["stdout"], serde_json::json!("hello-exec\n"));
21497 assert_eq!(r["stderr"], serde_json::json!(""));
21498 });
21499 }
21500
21501 #[test]
21502 fn pijs_child_process_exec_file_returns_child_and_forwards_timeout() {
21503 futures::executor::block_on(async {
21504 let clock = Arc::new(DeterministicClock::new(0));
21505 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21506 .await
21507 .expect("create runtime");
21508
21509 runtime
21510 .eval(
21511 r"
21512 globalThis.execFileShimResult = {};
21513 import('node:child_process').then(({ execFile }) => {
21514 const child = execFile('echo', ['hello-file'], { timeout: 222 }, (err, stdout, stderr) => {
21515 globalThis.execFileShimResult.cbDone = true;
21516 globalThis.execFileShimResult.cbErr = err ? String((err && err.message) || err) : null;
21517 globalThis.execFileShimResult.stdout = stdout;
21518 globalThis.execFileShimResult.stderr = stderr;
21519 });
21520 globalThis.execFileShimResult.hasPid = typeof child.pid === 'number';
21521 globalThis.execFileShimResult.hasKill = typeof child.kill === 'function';
21522 });
21523 ",
21524 )
21525 .await
21526 .expect("eval child_process execFile script");
21527
21528 let mut requests = runtime.drain_hostcall_requests();
21529 assert_eq!(requests.len(), 1);
21530 let request = requests.pop_front().expect("execFile hostcall");
21531 assert!(
21532 matches!(&request.kind, HostcallKind::Exec { cmd } if cmd == "echo"),
21533 "unexpected hostcall kind: {:?}",
21534 request.kind
21535 );
21536 assert_eq!(request.payload["args"], serde_json::json!(["hello-file"]));
21537 assert_eq!(request.payload["options"]["timeout"].as_i64(), Some(222));
21538
21539 runtime.complete_hostcall(
21540 request.call_id,
21541 HostcallOutcome::Success(serde_json::json!({
21542 "stdout": "hello-file\n",
21543 "stderr": "",
21544 "code": 0,
21545 "killed": false
21546 })),
21547 );
21548
21549 drain_until_idle(&runtime, &clock).await;
21550 let r = get_global_json(&runtime, "execFileShimResult").await;
21551 assert_eq!(r["hasPid"], serde_json::json!(true));
21552 assert_eq!(r["hasKill"], serde_json::json!(true));
21553 assert_eq!(r["cbDone"], serde_json::json!(true));
21554 assert_eq!(r["cbErr"], serde_json::Value::Null);
21555 assert_eq!(r["stdout"], serde_json::json!("hello-file\n"));
21556 assert_eq!(r["stderr"], serde_json::json!(""));
21557 });
21558 }
21559
21560 #[test]
21561 fn pijs_child_process_process_kill_targets_spawned_pid() {
21562 futures::executor::block_on(async {
21563 let clock = Arc::new(DeterministicClock::new(0));
21564 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21565 .await
21566 .expect("create runtime");
21567
21568 runtime
21569 .eval(
21570 r"
21571 globalThis.childKillResult = {};
21572 import('node:child_process').then(({ spawn }) => {
21573 const child = spawn('pi', ['--version'], {
21574 shell: false,
21575 detached: true,
21576 stdio: ['ignore', 'pipe', 'pipe'],
21577 });
21578 globalThis.childKillResult.pid = child.pid;
21579 child.on('close', (code) => {
21580 globalThis.childKillResult.code = code;
21581 globalThis.childKillResult.killed = child.killed;
21582 globalThis.childKillResult.done = true;
21583 });
21584 try {
21585 globalThis.childKillResult.killOk = process.kill(-child.pid, 'SIGKILL') === true;
21586 } catch (err) {
21587 globalThis.childKillResult.killErrorCode = String((err && err.code) || '');
21588 globalThis.childKillResult.killErrorMessage = String((err && err.message) || err || '');
21589 }
21590 });
21591 ",
21592 )
21593 .await
21594 .expect("eval child_process kill script");
21595
21596 let mut requests = runtime.drain_hostcall_requests();
21597 assert_eq!(requests.len(), 1);
21598 let request = requests.pop_front().expect("exec hostcall");
21599 runtime.complete_hostcall(
21600 request.call_id,
21601 HostcallOutcome::Success(serde_json::json!({
21602 "stdout": "",
21603 "stderr": "",
21604 "code": 0,
21605 "killed": false
21606 })),
21607 );
21608
21609 drain_until_idle(&runtime, &clock).await;
21610 let r = get_global_json(&runtime, "childKillResult").await;
21611 assert_eq!(r["killOk"], serde_json::json!(true));
21612 assert_eq!(r["killed"], serde_json::json!(true));
21613 assert_eq!(r["code"], serde_json::Value::Null);
21614 assert_eq!(r["done"], serde_json::json!(true));
21615 });
21616 }
21617
21618 #[test]
21619 fn pijs_child_process_denied_exec_emits_error_and_close() {
21620 futures::executor::block_on(async {
21621 let clock = Arc::new(DeterministicClock::new(0));
21622 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21623 .await
21624 .expect("create runtime");
21625
21626 runtime
21627 .eval(
21628 r"
21629 globalThis.childDeniedResult = {};
21630 import('node:child_process').then(({ spawn }) => {
21631 const child = spawn('pi', ['--version'], {
21632 shell: false,
21633 stdio: ['ignore', 'pipe', 'pipe'],
21634 });
21635 child.on('error', (err) => {
21636 globalThis.childDeniedResult.errorCode = String((err && err.code) || '');
21637 globalThis.childDeniedResult.errorMessage = String((err && err.message) || err || '');
21638 });
21639 child.on('close', (code) => {
21640 globalThis.childDeniedResult.code = code;
21641 globalThis.childDeniedResult.killed = child.killed;
21642 globalThis.childDeniedResult.done = true;
21643 });
21644 });
21645 ",
21646 )
21647 .await
21648 .expect("eval child_process denied script");
21649
21650 let mut requests = runtime.drain_hostcall_requests();
21651 assert_eq!(requests.len(), 1);
21652 let request = requests.pop_front().expect("exec hostcall");
21653 runtime.complete_hostcall(
21654 request.call_id,
21655 HostcallOutcome::Error {
21656 code: "denied".to_string(),
21657 message: "Capability 'exec' denied by policy".to_string(),
21658 },
21659 );
21660
21661 drain_until_idle(&runtime, &clock).await;
21662 let r = get_global_json(&runtime, "childDeniedResult").await;
21663 assert_eq!(r["done"], serde_json::json!(true));
21664 assert_eq!(r["errorCode"], serde_json::json!("denied"));
21665 assert_eq!(
21666 r["errorMessage"],
21667 serde_json::json!("Capability 'exec' denied by policy")
21668 );
21669 assert_eq!(r["code"], serde_json::json!(1));
21670 assert_eq!(r["killed"], serde_json::json!(false));
21671 });
21672 }
21673
21674 #[test]
21675 fn pijs_child_process_rejects_unsupported_shell_option() {
21676 futures::executor::block_on(async {
21677 let clock = Arc::new(DeterministicClock::new(0));
21678 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21679 .await
21680 .expect("create runtime");
21681
21682 runtime
21683 .eval(
21684 r"
21685 globalThis.childOptionResult = {};
21686 import('node:child_process').then(({ spawn }) => {
21687 try {
21688 spawn('pi', ['--version'], { shell: true });
21689 globalThis.childOptionResult.threw = false;
21690 } catch (err) {
21691 globalThis.childOptionResult.threw = true;
21692 globalThis.childOptionResult.message = String((err && err.message) || err || '');
21693 }
21694 globalThis.childOptionResult.done = true;
21695 });
21696 ",
21697 )
21698 .await
21699 .expect("eval child_process unsupported shell script");
21700
21701 drain_until_idle(&runtime, &clock).await;
21702 let r = get_global_json(&runtime, "childOptionResult").await;
21703 assert_eq!(r["done"], serde_json::json!(true));
21704 assert_eq!(r["threw"], serde_json::json!(true));
21705 assert_eq!(
21706 r["message"],
21707 serde_json::json!(
21708 "node:child_process.spawn: only shell=false is supported in PiJS"
21709 )
21710 );
21711 assert_eq!(runtime.drain_hostcall_requests().len(), 0);
21712 });
21713 }
21714
21715 #[test]
21720 fn pijs_node_os_module_exports() {
21721 futures::executor::block_on(async {
21722 let clock = Arc::new(DeterministicClock::new(0));
21723 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21724 .await
21725 .expect("create runtime");
21726
21727 runtime
21728 .eval(
21729 r"
21730 globalThis.osResults = {};
21731 import('node:os').then((os) => {
21732 globalThis.osResults.homedir = os.homedir();
21733 globalThis.osResults.tmpdir = os.tmpdir();
21734 globalThis.osResults.hostname = os.hostname();
21735 globalThis.osResults.platform = os.platform();
21736 globalThis.osResults.arch = os.arch();
21737 globalThis.osResults.type = os.type();
21738 globalThis.osResults.release = os.release();
21739 globalThis.osResults.done = true;
21740 });
21741 ",
21742 )
21743 .await
21744 .expect("eval node:os");
21745
21746 let r = get_global_json(&runtime, "osResults").await;
21747 assert_eq!(r["done"], serde_json::json!(true));
21748 assert!(r["homedir"].is_string());
21750 let expected_tmpdir = std::env::temp_dir().display().to_string();
21752 assert_eq!(r["tmpdir"].as_str().unwrap(), expected_tmpdir);
21753 assert!(
21755 r["hostname"].as_str().is_some_and(|s| !s.is_empty()),
21756 "hostname should be non-empty string"
21757 );
21758 let expected_platform = match std::env::consts::OS {
21760 "macos" => "darwin",
21761 "windows" => "win32",
21762 other => other,
21763 };
21764 assert_eq!(r["platform"].as_str().unwrap(), expected_platform);
21765 let expected_arch = match std::env::consts::ARCH {
21766 "x86_64" => "x64",
21767 "aarch64" => "arm64",
21768 other => other,
21769 };
21770 assert_eq!(r["arch"].as_str().unwrap(), expected_arch);
21771 let expected_type = match std::env::consts::OS {
21772 "linux" => "Linux",
21773 "macos" => "Darwin",
21774 "windows" => "Windows_NT",
21775 other => other,
21776 };
21777 assert_eq!(r["type"].as_str().unwrap(), expected_type);
21778 assert_eq!(r["release"], serde_json::json!("6.0.0"));
21779 });
21780 }
21781
21782 #[test]
21783 fn build_node_os_module_produces_valid_js() {
21784 let source = super::build_node_os_module();
21785 assert!(
21787 source.contains("export function platform()"),
21788 "missing platform"
21789 );
21790 assert!(source.contains("export function cpus()"), "missing cpus");
21791 assert!(source.contains("_numCpus"), "missing _numCpus");
21792 for (i, line) in source.lines().enumerate().take(20) {
21794 eprintln!(" {i}: {line}");
21795 }
21796 let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
21797 assert!(
21798 source.contains(&format!("const _numCpus = {num_cpus}")),
21799 "expected _numCpus = {num_cpus} in module"
21800 );
21801 }
21802
21803 #[test]
21804 fn pijs_node_os_native_values_cpus_and_userinfo() {
21805 futures::executor::block_on(async {
21806 let clock = Arc::new(DeterministicClock::new(0));
21807 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21808 .await
21809 .expect("create runtime");
21810
21811 runtime
21812 .eval(
21813 r"
21814 globalThis.nativeOsResults = {};
21815 import('node:os').then((os) => {
21816 globalThis.nativeOsResults.cpuCount = os.cpus().length;
21817 globalThis.nativeOsResults.totalmem = os.totalmem();
21818 globalThis.nativeOsResults.freemem = os.freemem();
21819 globalThis.nativeOsResults.eol = os.EOL;
21820 globalThis.nativeOsResults.endianness = os.endianness();
21821 globalThis.nativeOsResults.devNull = os.devNull;
21822 const ui = os.userInfo();
21823 globalThis.nativeOsResults.uid = ui.uid;
21824 globalThis.nativeOsResults.username = ui.username;
21825 globalThis.nativeOsResults.hasShell = typeof ui.shell === 'string';
21826 globalThis.nativeOsResults.hasHomedir = typeof ui.homedir === 'string';
21827 globalThis.nativeOsResults.done = true;
21828 });
21829 ",
21830 )
21831 .await
21832 .expect("eval node:os native");
21833
21834 let r = get_global_json(&runtime, "nativeOsResults").await;
21835 assert_eq!(r["done"], serde_json::json!(true));
21836 let expected_cpus =
21838 std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
21839 assert_eq!(r["cpuCount"], serde_json::json!(expected_cpus));
21840 assert!(r["totalmem"].as_f64().unwrap() > 0.0);
21842 assert!(r["freemem"].as_f64().unwrap() > 0.0);
21843 let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
21845 assert_eq!(r["eol"], serde_json::json!(expected_eol));
21846 assert_eq!(r["endianness"], serde_json::json!("LE"));
21847 let expected_dev_null = if cfg!(windows) {
21848 "\\\\.\\NUL"
21849 } else {
21850 "/dev/null"
21851 };
21852 assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
21853 assert!(r["uid"].is_number());
21855 assert!(r["username"].as_str().is_some_and(|s| !s.is_empty()));
21856 assert_eq!(r["hasShell"], serde_json::json!(true));
21857 assert_eq!(r["hasHomedir"], serde_json::json!(true));
21858 });
21859 }
21860
21861 #[test]
21862 fn pijs_node_os_bare_import_alias() {
21863 futures::executor::block_on(async {
21864 let clock = Arc::new(DeterministicClock::new(0));
21865 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21866 .await
21867 .expect("create runtime");
21868
21869 runtime
21870 .eval(
21871 r"
21872 globalThis.bare_os_ok = false;
21873 import('os').then((os) => {
21874 globalThis.bare_os_ok = typeof os.homedir === 'function'
21875 && typeof os.platform === 'function';
21876 });
21877 ",
21878 )
21879 .await
21880 .expect("eval bare os import");
21881
21882 assert_eq!(
21883 get_global_json(&runtime, "bare_os_ok").await,
21884 serde_json::json!(true)
21885 );
21886 });
21887 }
21888
21889 #[test]
21890 fn pijs_node_url_module_exports() {
21891 futures::executor::block_on(async {
21892 let clock = Arc::new(DeterministicClock::new(0));
21893 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21894 .await
21895 .expect("create runtime");
21896
21897 runtime
21898 .eval(
21899 r"
21900 globalThis.urlResults = {};
21901 import('node:url').then((url) => {
21902 globalThis.urlResults.fileToPath = url.fileURLToPath('file:///home/user/test.txt');
21903 globalThis.urlResults.pathToFile = url.pathToFileURL('/home/user/test.txt').href;
21904
21905 const u = new url.URL('https://example.com/path?key=val#frag');
21906 globalThis.urlResults.href = u.href;
21907 globalThis.urlResults.protocol = u.protocol;
21908 globalThis.urlResults.hostname = u.hostname;
21909 globalThis.urlResults.pathname = u.pathname;
21910 globalThis.urlResults.toString = u.toString();
21911
21912 globalThis.urlResults.done = true;
21913 });
21914 ",
21915 )
21916 .await
21917 .expect("eval node:url");
21918
21919 let r = get_global_json(&runtime, "urlResults").await;
21920 assert_eq!(r["done"], serde_json::json!(true));
21921 assert_eq!(r["fileToPath"], serde_json::json!("/home/user/test.txt"));
21922 assert_eq!(
21923 r["pathToFile"],
21924 serde_json::json!("file:///home/user/test.txt")
21925 );
21926 assert!(r["href"].as_str().unwrap().starts_with("https://"));
21928 assert_eq!(r["protocol"], serde_json::json!("https:"));
21929 assert_eq!(r["hostname"], serde_json::json!("example.com"));
21930 assert!(r["pathname"].as_str().unwrap().starts_with("/path"));
21932 });
21933 }
21934
21935 #[test]
21936 fn pijs_node_crypto_create_hash_and_uuid() {
21937 futures::executor::block_on(async {
21938 let clock = Arc::new(DeterministicClock::new(0));
21939 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21940 .await
21941 .expect("create runtime");
21942
21943 runtime
21944 .eval(
21945 r"
21946 globalThis.cryptoResults = {};
21947 import('node:crypto').then((crypto) => {
21948 // createHash
21949 const hash = crypto.createHash('sha256');
21950 hash.update('hello');
21951 globalThis.cryptoResults.hexDigest = hash.digest('hex');
21952
21953 // createHash chained
21954 globalThis.cryptoResults.chainedHex = crypto
21955 .createHash('sha256')
21956 .update('world')
21957 .digest('hex');
21958
21959 // randomUUID
21960 const uuid = crypto.randomUUID();
21961 globalThis.cryptoResults.uuidLength = uuid.length;
21962 // UUID v4 format: 8-4-4-4-12
21963 globalThis.cryptoResults.uuidHasDashes = uuid.split('-').length === 5;
21964
21965 globalThis.cryptoResults.done = true;
21966 });
21967 ",
21968 )
21969 .await
21970 .expect("eval node:crypto");
21971
21972 let r = get_global_json(&runtime, "cryptoResults").await;
21973 assert_eq!(r["done"], serde_json::json!(true));
21974 assert!(r["hexDigest"].is_string());
21976 let hex = r["hexDigest"].as_str().unwrap();
21977 assert!(!hex.is_empty());
21979 assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
21980 assert!(r["chainedHex"].is_string());
21982 let chained = r["chainedHex"].as_str().unwrap();
21983 assert!(!chained.is_empty());
21984 assert!(chained.chars().all(|c| c.is_ascii_hexdigit()));
21985 assert_ne!(r["hexDigest"], r["chainedHex"]);
21987 assert_eq!(r["uuidLength"], serde_json::json!(36));
21989 assert_eq!(r["uuidHasDashes"], serde_json::json!(true));
21990 });
21991 }
21992
21993 #[test]
21994 fn pijs_web_crypto_get_random_values_smoke() {
21995 futures::executor::block_on(async {
21996 let clock = Arc::new(DeterministicClock::new(0));
21997 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
21998 .await
21999 .expect("create runtime");
22000
22001 runtime
22002 .eval(
22003 r"
22004 const bytes = new Uint8Array(32);
22005 crypto.getRandomValues(bytes);
22006 globalThis.cryptoRng = {
22007 len: bytes.length,
22008 inRange: Array.from(bytes).every((n) => Number.isInteger(n) && n >= 0 && n <= 255),
22009 };
22010 ",
22011 )
22012 .await
22013 .expect("eval web crypto getRandomValues");
22014
22015 let r = get_global_json(&runtime, "cryptoRng").await;
22016 assert_eq!(r["len"], serde_json::json!(32));
22017 assert_eq!(r["inRange"], serde_json::json!(true));
22018 });
22019 }
22020
22021 #[test]
22022 fn pijs_buffer_global_operations() {
22023 futures::executor::block_on(async {
22024 let clock = Arc::new(DeterministicClock::new(0));
22025 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22026 .await
22027 .expect("create runtime");
22028
22029 runtime
22030 .eval(
22031 r"
22032 globalThis.bufResults = {};
22033 // Test the global Buffer polyfill (set up during runtime init)
22034 const B = globalThis.Buffer;
22035 globalThis.bufResults.hasBuffer = typeof B === 'function';
22036 globalThis.bufResults.hasFrom = typeof B.from === 'function';
22037
22038 // Buffer.from with array input
22039 const arr = B.from([65, 66, 67]);
22040 globalThis.bufResults.fromArrayLength = arr.length;
22041
22042 // Uint8Array allocation
22043 const zeroed = new Uint8Array(16);
22044 globalThis.bufResults.allocLength = zeroed.length;
22045
22046 globalThis.bufResults.done = true;
22047 ",
22048 )
22049 .await
22050 .expect("eval Buffer");
22051
22052 let r = get_global_json(&runtime, "bufResults").await;
22053 assert_eq!(r["done"], serde_json::json!(true));
22054 assert_eq!(r["hasBuffer"], serde_json::json!(true));
22055 assert_eq!(r["hasFrom"], serde_json::json!(true));
22056 assert_eq!(r["fromArrayLength"], serde_json::json!(3));
22057 assert_eq!(r["allocLength"], serde_json::json!(16));
22058 });
22059 }
22060
22061 #[test]
22062 fn pijs_node_fs_promises_async_roundtrip() {
22063 futures::executor::block_on(async {
22064 let clock = Arc::new(DeterministicClock::new(0));
22065 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22066 .await
22067 .expect("create runtime");
22068
22069 runtime
22070 .eval(
22071 r"
22072 globalThis.fspResults = {};
22073 import('node:fs/promises').then(async (fsp) => {
22074 // Write then read back
22075 await fsp.writeFile('/test/hello.txt', 'async content');
22076 const data = await fsp.readFile('/test/hello.txt', 'utf8');
22077 globalThis.fspResults.readBack = data;
22078
22079 // stat
22080 const st = await fsp.stat('/test/hello.txt');
22081 globalThis.fspResults.statIsFile = st.isFile();
22082 globalThis.fspResults.statSize = st.size;
22083
22084 // mkdir + readdir
22085 await fsp.mkdir('/test/subdir');
22086 await fsp.writeFile('/test/subdir/a.txt', 'aaa');
22087 const entries = await fsp.readdir('/test/subdir');
22088 globalThis.fspResults.dirEntries = entries;
22089
22090 // unlink
22091 await fsp.unlink('/test/subdir/a.txt');
22092 const exists = await fsp.access('/test/subdir/a.txt').then(() => true).catch(() => false);
22093 globalThis.fspResults.deletedFileExists = exists;
22094
22095 globalThis.fspResults.done = true;
22096 });
22097 ",
22098 )
22099 .await
22100 .expect("eval fs/promises");
22101
22102 drain_until_idle(&runtime, &clock).await;
22103
22104 let r = get_global_json(&runtime, "fspResults").await;
22105 assert_eq!(r["done"], serde_json::json!(true));
22106 assert_eq!(r["readBack"], serde_json::json!("async content"));
22107 assert_eq!(r["statIsFile"], serde_json::json!(true));
22108 assert!(r["statSize"].as_u64().unwrap() > 0);
22109 assert_eq!(r["dirEntries"], serde_json::json!(["a.txt"]));
22110 assert_eq!(r["deletedFileExists"], serde_json::json!(false));
22111 });
22112 }
22113
22114 #[test]
22115 fn pijs_node_process_module_exports() {
22116 futures::executor::block_on(async {
22117 let clock = Arc::new(DeterministicClock::new(0));
22118 let config = PiJsRuntimeConfig {
22119 cwd: "/test/project".to_string(),
22120 args: vec!["arg1".to_string(), "arg2".to_string()],
22121 env: HashMap::new(),
22122 limits: PiJsRuntimeLimits::default(),
22123 repair_mode: RepairMode::default(),
22124 allow_unsafe_sync_exec: false,
22125 deny_env: false,
22126 disk_cache_dir: None,
22127 };
22128 let runtime =
22129 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
22130 .await
22131 .expect("create runtime");
22132
22133 runtime
22134 .eval(
22135 r"
22136 globalThis.procResults = {};
22137 import('node:process').then((proc) => {
22138 globalThis.procResults.platform = proc.platform;
22139 globalThis.procResults.arch = proc.arch;
22140 globalThis.procResults.version = proc.version;
22141 globalThis.procResults.pid = proc.pid;
22142 globalThis.procResults.cwdType = typeof proc.cwd;
22143 globalThis.procResults.cwdValue = typeof proc.cwd === 'function'
22144 ? proc.cwd() : proc.cwd;
22145 globalThis.procResults.hasEnv = typeof proc.env === 'object';
22146 globalThis.procResults.hasStdout = typeof proc.stdout === 'object';
22147 globalThis.procResults.hasStderr = typeof proc.stderr === 'object';
22148 globalThis.procResults.hasNextTick = typeof proc.nextTick === 'function';
22149
22150 // nextTick should schedule microtask
22151 globalThis.procResults.nextTickRan = false;
22152 proc.nextTick(() => { globalThis.procResults.nextTickRan = true; });
22153
22154 // hrtime should return array
22155 const hr = proc.hrtime();
22156 globalThis.procResults.hrtimeIsArray = Array.isArray(hr);
22157 globalThis.procResults.hrtimeLength = hr.length;
22158
22159 globalThis.procResults.done = true;
22160 });
22161 ",
22162 )
22163 .await
22164 .expect("eval node:process");
22165
22166 drain_until_idle(&runtime, &clock).await;
22167
22168 let r = get_global_json(&runtime, "procResults").await;
22169 assert_eq!(r["done"], serde_json::json!(true));
22170 assert!(r["platform"].is_string(), "platform should be a string");
22172 let expected_arch = if cfg!(target_arch = "aarch64") {
22173 "arm64"
22174 } else {
22175 "x64"
22176 };
22177 assert_eq!(r["arch"], serde_json::json!(expected_arch));
22178 assert!(r["version"].is_string());
22179 assert_eq!(r["pid"], serde_json::json!(1));
22180 assert!(r["hasEnv"] == serde_json::json!(true));
22181 assert!(r["hasStdout"] == serde_json::json!(true));
22182 assert!(r["hasStderr"] == serde_json::json!(true));
22183 assert!(r["hasNextTick"] == serde_json::json!(true));
22184 assert_eq!(r["nextTickRan"], serde_json::json!(true));
22186 assert_eq!(r["hrtimeIsArray"], serde_json::json!(true));
22187 assert_eq!(r["hrtimeLength"], serde_json::json!(2));
22188 });
22189 }
22190
22191 #[test]
22192 fn pijs_pi_path_join_behavior() {
22193 futures::executor::block_on(async {
22194 let clock = Arc::new(DeterministicClock::new(0));
22195 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22196 .await
22197 .expect("create runtime");
22198
22199 runtime
22200 .eval(
22201 r"
22202 globalThis.joinResults = {};
22203 globalThis.joinResults.concatAbs = pi.path.join('/a', '/b');
22204 globalThis.joinResults.normal = pi.path.join('a', 'b');
22205 globalThis.joinResults.root = pi.path.join('/', 'a');
22206 globalThis.joinResults.dots = pi.path.join('/a', '..', 'b');
22207 globalThis.joinResults.done = true;
22208 ",
22209 )
22210 .await
22211 .expect("eval pi.path.join");
22212
22213 let r = get_global_json(&runtime, "joinResults").await;
22214 assert_eq!(r["done"], serde_json::json!(true));
22215 assert_eq!(r["concatAbs"], serde_json::json!("/a/b"));
22217 assert_eq!(r["normal"], serde_json::json!("a/b"));
22218 assert_eq!(r["root"], serde_json::json!("/a"));
22219 assert_eq!(r["dots"], serde_json::json!("/b"));
22220 });
22221 }
22222
22223 #[test]
22224 fn pijs_node_path_relative_resolve_format() {
22225 futures::executor::block_on(async {
22226 let clock = Arc::new(DeterministicClock::new(0));
22227 let config = PiJsRuntimeConfig {
22228 cwd: "/home/user/project".to_string(),
22229 args: Vec::new(),
22230 env: HashMap::new(),
22231 limits: PiJsRuntimeLimits::default(),
22232 repair_mode: RepairMode::default(),
22233 allow_unsafe_sync_exec: false,
22234 deny_env: false,
22235 disk_cache_dir: None,
22236 };
22237 let runtime =
22238 PiJsRuntime::with_clock_and_config_with_policy(Arc::clone(&clock), config, None)
22239 .await
22240 .expect("create runtime");
22241
22242 runtime
22243 .eval(
22244 r"
22245 globalThis.pathResults2 = {};
22246 import('node:path').then((path) => {
22247 // relative
22248 globalThis.pathResults2.relSameDir = path.relative('/a/b/c', '/a/b/c/d');
22249 globalThis.pathResults2.relUp = path.relative('/a/b/c', '/a/b');
22250 globalThis.pathResults2.relSame = path.relative('/a/b', '/a/b');
22251
22252 // resolve uses cwd as base
22253 globalThis.pathResults2.resolveAbs = path.resolve('/absolute/path');
22254 globalThis.pathResults2.resolveRel = path.resolve('relative');
22255
22256 // format
22257 globalThis.pathResults2.formatFull = path.format({
22258 dir: '/home/user',
22259 base: 'file.txt'
22260 });
22261
22262 // sep and delimiter constants
22263 globalThis.pathResults2.sep = path.sep;
22264 globalThis.pathResults2.delimiter = path.delimiter;
22265
22266 // dirname edge cases
22267 globalThis.pathResults2.dirnameRoot = path.dirname('/');
22268 globalThis.pathResults2.dirnameNested = path.dirname('/a/b/c');
22269
22270 // join edge cases
22271 globalThis.pathResults2.joinEmpty = path.join();
22272 globalThis.pathResults2.joinDots = path.join('a', '..', 'b');
22273
22274 globalThis.pathResults2.done = true;
22275 });
22276 ",
22277 )
22278 .await
22279 .expect("eval path extended 2");
22280
22281 let r = get_global_json(&runtime, "pathResults2").await;
22282 assert_eq!(r["done"], serde_json::json!(true));
22283 assert_eq!(r["relSameDir"], serde_json::json!("d"));
22284 assert_eq!(r["relUp"], serde_json::json!(".."));
22285 assert_eq!(r["relSame"], serde_json::json!("."));
22286 assert_eq!(r["resolveAbs"], serde_json::json!("/absolute/path"));
22287 assert!(r["resolveRel"].as_str().unwrap().ends_with("/relative"));
22289 assert_eq!(r["formatFull"], serde_json::json!("/home/user/file.txt"));
22290 assert_eq!(r["sep"], serde_json::json!("/"));
22291 assert_eq!(r["delimiter"], serde_json::json!(":"));
22292 assert_eq!(r["dirnameRoot"], serde_json::json!("/"));
22293 assert_eq!(r["dirnameNested"], serde_json::json!("/a/b"));
22294 let join_dots = r["joinDots"].as_str().unwrap();
22296 assert!(join_dots == "b" || join_dots == "a/../b");
22297 });
22298 }
22299
22300 #[test]
22301 fn pijs_node_util_module_exports() {
22302 futures::executor::block_on(async {
22303 let clock = Arc::new(DeterministicClock::new(0));
22304 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22305 .await
22306 .expect("create runtime");
22307
22308 runtime
22309 .eval(
22310 r"
22311 globalThis.utilResults = {};
22312 import('node:util').then((util) => {
22313 globalThis.utilResults.hasInspect = typeof util.inspect === 'function';
22314 globalThis.utilResults.hasPromisify = typeof util.promisify === 'function';
22315 globalThis.utilResults.inspectResult = util.inspect({ a: 1, b: [2, 3] });
22316 globalThis.utilResults.done = true;
22317 });
22318 ",
22319 )
22320 .await
22321 .expect("eval node:util");
22322
22323 let r = get_global_json(&runtime, "utilResults").await;
22324 assert_eq!(r["done"], serde_json::json!(true));
22325 assert_eq!(r["hasInspect"], serde_json::json!(true));
22326 assert_eq!(r["hasPromisify"], serde_json::json!(true));
22327 assert!(r["inspectResult"].is_string());
22329 });
22330 }
22331
22332 #[test]
22333 fn pijs_node_assert_module_pass_and_fail() {
22334 futures::executor::block_on(async {
22335 let clock = Arc::new(DeterministicClock::new(0));
22336 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22337 .await
22338 .expect("create runtime");
22339
22340 runtime
22341 .eval(
22342 r"
22343 globalThis.assertResults = {};
22344 import('node:assert').then((mod) => {
22345 const assert = mod.default;
22346
22347 // Passing assertions should not throw
22348 assert.ok(true);
22349 assert.strictEqual(1, 1);
22350 assert.deepStrictEqual({ a: 1 }, { a: 1 });
22351 assert.notStrictEqual(1, 2);
22352
22353 // Failing assertion should throw
22354 try {
22355 assert.strictEqual(1, 2);
22356 globalThis.assertResults.failDidNotThrow = true;
22357 } catch (e) {
22358 globalThis.assertResults.failThrew = true;
22359 globalThis.assertResults.failMessage = e.message || String(e);
22360 }
22361
22362 globalThis.assertResults.done = true;
22363 });
22364 ",
22365 )
22366 .await
22367 .expect("eval node:assert");
22368
22369 let r = get_global_json(&runtime, "assertResults").await;
22370 assert_eq!(r["done"], serde_json::json!(true));
22371 assert_eq!(r["failThrew"], serde_json::json!(true));
22372 assert!(r["failMessage"].is_string());
22373 });
22374 }
22375
22376 #[test]
22377 fn pijs_node_fs_sync_edge_cases() {
22378 futures::executor::block_on(async {
22379 let clock = Arc::new(DeterministicClock::new(0));
22380 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22381 .await
22382 .expect("create runtime");
22383
22384 runtime
22385 .eval(
22386 r"
22387 globalThis.fsEdge = {};
22388 import('node:fs').then((fs) => {
22389 // Write, overwrite, read back
22390 fs.writeFileSync('/edge/file.txt', 'first');
22391 fs.writeFileSync('/edge/file.txt', 'second');
22392 globalThis.fsEdge.overwrite = fs.readFileSync('/edge/file.txt', 'utf8');
22393
22394 // existsSync for existing vs non-existing
22395 globalThis.fsEdge.existsTrue = fs.existsSync('/edge/file.txt');
22396 globalThis.fsEdge.existsFalse = fs.existsSync('/nonexistent/file.txt');
22397
22398 // mkdirSync + readdirSync with withFileTypes
22399 fs.mkdirSync('/edge/dir');
22400 fs.writeFileSync('/edge/dir/a.txt', 'aaa');
22401 fs.mkdirSync('/edge/dir/sub');
22402 const dirents = fs.readdirSync('/edge/dir', { withFileTypes: true });
22403 globalThis.fsEdge.direntCount = dirents.length;
22404 const fileDirent = dirents.find(d => d.name === 'a.txt');
22405 const dirDirent = dirents.find(d => d.name === 'sub');
22406 globalThis.fsEdge.fileIsFile = fileDirent ? fileDirent.isFile() : null;
22407 globalThis.fsEdge.dirIsDir = dirDirent ? dirDirent.isDirectory() : null;
22408
22409 // rmSync recursive
22410 fs.writeFileSync('/edge/dir/sub/deep.txt', 'deep');
22411 fs.rmSync('/edge/dir', { recursive: true });
22412 globalThis.fsEdge.rmRecursiveGone = !fs.existsSync('/edge/dir');
22413
22414 // accessSync on non-existing file should throw
22415 try {
22416 fs.accessSync('/nope');
22417 globalThis.fsEdge.accessThrew = false;
22418 } catch (e) {
22419 globalThis.fsEdge.accessThrew = true;
22420 }
22421
22422 // statSync on directory
22423 fs.mkdirSync('/edge/statdir');
22424 const dStat = fs.statSync('/edge/statdir');
22425 globalThis.fsEdge.dirStatIsDir = dStat.isDirectory();
22426 globalThis.fsEdge.dirStatIsFile = dStat.isFile();
22427
22428 globalThis.fsEdge.done = true;
22429 });
22430 ",
22431 )
22432 .await
22433 .expect("eval fs edge cases");
22434
22435 let r = get_global_json(&runtime, "fsEdge").await;
22436 assert_eq!(r["done"], serde_json::json!(true));
22437 assert_eq!(r["overwrite"], serde_json::json!("second"));
22438 assert_eq!(r["existsTrue"], serde_json::json!(true));
22439 assert_eq!(r["existsFalse"], serde_json::json!(false));
22440 assert_eq!(r["direntCount"], serde_json::json!(2));
22441 assert_eq!(r["fileIsFile"], serde_json::json!(true));
22442 assert_eq!(r["dirIsDir"], serde_json::json!(true));
22443 assert_eq!(r["rmRecursiveGone"], serde_json::json!(true));
22444 assert_eq!(r["accessThrew"], serde_json::json!(true));
22445 assert_eq!(r["dirStatIsDir"], serde_json::json!(true));
22446 assert_eq!(r["dirStatIsFile"], serde_json::json!(false));
22447 });
22448 }
22449
22450 #[test]
22451 fn pijs_node_net_and_http_stubs_throw() {
22452 futures::executor::block_on(async {
22453 let clock = Arc::new(DeterministicClock::new(0));
22454 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22455 .await
22456 .expect("create runtime");
22457
22458 runtime
22459 .eval(
22460 r"
22461 globalThis.stubResults = {};
22462 (async () => {
22463 // node:net createServer should throw
22464 const net = await import('node:net');
22465 try {
22466 net.createServer();
22467 globalThis.stubResults.netThrew = false;
22468 } catch (e) {
22469 globalThis.stubResults.netThrew = true;
22470 }
22471
22472 // node:http createServer should throw
22473 const http = await import('node:http');
22474 try {
22475 http.createServer();
22476 globalThis.stubResults.httpThrew = false;
22477 } catch (e) {
22478 globalThis.stubResults.httpThrew = true;
22479 }
22480
22481 // node:https createServer should throw
22482 const https = await import('node:https');
22483 try {
22484 https.createServer();
22485 globalThis.stubResults.httpsThrew = false;
22486 } catch (e) {
22487 globalThis.stubResults.httpsThrew = true;
22488 }
22489
22490 globalThis.stubResults.done = true;
22491 })();
22492 ",
22493 )
22494 .await
22495 .expect("eval stub throws");
22496
22497 drain_until_idle(&runtime, &clock).await;
22498
22499 let r = get_global_json(&runtime, "stubResults").await;
22500 assert_eq!(r["done"], serde_json::json!(true));
22501 assert_eq!(r["netThrew"], serde_json::json!(true));
22502 assert_eq!(r["httpThrew"], serde_json::json!(true));
22503 assert_eq!(r["httpsThrew"], serde_json::json!(true));
22504 });
22505 }
22506
22507 #[test]
22508 fn pijs_node_readline_stub_exports() {
22509 futures::executor::block_on(async {
22510 let clock = Arc::new(DeterministicClock::new(0));
22511 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22512 .await
22513 .expect("create runtime");
22514
22515 runtime
22516 .eval(
22517 r"
22518 globalThis.rlResult = {};
22519 import('node:readline').then((rl) => {
22520 globalThis.rlResult.hasCreateInterface = typeof rl.createInterface === 'function';
22521 globalThis.rlResult.done = true;
22522 });
22523 ",
22524 )
22525 .await
22526 .expect("eval readline");
22527
22528 let r = get_global_json(&runtime, "rlResult").await;
22529 assert_eq!(r["done"], serde_json::json!(true));
22530 assert_eq!(r["hasCreateInterface"], serde_json::json!(true));
22531 });
22532 }
22533
22534 #[test]
22535 fn pijs_node_stream_promises_pipeline_pass_through() {
22536 futures::executor::block_on(async {
22537 let clock = Arc::new(DeterministicClock::new(0));
22538 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22539 .await
22540 .expect("create runtime");
22541
22542 runtime
22543 .eval(
22544 r#"
22545 globalThis.streamInterop = { done: false };
22546 (async () => {
22547 const { Readable, PassThrough, Writable } = await import("node:stream");
22548 const { pipeline } = await import("node:stream/promises");
22549
22550 const collected = [];
22551 const source = Readable.from(["alpha", "-", "omega"]);
22552 const through = new PassThrough();
22553 const sink = new Writable({
22554 write(chunk, _encoding, callback) {
22555 collected.push(String(chunk));
22556 callback(null);
22557 }
22558 });
22559
22560 await pipeline(source, through, sink);
22561 globalThis.streamInterop.value = collected.join("");
22562 globalThis.streamInterop.done = true;
22563 })().catch((e) => {
22564 globalThis.streamInterop.error = String(e && e.message ? e.message : e);
22565 globalThis.streamInterop.done = false;
22566 });
22567 "#,
22568 )
22569 .await
22570 .expect("eval node:stream pipeline");
22571
22572 drain_until_idle(&runtime, &clock).await;
22573
22574 let result = get_global_json(&runtime, "streamInterop").await;
22575 assert_eq!(result["done"], serde_json::json!(true));
22576 assert_eq!(result["value"], serde_json::json!("alpha-omega"));
22577 });
22578 }
22579
22580 #[test]
22581 fn pijs_fs_create_stream_pipeline_copies_content() {
22582 futures::executor::block_on(async {
22583 let clock = Arc::new(DeterministicClock::new(0));
22584 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22585 .await
22586 .expect("create runtime");
22587
22588 runtime
22589 .eval(
22590 r#"
22591 globalThis.fsStreamCopy = { done: false };
22592 (async () => {
22593 const fs = await import("node:fs");
22594 const { pipeline } = await import("node:stream/promises");
22595
22596 fs.writeFileSync("/tmp/source.txt", "stream-data-123");
22597 const src = fs.createReadStream("/tmp/source.txt");
22598 const dst = fs.createWriteStream("/tmp/dest.txt");
22599 await pipeline(src, dst);
22600
22601 globalThis.fsStreamCopy.value = fs.readFileSync("/tmp/dest.txt", "utf8");
22602 globalThis.fsStreamCopy.done = true;
22603 })().catch((e) => {
22604 globalThis.fsStreamCopy.error = String(e && e.message ? e.message : e);
22605 globalThis.fsStreamCopy.done = false;
22606 });
22607 "#,
22608 )
22609 .await
22610 .expect("eval fs stream copy");
22611
22612 drain_until_idle(&runtime, &clock).await;
22613
22614 let result = get_global_json(&runtime, "fsStreamCopy").await;
22615 assert_eq!(result["done"], serde_json::json!(true));
22616 assert_eq!(result["value"], serde_json::json!("stream-data-123"));
22617 });
22618 }
22619
22620 #[test]
22621 fn pijs_node_stream_web_stream_bridge_roundtrip() {
22622 futures::executor::block_on(async {
22623 let clock = Arc::new(DeterministicClock::new(0));
22624 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
22625 .await
22626 .expect("create runtime");
22627
22628 runtime
22629 .eval(
22630 r#"
22631 globalThis.webBridge = { done: false, skipped: false };
22632 (async () => {
22633 if (typeof ReadableStream !== "function" || typeof WritableStream !== "function") {
22634 globalThis.webBridge.skipped = true;
22635 globalThis.webBridge.done = true;
22636 return;
22637 }
22638
22639 const { Readable, Writable } = await import("node:stream");
22640 const { pipeline } = await import("node:stream/promises");
22641
22642 const webReadable = new ReadableStream({
22643 start(controller) {
22644 controller.enqueue("ab");
22645 controller.enqueue("cd");
22646 controller.close();
22647 }
22648 });
22649 const nodeReadable = Readable.fromWeb(webReadable);
22650
22651 const fromWebChunks = [];
22652 const webWritable = new WritableStream({
22653 write(chunk) {
22654 fromWebChunks.push(String(chunk));
22655 }
22656 });
22657 const nodeWritable = Writable.fromWeb(webWritable);
22658 await pipeline(nodeReadable, nodeWritable);
22659
22660 const nodeReadableRoundtrip = Readable.from(["x", "y"]);
22661 const webReadableRoundtrip = Readable.toWeb(nodeReadableRoundtrip);
22662 const reader = webReadableRoundtrip.getReader();
22663 const toWebChunks = [];
22664 while (true) {
22665 const { done, value } = await reader.read();
22666 if (done) break;
22667 toWebChunks.push(String(value));
22668 }
22669
22670 globalThis.webBridge.fromWeb = fromWebChunks.join("");
22671 globalThis.webBridge.toWeb = toWebChunks.join("");
22672 globalThis.webBridge.done = true;
22673 })().catch((e) => {
22674 globalThis.webBridge.error = String(e && e.message ? e.message : e);
22675 globalThis.webBridge.done = false;
22676 });
22677 "#,
22678 )
22679 .await
22680 .expect("eval web stream bridge");
22681
22682 drain_until_idle(&runtime, &clock).await;
22683
22684 let result = get_global_json(&runtime, "webBridge").await;
22685 assert_eq!(result["done"], serde_json::json!(true));
22686 if result["skipped"] == serde_json::json!(true) {
22687 return;
22688 }
22689 assert_eq!(result["fromWeb"], serde_json::json!("abcd"));
22690 assert_eq!(result["toWeb"], serde_json::json!("xy"));
22691 });
22692 }
22693
22694 #[test]
22697 fn pijs_stream_chunks_delivered_via_async_iterator() {
22698 futures::executor::block_on(async {
22699 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22700 .await
22701 .expect("create runtime");
22702
22703 runtime
22705 .eval(
22706 r#"
22707 globalThis.chunks = [];
22708 globalThis.done = false;
22709 (async () => {
22710 const stream = pi.exec("cat", ["big.txt"], { stream: true });
22711 for await (const chunk of stream) {
22712 globalThis.chunks.push(chunk);
22713 }
22714 globalThis.done = true;
22715 })();
22716 "#,
22717 )
22718 .await
22719 .expect("eval");
22720
22721 let requests = runtime.drain_hostcall_requests();
22722 assert_eq!(requests.len(), 1);
22723 let call_id = requests[0].call_id.clone();
22724
22725 for seq in 0..3 {
22727 runtime.complete_hostcall(
22728 call_id.clone(),
22729 HostcallOutcome::StreamChunk {
22730 sequence: seq,
22731 chunk: serde_json::json!({ "line": seq }),
22732 is_final: false,
22733 },
22734 );
22735 let stats = runtime.tick().await.expect("tick chunk");
22736 assert!(stats.ran_macrotask);
22737 }
22738
22739 assert!(
22741 runtime.hostcall_tracker.borrow().is_pending(&call_id),
22742 "hostcall should still be pending after non-final chunks"
22743 );
22744
22745 runtime.complete_hostcall(
22747 call_id.clone(),
22748 HostcallOutcome::StreamChunk {
22749 sequence: 3,
22750 chunk: serde_json::json!({ "line": 3 }),
22751 is_final: true,
22752 },
22753 );
22754 let stats = runtime.tick().await.expect("tick final");
22755 assert!(stats.ran_macrotask);
22756
22757 assert!(
22759 !runtime.hostcall_tracker.borrow().is_pending(&call_id),
22760 "hostcall should be completed after final chunk"
22761 );
22762
22763 runtime.tick().await.expect("tick settle");
22765
22766 let chunks = get_global_json(&runtime, "chunks").await;
22767 let arr = chunks.as_array().expect("chunks is array");
22768 assert_eq!(arr.len(), 4, "expected 4 chunks, got {arr:?}");
22769 for (i, c) in arr.iter().enumerate() {
22770 assert_eq!(c["line"], serde_json::json!(i), "chunk {i}");
22771 }
22772
22773 let done = get_global_json(&runtime, "done").await;
22774 assert_eq!(
22775 done,
22776 serde_json::json!(true),
22777 "async loop should have completed"
22778 );
22779 });
22780 }
22781
22782 #[test]
22783 fn pijs_stream_error_rejects_async_iterator() {
22784 futures::executor::block_on(async {
22785 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22786 .await
22787 .expect("create runtime");
22788
22789 runtime
22790 .eval(
22791 r#"
22792 globalThis.chunks = [];
22793 globalThis.errMsg = null;
22794 (async () => {
22795 try {
22796 const stream = pi.exec("fail", [], { stream: true });
22797 for await (const chunk of stream) {
22798 globalThis.chunks.push(chunk);
22799 }
22800 } catch (e) {
22801 globalThis.errMsg = e.message;
22802 }
22803 })();
22804 "#,
22805 )
22806 .await
22807 .expect("eval");
22808
22809 let requests = runtime.drain_hostcall_requests();
22810 let call_id = requests[0].call_id.clone();
22811
22812 runtime.complete_hostcall(
22814 call_id.clone(),
22815 HostcallOutcome::StreamChunk {
22816 sequence: 0,
22817 chunk: serde_json::json!("first"),
22818 is_final: false,
22819 },
22820 );
22821 runtime.tick().await.expect("tick chunk 0");
22822
22823 runtime.complete_hostcall(
22825 call_id,
22826 HostcallOutcome::Error {
22827 code: "STREAM_ERR".into(),
22828 message: "broken pipe".into(),
22829 },
22830 );
22831 runtime.tick().await.expect("tick error");
22832 runtime.tick().await.expect("tick settle");
22833
22834 let chunks = get_global_json(&runtime, "chunks").await;
22835 assert_eq!(
22836 chunks.as_array().expect("array").len(),
22837 1,
22838 "should have received 1 chunk before error"
22839 );
22840
22841 let err = get_global_json(&runtime, "errMsg").await;
22842 assert_eq!(err, serde_json::json!("broken pipe"));
22843 });
22844 }
22845
22846 #[test]
22847 fn pijs_stream_http_returns_async_iterator() {
22848 futures::executor::block_on(async {
22849 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22850 .await
22851 .expect("create runtime");
22852
22853 runtime
22854 .eval(
22855 r#"
22856 globalThis.chunks = [];
22857 globalThis.done = false;
22858 (async () => {
22859 const stream = pi.http({ url: "http://example.com", stream: true });
22860 for await (const chunk of stream) {
22861 globalThis.chunks.push(chunk);
22862 }
22863 globalThis.done = true;
22864 })();
22865 "#,
22866 )
22867 .await
22868 .expect("eval");
22869
22870 let requests = runtime.drain_hostcall_requests();
22871 assert_eq!(requests.len(), 1);
22872 let call_id = requests[0].call_id.clone();
22873
22874 runtime.complete_hostcall(
22876 call_id.clone(),
22877 HostcallOutcome::StreamChunk {
22878 sequence: 0,
22879 chunk: serde_json::json!("chunk-a"),
22880 is_final: false,
22881 },
22882 );
22883 runtime.tick().await.expect("tick a");
22884
22885 runtime.complete_hostcall(
22886 call_id,
22887 HostcallOutcome::StreamChunk {
22888 sequence: 1,
22889 chunk: serde_json::json!("chunk-b"),
22890 is_final: true,
22891 },
22892 );
22893 runtime.tick().await.expect("tick b");
22894 runtime.tick().await.expect("tick settle");
22895
22896 let chunks = get_global_json(&runtime, "chunks").await;
22897 let arr = chunks.as_array().expect("array");
22898 assert_eq!(arr.len(), 2);
22899 assert_eq!(arr[0], serde_json::json!("chunk-a"));
22900 assert_eq!(arr[1], serde_json::json!("chunk-b"));
22901
22902 assert_eq!(
22903 get_global_json(&runtime, "done").await,
22904 serde_json::json!(true)
22905 );
22906 });
22907 }
22908
22909 #[test]
22910 #[allow(clippy::too_many_lines)]
22911 fn pijs_stream_concurrent_exec_calls_have_independent_lifecycle() {
22912 futures::executor::block_on(async {
22913 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
22914 .await
22915 .expect("create runtime");
22916
22917 runtime
22918 .eval(
22919 r#"
22920 globalThis.streamA = [];
22921 globalThis.streamB = [];
22922 globalThis.doneA = false;
22923 globalThis.doneB = false;
22924 (async () => {
22925 const stream = pi.exec("cmd-a", [], { stream: true });
22926 for await (const chunk of stream) {
22927 globalThis.streamA.push(chunk);
22928 }
22929 globalThis.doneA = true;
22930 })();
22931 (async () => {
22932 const stream = pi.exec("cmd-b", [], { stream: true });
22933 for await (const chunk of stream) {
22934 globalThis.streamB.push(chunk);
22935 }
22936 globalThis.doneB = true;
22937 })();
22938 "#,
22939 )
22940 .await
22941 .expect("eval");
22942
22943 let requests = runtime.drain_hostcall_requests();
22944 assert_eq!(requests.len(), 2, "expected two streaming exec requests");
22945
22946 let mut call_a: Option<String> = None;
22947 let mut call_b: Option<String> = None;
22948 for request in &requests {
22949 match &request.kind {
22950 HostcallKind::Exec { cmd } if cmd == "cmd-a" => {
22951 call_a = Some(request.call_id.clone());
22952 }
22953 HostcallKind::Exec { cmd } if cmd == "cmd-b" => {
22954 call_b = Some(request.call_id.clone());
22955 }
22956 _ => {}
22957 }
22958 }
22959
22960 let call_a = call_a.expect("call_id for cmd-a");
22961 let call_b = call_b.expect("call_id for cmd-b");
22962 assert_ne!(call_a, call_b, "concurrent calls must have distinct ids");
22963 assert_eq!(runtime.pending_hostcall_count(), 2);
22964
22965 runtime.complete_hostcall(
22966 call_a.clone(),
22967 HostcallOutcome::StreamChunk {
22968 sequence: 0,
22969 chunk: serde_json::json!("a0"),
22970 is_final: false,
22971 },
22972 );
22973 runtime.tick().await.expect("tick a0");
22974
22975 runtime.complete_hostcall(
22976 call_b.clone(),
22977 HostcallOutcome::StreamChunk {
22978 sequence: 0,
22979 chunk: serde_json::json!("b0"),
22980 is_final: false,
22981 },
22982 );
22983 runtime.tick().await.expect("tick b0");
22984 assert_eq!(runtime.pending_hostcall_count(), 2);
22985
22986 runtime.complete_hostcall(
22987 call_b.clone(),
22988 HostcallOutcome::StreamChunk {
22989 sequence: 1,
22990 chunk: serde_json::json!("b1"),
22991 is_final: true,
22992 },
22993 );
22994 runtime.tick().await.expect("tick b1");
22995 assert_eq!(runtime.pending_hostcall_count(), 1);
22996 assert!(runtime.is_hostcall_pending(&call_a));
22997 assert!(!runtime.is_hostcall_pending(&call_b));
22998
22999 runtime.complete_hostcall(
23000 call_a.clone(),
23001 HostcallOutcome::StreamChunk {
23002 sequence: 1,
23003 chunk: serde_json::json!("a1"),
23004 is_final: true,
23005 },
23006 );
23007 runtime.tick().await.expect("tick a1");
23008 assert_eq!(runtime.pending_hostcall_count(), 0);
23009 assert!(!runtime.is_hostcall_pending(&call_a));
23010
23011 runtime.tick().await.expect("tick settle 1");
23012 runtime.tick().await.expect("tick settle 2");
23013
23014 let stream_a = get_global_json(&runtime, "streamA").await;
23015 let stream_b = get_global_json(&runtime, "streamB").await;
23016 assert_eq!(
23017 stream_a.as_array().expect("streamA array"),
23018 &vec![serde_json::json!("a0"), serde_json::json!("a1")]
23019 );
23020 assert_eq!(
23021 stream_b.as_array().expect("streamB array"),
23022 &vec![serde_json::json!("b0"), serde_json::json!("b1")]
23023 );
23024 assert_eq!(
23025 get_global_json(&runtime, "doneA").await,
23026 serde_json::json!(true)
23027 );
23028 assert_eq!(
23029 get_global_json(&runtime, "doneB").await,
23030 serde_json::json!(true)
23031 );
23032 });
23033 }
23034
23035 #[test]
23036 fn pijs_stream_chunk_ignored_after_hostcall_completed() {
23037 futures::executor::block_on(async {
23038 let runtime = PiJsRuntime::with_clock(DeterministicClock::new(0))
23039 .await
23040 .expect("create runtime");
23041
23042 runtime
23043 .eval(
23044 r#"
23045 globalThis.result = null;
23046 pi.tool("read", { path: "test.txt" }).then(r => {
23047 globalThis.result = r;
23048 });
23049 "#,
23050 )
23051 .await
23052 .expect("eval");
23053
23054 let requests = runtime.drain_hostcall_requests();
23055 let call_id = requests[0].call_id.clone();
23056
23057 runtime.complete_hostcall(
23059 call_id.clone(),
23060 HostcallOutcome::Success(serde_json::json!({ "content": "done" })),
23061 );
23062 runtime.tick().await.expect("tick success");
23063
23064 runtime.complete_hostcall(
23066 call_id,
23067 HostcallOutcome::StreamChunk {
23068 sequence: 0,
23069 chunk: serde_json::json!("stale"),
23070 is_final: false,
23071 },
23072 );
23073 let stats = runtime.tick().await.expect("tick stale chunk");
23075 assert!(stats.ran_macrotask, "macrotask should run (and be ignored)");
23076
23077 let result = get_global_json(&runtime, "result").await;
23078 assert_eq!(result["content"], serde_json::json!("done"));
23079 });
23080 }
23081
23082 #[test]
23085 fn pijs_exec_sync_denied_by_default_security_policy() {
23086 futures::executor::block_on(async {
23087 let clock = Arc::new(DeterministicClock::new(0));
23088 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23089 .await
23090 .expect("create runtime");
23091
23092 runtime
23093 .eval(
23094 r"
23095 globalThis.syncDenied = {};
23096 import('node:child_process').then(({ execSync }) => {
23097 try {
23098 execSync('echo should-not-run');
23099 globalThis.syncDenied.threw = false;
23100 } catch (e) {
23101 globalThis.syncDenied.threw = true;
23102 globalThis.syncDenied.msg = String((e && e.message) || e || '');
23103 }
23104 globalThis.syncDenied.done = true;
23105 });
23106 ",
23107 )
23108 .await
23109 .expect("eval execSync deny");
23110
23111 let r = get_global_json(&runtime, "syncDenied").await;
23112 assert_eq!(r["done"], serde_json::json!(true));
23113 assert_eq!(r["threw"], serde_json::json!(true));
23114 assert!(
23115 r["msg"]
23116 .as_str()
23117 .unwrap_or("")
23118 .contains("disabled by default"),
23119 "unexpected denial message: {}",
23120 r["msg"]
23121 );
23122 });
23123 }
23124
23125 #[test]
23126 fn pijs_exec_sync_enforces_exec_mediation_for_critical_commands() {
23127 futures::executor::block_on(async {
23128 let clock = Arc::new(DeterministicClock::new(0));
23129 let config = PiJsRuntimeConfig {
23130 allow_unsafe_sync_exec: true,
23131 ..PiJsRuntimeConfig::default()
23132 };
23133 let policy = crate::extensions::PolicyProfile::Permissive.to_policy();
23134 let runtime = PiJsRuntime::with_clock_and_config_with_policy(
23135 Arc::clone(&clock),
23136 config,
23137 Some(policy),
23138 )
23139 .await
23140 .expect("create runtime");
23141
23142 runtime
23143 .eval(
23144 r"
23145 globalThis.syncMediation = {};
23146 import('node:child_process').then(({ execSync }) => {
23147 try {
23148 execSync('dd if=/dev/zero of=/dev/null count=1');
23149 globalThis.syncMediation.threw = false;
23150 } catch (e) {
23151 globalThis.syncMediation.threw = true;
23152 globalThis.syncMediation.msg = String((e && e.message) || e || '');
23153 }
23154 globalThis.syncMediation.done = true;
23155 });
23156 ",
23157 )
23158 .await
23159 .expect("eval execSync mediation");
23160
23161 let r = get_global_json(&runtime, "syncMediation").await;
23162 assert_eq!(r["done"], serde_json::json!(true));
23163 assert_eq!(r["threw"], serde_json::json!(true));
23164 assert!(
23165 r["msg"].as_str().unwrap_or("").contains("exec mediation"),
23166 "unexpected mediation denial message: {}",
23167 r["msg"]
23168 );
23169 });
23170 }
23171
23172 #[test]
23173 fn pijs_exec_sync_runs_command_and_returns_stdout() {
23174 futures::executor::block_on(async {
23175 let clock = Arc::new(DeterministicClock::new(0));
23176 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23177
23178 runtime
23179 .eval(
23180 r"
23181 globalThis.syncResult = {};
23182 import('node:child_process').then(({ execSync }) => {
23183 try {
23184 const output = execSync('echo hello-sync');
23185 globalThis.syncResult.stdout = output.trim();
23186 globalThis.syncResult.done = true;
23187 } catch (e) {
23188 globalThis.syncResult.error = String(e);
23189 globalThis.syncResult.stack = e.stack || '';
23190 globalThis.syncResult.done = false;
23191 }
23192 }).catch(e => {
23193 globalThis.syncResult.promiseError = String(e);
23194 });
23195 ",
23196 )
23197 .await
23198 .expect("eval execSync test");
23199
23200 let r = get_global_json(&runtime, "syncResult").await;
23201 assert!(
23202 r["done"] == serde_json::json!(true),
23203 "execSync test failed: error={}, stack={}, promiseError={}",
23204 r["error"],
23205 r["stack"],
23206 r["promiseError"]
23207 );
23208 assert_eq!(r["stdout"], serde_json::json!("hello-sync"));
23209 });
23210 }
23211
23212 #[test]
23213 fn pijs_exec_sync_throws_on_nonzero_exit() {
23214 futures::executor::block_on(async {
23215 let clock = Arc::new(DeterministicClock::new(0));
23216 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23217
23218 runtime
23219 .eval(
23220 r"
23221 globalThis.syncErr = {};
23222 import('node:child_process').then(({ execSync }) => {
23223 try {
23224 execSync('exit 42');
23225 globalThis.syncErr.threw = false;
23226 } catch (e) {
23227 globalThis.syncErr.threw = true;
23228 globalThis.syncErr.status = e.status;
23229 globalThis.syncErr.hasStderr = typeof e.stderr === 'string';
23230 }
23231 globalThis.syncErr.done = true;
23232 });
23233 ",
23234 )
23235 .await
23236 .expect("eval execSync nonzero");
23237
23238 let r = get_global_json(&runtime, "syncErr").await;
23239 assert_eq!(r["done"], serde_json::json!(true));
23240 assert_eq!(r["threw"], serde_json::json!(true));
23241 assert_eq!(r["status"].as_f64(), Some(42.0));
23243 assert_eq!(r["hasStderr"], serde_json::json!(true));
23244 });
23245 }
23246
23247 #[test]
23248 fn pijs_exec_sync_empty_command_throws() {
23249 futures::executor::block_on(async {
23250 let clock = Arc::new(DeterministicClock::new(0));
23251 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23252
23253 runtime
23254 .eval(
23255 r"
23256 globalThis.emptyResult = {};
23257 import('node:child_process').then(({ execSync }) => {
23258 try {
23259 execSync('');
23260 globalThis.emptyResult.threw = false;
23261 } catch (e) {
23262 globalThis.emptyResult.threw = true;
23263 globalThis.emptyResult.msg = e.message;
23264 }
23265 globalThis.emptyResult.done = true;
23266 });
23267 ",
23268 )
23269 .await
23270 .expect("eval execSync empty");
23271
23272 let r = get_global_json(&runtime, "emptyResult").await;
23273 assert_eq!(r["done"], serde_json::json!(true));
23274 assert_eq!(r["threw"], serde_json::json!(true));
23275 assert!(
23276 r["msg"]
23277 .as_str()
23278 .unwrap_or("")
23279 .contains("command is required")
23280 );
23281 });
23282 }
23283
23284 #[test]
23285 fn pijs_spawn_sync_returns_result_object() {
23286 futures::executor::block_on(async {
23287 let clock = Arc::new(DeterministicClock::new(0));
23288 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23289
23290 runtime
23291 .eval(
23292 r"
23293 globalThis.spawnSyncResult = {};
23294 import('node:child_process').then(({ spawnSync }) => {
23295 const r = spawnSync('echo', ['spawn-test']);
23296 globalThis.spawnSyncResult.stdout = r.stdout.trim();
23297 globalThis.spawnSyncResult.status = r.status;
23298 globalThis.spawnSyncResult.hasOutput = Array.isArray(r.output);
23299 globalThis.spawnSyncResult.noError = r.error === undefined;
23300 globalThis.spawnSyncResult.done = true;
23301 });
23302 ",
23303 )
23304 .await
23305 .expect("eval spawnSync test");
23306
23307 let r = get_global_json(&runtime, "spawnSyncResult").await;
23308 assert_eq!(r["done"], serde_json::json!(true));
23309 assert_eq!(r["stdout"], serde_json::json!("spawn-test"));
23310 assert_eq!(r["status"].as_f64(), Some(0.0));
23311 assert_eq!(r["hasOutput"], serde_json::json!(true));
23312 assert_eq!(r["noError"], serde_json::json!(true));
23313 });
23314 }
23315
23316 #[test]
23317 fn pijs_spawn_sync_captures_nonzero_exit() {
23318 futures::executor::block_on(async {
23319 let clock = Arc::new(DeterministicClock::new(0));
23320 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23321
23322 runtime
23323 .eval(
23324 r"
23325 globalThis.spawnSyncFail = {};
23326 import('node:child_process').then(({ spawnSync }) => {
23327 const r = spawnSync('sh', ['-c', 'exit 7']);
23328 globalThis.spawnSyncFail.status = r.status;
23329 globalThis.spawnSyncFail.signal = r.signal;
23330 globalThis.spawnSyncFail.done = true;
23331 });
23332 ",
23333 )
23334 .await
23335 .expect("eval spawnSync fail");
23336
23337 let r = get_global_json(&runtime, "spawnSyncFail").await;
23338 assert_eq!(r["done"], serde_json::json!(true));
23339 assert_eq!(r["status"].as_f64(), Some(7.0));
23340 assert_eq!(r["signal"], serde_json::json!(null));
23341 });
23342 }
23343
23344 #[test]
23345 fn pijs_spawn_sync_bad_command_returns_error() {
23346 futures::executor::block_on(async {
23347 let clock = Arc::new(DeterministicClock::new(0));
23348 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23349
23350 runtime
23351 .eval(
23352 r"
23353 globalThis.badCmd = {};
23354 import('node:child_process').then(({ spawnSync }) => {
23355 const r = spawnSync('__nonexistent_binary_xyzzy__');
23356 globalThis.badCmd.hasError = r.error !== undefined;
23357 globalThis.badCmd.statusNull = r.status === null;
23358 globalThis.badCmd.done = true;
23359 });
23360 ",
23361 )
23362 .await
23363 .expect("eval spawnSync bad cmd");
23364
23365 let r = get_global_json(&runtime, "badCmd").await;
23366 assert_eq!(r["done"], serde_json::json!(true));
23367 assert_eq!(r["hasError"], serde_json::json!(true));
23368 assert_eq!(r["statusNull"], serde_json::json!(true));
23369 });
23370 }
23371
23372 #[test]
23373 fn pijs_exec_file_sync_runs_binary_directly() {
23374 futures::executor::block_on(async {
23375 let clock = Arc::new(DeterministicClock::new(0));
23376 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23377
23378 runtime
23379 .eval(
23380 r"
23381 globalThis.execFileResult = {};
23382 import('node:child_process').then(({ execFileSync }) => {
23383 const output = execFileSync('echo', ['file-sync-test']);
23384 globalThis.execFileResult.stdout = output.trim();
23385 globalThis.execFileResult.done = true;
23386 });
23387 ",
23388 )
23389 .await
23390 .expect("eval execFileSync test");
23391
23392 let r = get_global_json(&runtime, "execFileResult").await;
23393 assert_eq!(r["done"], serde_json::json!(true));
23394 assert_eq!(r["stdout"], serde_json::json!("file-sync-test"));
23395 });
23396 }
23397
23398 #[test]
23399 fn pijs_exec_sync_captures_stderr() {
23400 futures::executor::block_on(async {
23401 let clock = Arc::new(DeterministicClock::new(0));
23402 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23403
23404 runtime
23405 .eval(
23406 r"
23407 globalThis.stderrResult = {};
23408 import('node:child_process').then(({ execSync }) => {
23409 try {
23410 execSync('echo err-msg >&2 && exit 1');
23411 globalThis.stderrResult.threw = false;
23412 } catch (e) {
23413 globalThis.stderrResult.threw = true;
23414 globalThis.stderrResult.stderr = e.stderr.trim();
23415 }
23416 globalThis.stderrResult.done = true;
23417 });
23418 ",
23419 )
23420 .await
23421 .expect("eval execSync stderr");
23422
23423 let r = get_global_json(&runtime, "stderrResult").await;
23424 assert_eq!(r["done"], serde_json::json!(true));
23425 assert_eq!(r["threw"], serde_json::json!(true));
23426 assert_eq!(r["stderr"], serde_json::json!("err-msg"));
23427 });
23428 }
23429
23430 #[test]
23431 #[cfg(unix)]
23432 fn pijs_exec_sync_with_cwd_option() {
23433 futures::executor::block_on(async {
23434 let clock = Arc::new(DeterministicClock::new(0));
23435 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23436
23437 runtime
23438 .eval(
23439 r"
23440 globalThis.cwdResult = {};
23441 import('node:child_process').then(({ execSync }) => {
23442 const output = execSync('pwd', { cwd: '/tmp' });
23443 globalThis.cwdResult.dir = output.trim();
23444 globalThis.cwdResult.done = true;
23445 });
23446 ",
23447 )
23448 .await
23449 .expect("eval execSync cwd");
23450
23451 let r = get_global_json(&runtime, "cwdResult").await;
23452 assert_eq!(r["done"], serde_json::json!(true));
23453 let dir = r["dir"].as_str().unwrap_or("");
23455 assert!(
23456 dir == "/tmp" || dir.ends_with("/tmp"),
23457 "expected /tmp, got: {dir}"
23458 );
23459 });
23460 }
23461
23462 #[test]
23463 fn pijs_spawn_sync_empty_command_throws() {
23464 futures::executor::block_on(async {
23465 let clock = Arc::new(DeterministicClock::new(0));
23466 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23467
23468 runtime
23469 .eval(
23470 r"
23471 globalThis.emptySpawn = {};
23472 import('node:child_process').then(({ spawnSync }) => {
23473 try {
23474 spawnSync('');
23475 globalThis.emptySpawn.threw = false;
23476 } catch (e) {
23477 globalThis.emptySpawn.threw = true;
23478 globalThis.emptySpawn.msg = e.message;
23479 }
23480 globalThis.emptySpawn.done = true;
23481 });
23482 ",
23483 )
23484 .await
23485 .expect("eval spawnSync empty");
23486
23487 let r = get_global_json(&runtime, "emptySpawn").await;
23488 assert_eq!(r["done"], serde_json::json!(true));
23489 assert_eq!(r["threw"], serde_json::json!(true));
23490 assert!(
23491 r["msg"]
23492 .as_str()
23493 .unwrap_or("")
23494 .contains("command is required")
23495 );
23496 });
23497 }
23498
23499 #[test]
23500 #[cfg(unix)]
23501 fn pijs_spawn_sync_options_as_second_arg() {
23502 futures::executor::block_on(async {
23503 let clock = Arc::new(DeterministicClock::new(0));
23504 let runtime = runtime_with_sync_exec_enabled(Arc::clone(&clock)).await;
23505
23506 runtime
23508 .eval(
23509 r"
23510 globalThis.optsResult = {};
23511 import('node:child_process').then(({ spawnSync }) => {
23512 const r = spawnSync('pwd', { cwd: '/tmp' });
23513 globalThis.optsResult.stdout = r.stdout.trim();
23514 globalThis.optsResult.done = true;
23515 });
23516 ",
23517 )
23518 .await
23519 .expect("eval spawnSync opts as 2nd arg");
23520
23521 let r = get_global_json(&runtime, "optsResult").await;
23522 assert_eq!(r["done"], serde_json::json!(true));
23523 let stdout = r["stdout"].as_str().unwrap_or("");
23524 assert!(
23525 stdout == "/tmp" || stdout.ends_with("/tmp"),
23526 "expected /tmp, got: {stdout}"
23527 );
23528 });
23529 }
23530
23531 #[test]
23534 fn pijs_os_expanded_apis() {
23535 futures::executor::block_on(async {
23536 let clock = Arc::new(DeterministicClock::new(0));
23537 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23538 .await
23539 .expect("create runtime");
23540
23541 runtime
23542 .eval(
23543 r"
23544 globalThis.osEx = {};
23545 import('node:os').then((os) => {
23546 const cpuArr = os.cpus();
23547 globalThis.osEx.cpusIsArray = Array.isArray(cpuArr);
23548 globalThis.osEx.cpusLen = cpuArr.length;
23549 globalThis.osEx.cpuHasModel = typeof cpuArr[0].model === 'string';
23550 globalThis.osEx.cpuHasSpeed = typeof cpuArr[0].speed === 'number';
23551 globalThis.osEx.cpuHasTimes = typeof cpuArr[0].times === 'object';
23552
23553 globalThis.osEx.totalmem = os.totalmem();
23554 globalThis.osEx.totalMemPositive = os.totalmem() > 0;
23555 globalThis.osEx.freeMemPositive = os.freemem() > 0;
23556 globalThis.osEx.freeMemLessTotal = os.freemem() <= os.totalmem();
23557
23558 globalThis.osEx.uptimePositive = os.uptime() > 0;
23559
23560 const la = os.loadavg();
23561 globalThis.osEx.loadavgIsArray = Array.isArray(la);
23562 globalThis.osEx.loadavgLen = la.length;
23563
23564 globalThis.osEx.networkInterfacesIsObj = typeof os.networkInterfaces() === 'object';
23565
23566 const ui = os.userInfo();
23567 globalThis.osEx.userInfoHasUid = typeof ui.uid === 'number';
23568 globalThis.osEx.userInfoHasUsername = typeof ui.username === 'string';
23569 globalThis.osEx.userInfoHasHomedir = typeof ui.homedir === 'string';
23570 globalThis.osEx.userInfoHasShell = typeof ui.shell === 'string';
23571
23572 globalThis.osEx.endianness = os.endianness();
23573 globalThis.osEx.eol = os.EOL;
23574 globalThis.osEx.devNull = os.devNull;
23575 globalThis.osEx.hasConstants = typeof os.constants === 'object';
23576
23577 globalThis.osEx.done = true;
23578 });
23579 ",
23580 )
23581 .await
23582 .expect("eval node:os expanded");
23583
23584 let r = get_global_json(&runtime, "osEx").await;
23585 assert_eq!(r["done"], serde_json::json!(true));
23586 assert_eq!(r["cpusIsArray"], serde_json::json!(true));
23588 assert!(r["cpusLen"].as_f64().unwrap_or(0.0) >= 1.0);
23589 assert_eq!(r["cpuHasModel"], serde_json::json!(true));
23590 assert_eq!(r["cpuHasSpeed"], serde_json::json!(true));
23591 assert_eq!(r["cpuHasTimes"], serde_json::json!(true));
23592 assert_eq!(r["totalMemPositive"], serde_json::json!(true));
23594 assert_eq!(r["freeMemPositive"], serde_json::json!(true));
23595 assert_eq!(r["freeMemLessTotal"], serde_json::json!(true));
23596 assert_eq!(r["uptimePositive"], serde_json::json!(true));
23598 assert_eq!(r["loadavgIsArray"], serde_json::json!(true));
23600 assert_eq!(r["loadavgLen"].as_f64(), Some(3.0));
23601 assert_eq!(r["networkInterfacesIsObj"], serde_json::json!(true));
23603 assert_eq!(r["userInfoHasUid"], serde_json::json!(true));
23605 assert_eq!(r["userInfoHasUsername"], serde_json::json!(true));
23606 assert_eq!(r["userInfoHasHomedir"], serde_json::json!(true));
23607 assert_eq!(r["userInfoHasShell"], serde_json::json!(true));
23608 assert_eq!(r["endianness"], serde_json::json!("LE"));
23610 let expected_eol = if cfg!(windows) { "\r\n" } else { "\n" };
23611 assert_eq!(r["eol"], serde_json::json!(expected_eol));
23612 let expected_dev_null = if cfg!(windows) {
23613 "\\\\.\\NUL"
23614 } else {
23615 "/dev/null"
23616 };
23617 assert_eq!(r["devNull"], serde_json::json!(expected_dev_null));
23618 assert_eq!(r["hasConstants"], serde_json::json!(true));
23619 });
23620 }
23621
23622 #[test]
23625 fn pijs_buffer_expanded_apis() {
23626 futures::executor::block_on(async {
23627 let clock = Arc::new(DeterministicClock::new(0));
23628 let runtime = PiJsRuntime::with_clock(Arc::clone(&clock))
23629 .await
23630 .expect("create runtime");
23631
23632 runtime
23633 .eval(
23634 r"
23635 globalThis.bufResult = {};
23636 (() => {
23637 const B = globalThis.Buffer;
23638
23639 // alloc
23640 const a = B.alloc(4, 0xAB);
23641 globalThis.bufResult.allocFill = Array.from(a);
23642
23643 // from string + hex encoding
23644 const hex = B.from('48656c6c6f', 'hex');
23645 globalThis.bufResult.hexDecode = hex.toString('utf8');
23646
23647 // concat
23648 const c = B.concat([B.from('Hello'), B.from(' World')]);
23649 globalThis.bufResult.concat = c.toString();
23650
23651 // byteLength
23652 globalThis.bufResult.byteLength = B.byteLength('Hello');
23653
23654 // compare
23655 globalThis.bufResult.compareEqual = B.compare(B.from('abc'), B.from('abc'));
23656 globalThis.bufResult.compareLess = B.compare(B.from('abc'), B.from('abd'));
23657 globalThis.bufResult.compareGreater = B.compare(B.from('abd'), B.from('abc'));
23658
23659 // isEncoding
23660 globalThis.bufResult.isEncodingUtf8 = B.isEncoding('utf8');
23661 globalThis.bufResult.isEncodingFake = B.isEncoding('fake');
23662
23663 // isBuffer
23664 globalThis.bufResult.isBufferTrue = B.isBuffer(B.from('x'));
23665 globalThis.bufResult.isBufferFalse = B.isBuffer('x');
23666
23667 // instance methods
23668 const b = B.from('Hello World');
23669 globalThis.bufResult.indexOf = b.indexOf('World');
23670 globalThis.bufResult.includes = b.includes('World');
23671 globalThis.bufResult.notIncludes = b.includes('xyz');
23672
23673 const sliced = b.slice(0, 5);
23674 globalThis.bufResult.slice = sliced.toString();
23675
23676 globalThis.bufResult.toJSON = b.toJSON().type;
23677
23678 const eq1 = B.from('abc');
23679 const eq2 = B.from('abc');
23680 const eq3 = B.from('xyz');
23681 globalThis.bufResult.equalsTrue = eq1.equals(eq2);
23682 globalThis.bufResult.equalsFalse = eq1.equals(eq3);
23683
23684 // copy
23685 const src = B.from('Hello');
23686 const dst = B.alloc(5);
23687 src.copy(dst);
23688 globalThis.bufResult.copy = dst.toString();
23689
23690 // write
23691 const wb = B.alloc(10);
23692 wb.write('Hi');
23693 globalThis.bufResult.write = wb.toString('utf8', 0, 2);
23694
23695 // readUInt / writeUInt
23696 const nb = B.alloc(4);
23697 nb.writeUInt16BE(0x1234, 0);
23698 globalThis.bufResult.readUInt16BE = nb.readUInt16BE(0);
23699 nb.writeUInt32LE(0xDEADBEEF, 0);
23700 globalThis.bufResult.readUInt32LE = nb.readUInt32LE(0);
23701
23702 // hex encoding
23703 const hb = B.from([0xDE, 0xAD]);
23704 globalThis.bufResult.toHex = hb.toString('hex');
23705
23706 // base64 round-trip
23707 const b64 = B.from('Hello').toString('base64');
23708 const roundTrip = B.from(b64, 'base64').toString();
23709 globalThis.bufResult.base64Round = roundTrip;
23710
23711 globalThis.bufResult.done = true;
23712 })();
23713 ",
23714 )
23715 .await
23716 .expect("eval Buffer expanded");
23717
23718 let r = get_global_json(&runtime, "bufResult").await;
23719 assert_eq!(r["done"], serde_json::json!(true));
23720 assert_eq!(r["allocFill"], serde_json::json!([0xAB, 0xAB, 0xAB, 0xAB]));
23722 assert_eq!(r["hexDecode"], serde_json::json!("Hello"));
23724 assert_eq!(r["concat"], serde_json::json!("Hello World"));
23726 assert_eq!(r["byteLength"].as_f64(), Some(5.0));
23728 assert_eq!(r["compareEqual"].as_f64(), Some(0.0));
23730 assert!(r["compareLess"].as_f64().unwrap_or(0.0) < 0.0);
23731 assert!(r["compareGreater"].as_f64().unwrap_or(0.0) > 0.0);
23732 assert_eq!(r["isEncodingUtf8"], serde_json::json!(true));
23734 assert_eq!(r["isEncodingFake"], serde_json::json!(false));
23735 assert_eq!(r["isBufferTrue"], serde_json::json!(true));
23737 assert_eq!(r["isBufferFalse"], serde_json::json!(false));
23738 assert_eq!(r["indexOf"].as_f64(), Some(6.0));
23740 assert_eq!(r["includes"], serde_json::json!(true));
23741 assert_eq!(r["notIncludes"], serde_json::json!(false));
23742 assert_eq!(r["slice"], serde_json::json!("Hello"));
23744 assert_eq!(r["toJSON"], serde_json::json!("Buffer"));
23746 assert_eq!(r["equalsTrue"], serde_json::json!(true));
23748 assert_eq!(r["equalsFalse"], serde_json::json!(false));
23749 assert_eq!(r["copy"], serde_json::json!("Hello"));
23751 assert_eq!(r["write"], serde_json::json!("Hi"));
23753 assert_eq!(r["readUInt16BE"].as_f64(), Some(f64::from(0x1234)));
23755 assert_eq!(r["readUInt32LE"].as_f64(), Some(f64::from(0xDEAD_BEEF_u32)));
23757 assert_eq!(r["toHex"], serde_json::json!("dead"));
23759 assert_eq!(r["base64Round"], serde_json::json!("Hello"));
23761 });
23762 }
23763}