diff --git a/Cargo.lock b/Cargo.lock index 02233b712..f583c26d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10243,7 +10243,6 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2 0.4.7", diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index bcd4ed233..be167cd7c 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -67,9 +67,8 @@ dunce = { workspace = true } dirs = { workspace = true } reqwest = { workspace = true, features = [ "rustls-tls", - "stream", "trust-dns", - "blocking", + "json" ] } tower = { workspace = true } once_cell = "1.19.0" diff --git a/packages/cli/src/build/bundle.rs b/packages/cli/src/build/bundle.rs index a25e7569e..85d9be6e9 100644 --- a/packages/cli/src/build/bundle.rs +++ b/packages/cli/src/build/bundle.rs @@ -1,3 +1,4 @@ +use super::prerender::pre_render_static_routes; use super::templates::InfoPlistData; use crate::wasm_bindgen::WasmBindgenBuilder; use crate::{BuildRequest, Platform}; @@ -284,6 +285,7 @@ impl AppBundle { .context("Failed to write assets")?; bundle.write_metadata().await?; bundle.optimize().await?; + bundle.pre_render_ssg_routes().await?; bundle .assemble() .await @@ -558,11 +560,6 @@ impl AppBundle { }) .await .unwrap()?; - - // Run SSG and cache static routes - if self.build.build.ssg { - self.run_ssg().await?; - } } Platform::MacOS => {} Platform::Windows => {} @@ -686,76 +683,18 @@ impl AppBundle { Ok(()) } - async fn run_ssg(&self) -> anyhow::Result<()> { - use futures_util::stream::FuturesUnordered; - use futures_util::StreamExt; - use tokio::process::Command; - - let fullstack_address = dioxus_cli_config::fullstack_address_or_localhost(); - let address = fullstack_address.ip().to_string(); - let port = fullstack_address.port().to_string(); - - tracing::info!("Running SSG"); - - // Run the server executable - let _child = Command::new( - self.server_exe() + async fn pre_render_ssg_routes(&self) -> Result<()> { + // Run SSG and cache static routes + if !self.build.build.ssg { + return Ok(()); + } + self.build.status_prerendering_routes(); + pre_render_static_routes( + &self + .server_exe() .context("Failed to find server executable")?, ) - .env(dioxus_cli_config::SERVER_PORT_ENV, port.clone()) - .env(dioxus_cli_config::SERVER_IP_ENV, address.clone()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - // Wait a second for the server to start - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - // Get the routes from the `/static_routes` endpoint - let mut routes = reqwest::Client::builder() - .build()? - .post(format!("http://{address}:{port}/api/static_routes")) - .send() - .await - .context("Failed to get static routes from server")? - .text() - .await - .map(|raw| serde_json::from_str::>(&raw).unwrap()) - .inspect(|text| tracing::debug!("Got static routes: {text:?}")) - .context("Failed to parse static routes from server")? - .into_iter() - .map(|line| { - let port = port.clone(); - let address = address.clone(); - async move { - tracing::info!("SSG: {line}"); - reqwest::Client::builder() - .build()? - .get(format!("http://{address}:{port}{line}")) - .header("Accept", "text/html") - .send() - .await - } - }) - .collect::>(); - - while let Some(route) = routes.next().await { - match route { - Ok(route) => tracing::debug!("ssg success: {route:?}"), - Err(err) => tracing::error!("ssg error: {err:?}"), - } - } - - // Wait a second for the cache to be written by the server - tracing::info!("Waiting a moment for isrg to propagate..."); - - tokio::time::sleep(std::time::Duration::from_secs(10)).await; - - tracing::info!("SSG complete"); - - drop(_child); - + .await?; Ok(()) } diff --git a/packages/cli/src/build/mod.rs b/packages/cli/src/build/mod.rs index 8e2aab64c..56d9eb40b 100644 --- a/packages/cli/src/build/mod.rs +++ b/packages/cli/src/build/mod.rs @@ -7,6 +7,7 @@ mod builder; mod bundle; +mod prerender; mod progress; mod request; mod templates; diff --git a/packages/cli/src/build/prerender.rs b/packages/cli/src/build/prerender.rs new file mode 100644 index 000000000..3f52e8933 --- /dev/null +++ b/packages/cli/src/build/prerender.rs @@ -0,0 +1,116 @@ +use anyhow::Context; +use dioxus_cli_config::{server_ip, server_port}; +use futures_util::stream::FuturesUnordered; +use futures_util::StreamExt; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::Path, + time::Duration, +}; +use tokio::process::Command; + +pub(crate) async fn pre_render_static_routes(server_exe: &Path) -> anyhow::Result<()> { + // Use the address passed in through environment variables or default to localhost:9999. We need + // to default to a value that is different than the CLI default address to avoid conflicts + let ip = server_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + let port = server_port().unwrap_or(9999); + let fullstack_address = SocketAddr::new(ip, port); + let address = fullstack_address.ip().to_string(); + let port = fullstack_address.port().to_string(); + // Borrow port and address so we can easily moe them into multiple tasks below + let address = &address; + let port = &port; + + tracing::info!("Running SSG at http://{address}:{port}"); + + // Run the server executable + let _child = Command::new(server_exe) + .env(dioxus_cli_config::SERVER_PORT_ENV, port) + .env(dioxus_cli_config::SERVER_IP_ENV, address) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn()?; + + let reqwest_client = reqwest::Client::new(); + // Borrow reqwest_client so we only move the reference into the futures + let reqwest_client = &reqwest_client; + + // Get the routes from the `/static_routes` endpoint + let mut routes = None; + + // The server may take a few seconds to start up. Try fetching the route up to 5 times with a one second delay + const RETRY_ATTEMPTS: usize = 5; + for i in 0..=RETRY_ATTEMPTS { + let request = reqwest_client + .post(format!("http://{address}:{port}/api/static_routes")) + .send() + .await; + match request { + Ok(request) => { + routes = Some(request + .json::>() + .await + .inspect(|text| tracing::debug!("Got static routes: {text:?}")) + .context("Failed to parse static routes from the server. Make sure your server function returns Vec with the (default) json encoding")?); + break; + } + Err(err) => { + // If the request fails, try up to 5 times with a one second delay + // If it fails 5 times, return the error + if i == RETRY_ATTEMPTS { + return Err(err).context("Failed to get static routes from server. Make sure you have a server function at the `/api/static_routes` endpoint that returns Vec of static routes."); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } + + let routes = routes.expect( + "static routes should exist or an error should have been returned on the last attempt", + ); + + // Create a pool of futures that cache each route + let mut resolved_routes = routes + .into_iter() + .map(|route| async move { + tracing::info!("Rendering {route} for SSG"); + // For each route, ping the server to force it to cache the response for ssg + let request = reqwest_client + .get(format!("http://{address}:{port}{route}")) + .header("Accept", "text/html") + .send() + .await?; + // If it takes longer than 30 seconds to resolve the route, log a warning + let warning_task = tokio::spawn({ + let route = route.clone(); + async move { + tokio::time::sleep(Duration::from_secs(30)).await; + tracing::warn!("Route {route} has been rendering for 30 seconds"); + } + }); + // Wait for the streaming response to completely finish before continuing. We don't use the html it returns directly + // because it may contain artifacts of intermediate streaming steps while the page is loading. The SSG app should write + // the final clean HTML to the disk automatically after the request completes. + let _html = request.text().await?; + + // Cancel the warning task if it hasn't already run + warning_task.abort(); + + Ok::<_, reqwest::Error>(route) + }) + .collect::>(); + + while let Some(route) = resolved_routes.next().await { + match route { + Ok(route) => tracing::debug!("ssg success: {route:?}"), + Err(err) => tracing::error!("ssg error: {err:?}"), + } + } + + tracing::info!("SSG complete"); + + drop(_child); + + Ok(()) +} diff --git a/packages/cli/src/build/progress.rs b/packages/cli/src/build/progress.rs index d8c34e64a..1d0f58c4e 100644 --- a/packages/cli/src/build/progress.rs +++ b/packages/cli/src/build/progress.rs @@ -90,6 +90,12 @@ impl BuildRequest { }); } + pub(crate) fn status_prerendering_routes(&self) { + _ = self.progress.unbounded_send(BuildUpdate::Progress { + stage: BuildStage::PrerenderingRoutes {}, + }); + } + pub(crate) fn status_installing_tooling(&self) { _ = self.progress.unbounded_send(BuildUpdate::Progress { stage: BuildStage::InstallingTooling {}, diff --git a/packages/cli/src/cli/serve.rs b/packages/cli/src/cli/serve.rs index 0fb66c682..48ee57753 100644 --- a/packages/cli/src/cli/serve.rs +++ b/packages/cli/src/cli/serve.rs @@ -103,7 +103,8 @@ impl ServeArgs { pub(crate) fn should_proxy_build(&self) -> bool { match self.build_arguments.platform() { Platform::Server => true, - _ => self.build_arguments.fullstack, + // During SSG, just serve the static files instead of running the server + _ => self.build_arguments.fullstack && !self.build_arguments.ssg, } } } diff --git a/packages/cli/src/serve/handle.rs b/packages/cli/src/serve/handle.rs index 40b2cdba9..ff6e16245 100644 --- a/packages/cli/src/serve/handle.rs +++ b/packages/cli/src/serve/handle.rs @@ -60,13 +60,9 @@ impl AppHandle { pub(crate) async fn open( &mut self, devserver_ip: SocketAddr, - fullstack_address: Option, + start_fullstack_on_address: Option, open_browser: bool, ) -> Result<()> { - if let Some(addr) = fullstack_address { - tracing::debug!("Proxying fullstack server from port {:?}", addr); - } - // Set the env vars that the clients will expect // These need to be stable within a release version (ie 0.6.0) let mut envs = vec![ @@ -89,13 +85,12 @@ impl AppHandle { envs.push((dioxus_cli_config::ASSET_ROOT_ENV, base_path.clone())); } - if let Some(addr) = fullstack_address { + // Launch the server if we were given an address to start it on, and the build includes a server. After we + // start the server, consume its stdout/stderr. + if let (Some(addr), Some(server)) = (start_fullstack_on_address, self.app.server_exe()) { + tracing::debug!("Proxying fullstack server from port {:?}", addr); envs.push((dioxus_cli_config::SERVER_IP_ENV, addr.ip().to_string())); envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string())); - } - - // Launch the server if we have one and consume its stdout/stderr - if let Some(server) = self.app.server_exe() { tracing::debug!("Launching server from path: {server:?}"); let mut child = Command::new(server) .envs(envs.clone()) diff --git a/packages/cli/src/serve/output.rs b/packages/cli/src/serve/output.rs index 486fd507d..bffcb74c8 100644 --- a/packages/cli/src/serve/output.rs +++ b/packages/cli/src/serve/output.rs @@ -482,6 +482,7 @@ impl Output { lines.push(krate.as_str().dark_gray()) } BuildStage::OptimizingWasm {} => lines.push("Optimizing wasm".yellow()), + BuildStage::PrerenderingRoutes {} => lines.push("Prerendering static routes".yellow()), BuildStage::RunningBindgen {} => lines.push("Running wasm-bindgen".yellow()), BuildStage::RunningGradle {} => lines.push("Running gradle assemble".yellow()), BuildStage::Bundling {} => lines.push("Bundling app".yellow()), diff --git a/packages/cli/src/serve/runner.rs b/packages/cli/src/serve/runner.rs index 33d173f4f..e2d696cc4 100644 --- a/packages/cli/src/serve/runner.rs +++ b/packages/cli/src/serve/runner.rs @@ -79,7 +79,7 @@ impl AppRunner { }, Some(status) = OptionFuture::from(handle.server_child.as_mut().map(|f| f.wait())) => { match status { - Ok(status) => ProcessExited { status, platform: Platform::Server }, + Ok(status) => ProcessExited { status, platform }, Err(_err) => todo!("handle error in process joining?"), } } diff --git a/packages/cli/src/serve/server.rs b/packages/cli/src/serve/server.rs index 8edfc5bfd..a4c4e17af 100644 --- a/packages/cli/src/serve/server.rs +++ b/packages/cli/src/serve/server.rs @@ -383,18 +383,18 @@ fn build_devserver_router( // For fullstack, liveview, and server, forward all requests to the inner server let address = fullstack_address.unwrap(); router = router.nest_service("/",super::proxy::proxy_to( - format!("http://{address}").parse().unwrap(), - true, - |error| { - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from(format!( - "Backend connection failed. The backend is likely still starting up. Please try again in a few seconds. Error: {:#?}", - error - ))) - .unwrap() - }, - )); + format!("http://{address}").parse().unwrap(), + true, + |error| { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from(format!( + "Backend connection failed. The backend is likely still starting up. Please try again in a few seconds. Error: {:#?}", + error + ))) + .unwrap() + }, + )); } else { // Otherwise, just serve the dir ourselves // Route file service to output the .wasm and assets if this is a web build diff --git a/packages/dx-wire-format/src/lib.rs b/packages/dx-wire-format/src/lib.rs index b8777e868..9b63ad2e1 100644 --- a/packages/dx-wire-format/src/lib.rs +++ b/packages/dx-wire-format/src/lib.rs @@ -57,6 +57,7 @@ pub enum BuildStage { }, RunningBindgen, OptimizingWasm, + PrerenderingRoutes, CopyingAssets { current: usize, total: usize, diff --git a/packages/playwright-tests/nested-suspense-ssg.spec.js b/packages/playwright-tests/nested-suspense-ssg.spec.js new file mode 100644 index 000000000..4ae98d6d7 --- /dev/null +++ b/packages/playwright-tests/nested-suspense-ssg.spec.js @@ -0,0 +1,50 @@ +// @ts-check +const { test, expect } = require("@playwright/test"); + +test("nested suspense resolves", async ({ page }) => { + // Wait for the dev server to reload + await page.goto("http://localhost:5050"); + // Then wait for the page to start loading + await page.goto("http://localhost:5050", { waitUntil: "commit" }); + + // Expect the page to contain the suspense result from the server + const mainMessageTitle = page.locator("#title-0"); + await expect(mainMessageTitle).toContainText("The robot says hello world"); + const mainMessageBody = page.locator("#body-0"); + await expect(mainMessageBody).toContainText( + "The robot becomes sentient and says hello world" + ); + + // And expect the title to have resolved on the client + await expect(page).toHaveTitle("The robot says hello world"); + + // Nested suspense should be resolved + const nestedMessageTitle1 = page.locator("#title-1"); + await expect(nestedMessageTitle1).toContainText("The world says hello back"); + const nestedMessageBody1 = page.locator("#body-1"); + await expect(nestedMessageBody1).toContainText( + "In a stunning turn of events, the world collectively unites and says hello back" + ); + + const nestedMessageDiv2 = page.locator("#children-2"); + await expect(nestedMessageDiv2).toBeEmpty(); + const nestedMessageTitle2 = page.locator("#title-2"); + await expect(nestedMessageTitle2).toContainText("Goodbye Robot"); + const nestedMessageBody2 = page.locator("#body-2"); + await expect(nestedMessageBody2).toContainText("The robot says goodbye"); + + const nestedMessageDiv3 = page.locator("#children-3"); + await expect(nestedMessageDiv3).toBeEmpty(); + const nestedMessageTitle3 = page.locator("#title-3"); + await expect(nestedMessageTitle3).toContainText("Goodbye World"); + const nestedMessageBody3 = page.locator("#body-3"); + await expect(nestedMessageBody3).toContainText("The world says goodbye"); + + // Deeply nested suspense should be resolved + const nestedMessageDiv4 = page.locator("#children-4"); + await expect(nestedMessageDiv4).toBeEmpty(); + const nestedMessageTitle4 = page.locator("#title-4"); + await expect(nestedMessageTitle4).toContainText("Hello World"); + const nestedMessageBody4 = page.locator("#body-4"); + await expect(nestedMessageBody4).toContainText("The world says hello again"); +}); diff --git a/packages/playwright-tests/nested-suspense/Cargo.toml b/packages/playwright-tests/nested-suspense/Cargo.toml index 1106cb299..d6fbb0e98 100644 --- a/packages/playwright-tests/nested-suspense/Cargo.toml +++ b/packages/playwright-tests/nested-suspense/Cargo.toml @@ -15,3 +15,12 @@ tokio = { workspace = true, features = ["full"], optional = true } default = [] server = ["dioxus/server", "tokio"] web = ["dioxus/web"] + +# We need a separate bin for the SSG build to avoid conflicting server caches +[[bin]] +name = "nested-suspense-ssg" +path = "src/ssg.rs" + +[[bin]] +name = "nested-suspense" +path = "src/main.rs" diff --git a/packages/playwright-tests/nested-suspense/src/lib.rs b/packages/playwright-tests/nested-suspense/src/lib.rs new file mode 100644 index 000000000..1a9d472e2 --- /dev/null +++ b/packages/playwright-tests/nested-suspense/src/lib.rs @@ -0,0 +1,114 @@ +// This test is used by playwright configured in the root of the repo +// Tests: +// - SEO without JS +// - Streaming hydration +// - Suspense +// - Server functions +// +// Without Javascript, content may not load into the right location, but it should still be somewhere in the html even if it is invisible + +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +pub fn app() -> Element { + rsx! { + SuspenseBoundary { + fallback: move |_| rsx! {}, + document::Style { + href: asset!("/assets/style.css") + } + LoadTitle {} + } + MessageWithLoader { id: 0 } + } +} + +#[component] +fn MessageWithLoader(id: usize) -> Element { + rsx! { + SuspenseBoundary { + fallback: move |_| rsx! { + "Loading {id}..." + }, + Message { id } + } + } +} + +#[component] +fn LoadTitle() -> Element { + let title = use_server_future(move || server_content(0))?() + .unwrap() + .unwrap(); + + rsx! { + "title loaded" + document::Title { "{title.title}" } + } +} + +#[component] +fn Message(id: usize) -> Element { + let message = use_server_future(move || server_content(id))?() + .unwrap() + .unwrap(); + + rsx! { + h2 { + id: "title-{id}", + "{message.title}" + } + p { + id: "body-{id}", + "{message.body}" + } + div { + id: "children-{id}", + padding: "10px", + for child in message.children { + MessageWithLoader { id: child } + } + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Content { + title: String, + body: String, + children: Vec, +} + +#[server] +async fn server_content(id: usize) -> Result { + let content_tree = [ + Content { + title: "The robot says hello world".to_string(), + body: "The robot becomes sentient and says hello world".to_string(), + children: vec![1, 2, 3], + }, + Content { + title: "The world says hello back".to_string(), + body: "In a stunning turn of events, the world collectively unites and says hello back" + .to_string(), + children: vec![4], + }, + Content { + title: "Goodbye Robot".to_string(), + body: "The robot says goodbye".to_string(), + children: vec![], + }, + Content { + title: "Goodbye World".to_string(), + body: "The world says goodbye".to_string(), + children: vec![], + }, + Content { + title: "Hello World".to_string(), + body: "The world says hello again".to_string(), + children: vec![], + }, + ]; + tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + Ok(content_tree[id].clone()) +} diff --git a/packages/playwright-tests/nested-suspense/src/main.rs b/packages/playwright-tests/nested-suspense/src/main.rs index 9d2422cea..b07d1fe77 100644 --- a/packages/playwright-tests/nested-suspense/src/main.rs +++ b/packages/playwright-tests/nested-suspense/src/main.rs @@ -1,123 +1,12 @@ -// This test is used by playwright configured in the root of the repo -// Tests: -// - SEO without JS -// - Streaming hydration -// - Suspense -// - Server functions -// -// Without Javascript, content may not load into the right location, but it should still be somewhere in the html even if it is invisible - #![allow(non_snake_case)] use dioxus::prelude::*; -use serde::{Deserialize, Serialize}; +use nested_suspense::app; fn main() { LaunchBuilder::new() .with_cfg(server_only! { - dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + ServeConfig::builder() + .enable_out_of_order_streaming() }) .launch(app); } - -fn app() -> Element { - rsx! { - SuspenseBoundary { - fallback: move |_| rsx! {}, - document::Style { - href: asset!("/assets/style.css") - } - LoadTitle {} - } - MessageWithLoader { id: 0 } - } -} - -#[component] -fn MessageWithLoader(id: usize) -> Element { - rsx! { - SuspenseBoundary { - fallback: move |_| rsx! { - "Loading {id}..." - }, - Message { id } - } - } -} - -#[component] -fn LoadTitle() -> Element { - let title = use_server_future(move || server_content(0))?() - .unwrap() - .unwrap(); - - rsx! { - "title loaded" - document::Title { "{title.title}" } - } -} - -#[component] -fn Message(id: usize) -> Element { - let message = use_server_future(move || server_content(id))?() - .unwrap() - .unwrap(); - - rsx! { - h2 { - id: "title-{id}", - "{message.title}" - } - p { - id: "body-{id}", - "{message.body}" - } - div { - id: "children-{id}", - padding: "10px", - for child in message.children { - MessageWithLoader { id: child } - } - } - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct Content { - title: String, - body: String, - children: Vec, -} - -#[server] -async fn server_content(id: usize) -> Result { - let content_tree = [ - Content { - title: "The robot says hello world".to_string(), - body: "The robot becomes sentient and says hello world".to_string(), - children: vec![1, 2, 3], - }, - Content { - title: "The world says hello back".to_string(), - body: "In a stunning turn of events, the world collectively unites and says hello back" - .to_string(), - children: vec![4], - }, - Content { - title: "Goodbye Robot".to_string(), - body: "The robot says goodbye".to_string(), - children: vec![], - }, - Content { - title: "Goodbye World".to_string(), - body: "The world says goodbye".to_string(), - children: vec![], - }, - Content { - title: "Hello World".to_string(), - body: "The world says hello again".to_string(), - children: vec![], - }, - ]; - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - Ok(content_tree[id].clone()) -} diff --git a/packages/playwright-tests/nested-suspense/src/ssg.rs b/packages/playwright-tests/nested-suspense/src/ssg.rs new file mode 100644 index 000000000..36b461632 --- /dev/null +++ b/packages/playwright-tests/nested-suspense/src/ssg.rs @@ -0,0 +1,27 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use nested_suspense::app; + +fn main() { + dioxus::LaunchBuilder::new() + .with_cfg(server_only! { + ServeConfig::builder() + .incremental( + IncrementalRendererConfig::new() + .static_dir( + std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .join("public") + ) + ) + .enable_out_of_order_streaming() + }) + .launch(app); +} + +#[server(endpoint = "static_routes")] +async fn static_routes() -> Result, ServerFnError> { + Ok(vec!["/".to_string()]) +} diff --git a/packages/playwright-tests/playwright.config.js b/packages/playwright-tests/playwright.config.js index 9fd8ff0f7..7eaa94619 100644 --- a/packages/playwright-tests/playwright.config.js +++ b/packages/playwright-tests/playwright.config.js @@ -129,5 +129,14 @@ module.exports = defineConfig({ reuseExistingServer: !process.env.CI, stdout: "pipe", }, + { + cwd: path.join(process.cwd(), "nested-suspense"), + command: + 'cargo run --package dioxus-cli --release -- serve --bin nested-suspense-ssg --force-sequential --platform web --ssg --addr "127.0.0.1" --port 6060', + port: 6060, + timeout: 50 * 60 * 1000, + reuseExistingServer: !process.env.CI, + stdout: "pipe", + }, ], });