1use std::any::{type_name, Any, TypeId};
10use std::cell::{Cell, RefCell};
11use std::collections::HashMap;
12use std::fmt;
13use std::marker::PhantomData;
14use std::ops::Deref;
15use std::rc::Rc;
16
17use crate::app::Component;
18use crate::reactive::ScopeId;
19
20type HookDispatch = Rc<dyn Fn(&PluginRegistry, &dyn Any)>;
21
22thread_local! {
23 static ACTIVE_PLUGINS: RefCell<PluginRegistry> = RefCell::new(PluginRegistry::default());
24 static ACTIVE_HOOK_MASK: Cell<HookMask> = const { Cell::new(0) };
25}
26
27const APP_PROVIDER: &str = "app";
28type HookMask = u16;
29
30const HOOK_APP_BOOT_STARTED: HookMask = 1 << 0;
31const HOOK_APP_BOOT_COMPLETED: HookMask = 1 << 1;
32const HOOK_APP_BOOT_FAILED: HookMask = 1 << 2;
33const HOOK_ROUTE_NAVIGATION_STARTED: HookMask = 1 << 3;
34const HOOK_ROUTE_NAVIGATION_COMPLETED: HookMask = 1 << 4;
35const HOOK_ROUTE_NAVIGATION_FAILED: HookMask = 1 << 5;
36const HOOK_COMPONENT_SETUP: HookMask = 1 << 6;
37const HOOK_COMPONENT_MOUNTED: HookMask = 1 << 7;
38const HOOK_COMPONENT_READY: HookMask = 1 << 8;
39const HOOK_COMPONENT_UNMOUNTED: HookMask = 1 << 9;
40const HOOK_SERVER_FUNCTION_CLIENT_STARTED: HookMask = 1 << 10;
41const HOOK_SERVER_FUNCTION_CLIENT_COMPLETED: HookMask = 1 << 11;
42const HOOK_SERVER_FUNCTION_CLIENT_FAILED: HookMask = 1 << 12;
43const HOOK_COMPONENT_NAME_EVENTS: HookMask =
44 HOOK_COMPONENT_MOUNTED | HOOK_COMPONENT_READY | HOOK_COMPONENT_UNMOUNTED;
45const HOOK_ROUTE_NAVIGATION_EVENTS: HookMask =
46 HOOK_ROUTE_NAVIGATION_STARTED | HOOK_ROUTE_NAVIGATION_COMPLETED | HOOK_ROUTE_NAVIGATION_FAILED;
47const HOOK_SERVER_FUNCTION_CLIENT_EVENTS: HookMask = HOOK_SERVER_FUNCTION_CLIENT_STARTED
48 | HOOK_SERVER_FUNCTION_CLIENT_COMPLETED
49 | HOOK_SERVER_FUNCTION_CLIENT_FAILED;
50
51#[derive(Clone, Copy)]
59pub(crate) struct ComponentHookActivity {
60 pub(crate) needs_component_name: bool,
61 pub(crate) needs_mount_start: bool,
62}
63
64pub struct Plugin<T: 'static> {
78 service: Rc<T>,
79}
80
81impl<T: 'static> Clone for Plugin<T> {
82 fn clone(&self) -> Self {
83 Self {
84 service: self.service.clone(),
85 }
86 }
87}
88
89impl<T: 'static> Deref for Plugin<T> {
90 type Target = T;
91
92 fn deref(&self) -> &Self::Target {
93 self.service.as_ref()
94 }
95}
96
97impl<T: 'static> Plugin<T> {
98 pub fn get(&self) -> &T {
99 self.service.as_ref()
100 }
101}
102
103#[derive(Clone, Copy, Debug, Default)]
105pub struct Plugins;
106
107impl Plugins {
108 pub fn get<T: 'static>(&self) -> Option<Plugin<T>> {
109 active_plugin::<T>()
110 }
111}
112
113pub trait ComponentPluginExt: Component {
120 fn plugins(&self) -> Plugins {
121 Plugins
122 }
123
124 fn plugin<T: 'static>(&self) -> Plugin<T> {
125 required_plugin::<T>()
126 }
127}
128
129impl<C: Component + ?Sized> ComponentPluginExt for C {}
130
131pub trait Hook<E>: 'static {
141 fn call(&self, event: E);
142}
143
144#[derive(Copy, Clone, Debug)]
146pub struct AppBootStarted {
147 pub component_count: usize,
148 pub route_count: usize,
149}
150
151#[derive(Copy, Clone, Debug)]
155pub struct AppBootCompleted {
156 pub duration_ms: f64,
157}
158
159#[derive(Copy, Clone, Debug)]
165pub struct AppBootFailed {
166 pub reason: &'static str,
167}
168
169#[derive(Clone, Debug)]
175pub struct RouteNavigationStarted {
176 pub path: String,
177 pub route_pattern: Option<&'static str>,
178 pub component: Option<&'static str>,
179}
180
181#[derive(Clone, Debug)]
184pub struct RouteNavigationCompleted {
185 pub path: String,
186 pub route_pattern: Option<&'static str>,
187 pub component: Option<&'static str>,
188 pub duration_ms: f64,
189}
190
191#[derive(Clone, Debug)]
196pub struct RouteNavigationFailed {
197 pub path: String,
198 pub route_pattern: Option<&'static str>,
199 pub component: Option<&'static str>,
200 pub reason: &'static str,
201 pub duration_ms: f64,
202}
203
204#[derive(Clone, Debug)]
210pub struct ServerFunctionClientStarted {
211 pub route: String,
212}
213
214#[derive(Clone, Debug)]
216pub struct ServerFunctionClientCompleted {
217 pub route: String,
218 pub duration_ms: f64,
219 pub status_code: u16,
220}
221
222#[derive(Clone, Debug)]
228pub struct ServerFunctionClientFailed {
229 pub route: String,
230 pub duration_ms: f64,
231 pub error_kind: &'static str,
232}
233
234pub trait ComponentEvent: Clone + 'static {
240 fn component(&self) -> &'static str;
241
242 fn scope_id(&self) -> ScopeId;
243}
244
245pub struct ForComponent<C, E> {
254 event: E,
255 _component: PhantomData<fn() -> C>,
256}
257
258impl<C, E> ForComponent<C, E> {
259 pub(crate) fn new(event: E) -> Self {
260 Self {
261 event,
262 _component: PhantomData,
263 }
264 }
265
266 pub fn event(&self) -> &E {
267 &self.event
268 }
269
270 pub fn into_event(self) -> E {
271 self.event
272 }
273}
274
275impl<C, E: Clone> Clone for ForComponent<C, E> {
276 fn clone(&self) -> Self {
277 Self::new(self.event.clone())
278 }
279}
280
281impl<C, E> Deref for ForComponent<C, E> {
282 type Target = E;
283
284 fn deref(&self) -> &Self::Target {
285 &self.event
286 }
287}
288
289impl<C, E: fmt::Debug> fmt::Debug for ForComponent<C, E> {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 f.debug_tuple("ForComponent").field(&self.event).finish()
292 }
293}
294
295#[derive(Copy, Clone, Debug)]
298pub struct ComponentSetup {
299 pub component: &'static str,
300 pub scope_id: ScopeId,
301}
302
303#[derive(Copy, Clone, Debug)]
305pub struct ComponentMounted {
306 pub component: &'static str,
307 pub scope_id: ScopeId,
308 pub duration_ms: f64,
309}
310
311#[derive(Copy, Clone, Debug)]
314pub struct ComponentReady {
315 pub component: &'static str,
316 pub scope_id: ScopeId,
317}
318
319#[derive(Copy, Clone, Debug)]
321pub struct ComponentUnmounted {
322 pub component: &'static str,
323 pub scope_id: ScopeId,
324}
325
326impl ComponentEvent for ComponentSetup {
327 fn component(&self) -> &'static str {
328 self.component
329 }
330
331 fn scope_id(&self) -> ScopeId {
332 self.scope_id
333 }
334}
335
336impl ComponentEvent for ComponentMounted {
337 fn component(&self) -> &'static str {
338 self.component
339 }
340
341 fn scope_id(&self) -> ScopeId {
342 self.scope_id
343 }
344}
345
346impl ComponentEvent for ComponentReady {
347 fn component(&self) -> &'static str {
348 self.component
349 }
350
351 fn scope_id(&self) -> ScopeId {
352 self.scope_id
353 }
354}
355
356impl ComponentEvent for ComponentUnmounted {
357 fn component(&self) -> &'static str {
358 self.component
359 }
360
361 fn scope_id(&self) -> ScopeId {
362 self.scope_id
363 }
364}
365
366struct PluginService {
367 service: Rc<dyn Any>,
368 provider: &'static str,
369}
370
371struct HookRequirement {
372 plugin: &'static str,
373 service: &'static str,
374 service_type: TypeId,
375 event: &'static str,
376 component: Option<&'static str>,
377}
378
379#[derive(Clone, Debug, PartialEq, Eq)]
386pub struct PluginValidationError {
387 pub plugin: &'static str,
388 pub service: &'static str,
389 pub event: &'static str,
390 pub component: Option<&'static str>,
391}
392
393impl fmt::Display for PluginValidationError {
394 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
395 match self.component {
396 Some(component) => write!(
397 f,
398 "plugin `{}` registered a hook for component `{}` and event `{}` \
399 requiring service `{}`, but that service was not provided",
400 self.plugin, component, self.event, self.service
401 ),
402 None => write!(
403 f,
404 "plugin `{}` registered a hook for event `{}` requiring service `{}`, \
405 but that service was not provided",
406 self.plugin, self.event, self.service
407 ),
408 }
409 }
410}
411
412#[derive(Default)]
413pub(crate) struct PluginRegistry {
414 services: HashMap<TypeId, PluginService>,
415 hooks: HashMap<TypeId, Vec<HookDispatch>>,
416 requirements: Vec<HookRequirement>,
417}
418
419impl PluginRegistry {
420 pub(crate) fn provide<T: 'static>(&mut self, service: T, provider: Option<&'static str>) {
421 let service_type = TypeId::of::<T>();
422 let provider = provider.unwrap_or(APP_PROVIDER);
423 if let Some(previous) = self.services.get(&service_type) {
424 panic!(
425 "plugin service `{}` is already installed (first provider: `{}`, \
426 second provider: `{}`)",
427 type_name::<T>(),
428 previous.provider,
429 provider,
430 );
431 }
432 self.services.insert(
433 service_type,
434 PluginService {
435 service: Rc::new(service),
436 provider,
437 },
438 );
439 }
440
441 pub(crate) fn hook_plugin<T, E>(&mut self, plugin: Option<&'static str>)
442 where
443 T: Hook<E> + 'static,
444 E: Clone + 'static,
445 {
446 let plugin = plugin.unwrap_or(APP_PROVIDER);
447 self.requirements.push(HookRequirement {
448 plugin,
449 service: type_name::<T>(),
450 service_type: TypeId::of::<T>(),
451 event: type_name::<E>(),
452 component: None,
453 });
454 self.hooks
455 .entry(TypeId::of::<E>())
456 .or_default()
457 .push(Rc::new(|registry, event| {
458 let event = event
459 .downcast_ref::<E>()
460 .expect("plugin hook dispatched with the wrong event type")
461 .clone();
462 let service = registry.plugin::<T>().unwrap_or_else(|| {
463 panic!(
464 "plugin hook for event `{}` requires plugin service `{}`, \
465 but that service is not installed. Install it with \
466 `App::provide_plugin(...)` before `App::hook_plugin::<{}, {}>()`.",
467 type_name::<E>(),
468 type_name::<T>(),
469 type_name::<T>(),
470 type_name::<E>(),
471 )
472 });
473 service.get().call(event);
474 }));
475 }
476
477 pub(crate) fn hook_component_plugin<T, C, E>(&mut self, plugin: Option<&'static str>)
478 where
479 T: Hook<ForComponent<C, E>> + 'static,
480 C: Component + 'static,
481 E: ComponentEvent,
482 {
483 let plugin = plugin.unwrap_or(APP_PROVIDER);
484 self.requirements.push(HookRequirement {
485 plugin,
486 service: type_name::<T>(),
487 service_type: TypeId::of::<T>(),
488 event: type_name::<E>(),
489 component: Some(C::NAME),
490 });
491 self.hooks
492 .entry(TypeId::of::<E>())
493 .or_default()
494 .push(Rc::new(|registry, event| {
495 let event = event
496 .downcast_ref::<E>()
497 .expect("plugin hook dispatched with the wrong event type")
498 .clone();
499 if event.component() != C::NAME {
500 return;
501 }
502 let service = registry.plugin::<T>().unwrap_or_else(|| {
503 panic!(
504 "plugin hook for component `{}` and event `{}` requires \
505 plugin service `{}`, but that service is not installed. \
506 Install it with `App::provide_plugin(...)` before \
507 `App::hook_component_plugin::<{}, {}, {}>()`.",
508 C::NAME,
509 type_name::<E>(),
510 type_name::<T>(),
511 type_name::<T>(),
512 type_name::<C>(),
513 type_name::<E>(),
514 )
515 });
516 service.get().call(ForComponent::new(event));
517 }));
518 }
519
520 pub(crate) fn validate(&self) -> Result<(), Vec<PluginValidationError>> {
521 let errors: Vec<_> = self
522 .requirements
523 .iter()
524 .filter(|requirement| !self.services.contains_key(&requirement.service_type))
525 .map(|requirement| PluginValidationError {
526 plugin: requirement.plugin,
527 service: requirement.service,
528 event: requirement.event,
529 component: requirement.component,
530 })
531 .collect();
532 if errors.is_empty() {
533 Ok(())
534 } else {
535 Err(errors)
536 }
537 }
538
539 fn plugin<T: 'static>(&self) -> Option<Plugin<T>> {
540 self.services
541 .get(&TypeId::of::<T>())
542 .and_then(|service| service.service.clone().downcast::<T>().ok())
543 .map(|service| Plugin { service })
544 }
545
546 fn emit<E>(&self, event: E)
547 where
548 E: Clone + 'static,
549 {
550 if let Some(hooks) = self.hooks.get(&TypeId::of::<E>()) {
551 for hook in hooks {
552 hook(self, &event);
553 }
554 }
555 }
556
557 fn has_stored_hooks<E: 'static>(&self) -> bool {
558 self.hooks
559 .get(&TypeId::of::<E>())
560 .map(|hooks| !hooks.is_empty())
561 .unwrap_or(false)
562 }
563
564 fn hook_mask(&self) -> HookMask {
565 let mut mask = 0;
566 if self.has_stored_hooks::<AppBootStarted>() {
567 mask |= HOOK_APP_BOOT_STARTED;
568 }
569 if self.has_stored_hooks::<AppBootCompleted>() {
570 mask |= HOOK_APP_BOOT_COMPLETED;
571 }
572 if self.has_stored_hooks::<AppBootFailed>() {
573 mask |= HOOK_APP_BOOT_FAILED;
574 }
575 if self.has_stored_hooks::<RouteNavigationStarted>() {
576 mask |= HOOK_ROUTE_NAVIGATION_STARTED;
577 }
578 if self.has_stored_hooks::<RouteNavigationCompleted>() {
579 mask |= HOOK_ROUTE_NAVIGATION_COMPLETED;
580 }
581 if self.has_stored_hooks::<RouteNavigationFailed>() {
582 mask |= HOOK_ROUTE_NAVIGATION_FAILED;
583 }
584 if self.has_stored_hooks::<ComponentSetup>() {
585 mask |= HOOK_COMPONENT_SETUP;
586 }
587 if self.has_stored_hooks::<ComponentMounted>() {
588 mask |= HOOK_COMPONENT_MOUNTED;
589 }
590 if self.has_stored_hooks::<ComponentReady>() {
591 mask |= HOOK_COMPONENT_READY;
592 }
593 if self.has_stored_hooks::<ComponentUnmounted>() {
594 mask |= HOOK_COMPONENT_UNMOUNTED;
595 }
596 if self.has_stored_hooks::<ServerFunctionClientStarted>() {
597 mask |= HOOK_SERVER_FUNCTION_CLIENT_STARTED;
598 }
599 if self.has_stored_hooks::<ServerFunctionClientCompleted>() {
600 mask |= HOOK_SERVER_FUNCTION_CLIENT_COMPLETED;
601 }
602 if self.has_stored_hooks::<ServerFunctionClientFailed>() {
603 mask |= HOOK_SERVER_FUNCTION_CLIENT_FAILED;
604 }
605 mask
606 }
607}
608
609pub(crate) fn activate(registry: PluginRegistry) {
615 let hook_mask = registry.hook_mask();
616 ACTIVE_PLUGINS.with(|plugins| {
617 *plugins.borrow_mut() = registry;
618 });
619 ACTIVE_HOOK_MASK.with(|mask| mask.set(hook_mask));
620}
621
622pub(crate) fn emit<E>(event: E)
635where
636 E: Clone + 'static,
637{
638 ACTIVE_PLUGINS.with(|plugins| {
639 plugins.borrow().emit(event);
640 });
641}
642
643#[inline]
644pub(crate) fn component_hook_activity() -> ComponentHookActivity {
645 ACTIVE_HOOK_MASK.with(|active| {
646 let active = active.get();
647 ComponentHookActivity {
648 needs_component_name: active & HOOK_COMPONENT_NAME_EVENTS != 0,
649 needs_mount_start: active & HOOK_COMPONENT_MOUNTED != 0,
650 }
651 })
652}
653
654#[inline]
655pub(crate) fn has_component_setup_hooks() -> bool {
656 active_hook_mask_contains(HOOK_COMPONENT_SETUP)
657}
658
659#[inline]
660pub(crate) fn has_component_mounted_hooks() -> bool {
661 active_hook_mask_contains(HOOK_COMPONENT_MOUNTED)
662}
663
664#[inline]
665pub(crate) fn has_component_ready_hooks() -> bool {
666 active_hook_mask_contains(HOOK_COMPONENT_READY)
667}
668
669#[inline]
670pub(crate) fn has_component_unmounted_hooks() -> bool {
671 active_hook_mask_contains(HOOK_COMPONENT_UNMOUNTED)
672}
673
674#[inline]
675pub(crate) fn has_route_navigation_hooks() -> bool {
676 active_hook_mask_contains(HOOK_ROUTE_NAVIGATION_EVENTS)
677}
678
679#[inline]
680pub(crate) fn has_server_function_client_hooks() -> bool {
681 active_hook_mask_contains(HOOK_SERVER_FUNCTION_CLIENT_EVENTS)
682}
683
684#[inline]
685fn active_hook_mask_contains(mask: HookMask) -> bool {
686 ACTIVE_HOOK_MASK.with(|active| active.get() & mask != 0)
687}
688
689pub(crate) fn active_plugin<T: 'static>() -> Option<Plugin<T>> {
690 ACTIVE_PLUGINS.with(|plugins| plugins.borrow().plugin::<T>())
691}
692
693pub(crate) fn required_plugin<T: 'static>() -> Plugin<T> {
694 active_plugin::<T>().unwrap_or_else(|| {
695 panic!(
696 "plugin service `{}` is not installed. Install it from an app \
697 plugin with `App::provide_plugin(...)`, or use \
698 `Option<Plugin<{}>>` for reusable components where the plugin is optional.",
699 type_name::<T>(),
700 type_name::<T>(),
701 )
702 })
703}
704
705pub(crate) fn render_plugin_boot_error(errors: &[PluginValidationError]) {
706 let Some(win) = web_sys::window() else { return };
707 let Some(doc) = win.document() else { return };
708 let Some(body) = doc.body() else { return };
709 if let Ok(Some(existing)) = body.query_selector("[data-pocopine-boot-error=\"plugin\"]") {
710 existing.remove();
711 }
712 let Ok(banner) = doc.create_element("div") else {
713 return;
714 };
715 let _ = banner.set_attribute("data-pocopine-boot-error", "plugin");
716 let _ = banner.set_attribute(
717 "style",
718 "position:fixed;inset:0;background:#1b1b1f;color:#f5f5f7;\
719 font-family:ui-monospace,monospace;padding:24px;overflow:auto;\
720 z-index:2147483647;",
721 );
722 let mut html = String::from(
723 "<h2 style=\"margin:0 0 12px 0;color:#ff6b6b;\">pocopine: \
724 app plugin configuration is invalid</h2>\
725 <p style=\"margin:0 0 16px 0;\">The runtime refused to mount \
726 because one or more plugin hooks require services that were not \
727 installed.</p><ul style=\"margin:0;padding-left:20px;\">",
728 );
729 for err in errors {
730 html.push_str("<li style=\"margin-bottom:8px;\">");
731 html.push_str(&html_escape(&err.to_string()));
732 html.push_str("</li>");
733 }
734 html.push_str("</ul>");
735 banner.set_inner_html(&html);
736 let _ = body.append_child(&banner);
737 web_sys::console::error_1(
738 &format!(
739 "pocopine: app plugin configuration has {} error(s); refusing to mount",
740 errors.len()
741 )
742 .into(),
743 );
744 for err in errors {
745 web_sys::console::error_1(&err.to_string().into());
746 }
747}
748
749fn html_escape(s: &str) -> String {
750 s.replace('&', "&")
751 .replace('<', "<")
752 .replace('>', ">")
753}