From 77c902a7a5dfb84ada88c6a59aa13cbf351d60bc Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Sun, 9 Mar 2025 11:32:05 +0100 Subject: [PATCH] feat: display error overlay (#607) Co-authored-by: Marco Pasqualetti --- crates/tuono/src/cli.rs | 5 +- crates/tuono/src/source_builder.rs | 64 ++++- crates/tuono_internal/src/config.rs | 26 +- crates/tuono_lib/src/ssr.rs | 31 ++- packages/tuono/src/build/error-overlay.ts | 293 ++++++++++++++++++++++ packages/tuono/src/build/index.ts | 3 + packages/tuono/vite.config.ts | 5 + 7 files changed, 412 insertions(+), 15 deletions(-) create mode 100644 packages/tuono/src/build/error-overlay.ts diff --git a/crates/tuono/src/cli.rs b/crates/tuono/src/cli.rs index b19ff4a0..4115ca4b 100644 --- a/crates/tuono/src/cli.rs +++ b/crates/tuono/src/cli.rs @@ -8,7 +8,9 @@ use crate::app::App; use crate::build; use crate::mode::Mode; use crate::scaffold_project; -use crate::source_builder::{bundle_axum_source, check_tuono_folder, create_client_entry_files}; +use crate::source_builder::{ + bundle_axum_source, check_tuono_folder, create_client_entry_files, generate_fallback_html, +}; use crate::watch; #[derive(Subcommand, Debug)] @@ -82,6 +84,7 @@ pub fn app() -> std::io::Result<()> { app.build_tuono_config() .expect("Failed to build tuono.config.ts"); + generate_fallback_html(&app)?; app.check_server_availability(Mode::Dev); watch::watch().unwrap(); diff --git a/crates/tuono/src/source_builder.rs b/crates/tuono/src/source_builder.rs index 2244edad..7586c246 100644 --- a/crates/tuono/src/source_builder.rs +++ b/crates/tuono/src/source_builder.rs @@ -5,13 +5,32 @@ use std::io::prelude::*; use std::path::Path; use clap::crate_version; +use tracing::error; use crate::app::App; use crate::mode::Mode; use crate::route::AxumInfo; use crate::route::Route; -pub const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono +const FALLBACK_HTML: &str = r#" + + + + + + + +"#; + +const SERVER_ENTRY_DATA: &str = "// File automatically generated by tuono // Do not manually update this file import { routeTree } from './routeTree.gen' import { serverSideRendering } from 'tuono/ssr' @@ -19,7 +38,7 @@ import { serverSideRendering } from 'tuono/ssr' export const renderFn = serverSideRendering(routeTree) "; -pub const CLIENT_ENTRY_DATA: &str = "// File automatically generated by tuono +const CLIENT_ENTRY_DATA: &str = "// File automatically generated by tuono // Do not manually update this file import 'vite/modulepreload-polyfill' import { hydrate } from 'tuono/hydration' @@ -31,7 +50,7 @@ import { routeTree } from './routeTree.gen' hydrate(routeTree) "; -pub const AXUM_ENTRY_POINT: &str = r##" +const AXUM_ENTRY_POINT: &str = r##" // File automatically generated // Do not manually change it @@ -184,6 +203,33 @@ fn generate_axum_source(app: &App, mode: Mode) -> String { src.replace("// AXUM_GET_ROUTE_HANDLER", &import_http_handler) } +fn create_html_fallback(app: &App) -> String { + if let Some(config) = app.config.as_ref() { + if let Some(origin) = &config.server.origin { + FALLBACK_HTML.replace("[BASE_URL]", origin) + } else { + let url = format!("http://{}:{}", config.server.host, config.server.port); + FALLBACK_HTML.replace("[BASE_URL]", url.as_str()) + } + } else { + "".to_string() + } +} + +pub fn generate_fallback_html(app: &App) -> io::Result<()> { + let base_path = std::env::current_dir().unwrap_or_else(|_| { + error!("Failed to get current directory"); + std::process::exit(1); + }); + let mut data_file = fs::File::create(base_path.join(".tuono/index.html"))?; + + let fallback_html = create_html_fallback(app); + + data_file.write_all(fallback_html.as_bytes())?; + + Ok(()) +} + pub fn check_tuono_folder() -> io::Result<()> { let dev_folder = Path::new(DEV_FOLDER); if !&dev_folder.is_dir() { @@ -244,4 +290,16 @@ mod tests { let dev_bundle = generate_axum_source(&source_builder, Mode::Dev); assert!(dev_bundle.contains("use tuono_lib::axum::routing::get;")); } + + #[test] + fn should_create_fallback_html_with_default_config() { + let mut app = App::new(); + app.config = Some(Default::default()); + + let fallback_html = create_html_fallback(&app); + + assert!(fallback_html.contains("http://localhost:3000/vite-server/@react-refresh")); + assert!(fallback_html.contains("http://localhost:3000/vite-server/@vite/client")); + assert!(fallback_html.contains("http://localhost:3000/vite-server/client-main.tsx")); + } } diff --git a/crates/tuono_internal/src/config.rs b/crates/tuono_internal/src/config.rs index f5c02866..41237fe7 100644 --- a/crates/tuono_internal/src/config.rs +++ b/crates/tuono_internal/src/config.rs @@ -10,7 +10,17 @@ pub struct ServerConfig { pub port: u16, } -#[derive(Deserialize, Serialize, Debug, Clone)] +impl Default for ServerConfig { + fn default() -> Self { + ServerConfig { + host: "localhost".to_string(), + origin: None, + port: 3000, + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, Default)] pub struct Config { pub server: ServerConfig, } @@ -23,3 +33,17 @@ impl Config { .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_default() { + let config = Config::default(); + + assert_eq!(config.server.host, "localhost".to_string()); + assert_eq!(config.server.origin, None); + assert_eq!(config.server.port, 3000); + } +} diff --git a/crates/tuono_lib/src/ssr.rs b/crates/tuono_lib/src/ssr.rs index a468aaf0..ebc96ee8 100644 --- a/crates/tuono_lib/src/ssr.rs +++ b/crates/tuono_lib/src/ssr.rs @@ -36,10 +36,10 @@ struct ProdJs; impl ProdJs { thread_local! { pub static SSR: RefCell> = RefCell::new( - Ssr::from( - read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), "" - ).unwrap() - ) + Ssr::from( + read_to_string(PathBuf::from(PROD_BUNDLE_PATH)).expect("Server bundle not found"), "" + ).unwrap() + ) } } @@ -47,11 +47,22 @@ struct DevJs; impl DevJs { pub fn render_to_string(params: Option<&str>) -> Result { - Ssr::from( - read_to_string(PathBuf::from(DEV_BUNDLE_PATH)).expect("Server bundle not found"), - "", - ) - .unwrap() - .render_to_string(params) + let bundle_path = read_to_string(PathBuf::from(DEV_BUNDLE_PATH)); + + if let Ok(source) = bundle_path { + let ssr = Ssr::from(source, ""); + if let Ok(mut ssr) = ssr { + ssr.render_to_string(params) + } else { + let fallback_html = read_to_string(PathBuf::from("./.tuono/index.html")) + .unwrap_or("Fallback HTML not loaded".to_string()); + Ok(fallback_html.replace("[SERVER_PAYLOAD]", params.unwrap_or(""))) + } + } else { + let fallback_html = read_to_string(PathBuf::from("./.tuono/index.html")) + .unwrap_or("Fallback HTML not loaded".to_string()); + + Ok(fallback_html.replace("[SERVER_PAYLOAD]", params.unwrap_or(""))) + } } } diff --git a/packages/tuono/src/build/error-overlay.ts b/packages/tuono/src/build/error-overlay.ts new file mode 100644 index 00000000..cf9fb5c0 --- /dev/null +++ b/packages/tuono/src/build/error-overlay.ts @@ -0,0 +1,293 @@ +/** + * Most of this file is derived from the Vite project. + * We need to re-export it to implement the required changes for Tuono. + * + * Source: https://github.com/vitejs/vite/blob/2c51565ec044904a080ef5649034c37f02212c7b/packages/vite/src/client/overlay.ts#L209 + * License: https://github.com/vitejs/vite/blob/main/LICENSE + * + * @see https://github.com/tuono-labs/tuono/pull/607 + * @see https://github.com/vitejs/vite/issues/19552 + */ +import type { ErrorPayload, Plugin } from 'vite' + +// Set the `:host` styles to ensure that Playwright can detect the element as visible +const templateStyle = /*css*/ ` +:host { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 99999; + --monospace: 'SFMono-Regular', Consolas, + 'Liberation Mono', Menlo, Courier, monospace; + --red: #ff5555; + --yellow: #e2aa53; + --purple: #cfa4ff; + --cyan: #2dd9da; + --dim: #c9c9c9; + + --window-background: #181818; + --window-color: #d8d8d8; +} + +.backdrop { + position: fixed; + z-index: 99999; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow-y: scroll; + margin: 0; + background: rgba(0, 0, 0, 0.66); +} + +.window { + font-family: var(--monospace); + line-height: 1.5; + max-width: 80vw; + color: var(--window-color); + box-sizing: border-box; + margin: 30px auto; + padding: 2.5vh 4vw; + position: relative; + background: var(--window-background); + border-radius: 6px 6px 8px 8px; + box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22); + overflow: hidden; + border-top: 8px solid var(--red); + direction: ltr; + text-align: left; +} + +pre { + font-family: var(--monospace); + font-size: 16px; + margin-top: 0; + overflow-x: scroll; + scrollbar-width: none; +} + +pre::-webkit-scrollbar { + display: none; +} + +pre.frame::-webkit-scrollbar { + display: block; + height: 5px; +} + +pre.frame::-webkit-scrollbar-thumb { + background: #999; + border-radius: 5px; +} + +pre.frame { + scrollbar-width: thin; +} + +.message { + line-height: 1.3; + font-weight: 600; + white-space: pre-wrap; +} + +.message-body { + color: var(--red); +} + +.plugin { + color: var(--purple); +} + +.file { + color: var(--cyan); + margin-bottom: 0; + white-space: pre-wrap; + word-break: break-all; +} + +.frame { + color: var(--yellow); +} + +.stack { + font-size: 13px; + color: var(--dim); +} + +.tip { + font-size: 13px; + color: #999; + border-top: 1px dotted #999; + padding-top: 13px; + line-height: 1.8; +} + +code { + font-size: 13px; + font-family: var(--monospace); + color: var(--yellow); +} + +.file-link { + text-decoration: underline; + cursor: pointer; +} + +kbd { + line-height: 1.5; + font-family: ui-monospace, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.75rem; + font-weight: 700; + background-color: rgb(38, 40, 44); + color: rgb(166, 167, 171); + padding: 0.15rem 0.3rem; + border-radius: 0.25rem; + border-width: 0.0625rem 0.0625rem 0.1875rem; + border-style: solid; + border-color: rgb(54, 57, 64); + border-image: initial; +} +` + +const fileRE = /(?:[a-zA-Z]:\\|\/).*?:\d+:\d+/g +const codeframeRE = /^(?:>?\s*\d+\s+\|.*|\s+\|\s*\^.*)\r?\n/gm + +const overlayTemplate = ` +
+
+
+      
+      
+    
+
+    
+

+    

+    
Click outside, press Esc key, or fix the code to dismiss.
+
+ +
+` + +const HTMLElement: typeof globalThis.HTMLElement = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-extraneous-class + globalThis.HTMLElement ?? class {} + +export class ErrorOverlay extends HTMLElement { + root: ShadowRoot + closeOnEsc: (event: KeyboardEvent) => void + + constructor(err: ErrorPayload['err'], links = true) { + super() + + this.root = this.attachShadow({ mode: 'open' }) + const root = this.getRoot() + + root.innerHTML = overlayTemplate + + codeframeRE.lastIndex = 0 + const hasFrame = err.frame && codeframeRE.test(err.frame) + const message = hasFrame + ? err.message.replace(codeframeRE, '') + : err.message + + if (err.plugin) { + this.text('.plugin', `[plugin:${err.plugin}] `) + } + this.text('.message-body', message.trim()) + + const [file] = (err.loc?.file || err.id || 'unknown file').split(`?`) + if (err.loc && file) { + this.text('.file', `${file}:${err.loc.line}:${err.loc.column}`, links) + } else if (err.id && file) { + this.text('.file', file) + } + + if (hasFrame && err.frame) { + this.text('.frame', err.frame.trim()) + } + this.text('.stack', err.stack, links) + + const rootWindowElement = root.querySelector('.window') as HTMLElement + rootWindowElement.addEventListener('click', (event: Event) => { + event.stopPropagation() + }) + + this.addEventListener('click', () => { + this.close() + }) + + this.closeOnEsc = (event): void => { + if (event.key === 'Escape' || event.code === 'Escape') { + this.close() + } + } + + const closeOnEsc = this.getCloseOnEsc() + + document.addEventListener('keydown', closeOnEsc) + } + + getRoot(): ShadowRoot { + return this.root + } + + getCloseOnEsc(): this['closeOnEsc'] { + return this.closeOnEsc + } + + text(selector: string, text: string, linkFiles = false): void { + const root = this.getRoot() + + const el = root.querySelector(selector) as HTMLElement + if (!linkFiles) { + el.textContent = text + } else { + let curIndex = 0 + let match: RegExpExecArray | null + fileRE.lastIndex = 0 + while ((match = fileRE.exec(text))) { + const { 0: file, index } = match + const frag = text.slice(curIndex, index) + el.appendChild(document.createTextNode(frag)) + const link = document.createElement('a') + link.textContent = file + link.className = 'file-link' + el.appendChild(link) + curIndex += frag.length + file.length + } + } + } + close(): void { + const closeOnEsc = this.getCloseOnEsc() + this.parentNode?.removeChild(this) + document.removeEventListener('keydown', closeOnEsc) + } +} + +function getOverlayCode(): string { + return ` + const overlayTemplate = \`${overlayTemplate}\`; + ${ErrorOverlay.toString()} + ` +} + +function patchOverlay(code: string): string { + return code.replace( + 'class ErrorOverlay', + getOverlayCode() + '\nclass ViteErrorOverlay', + ) +} + +export const ErrorOverlayVitePlugin: Plugin = { + name: 'tuono-error-overlay-plugin', + transform(code, id, opts = {}) { + if (opts.ssr) return + if (!id.includes('vite/dist/client/client.mjs')) return + + return patchOverlay(code) + }, +} diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index 44188174..75e5d1ac 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -6,6 +6,8 @@ import { TuonoFsRouterPlugin } from 'tuono-fs-router-vite-plugin' import type { TuonoConfig } from '../config' +import { ErrorOverlayVitePlugin } from './error-overlay' + import { blockingAsync } from './utils' import { createJsonConfig, loadConfig } from './config' @@ -119,6 +121,7 @@ const developmentCSRWatch = (): void => { { // Entry point for the development vite proxy base: '/vite-server/', + plugins: [ErrorOverlayVitePlugin], server: { host: config.server.host, diff --git a/packages/tuono/vite.config.ts b/packages/tuono/vite.config.ts index d131d7ee..5c631f98 100644 --- a/packages/tuono/vite.config.ts +++ b/packages/tuono/vite.config.ts @@ -6,6 +6,11 @@ import react from '@vitejs/plugin-react-swc' export default mergeConfig( defineConfig({ + /** + * add explicit build target to avoid transpilation on class properties + * @see https://github.com/tuono-labs/tuono/pull/607#discussion_r1983979427 + */ + build: { target: 'es2022' }, plugins: [react()], }), defineViteConfig({