diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7cc5e5d..9bde3756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: run: cargo build -p rex_cli - name: Run e2e tests - run: cargo test -p rex_e2e --lib -- --ignored + run: cargo test -p rex_e2e -- --ignored docker: name: Docker Build diff --git a/.gitignore b/.gitignore index baafe702..d9578002 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ dist/ *.tsbuildinfo coverage.json +server*.log diff --git a/Cargo.lock b/Cargo.lock index 738bdba7..751865ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4873,6 +4873,7 @@ dependencies = [ "rex_v8", "serde", "serde_json", + "tempfile", "tokio", "tower", "tower-http", diff --git a/crates/rex_build/src/bundler.rs b/crates/rex_build/src/bundler.rs index edef3982..d70e7188 100644 --- a/crates/rex_build/src/bundler.rs +++ b/crates/rex_build/src/bundler.rs @@ -144,7 +144,6 @@ pub async fn build_bundles_with_id( let has_pages = !scan.routes.is_empty() || scan.app.is_some(); let (server_bundle_path, mut manifest) = if has_pages { - // Build server and client bundles in parallel let server_fut = build_server_bundle( config, scan, @@ -155,20 +154,36 @@ pub async fn build_bundles_with_id( &module_dirs, ) .instrument(info_span!("build_server_bundle")); - let client_fut = build_client_bundles( - config, - scan, - &client_dir, - &build_id, - &css_modules_merged, - &define, - &tailwind_outputs, - project_config, - &module_dirs, - ) - .instrument(info_span!("build_client_bundles")); - tokio::try_join!(server_fut, client_fut)? + if config.dev { + // Dev mode: skip client bundling. Build server IIFE only. + // Client modules served individually via /_rex/src/ and /_rex/entry/ + let server_path = server_fut.await?; + let manifest = build_dev_manifest( + scan, + &build_id, + &css_modules_merged, + &tailwind_outputs, + &client_dir, + )?; + (server_path, manifest) + } else { + // Production: build both server + client bundles in parallel + let client_fut = build_client_bundles( + config, + scan, + &client_dir, + &build_id, + &css_modules_merged, + &define, + &tailwind_outputs, + project_config, + &module_dirs, + ) + .instrument(info_span!("build_client_bundles")); + + tokio::try_join!(server_fut, client_fut)? + } } else { // App-only project: create a minimal server bundle with V8 polyfills + React + stubs build_minimal_server_bundle( @@ -564,3 +579,69 @@ globalThis.__rex_resolve_api = function() { let manifest = AssetManifest::new(build_id.to_string()); Ok((bundle_path, manifest)) } + +/// Build a minimal manifest for dev mode (no client bundling). +/// Pages map to `/_rex/entry/` URLs instead of bundled chunk filenames. +fn build_dev_manifest( + scan: &ScanResult, + build_id: &str, + css_modules: &crate::css_modules::CssModuleProcessing, + tailwind_outputs: &std::collections::HashMap, + client_dir: &Path, +) -> Result { + let mut manifest = AssetManifest::new(build_id.to_string()); + + // Collect CSS files first (same as production path — CSS is independent of JS bundling). + // This may create page entries with production-style JS filenames, which we overwrite below. + crate::css_collect::collect_css_files( + scan, + client_dir, + build_id, + &mut manifest, + tailwind_outputs, + &css_modules.page_overrides, + )?; + + // Add CSS module global files + for css_file in &css_modules.global_css { + manifest.global_css.push(css_file.clone()); + } + + // Add CSS module per-route files + for (pattern, css_files) in &css_modules.route_css { + if let Some(existing) = manifest.pages.get_mut(pattern) { + existing.css.extend(css_files.iter().cloned()); + } + } + + // Register/update pages with /_rex/entry/ URLs (overwriting any JS filenames from CSS collect) + for route in &scan.routes { + let entry_url = format!("/_rex/entry/{}", route.pattern); + let strategy = + crate::page_exports::detect_data_strategy(&route.abs_path).unwrap_or_default(); + let has_static_paths = + crate::page_exports::detect_has_static_paths(&route.abs_path).unwrap_or(false); + + let page = manifest + .pages + .entry(route.pattern.clone()) + .or_insert_with(|| rex_core::manifest::PageAssets { + js: entry_url.clone(), + css: Vec::new(), + data_strategy: strategy.clone(), + render_mode: rex_core::RenderMode::default(), + has_static_paths: false, + fallback: rex_core::Fallback::default(), + }); + page.js = entry_url; + page.data_strategy = strategy; + page.has_static_paths = has_static_paths; + } + + // _app entry + if scan.app.is_some() { + manifest.app_script = Some("/_rex/entry/_app".to_string()); + } + + Ok(manifest) +} diff --git a/crates/rex_build/src/client_dep_bundle.rs b/crates/rex_build/src/client_dep_bundle.rs new file mode 100644 index 00000000..255cba74 --- /dev/null +++ b/crates/rex_build/src/client_dep_bundle.rs @@ -0,0 +1,380 @@ +//! Browser-side dependency pre-bundling for unbundled dev serving. +//! +//! Bundles React and npm deps as self-contained ESM modules for the browser. +//! These are served via `/_rex/dep/{specifier}.js` and mapped through an +//! HTML import map so user source files can use bare specifiers (`react`, etc.). +//! +//! Unlike `server_dep_bundle` (which targets V8 with react-server conditions), +//! this uses standard browser conditions and bundles `react-dom/client` for +//! hydration instead of `react-dom/server`. No V8 polyfills are injected. + +use crate::build_utils::runtime_client_dir; +use crate::esm_transform::DepImport; +use anyhow::Result; +use rex_core::RexConfig; +use std::collections::HashMap; +use tracing::debug; + +/// Result of browser dep pre-bundling. +pub struct ClientDepBundle { + /// Mapping of URL key → ESM source code. + /// Keys use URL-safe encoding (e.g., "react__jsx-runtime" for "react/jsx-runtime"). + pub modules: HashMap, + /// The generated import map JSON string for injection into HTML `\n" + )); + } + // CSS: inline content to avoid render-blocking network requests for css in params.css_files { if let Some(content) = params.css_contents.get(css) { @@ -107,18 +117,32 @@ pub fn assemble_document(params: &DocumentParams<'_>) -> String { )); } + let unbundled = params.import_map_json.is_some(); + // _app client chunk (must load before page scripts for hydration wrapping) if let Some(app) = params.app_script { - html.push_str(&format!( - " \n" - )); + if unbundled { + html.push_str(&format!( + " \n" + )); + } else { + html.push_str(&format!( + " \n" + )); + } } - // Client chunks (ESM bundles produced by rolldown) + // Client chunks (ESM bundles produced by rolldown, or /_rex/entry/ URLs in dev) for script in params.client_scripts { - html.push_str(&format!( - " \n" - )); + if unbundled { + html.push_str(&format!( + " \n" + )); + } else { + html.push_str(&format!( + " \n" + )); + } } // Client-side router (must load after page scripts register __REX_RENDER__) @@ -142,7 +166,7 @@ pub fn assemble_document(params: &DocumentParams<'_>) -> String { /// `` (or ``, `