ssr/
ssr.rs

1use std::{fs, net::Shutdown, path::PathBuf, sync::Arc};
2
3use http::Uri;
4use serde::Serialize;
5use tokio::{
6    io::{AsyncReadExt, AsyncWriteExt},
7    net::TcpStream,
8};
9use uuid::Uuid;
10
11use crate::{
12    error::{InitializationError, RenderingError},
13    worker::{Port, Worker},
14};
15
16/// Enum that instructs which JS renderer to use when [`ssr.render`](Ssr::render) gets called.
17pub enum JsRenderer {
18    /// Global JS renderer that was passed to [`Ssr::new`](Ssr::new) during initialization via
19    /// [`SsrConfig`](SsrConfig::global_js_renderer).
20    Global,
21    /// JS renderer specific to the current request.
22    PerRequest {
23        /// A path to JS renderer
24        path: PathBuf,
25    },
26}
27
28/// Sets log verbosity of Node.js worker.
29pub enum JsWorkerLog {
30    /// Logs only warnings and errors.
31    Minimal,
32    /// Logs all debugging information.
33    Verbose,
34}
35
36impl JsWorkerLog {
37    pub(crate) fn to_str(&self) -> &str {
38        match self {
39            JsWorkerLog::Minimal => "minimal",
40            JsWorkerLog::Verbose => "verbose",
41        }
42    }
43}
44
45/// A global configuration for [`Ssr`](Ssr) instance.
46pub struct SsrConfig {
47    /// A port that Node.js worker will be listening on.
48    pub port: u16,
49    /// Path to Node.js worker installed from `npm`. It should be relative to the
50    /// [`std::env::current_dir`](std::env::current_dir).
51    pub js_worker: PathBuf,
52    /// Log verbosity of Node.js worker.
53    pub js_worker_log: JsWorkerLog,
54    /// If your web app is a SPA (Single Page Application), then you should have a single entry
55    /// point for all rendering requests. If it's the case, provide a path to this file here and it
56    /// will be used by the worker to render all responses. Another option is to provide a JS
57    /// renderer per request but keep in mind that it would introduce additional runtime overhead
58    /// since JS module has to be required during a request as opposed to requiring it once on
59    /// application startup.
60    pub global_js_renderer: Option<PathBuf>,
61}
62
63/// The main struct of the crate that manages Node.js process and handles rendering.
64#[derive(Clone)]
65pub struct Ssr {
66    worker: Arc<Worker>,
67    js_worker: PathBuf,
68    global_js_renderer: Option<PathBuf>,
69}
70
71impl Ssr {
72    /// Creates an [`Ssr`](Ssr) instance.
73    ///
74    /// # Example
75    ///
76    /// ```rust
77    /// let ssr =
78    ///   Ssr::new(
79    ///     SsrConfig {
80    ///       port: 9000,
81    ///       js_worker: PathBuf::from("./node_modules/ssr-rs/worker.js"),
82    ///       js_worker_log: JsWorkerLog::Verbose,
83    ///       global_js_renderer: Some(PathBuf::from("./js/ssr.js")),
84    ///     }
85    ///   );
86    /// ```
87    pub async fn new(cfg: SsrConfig) -> Result<Self, InitializationError> {
88        let port = Port::new(cfg.port);
89        let js_worker = match fs::canonicalize(cfg.js_worker) {
90            Ok(path) => path,
91            Err(err) => return Err(InitializationError::InvalidJsWorkerPath(err)),
92        };
93        let global_js_renderer = match cfg.global_js_renderer {
94            Some(path) => match fs::canonicalize(path) {
95                Ok(path) => Some(path),
96                Err(err) => return Err(InitializationError::InvalidGlobalJsRendererPath(err)),
97            },
98            None => None,
99        };
100        let worker =
101            Worker::new(&port, &js_worker, &cfg.js_worker_log, &global_js_renderer).await?;
102        Ok(Self {
103            worker: Arc::new(worker),
104            js_worker,
105            global_js_renderer,
106        })
107    }
108
109    /// Renders a response to an incoming request using Node.js worker.
110    ///
111    /// # Example
112    ///
113    /// ```rust
114    /// let uri = req.uri();
115    /// let data = db::get_data();
116    /// match ssr.render(uri, &data, JsRenderer::Global).await {
117    ///     Ok(html) => HttpResponse::Ok().body(html),
118    ///     Err(error) => {
119    ///         error!("Error: {}", error);
120    ///         HttpResponse::InternalServerError().finish()
121    ///     }
122    /// }
123    pub async fn render<D: Serialize>(
124        &self,
125        uri: &Uri,
126        data: &D,
127        js_renderer: JsRenderer,
128    ) -> Result<String, RenderingError> {
129        let request_id = Uuid::new_v4();
130
131        trace!("Starting request {}", request_id);
132
133        let worker = &self.worker;
134
135        let mut stream = match worker.connect().await {
136            Ok(stream) => stream,
137            Err(err) => {
138                error!(
139                    "{worker}: Failed to connect: {err}",
140                    worker = worker.display_with_request_id(&request_id),
141                    err = err
142                );
143                return Err(RenderingError::ConnectionError(err));
144            }
145        };
146
147        let url = match uri.path_and_query() {
148            Some(url) => url,
149            None => {
150                Self::finalize_rendering_session(&worker, &stream, &request_id);
151                return Err(RenderingError::InvalidUri);
152            }
153        };
154
155        let request_renderer = match (&self.global_js_renderer, js_renderer) {
156            (Some(_), JsRenderer::Global) => None,
157            (_, JsRenderer::PerRequest { path }) => Some(path),
158            (None, JsRenderer::Global) => {
159                Self::finalize_rendering_session(&worker, &stream, &request_id);
160                return Err(RenderingError::GlobalRendererNotProvided);
161            }
162        };
163
164        let meta = json!({
165          "requestId": request_id,
166          "requestRenderer": request_renderer,
167          "url": json!({"path": url.path(), "query": url.query()}),
168        });
169        let meta_bytes = match serde_json::to_vec(&meta) {
170            Ok(bytes) => bytes,
171            Err(err) => {
172                Self::finalize_rendering_session(&worker, &stream, &request_id);
173                return Err(RenderingError::UrlSerializationError(err));
174            }
175        };
176        let data = match serde_json::to_string(&data) {
177            Ok(data) => data,
178            Err(err) => {
179                Self::finalize_rendering_session(&worker, &stream, &request_id);
180                return Err(RenderingError::DataSerializationError(err));
181            }
182        };
183        let data_bytes = match crate::json::to_vec(&data) {
184            Ok(bytes) => bytes,
185            Err(err) => {
186                Self::finalize_rendering_session(&worker, &stream, &request_id);
187                return Err(RenderingError::DataSerializationError(err));
188            }
189        };
190        let meta_len = meta_bytes.len() as u32;
191        let data_len = data_bytes.len() as u32;
192        let meta_len_bytes = meta_len.to_be_bytes();
193        let data_len_bytes = data_len.to_be_bytes();
194        let mut input = meta_len_bytes.to_vec();
195        input.extend_from_slice(&data_len_bytes);
196        input.extend(meta_bytes);
197        input.extend(data_bytes);
198
199        let mut res = String::new();
200
201        trace!(
202            "{worker}: Writing input to socket",
203            worker = worker.display_with_request_id(&request_id),
204        );
205
206        if let Err(err) = stream.write_all(input.as_slice()).await {
207            Self::finalize_rendering_session(&worker, &stream, &request_id);
208            return Err(RenderingError::RenderRequestError(err));
209        };
210
211        trace!(
212            "{worker}: Input written to socket",
213            worker = worker.display_with_request_id(&request_id),
214        );
215
216        if let Err(err) = stream.read_to_string(&mut res).await {
217            Self::finalize_rendering_session(&worker, &stream, &request_id);
218            return Err(RenderingError::RenderResponseError(err));
219        };
220
221        trace!(
222            "{worker}: Output written to result buffer",
223            worker = worker.display_with_request_id(&request_id),
224        );
225
226        // No need to shutdown connection as it's already closed by the js worker
227        if res.starts_with("ERROR:") {
228            trace!(
229                "{worker}: Output is an error",
230                worker = worker.display_with_request_id(&request_id),
231            );
232            let stack = res.strip_prefix("ERROR:").unwrap();
233            Err(RenderingError::JsExceptionDuringRendering(stack.to_string()))
234        } else {
235            trace!(
236                "{worker}: Output is ok",
237                worker = worker.display_with_request_id(&request_id),
238            );
239            Ok(res)
240        }
241    }
242
243    fn finalize_rendering_session(worker: &Worker, connection: &TcpStream, request_id: &Uuid) {
244        if let Err(err) = connection.shutdown(Shutdown::Both) {
245            warn!(
246                "{worker}: Failed to shutdown connection to the js worker: {err}",
247                worker = worker.display_with_request_id(&request_id),
248                err = err
249            );
250        };
251    }
252}