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({