web_panic_report/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
use std::sync::{
    atomic::{AtomicBool, Ordering},
    Arc,
};
use wasm_bindgen::prelude::*;
use web_sys::{wasm_bindgen::JsCast, Element, HtmlButtonElement, HtmlTextAreaElement};

#[cfg(feature = "default-form")]
mod default_form;
#[cfg(feature = "default-form")]
pub use default_form::set_default_hook_with;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn error(msg: String);

    type Error;

    #[wasm_bindgen(constructor)]
    fn new() -> Error;

    #[wasm_bindgen(structural, method, getter)]
    fn stack(error: &Error) -> String;
}

/// The ID of the text area which is loaded with the stack trace in the default form.
pub const FORM_TEXTAREA_ID: &str = "panic_info_form_text";

/// The ID of the `Send Report` button element in the default form.
pub const FORM_SUBMIT_ID: &str = "panic_info_form_submit";

/// Information about the panic that occurred, potentially useful to report.
///
/// Why not [`PanicInfo`](std::panic::PanicInfo)? `PanicInfo` is unable to be owned, doesn't
/// implement `Clone`, and is `!Send`, making it unable to pass to a callback.
#[derive(Debug)]
pub struct WasmPanicInfo {
    /// The file that triggered the panic
    pub file: String,
    /// The line the panic was triggered on
    pub line: u32,
    /// The column the panic was triggered on
    pub col: u32,
    /// Equivalent to [`PanicInfo`](std::panic::PanicInfo)'s `to_string()` method.
    pub display: String,
    /// The full stack trace retrieved.
    pub stack: String,
}
impl std::fmt::Display for WasmPanicInfo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.display.fmt(f)
    }
}

/// Set the panic hook.
///
/// # Params
/// `container_id`: The ID of the HTML element that will be unmounted in favor of the form.\
/// `form_html`: The raw HTML that will replace the container.\
/// `on_panic_callback`: A closure that is triggered on panic automatically.\
/// `submit_callback`: The closure that will run when the user hits the send report button.
///
/// # Panics
/// This will panic (ironically) if the panic is caught in a headless environment.
pub fn set_hook_with<F1, F2>(
    container_id: impl Into<String>,
    form_html: impl Into<String>,
    on_panic_callback: F1,
    submit_callback: F2,
) where
    F1: Fn(&WasmPanicInfo) + Send + Sync + 'static,
    F2: Fn(&WasmPanicInfo) + Send + Sync + 'static,
{
    let form_html = form_html.into();
    let container_id = container_id.into();
    let already_hydrated: AtomicBool = AtomicBool::new(false);
    let on_panic_callback = Arc::new(on_panic_callback);
    let submit_callback = Arc::new(submit_callback);

    std::panic::set_hook(Box::new(move |panic_info| {
        // Collect stack trace
        let e = Error::new();
        let stack = e.stack();

        // Log to stderr on console
        let console_message = {
            let mut stack_trace = panic_info.to_string();
            stack_trace.push_str("\n\nStack:\n\n");
            stack_trace.push_str(&stack);
            stack_trace.push_str("\n\n");
            stack_trace
        };
        // Print error
        error(console_message.clone());

        if already_hydrated.swap(true, Ordering::Relaxed) {
            // Error already hydrated to the form body
            return;
        }

        let wasm_panic_info = WasmPanicInfo {
            file: panic_info
                .location()
                .map(|pi: &std::panic::Location<'_>| pi.file())
                .unwrap_or_default()
                .to_owned(),
            line: panic_info
                .location()
                .map(|pi| pi.line())
                .unwrap_or_default(),
            col: panic_info
                .location()
                .map(|pi| pi.column())
                .unwrap_or_default(),
            display: panic_info.to_string(),
            stack: e.stack(),
        };
        // Trigger callback on panic.
        on_panic_callback(&wasm_panic_info);

        // Retrieve the parent container we will be replacing with the report form.
        let window = web_sys::window().expect("global window does not exists");
        let document = window.document().expect("expecting a document on window");
        let parent: Element = document
            .get_element_by_id(&container_id)
            .unwrap_or_else(|| {
                panic!("`web_panic_report`: element `{container_id}` does not exist in the DOM",)
            });
        // Replace inner html with our report form
        parent.set_inner_html(&form_html);

        // Hydrate the stack trace
        let text_area: HtmlTextAreaElement = document
            .get_element_by_id(FORM_TEXTAREA_ID)
            .expect("form text id doesn't exist")
            .unchecked_into();
        text_area.set_value(&console_message);

        // Add onclick handler to the submit button
        let submit_button: HtmlButtonElement = document
            .get_element_by_id(FORM_SUBMIT_ID)
            .expect("form submit id doesn't exist")
            .unchecked_into();
        let closure: Closure<dyn Fn()> = Closure::new({
            let callback = submit_callback.clone();
            move || {
                callback(&wasm_panic_info);
            }
        });
        let closure = closure.into_js_value();
        submit_button.set_onclick(Some(closure.as_ref().unchecked_ref()));
    }));
}