use std::path::{Path, PathBuf};
pub use crate::build::page_slug;
use crate::discovery::discover_routes;
#[derive(Default)]
pub struct BundleConfig<'a> {
pub app_dir: &'a str,
pub client_dir: &'a str,
pub client_alias: &'a str,
pub public_dist: &'a str,
pub aliases: &'a [(&'a str, &'a str)],
}
pub fn bundle_pages(cfg: &BundleConfig) -> std::io::Result<()> {
println!("cargo:rerun-if-env-changed=NEXTRS_SKIP_BUNDLE");
if std::env::var_os("NEXTRS_SKIP_BUNDLE").is_some_and(|v| v == "1") {
return Ok(());
}
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set in build.rs"),
);
let abs_app = manifest_dir.join(cfg.app_dir).canonicalize()?;
let client_dir = manifest_dir.join(cfg.client_dir).canonicalize()?;
println!(
"cargo:rerun-if-changed={}",
client_dir.join("src").display()
);
println!(
"cargo:rerun-if-changed={}",
client_dir.join("package.json").display()
);
let tsx_pages: Vec<(String, PathBuf)> = discover_routes(&abs_app)
.into_iter()
.filter_map(|r| r.page.tsx.map(|p| (page_slug(&r.url_path), p)))
.collect();
let dist = manifest_dir.join(cfg.public_dist);
if tsx_pages.is_empty() {
if dist.is_dir() {
std::fs::remove_dir_all(&dist)?;
}
return Ok(());
}
let node_modules = client_dir.join("node_modules");
if !node_modules.is_dir() {
return Err(std::io::Error::other(format!(
"nextrs: page.tsx pages found but {} is missing — run `npm install` in {}",
node_modules.display(),
client_dir.display()
)));
}
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR must be set"));
let entries_dir = out_dir.join("nextrs_tsx");
std::fs::create_dir_all(&entries_dir)?;
let client_helper = client_dir.join("src/nextrs-client.ts");
let mut inputs = Vec::with_capacity(tsx_pages.len());
for (slug, page_path) in &tsx_pages {
let entry_path = entries_dir.join(format!("{}.tsx", slug));
let entry_src = entry_wrapper(page_path, &client_helper);
write_if_changed(&entry_path, entry_src.as_bytes())?;
inputs.push(rolldown::InputItem {
name: Some(slug.clone()),
import: entry_path.display().to_string(),
});
}
let staging = out_dir.join("nextrs_dist");
if staging.is_dir() {
std::fs::remove_dir_all(&staging)?;
}
std::fs::create_dir_all(&staging)?;
run_bundler(inputs, &staging, &client_dir, cfg.client_alias, cfg.aliases)?;
std::fs::create_dir_all(&dist)?;
mirror_by_content(&staging, &dist)
}
fn entry_wrapper(page_path: &Path, client_helper: &Path) -> String {
format!(
r#"// @generated by nextrs::bundle. Do not edit by hand.
import {{ createRoot }} from "react-dom/client";
import {{ QueryClient, QueryClientProvider }} from "@tanstack/react-query";
import {{ seedQueryClient }} from "{helper}";
import Page from "{page}";
// staleTime > 0 so server-seeded entries (see props.rs) render without an
// immediate background refetch; with no seeds this is just a sane default.
const qc = new QueryClient({{
defaultOptions: {{ queries: {{ staleTime: 30_000 }} }},
}});
seedQueryClient(qc);
createRoot(document.getElementById("__nx_root__")!).render(
<QueryClientProvider client={{qc}}>
<Page />
</QueryClientProvider>,
);
"#,
helper = client_helper.display(),
page = page_path.display(),
)
}
fn build_aliases(
client_dir: &Path,
client_alias: &str,
user_aliases: &[(&str, &str)],
) -> Vec<(String, Vec<Option<String>>)> {
let mut aliases = vec![
(
client_alias.to_string(),
vec![Some(client_dir.join("src/index.ts").display().to_string())],
),
(
"@/*".to_string(),
vec![Some(client_dir.join("src/*").display().to_string())],
),
];
for (pattern, replacement) in user_aliases {
aliases.push((
pattern.to_string(),
vec![Some(client_dir.join(replacement).display().to_string())],
));
}
aliases
}
fn run_bundler(
inputs: Vec<rolldown::InputItem>,
staging: &Path,
client_dir: &Path,
client_alias: &str,
user_aliases: &[(&str, &str)],
) -> std::io::Result<()> {
use rolldown::{Bundler, BundlerOptions, OutputFormat, Platform, RawMinifyOptions};
let release = std::env::var("PROFILE").is_ok_and(|p| p == "release");
let node_env = if release {
"\"production\""
} else {
"\"development\""
};
let options = BundlerOptions {
input: Some(inputs),
cwd: Some(client_dir.to_path_buf()),
dir: Some(staging.display().to_string()),
format: Some(OutputFormat::Esm),
platform: Some(Platform::Browser),
entry_filenames: Some("[name].js".to_string().into()),
chunk_filenames: Some("chunks/[name].js".to_string().into()),
minify: Some(RawMinifyOptions::Bool(release)),
define: Some(
std::iter::once(("process.env.NODE_ENV".to_string(), node_env.to_string())).collect(),
),
resolve: Some(rolldown::ResolveOptions {
alias: Some(build_aliases(client_dir, client_alias, user_aliases)),
modules: Some(vec![client_dir.join("node_modules").display().to_string()]),
..Default::default()
}),
..Default::default()
};
let mut bundler =
Bundler::new(options).map_err(|e| std::io::Error::other(format!("rolldown: {e:?}")))?;
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(bundler.write())
.map_err(|e| std::io::Error::other(format!("rolldown bundling failed: {e:?}")))?;
Ok(())
}
fn mirror_by_content(src: &Path, dst: &Path) -> std::io::Result<()> {
use std::collections::HashSet;
let mut seen: HashSet<std::ffi::OsString> = HashSet::new();
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
seen.insert(name.clone());
let from = entry.path();
let to = dst.join(&name);
if entry.file_type()?.is_dir() {
std::fs::create_dir_all(&to)?;
mirror_by_content(&from, &to)?;
} else {
let bytes = std::fs::read(&from)?;
write_if_changed(&to, &bytes)?;
}
}
for entry in std::fs::read_dir(dst)? {
let entry = entry?;
if seen.contains(&entry.file_name()) {
continue;
}
let path = entry.path();
if entry.file_type()?.is_dir() {
std::fs::remove_dir_all(&path)?;
} else {
std::fs::remove_file(&path)?;
}
}
Ok(())
}
fn write_if_changed(path: &Path, content: &[u8]) -> std::io::Result<()> {
if let Ok(existing) = std::fs::read(path) {
if existing == content {
return Ok(());
}
}
let tmp = path.with_extension("nextrs-tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_slug_shapes() {
assert_eq!(page_slug("/"), "index");
assert_eq!(page_slug("/todos"), "todos");
assert_eq!(page_slug("/users/{id}"), "users-_id_");
assert_eq!(page_slug("/a/b/c"), "a-b-c");
}
#[test]
fn aliases_include_barrel_and_shadcn_default() {
let aliases = build_aliases(Path::new("/proj/client"), "@site/client", &[]);
assert!(
aliases
.iter()
.any(|(k, v)| k == "@site/client" && v[0].as_deref() == Some("/proj/client/src/index.ts")),
"{aliases:?}"
);
assert!(
aliases
.iter()
.any(|(k, v)| k == "@/*" && v[0].as_deref() == Some("/proj/client/src/*")),
"{aliases:?}"
);
}
#[test]
fn user_aliases_resolve_relative_to_client_dir() {
let aliases = build_aliases(
Path::new("/proj/client"),
"@site/client",
&[("~/*", "vendor/*")],
);
assert!(
aliases
.iter()
.any(|(k, v)| k == "~/*" && v[0].as_deref() == Some("/proj/client/vendor/*")),
"{aliases:?}"
);
}
#[test]
fn bundle_config_default_allows_partial_construction() {
let cfg = BundleConfig {
app_dir: "app",
..Default::default()
};
assert_eq!(cfg.app_dir, "app");
assert!(cfg.aliases.is_empty());
}
#[test]
fn write_if_changed_skips_identical() {
let tmp = tempfile::tempdir().unwrap();
let p = tmp.path().join("x.js");
write_if_changed(&p, b"same").unwrap();
let mtime1 = std::fs::metadata(&p).unwrap().modified().unwrap();
write_if_changed(&p, b"same").unwrap();
assert_eq!(mtime1, std::fs::metadata(&p).unwrap().modified().unwrap());
write_if_changed(&p, b"diff").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"diff");
}
#[test]
fn mirror_prunes_and_copies() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
let dst = tmp.path().join("dst");
std::fs::create_dir_all(src.join("chunks")).unwrap();
std::fs::create_dir_all(&dst).unwrap();
std::fs::write(src.join("a.js"), "a").unwrap();
std::fs::write(src.join("chunks/shared.js"), "s").unwrap();
std::fs::write(dst.join("stale.js"), "old").unwrap();
mirror_by_content(&src, &dst).unwrap();
assert_eq!(std::fs::read(dst.join("a.js")).unwrap(), b"a");
assert_eq!(std::fs::read(dst.join("chunks/shared.js")).unwrap(), b"s");
assert!(!dst.join("stale.js").exists());
}
#[test]
fn entry_wrapper_mentions_mount_and_provider() {
let s = entry_wrapper(Path::new("/abs/page.tsx"), Path::new("/abs/helper.ts"));
assert!(s.contains("__nx_root__"));
assert!(s.contains("QueryClientProvider"));
assert!(s.contains("seedQueryClient"));
assert!(s.contains("/abs/page.tsx"));
}
}