1use super::*;
2
3pub fn has_build_mode_flag(wasm_pack_args: &[String]) -> bool {
16 wasm_pack_args
17 .iter()
18 .any(|arg: &String| arg == DEV_FLAG || arg == RELEASE_FLAG || arg == PROFILING_FLAG)
19}
20
21pub fn filter_euv_args(wasm_pack_args: &[String]) -> Vec<String> {
36 let raw_args: &[String] = if let Some(position) = wasm_pack_args
37 .iter()
38 .rposition(|arg: &String| arg == DOUBLE_DASH)
39 {
40 &wasm_pack_args[position + 1..]
41 } else {
42 wasm_pack_args
43 };
44 let mut filtered: Vec<String> = Vec::new();
45 let mut skip_next: bool = false;
46 for arg in raw_args {
47 if skip_next {
48 skip_next = false;
49 continue;
50 }
51 let name: &str = arg.split('=').next().unwrap_or(arg.as_str());
57 if EUV_ARGS.contains(&name) {
58 if arg.contains('=') || name == NO_GITIGNORE_ARG {
59 continue;
63 }
64 skip_next = true;
65 continue;
66 }
67 filtered.push(arg.clone());
68 }
69 filtered
70}
71
72pub fn reconcile_args(args: &mut ModeArgs) {
87 let wasm_pack_args: Vec<String> = args.get_wasm_pack_args().clone();
88 let mut crate_path: Option<PathBuf> = None;
89 let mut port: Option<u16> = None;
90 let mut www_dir: Option<String> = None;
91 let mut index_html: Option<Option<PathBuf>> = None;
92 let mut no_gitignore: Option<bool> = None;
93 let mut dev: Option<bool> = None;
94 let mut release: Option<bool> = None;
95 let mut profiling: Option<bool> = None;
96 let mut iter: Iter<String> = wasm_pack_args.iter();
97 while let Some(arg) = iter.next() {
98 match arg.as_str() {
99 CRATE_PATH_ARG | CRATE_PATH_ARG_SHORT => {
100 if let Some(value) = iter.next() {
101 crate_path = Some(PathBuf::from(value));
102 }
103 }
104 PORT_ARG | PORT_ARG_SHORT => {
105 if let Some(value) = iter.next()
106 && let Ok(parsed_port) = value.parse::<u16>()
107 {
108 port = Some(parsed_port);
109 }
110 }
111 WWW_DIR_ARG => {
112 if let Some(value) = iter.next() {
113 www_dir = Some(value.clone());
114 }
115 }
116 INDEX_HTML_ARG => {
117 if let Some(value) = iter.next() {
118 index_html = Some(Some(PathBuf::from(value)));
119 }
120 }
121 NO_GITIGNORE_ARG => {
122 no_gitignore = Some(true);
123 }
124 DEV_FLAG => {
125 dev = Some(true);
126 }
127 RELEASE_FLAG => {
128 release = Some(true);
129 }
130 PROFILING_FLAG => {
131 profiling = Some(true);
132 }
133 other => {
134 if let Some(value) = other.strip_prefix(&format!("{CRATE_PATH_ARG}=")) {
135 crate_path = Some(PathBuf::from(value));
136 } else if let Some(value) = other.strip_prefix(&format!("{PORT_ARG}=")) {
137 if let Ok(parsed_port) = value.parse::<u16>() {
138 port = Some(parsed_port);
139 }
140 } else if let Some(value) = other.strip_prefix(&format!("{WWW_DIR_ARG}=")) {
141 www_dir = Some(value.to_string());
142 } else if let Some(value) = other.strip_prefix(&format!("{INDEX_HTML_ARG}=")) {
143 index_html = Some(Some(PathBuf::from(value)));
144 }
145 }
146 }
147 }
148 if let Some(value) = crate_path {
149 args.set_crate_path(value);
150 }
151 if let Some(value) = port {
152 args.set_port(value);
153 }
154 if let Some(value) = www_dir {
155 args.set_www_dir(value);
156 }
157 if let Some(value) = index_html {
158 args.set_index_html(value);
159 }
160 if let Some(value) = no_gitignore {
161 args.set_no_gitignore(value);
162 }
163 if let Some(value) = dev {
164 args.set_dev(value);
165 }
166 if let Some(value) = release {
167 args.set_release(value);
168 }
169 if let Some(value) = profiling {
170 args.set_profiling(value);
171 }
172}
173
174pub fn resolve_build_mode(args: &ModeArgs) -> BuildMode {
189 if args.get_profiling() {
190 BuildMode::Profiling
191 } else if args.get_release() {
192 BuildMode::Release
193 } else if args.get_dev() {
194 BuildMode::Dev
195 } else if args
196 .get_wasm_pack_args()
197 .iter()
198 .any(|arg: &String| arg == PROFILING_FLAG)
199 {
200 BuildMode::Profiling
201 } else if args
202 .get_wasm_pack_args()
203 .iter()
204 .any(|arg: &String| arg == RELEASE_FLAG)
205 {
206 BuildMode::Release
207 } else {
208 BuildMode::Dev
209 }
210}
211
212pub fn build_mode_to_flag(build_mode: BuildMode) -> &'static str {
222 match build_mode {
223 BuildMode::Dev => DEV_FLAG,
224 BuildMode::Release => RELEASE_FLAG,
225 BuildMode::Profiling => PROFILING_FLAG,
226 }
227}
228
229async fn build_gitignore(root: &PathBuf) -> Gitignore {
239 let gitignore_path: PathBuf = root.join(GITIGNORE_FILE_NAME);
240 let mut builder: GitignoreBuilder = GitignoreBuilder::new(root);
241 let gitignore_exists: bool = metadata(&gitignore_path).await.is_ok();
242 if gitignore_exists && let Some(error) = builder.add(&gitignore_path) {
243 log::warn!("Failed to load .gitignore: {error}");
244 }
245 match builder.build() {
246 Ok(gitignore) => {
247 if gitignore_exists {
248 log::info!("Loaded .gitignore to filter file change events");
249 }
250 gitignore
251 }
252 Err(error) => {
253 log::warn!("Failed to build gitignore matcher: {error}");
254 GitignoreBuilder::new(root)
255 .build()
256 .unwrap_or_else(|_error: ignore::Error| Gitignore::empty())
257 }
258 }
259}
260
261fn extract_out_name(wasm_pack_args: &[String]) -> Option<String> {
273 let mut iter: Iter<'_, String> = wasm_pack_args.iter();
274 while let Some(arg) = iter.next() {
275 if arg == OUT_NAME_ARG {
276 return iter.next().cloned();
277 }
278 if let Some(value) = arg.strip_prefix(&format!("{OUT_NAME_ARG}=")) {
279 return Some(value.to_string());
280 }
281 }
282 None
283}
284
285fn extract_out_dir(wasm_pack_args: &[String]) -> Option<String> {
297 let mut iter: Iter<'_, String> = wasm_pack_args.iter();
298 while let Some(arg) = iter.next() {
299 if arg == OUT_DIR_ARG {
300 return iter.next().cloned();
301 }
302 if let Some(value) = arg.strip_prefix(&format!("{OUT_DIR_ARG}=")) {
303 return Some(value.to_string());
304 }
305 }
306 None
307}
308
309pub fn resolve_out_name(args: &ModeArgs) -> String {
324 let name: String = if let Some(out_name) = extract_out_name(args.get_wasm_pack_args()) {
325 out_name.replace(STR_HYPHEN, STR_UNDERSCORE)
326 } else {
327 let cargo_toml_path: PathBuf = args.get_crate_path().join(CARGO_TOML_FILE_NAME);
328 read_crate_name_from_toml(&cargo_toml_path)
329 .unwrap_or_else(|| {
330 args.get_crate_path()
331 .file_name()
332 .unwrap_or_default()
333 .to_string_lossy()
334 .to_string()
335 })
336 .replace(STR_HYPHEN, STR_UNDERSCORE)
337 };
338 format!("{name}{JS_EXTENSION}")
339}
340
341fn read_crate_name_from_toml(path: &Path) -> Option<String> {
353 let content: String = sync_read_to_string(path).ok()?;
354 let mut in_package: bool = false;
355 for line in content.lines() {
356 let trimmed: &str = line.trim();
357 if trimmed.starts_with('[') {
358 in_package = trimmed == "[package]";
359 continue;
360 }
361 if in_package
362 && trimmed.starts_with("name")
363 && let Some(value) = trimmed.strip_prefix("name")
364 {
365 let value: &str = value.trim().strip_prefix('=')?.trim();
366 let value: &str = value.strip_prefix('"')?.strip_suffix('"')?;
367 return Some(value.to_string());
368 }
369 }
370 None
371}
372
373fn compute_relative_path(base: &Path, target: &Path) -> PathBuf {
388 let base_components: Vec<Component> = base.components().collect();
389 let target_components: Vec<Component> = target.components().collect();
390 let common_len: usize = base_components
391 .iter()
392 .zip(target_components.iter())
393 .take_while(
394 |(base_component, target_component): &(&Component, &Component)| {
395 base_component == target_component
396 },
397 )
398 .count();
399 let mut result: PathBuf = PathBuf::new();
400 for _ in &base_components[common_len..] {
401 result.push("..");
402 }
403 for component in &target_components[common_len..] {
404 if let Component::Normal(os_str) = component {
405 result.push(os_str);
406 }
407 }
408 result
409}
410
411pub async fn resolve_serving_root(args: &ModeArgs) -> PathBuf {
425 let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
426 let out_dir_absolute: PathBuf = resolve_out_dir(args);
427 if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
428 resolve_www_dir(&www_absolute).await
429 } else {
430 out_dir_absolute
431 .parent()
432 .map(|p: &Path| p.to_path_buf())
433 .unwrap_or_else(|| www_absolute)
434 }
435}
436
437pub fn resolve_serving_route_prefix(args: &ModeArgs) -> String {
450 let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
451 let out_dir_absolute: PathBuf = resolve_out_dir(args);
452 let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
453 www_absolute
454 } else {
455 out_dir_absolute
456 .parent()
457 .map(|p: &Path| p.to_path_buf())
458 .unwrap_or_else(|| www_absolute)
459 };
460 serving_root
461 .strip_prefix(args.get_crate_path())
462 .map(|rel: &Path| {
463 rel.to_string_lossy()
464 .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
465 })
466 .unwrap_or_else(|_| {
467 serving_root
468 .file_name()
469 .and_then(|name: &ffi::OsStr| name.to_str())
470 .unwrap_or_default()
471 .to_string()
472 })
473}
474
475pub fn resolve_import_path(args: &ModeArgs) -> String {
489 let out_name: String = resolve_out_name(args);
490 let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
491 let out_dir_absolute: PathBuf = resolve_out_dir(args);
492 let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
493 www_absolute
494 } else {
495 out_dir_absolute
496 .parent()
497 .map(|p: &Path| p.to_path_buf())
498 .unwrap_or_else(|| www_absolute)
499 };
500 let relative: PathBuf = compute_relative_path(&serving_root, &out_dir_absolute);
501 let mut components: Vec<String> = relative
502 .components()
503 .filter_map(|component: Component| match component {
504 Component::Normal(os_str) => os_str.to_str().map(|text: &str| text.to_string()),
505 Component::ParentDir => Some(PARENT_DIR.to_string()),
506 _ => None,
507 })
508 .collect();
509 components.push(out_name);
510 format!("{RELATIVE_PATH_PREFIX}{}", components.join(PATH_SEPARATOR))
511}
512
513pub fn resolve_out_dir(args: &ModeArgs) -> PathBuf {
528 let out_dir_path: PathBuf = PathBuf::from(
529 extract_out_dir(args.get_wasm_pack_args())
530 .unwrap_or_else(|| format!("{}/{PKG_DIR_NAME}", args.get_www_dir())),
531 );
532 if out_dir_path.is_absolute() {
533 out_dir_path
534 } else {
535 args.get_crate_path().join(&out_dir_path)
536 }
537}
538
539pub async fn run_build_only_pipeline(args: &ModeArgs) -> Result<(), EuvError> {
554 let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
555 if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
556 log::warn!("euv fmt error: {error}");
557 }
558 build_wasm(args).await?;
559 log::info!("WASM build completed successfully");
560 finalize_html_output(args, false).await?;
561 Ok(())
562}
563
564pub(crate) async fn finalize_html_output(
580 args: &ModeArgs,
581 is_dev_server: bool,
582) -> Result<String, EuvError> {
583 let html_config: HtmlConfig = HtmlConfig::new(
584 resolve_serving_root(args).await,
585 resolve_import_path(args),
586 resolve_build_mode(args) == BuildMode::Release,
587 args.try_get_index_html().clone(),
588 is_dev_server.then(|| resolve_serving_route_prefix(args)),
589 );
590 let html: String = generate_html(&html_config).await?;
591 let pkg_dir: PathBuf = resolve_out_dir(args);
592 let removed: Vec<PathBuf> = remove_generated_js(&pkg_dir).await?;
593 if !removed.is_empty() {
594 log::info!(
595 "Inlined {} generated JavaScript file(s) into {} and removed them from {}",
596 removed.len(),
597 INDEX_HTML_FILE_NAME,
598 pkg_dir.display()
599 );
600 }
601 assert_no_js_artifacts(&pkg_dir).await?;
602 Ok(html)
603}
604
605async fn assert_no_js_artifacts(pkg_dir: &Path) -> Result<(), EuvError> {
620 let mut pending: Vec<PathBuf> = vec![pkg_dir.to_path_buf()];
621 while let Some(dir) = pending.pop() {
622 let mut entries: ReadDir =
623 read_dir(&dir)
624 .await
625 .map_err(|error: io::Error| EuvError::IoPath {
626 message: String::from("Failed to inspect finalized WASM output"),
627 path: dir.clone(),
628 error,
629 })?;
630 while let Some(entry) =
631 entries
632 .next_entry()
633 .await
634 .map_err(|error: io::Error| EuvError::IoPath {
635 message: String::from("Failed to inspect an entry of finalized WASM output"),
636 path: dir.clone(),
637 error,
638 })?
639 {
640 let path: PathBuf = entry.path();
641 let file_type: FileType =
642 entry
643 .file_type()
644 .await
645 .map_err(|error: io::Error| EuvError::IoPath {
646 message: String::from("Failed to inspect a finalized WASM output file"),
647 path: path.clone(),
648 error,
649 })?;
650 if file_type.is_dir() {
651 pending.push(path);
652 continue;
653 }
654 if is_generated_js_artifact(&path) {
655 return Err(EuvError::Message(format!(
656 "{} survived the cleanup, so this build would deploy JavaScript; \
657 the page no longer imports it, and it cannot be served",
658 path.display()
659 )));
660 }
661 }
662 }
663 Ok(())
664}
665
666pub async fn clean_out_dir(out_dir: &Path) {
676 let mut entries: ReadDir = match read_dir(out_dir).await {
677 Ok(dir) => dir,
678 Err(_) => return,
679 };
680 while let Ok(Some(entry)) = entries.next_entry().await {
681 let path: PathBuf = entry.path();
682 if path.is_dir() {
683 if let Err(error) = remove_dir_all(&path).await {
684 log::warn!("Failed to remove directory '{}': {error}", path.display());
685 }
686 } else if let Err(error) = remove_file(&path).await {
687 log::warn!("Failed to remove file '{}': {error}", path.display());
688 }
689 }
690}
691
692pub async fn run_build_pipeline(
709 args: &ModeArgs,
710 reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
711) -> Result<String, EuvError> {
712 let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
713 if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
714 log::warn!("euv fmt error: {error}");
715 }
716 if let Err(error) = build_wasm(args).await {
717 log::error!("WASM build failed: {error}");
718 notify_reload(reload_tx, Err(error.to_string()));
719 return Err(error);
720 }
721 log::info!("WASM build completed successfully");
722 let html: String = match finalize_html_output(args, true).await {
723 Ok(html) => html,
724 Err(error) => {
725 log::error!("Page generation failed: {error}");
726 notify_reload(reload_tx, Err(error.to_string()));
727 return Err(error);
728 }
729 };
730 notify_reload(reload_tx, Ok(()));
731 Ok(html)
732}
733
734fn notify_reload(reload_tx: Option<&broadcast::Sender<ReloadEvent>>, outcome: Result<(), String>) {
742 let Some(sender) = reload_tx else {
743 return;
744 };
745 let event: ReloadEvent = match outcome {
746 Ok(()) => ReloadEvent::Reload,
747 Err(message) => ReloadEvent::Error(message),
748 };
749 let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
750 sender.send(event);
751}
752
753pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
763 let crate_path: PathBuf = state.get_args().get_crate_path().clone();
764 let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
765 let gitignore: Gitignore = build_gitignore(&crate_path).await;
766 let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
767 let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
768 move |result: Result<Event, notify::Error>| {
769 if let Ok(event) = result {
770 let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
771 tx.blocking_send(event);
772 }
773 },
774 Config::default(),
775 )?;
776 watcher.watch(&src_path, RecursiveMode::Recursive)?;
777 log::info!("Watching {} for changes...", src_path.display());
778 let mut debounce: Interval = interval(Duration::from_millis(500));
779 debounce.tick().await;
780 while let Some(event) = rx.recv().await {
781 let filtered_paths: Vec<String> = event
782 .paths
783 .iter()
784 .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
785 .map(|path: &PathBuf| path.display().to_string())
786 .collect();
787 if filtered_paths.is_empty() {
788 continue;
789 }
790 log::warn!("File change detected: {}", filtered_paths.join(", "));
791 debounce.reset();
792 sleep(Duration::from_millis(300)).await;
793 let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
794 if *building {
795 continue;
796 }
797 *building = true;
798 drop(building);
799 let state_for_build: Arc<AppState> = Arc::clone(&state);
800 spawn(async move {
801 let args: ModeArgs = state_for_build.get_args().clone();
802 let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
803 match run_build_pipeline(&args, Some(&reload_tx)).await {
804 Ok(html) => {
805 let mut content: RwLockWriteGuard<String> =
806 state_for_build.get_html_content().write().await;
807 *content = html;
808 }
809 Err(error) => {
810 log::error!("Build pipeline error: {error}");
811 }
812 }
813 let mut building: RwLockWriteGuard<bool> =
814 state_for_build.get_is_building().write().await;
815 *building = false;
816 });
817 }
818 Ok(())
819}
820
821pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
836 let build_mode: BuildMode = resolve_build_mode(args);
837 let build_mode_flag: &str = build_mode_to_flag(build_mode);
838 let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
839 let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
840 let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
841 let mut command: Command = Command::new(WASM_PACK_COMMAND);
842 command.arg(WASM_PACK_BUILD_SUBCOMMAND);
843 if !has_existing_build_mode {
844 command.arg(build_mode_flag);
845 }
846 command
847 .args(&filtered_args)
848 .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
849 let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
850 if !has_out_dir {
851 command.arg(OUT_DIR_ARG).arg(&default_out_dir);
852 }
853 let has_target: bool = filtered_args
854 .iter()
855 .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
856 if !has_target {
857 command.arg(TARGET_ARG).arg(TARGET_WEB);
858 }
859 command.current_dir(args.get_crate_path());
860 command.stdout(Stdio::piped()).stderr(Stdio::piped());
861 let display_args: Vec<String> = (if has_existing_build_mode {
862 filtered_args.to_vec()
863 } else {
864 once(build_mode_flag.to_string())
865 .chain(filtered_args.iter().cloned())
866 .collect::<Vec<String>>()
867 })
868 .into_iter()
869 .chain(if has_out_dir {
870 Vec::new()
871 } else {
872 vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
873 })
874 .chain(if has_target {
875 Vec::new()
876 } else {
877 vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
878 })
879 .collect();
880 let out_dir_absolute: PathBuf = resolve_out_dir(args);
881 create_dir_all(&out_dir_absolute)
882 .await
883 .map_err(|error: io::Error| EuvError::IoPath {
884 message: String::from("Failed to create output directory"),
885 path: out_dir_absolute.clone(),
886 error,
887 })?;
888 log::info!(
889 "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
890 display_args.join(" ")
891 );
892 let output: Output = command
893 .output()
894 .await
895 .map_err(|error: io::Error| EuvError::Io {
896 message: String::from("Failed to execute wasm-pack"),
897 error,
898 })?;
899 let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
900 let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
901 if args.get_no_gitignore() {
902 let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
903 if gitignore_path.exists()
904 && let Err(error) = remove_file(&gitignore_path).await
905 {
906 log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
907 }
908 }
909 for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
910 log::info!("{line}");
911 }
912 if output.status.success() {
913 for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
914 log::info!("{line}");
915 }
916 } else {
917 for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
918 log::error!("{line}");
919 }
920 return Err(EuvError::Message(String::from("wasm-pack build failed")));
921 }
922 Ok(())
923}
924
925pub fn print_banner(action: Action) {
931 let version: &str = env!("CARGO_PKG_VERSION");
932 if version.is_empty() {
933 log::warn!("Failed to parse version from root Cargo.toml");
934 } else {
935 log::info!("euv v{version}");
936 }
937 let action_name: &str = match action {
938 Action::Run => ACTION_RUN,
939 Action::Build => ACTION_BUILD,
940 };
941 log::info!("Mode: {action_name}");
942 log::info!(
943 "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
944 );
945}
946
947pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
959 let port: u16 = config.get_port();
960 let route_prefix: &str = config.get_route_prefix();
961 let index_html_file_name: &str = config.get_index_html_file_name();
962 let mut addresses: Vec<IpAddr> = Vec::new();
963 match if_addrs::get_if_addrs() {
964 Ok(interfaces) => {
965 for interface in interfaces {
966 let ip: IpAddr = interface.addr.ip();
967 if !addresses.contains(&ip) {
968 addresses.push(ip);
969 }
970 }
971 }
972 Err(error) => {
973 log::warn!("Failed to enumerate network interfaces: {error}");
974 }
975 }
976 if addresses.is_empty() {
977 addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
978 }
979 for ip in addresses {
980 let host: String = match ip {
981 IpAddr::V6(_) => format!("[{ip}]"),
982 IpAddr::V4(_) => format!("{ip}"),
983 };
984 let url: String =
985 format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
986 log::info!("Server: {url}");
987 match QrCode::new(url.as_str()) {
988 Ok(code) => {
989 let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
990 for line in string.lines() {
991 log::info!("{line}");
992 }
993 }
994 Err(error) => {
995 log::warn!("Failed to generate QR code: {error}");
996 }
997 }
998 }
999}
1000
1001pub(crate) async fn build_inline_bridge(
1028 pkg_dir: &Path,
1029 js_name: &str,
1030 wasm_url: &str,
1031) -> Result<String, EuvError> {
1032 let js_path: PathBuf = pkg_dir.join(js_name);
1033 let bridge_source: String =
1034 read_to_string(&js_path)
1035 .await
1036 .map_err(|error: io::Error| EuvError::IoPath {
1037 message: String::from("Failed to read wasm-bindgen JS bridge"),
1038 path: js_path.clone(),
1039 error,
1040 })?;
1041 let inlined: String = inline_module_graph(&bridge_source, pkg_dir).await?;
1042 let js_only: String = strip_trailing_module_exports(&inlined);
1043 if !js_only.contains("function main(") {
1044 return Err(EuvError::Message(format!(
1045 "wasm-bindgen bridge '{js_name}' declares no main() for the bootstrap to call"
1046 )));
1047 }
1048 if !js_only.contains("__wbg_get_imports") {
1049 return Err(EuvError::Message(format!(
1050 "wasm-bindgen bridge '{js_name}' declares no __wbg_get_imports() \
1051 for the bootstrap to instantiate the module with"
1052 )));
1053 }
1054 let wasm_url_json: String = encode_js_string(wasm_url);
1055 Ok(format!(
1056 "(function() {{\n\
1057 {js_only}\n\
1058 var __euv_wasm_url = {wasm_url_json};\n\
1059 var __euv_base = (document.querySelector('base[href]') && document.baseURI) || location.href;\n\
1060 var __euv_url = new URL(__euv_wasm_url, __euv_base).toString();\n\
1061 function __euv_main() {{ if (typeof main === 'function') {{ main(); }} }}\n\
1062 if (typeof __wbg_init === 'function') {{\n\
1063 __wbg_init(__euv_url).then(__euv_main).catch(function(e) {{\n\
1064 console.error('[euv] inline bridge init failed:', e);\n\
1065 }});\n\
1066 }} else if (typeof initSync === 'function') {{\n\
1067 fetch(__euv_url).then(function(response) {{ return response.arrayBuffer(); }})\n\
1068 .then(function(bytes) {{ initSync({{ module: bytes }}); __euv_main(); }})\n\
1069 .catch(function(e) {{ console.error('[euv] inline bridge init failed:', e); }});\n\
1070 }} else {{\n\
1071 console.error('[euv] inline bridge: no __wbg_init or initSync to instantiate the module');\n\
1072 }}\n\
1073 }})();\n"
1074 ))
1075}
1076
1077async fn inline_module_graph(bridge_source: &str, pkg_dir: &Path) -> Result<String, EuvError> {
1096 let mut snippets: Vec<(PathBuf, String)> = Vec::new();
1097 let mut namespace_clauses: Vec<NamespaceImport> = Vec::new();
1098 for line in bridge_source.lines() {
1099 let trimmed: &str = line.trim();
1100 if !trimmed.starts_with("import ") {
1101 continue;
1102 }
1103 let rest: &str = trimmed.strip_prefix("import ").unwrap_or_default();
1104 let clause: ImportClause<'_> = parse_import_clause(rest)?;
1105 if clause.default_binding.is_some() || !clause.named.is_empty() {
1106 return Err(EuvError::Message(format!(
1107 "wasm-bindgen bridge imports a default or named binding from '{}', \
1108 which a self-contained bootstrap cannot reproduce",
1109 clause.spec
1110 )));
1111 }
1112 if !(clause.spec.starts_with("./") || clause.spec.starts_with("../")) {
1113 return Err(EuvError::Message(format!(
1114 "wasm-bindgen bridge imports the bare module '{}', which a \
1115 self-contained bootstrap cannot fetch",
1116 clause.spec
1117 )));
1118 }
1119 if let Some(alias) = clause.namespace {
1120 namespace_clauses.push(NamespaceImport {
1121 alias: alias.to_string(),
1122 spec: clause.spec.to_string(),
1123 });
1124 }
1125 let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, clause.spec).await?;
1126 if snippets
1127 .iter()
1128 .any(|(path, _): &(PathBuf, String)| path == &snippet_path)
1129 {
1130 continue;
1131 }
1132 let raw: String = read_to_string(&snippet_path)
1133 .await
1134 .map_err(|error: io::Error| EuvError::IoPath {
1135 message: String::from("Failed to read wasm-pack snippet module"),
1136 path: snippet_path.clone(),
1137 error,
1138 })?;
1139 snippets.push((snippet_path, raw));
1140 }
1141 let mut out: String = String::new();
1142 for (snippet_path, raw) in &snippets {
1143 let body: String = strip_module_exports(raw).map_err(|error: EuvError| match error {
1144 EuvError::Message(message) => EuvError::Message(format!(
1145 "{} in snippet module '{}'",
1146 message,
1147 snippet_path.display()
1148 )),
1149 other => other,
1150 })?;
1151 if body.trim().is_empty() {
1152 return Err(EuvError::Message(format!(
1153 "snippet module '{}' has no inlinable body",
1154 snippet_path.display()
1155 )));
1156 }
1157 out.push_str(&body);
1158 out.push('\n');
1159 }
1160 for clause in &namespace_clauses {
1161 let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, &clause.spec).await?;
1162 let raw: String = snippets
1163 .iter()
1164 .find(|(path, _): &&(PathBuf, String)| *path == snippet_path)
1165 .map(|(_, raw): &(PathBuf, String)| raw.clone())
1166 .unwrap_or_default();
1167 let names: Vec<String> = extract_exported_names(&raw);
1168 if names.is_empty() {
1169 return Err(EuvError::Message(format!(
1170 "snippet module '{}' exports nothing the namespace binding '{}' \
1171 could reference",
1172 clause.spec, clause.alias
1173 )));
1174 }
1175 out.push_str(&render_namespace_object(&clause.alias, &names));
1176 }
1177 for line in bridge_source.lines() {
1178 let trimmed: &str = line.trim();
1179 if trimmed.starts_with("import ") {
1180 continue;
1181 }
1182 if trimmed.contains(IMPORT_META_URL_MARKER) {
1183 continue;
1184 }
1185 out.push_str(line);
1186 out.push('\n');
1187 }
1188 Ok(out)
1189}
1190
1191pub fn parse_import_clause(rest: &str) -> Result<ImportClause<'_>, EuvError> {
1201 let rest: &str = rest.trim().trim_end_matches(';').trim();
1202 let from_idx: usize = rest.find(" from ").ok_or_else(|| {
1203 EuvError::Message(format!(
1204 "bridge import '{rest}' is not a static `from` clause, which a \
1205 self-contained bootstrap cannot reproduce"
1206 ))
1207 })?;
1208 let head: &str = rest[..from_idx].trim();
1209 let quoted: &str = rest[from_idx + " from ".len()..].trim();
1210 let quote: char = match quoted.chars().next() {
1211 Some(quote @ ('\'' | '"')) => quote,
1212 _ => {
1213 return Err(EuvError::Message(format!(
1214 "bridge import specifier '{quoted}' is not a quoted string"
1215 )));
1216 }
1217 };
1218 let after: &str = "ed[quote.len_utf8()..];
1219 let spec: &str = after.split(quote).next().unwrap_or_default();
1220 if spec.is_empty() {
1221 return Err(EuvError::Message(format!(
1222 "bridge import '{rest}' names an empty specifier"
1223 )));
1224 }
1225 if head.starts_with('*') {
1226 let alias: &str = head
1227 .strip_prefix('*')
1228 .map(|value: &str| value.trim())
1229 .and_then(|value: &str| value.strip_prefix("as "))
1230 .map(|value: &str| value.trim())
1231 .unwrap_or_default();
1232 if alias.is_empty() {
1233 return Err(EuvError::Message(format!(
1234 "bridge import '{rest}' is a namespace import without a binding"
1235 )));
1236 }
1237 return Ok(ImportClause {
1238 spec,
1239 namespace: Some(alias),
1240 default_binding: None,
1241 named: Vec::new(),
1242 });
1243 }
1244 if let Some(inner) = head.strip_prefix('{') {
1245 let inner: &str = inner.strip_suffix('}').ok_or_else(|| {
1246 EuvError::Message(format!(
1247 "bridge import '{rest}' has an unbalanced `{{` clause"
1248 ))
1249 })?;
1250 return Ok(ImportClause {
1251 spec,
1252 namespace: None,
1253 default_binding: None,
1254 named: inner
1255 .split(',')
1256 .map(|entry: &str| entry.trim())
1257 .filter(|entry: &&str| !entry.is_empty())
1258 .collect(),
1259 });
1260 }
1261 Ok(ImportClause {
1262 spec,
1263 namespace: None,
1264 default_binding: Some(head),
1265 named: Vec::new(),
1266 })
1267}
1268
1269fn render_namespace_object(alias: &str, names: &[String]) -> String {
1281 let mut out: String = format!("var {alias} = {{");
1282 for (index, name) in names.iter().enumerate() {
1283 if index > 0 {
1284 out.push(',');
1285 }
1286 out.push_str(name);
1287 out.push(':');
1288 out.push_str(name);
1289 }
1290 out.push_str("};\n");
1291 out
1292}
1293
1294fn strip_module_exports(source: &str) -> Result<String, EuvError> {
1306 let mut out: String = String::with_capacity(source.len());
1307 for line in source.lines() {
1308 let trimmed: &str = line.trim();
1309 let Some(rest) = trimmed.strip_prefix("export ") else {
1310 out.push_str(line);
1311 out.push('\n');
1312 continue;
1313 };
1314 match classify_export(rest) {
1315 ExportForm::Aliases(clause) => {
1316 out.push_str(&render_export_aliases(clause)?);
1317 }
1318 ExportForm::Declaration(declaration) => {
1319 out.push_str(declaration);
1320 out.push('\n');
1321 }
1322 ExportForm::Unsupported => {
1323 return Err(EuvError::Message(format!(
1324 "snippet exports '{rest}', a form a self-contained \
1325 bootstrap cannot reproduce"
1326 )));
1327 }
1328 }
1329 }
1330 Ok(out)
1331}
1332
1333fn classify_export(rest: &str) -> ExportForm<'_> {
1343 let trimmed: &str = rest.trim_end().trim_end_matches(';');
1344 if let Some(inner) = trimmed.strip_prefix('{')
1345 && inner.trim_end().ends_with('}')
1346 {
1347 return ExportForm::Aliases(inner.trim_end().trim_end_matches('}'));
1348 }
1349 if EXPORT_DECLARATION_KEYWORDS
1350 .iter()
1351 .any(|keyword: &&str| trimmed.starts_with(keyword))
1352 {
1353 return ExportForm::Declaration(trimmed);
1354 }
1355 ExportForm::Unsupported
1356}
1357
1358fn render_export_aliases(clause: &str) -> Result<String, EuvError> {
1375 let mut out: String = String::new();
1376 for entry in clause.split(',') {
1377 let entry: &str = entry.trim();
1378 if entry.is_empty() {
1379 continue;
1380 }
1381 let (original, exported): (&str, &str) = match entry.split_once(" as ") {
1382 Some((original, exported)) => (original.trim(), exported.trim()),
1383 None => (entry, entry),
1384 };
1385 if !is_identifier(original) || !is_identifier(exported) {
1386 return Err(EuvError::Message(format!(
1387 "snippet exports the non-identifier entry '{entry}'"
1388 )));
1389 }
1390 if original == exported || exported == "default" || original == "wasm" {
1391 continue;
1392 }
1393 out.push_str(exported);
1394 out.push_str(" = ");
1395 out.push_str(original);
1396 out.push_str(";\n");
1397 }
1398 Ok(out)
1399}
1400
1401fn is_identifier(value: &str) -> bool {
1411 !value.is_empty()
1412 && !value.starts_with(|c: char| c.is_ascii_digit())
1413 && value
1414 .chars()
1415 .all(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1416}
1417
1418pub fn extract_exported_names(source: &str) -> Vec<String> {
1430 let mut names: Vec<String> = Vec::new();
1431 for line in source.lines() {
1432 let trimmed: &str = line.trim();
1433 let Some(rest) = trimmed.strip_prefix("export ") else {
1434 continue;
1435 };
1436 let declaration: Option<&str> = EXPORT_DECLARATION_KEYWORDS
1437 .iter()
1438 .find_map(|keyword: &&str| rest.strip_prefix(keyword));
1439 if let Some(name) = declaration.and_then(leading_identifier)
1440 && !names.iter().any(|existing: &String| existing == name)
1441 {
1442 names.push(name.to_string());
1443 }
1444 }
1445 names
1446}
1447
1448fn leading_identifier(value: &str) -> Option<&str> {
1459 let end: usize = value
1460 .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$')
1461 .unwrap_or(value.len());
1462 if end == 0 { None } else { Some(&value[..end]) }
1463}
1464
1465async fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result<PathBuf, EuvError> {
1477 let base: &str = spec.strip_prefix("./").unwrap_or(spec);
1478 let mut path: PathBuf = pkg_dir.to_path_buf();
1479 for segment in base.split('/') {
1480 if segment.is_empty() || segment == "." {
1481 continue;
1482 }
1483 if segment == ".." {
1484 return Err(EuvError::Message(String::from(
1485 "Snippet import spec escapes pkg directory",
1486 )));
1487 }
1488 path.push(segment);
1489 }
1490 let root: PathBuf =
1491 canonicalize(pkg_dir)
1492 .await
1493 .map_err(|error: io::Error| EuvError::IoPath {
1494 message: String::from("Failed to resolve the WASM output directory"),
1495 path: pkg_dir.to_path_buf(),
1496 error,
1497 })?;
1498 let resolved: PathBuf =
1499 canonicalize(&path)
1500 .await
1501 .map_err(|error: io::Error| EuvError::IoPath {
1502 message: String::from("Failed to resolve the imported snippet"),
1503 path: path.clone(),
1504 error,
1505 })?;
1506 if !resolved.starts_with(root) {
1507 return Err(EuvError::Message(format!(
1508 "Snippet import '{spec}' resolves outside the WASM output directory",
1509 )));
1510 }
1511 Ok(resolved)
1512}
1513
1514fn strip_trailing_module_exports(source: &str) -> String {
1527 let mut out: String = String::with_capacity(source.len());
1528 for line in source.lines() {
1529 let trimmed: &str = line.trim();
1530 let Some(rest) = trimmed.strip_prefix("export ") else {
1531 out.push_str(line);
1532 out.push('\n');
1533 continue;
1534 };
1535 match classify_export(rest) {
1536 ExportForm::Aliases(clause) => {
1537 if let Ok(aliases) = render_export_aliases(clause) {
1538 out.push_str(&aliases);
1539 }
1540 }
1541 ExportForm::Declaration(declaration) => {
1542 out.push_str(declaration);
1543 out.push('\n');
1544 }
1545 ExportForm::Unsupported => {
1546 out.push_str(line);
1547 out.push('\n');
1548 }
1549 }
1550 }
1551 out
1552}
1553
1554pub(crate) fn encode_js_string(value: &str) -> String {
1567 let mut out: String = String::with_capacity(value.len() + 2);
1568 out.push('"');
1569 for ch in value.chars() {
1570 match ch {
1571 '"' => out.push_str("\\\""),
1572 '\\' => out.push_str("\\\\"),
1573 '\n' => out.push_str("\\n"),
1574 '\r' => out.push_str("\\r"),
1575 '\t' => out.push_str("\\t"),
1576 '\u{08}' => out.push_str("\\b"),
1577 '\u{0C}' => out.push_str("\\f"),
1578 '<' => out.push_str("\\u003c"),
1579 '>' => out.push_str("\\u003e"),
1580 '&' => out.push_str("\\u0026"),
1581 '\u{2028}' => out.push_str("\\u2028"),
1582 '\u{2029}' => out.push_str("\\u2029"),
1583 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1584 c => out.push(c),
1585 }
1586 }
1587 out.push('"');
1588 out
1589}
1590
1591pub(crate) async fn remove_generated_js(pkg_dir: &Path) -> Result<Vec<PathBuf>, EuvError> {
1618 let root: PathBuf = match canonicalize(pkg_dir).await {
1619 Ok(root) => root,
1620 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1621 return Ok(Vec::new());
1622 }
1623 Err(error) => {
1624 return Err(EuvError::IoPath {
1625 message: String::from("Failed to resolve the wasm-pack output directory"),
1626 path: pkg_dir.to_path_buf(),
1627 error,
1628 });
1629 }
1630 };
1631 let mut removed: Vec<PathBuf> = Vec::new();
1632 let mut generated_dirs: Vec<PathBuf> = Vec::new();
1633 let mut pending: Vec<PathBuf> = vec![root.clone()];
1634 while let Some(dir) = pending.pop() {
1635 let mut entries: ReadDir = read_dir(&dir).await.map_err(|error: io::Error| {
1636 EuvError::IoPath {
1637 message: String::from(
1638 "Failed to list the wasm-pack output directory while removing inlined JavaScript",
1639 ),
1640 path: dir.clone(),
1641 error,
1642 }
1643 })?;
1644 loop {
1645 let entry: DirEntry = match entries.next_entry().await {
1646 Ok(Some(entry)) => entry,
1647 Ok(None) => break,
1648 Err(error) => {
1649 return Err(EuvError::IoPath {
1650 message: String::from(
1651 "Failed to read an entry of the wasm-pack output directory while \
1652 removing inlined JavaScript",
1653 ),
1654 path: dir.clone(),
1655 error,
1656 });
1657 }
1658 };
1659 let path: PathBuf = entry.path();
1660 let file_type: FileType =
1661 entry
1662 .file_type()
1663 .await
1664 .map_err(|error: io::Error| EuvError::IoPath {
1665 message: String::from(
1666 "Failed to stat an entry of the wasm-pack output directory while \
1667 removing inlined JavaScript",
1668 ),
1669 path: path.clone(),
1670 error,
1671 })?;
1672 if file_type.is_symlink() {
1673 log::warn!(
1676 "Leaving '{}' in place: it is a symlink, and the cleanup never follows one",
1677 path.display()
1678 );
1679 continue;
1680 }
1681 if file_type.is_dir() {
1682 if path.starts_with(root.join(SNIPPET_DIR_NAME)) {
1683 generated_dirs.push(path.clone());
1684 }
1685 pending.push(path);
1686 continue;
1687 }
1688 let canonical: PathBuf =
1689 canonicalize(&path)
1690 .await
1691 .map_err(|error: io::Error| EuvError::IoPath {
1692 message: String::from(
1693 "Failed to resolve an entry of the wasm-pack output directory before \
1694 deleting it",
1695 ),
1696 path: path.clone(),
1697 error,
1698 })?;
1699 if !canonical.starts_with(&root) {
1700 log::warn!(
1701 "Leaving '{}' in place: it resolves outside the wasm-pack output",
1702 path.display()
1703 );
1704 continue;
1705 }
1706 if !is_generated_js_artifact(&path) {
1707 continue;
1708 }
1709 remove_file(&path)
1710 .await
1711 .map_err(|error: io::Error| EuvError::IoPath {
1712 message: String::from(
1713 "Failed to remove a generated JavaScript file after inlining it",
1714 ),
1715 path: path.clone(),
1716 error,
1717 })?;
1718 removed.push(path);
1719 }
1720 }
1721 for dir in generated_dirs.iter().rev() {
1722 match remove_dir(dir).await {
1723 Ok(()) => {}
1724 Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => {}
1725 Err(error) => {
1726 return Err(EuvError::IoPath {
1727 message: String::from("Failed to remove an emptied snippet directory"),
1728 path: dir.clone(),
1729 error,
1730 });
1731 }
1732 }
1733 }
1734 log::debug!(
1735 "Removed {} generated JavaScript file(s) and {} empty generated directory(ies)",
1736 removed.len(),
1737 generated_dirs.len()
1738 );
1739 Ok(removed)
1740}
1741
1742fn is_generated_js_artifact(path: &Path) -> bool {
1753 let name: String = path
1754 .file_name()
1755 .and_then(|name: &ffi::OsStr| name.to_str())
1756 .unwrap_or_default()
1757 .to_ascii_lowercase();
1758 GENERATED_JS_SUFFIXES
1759 .iter()
1760 .any(|suffix: &&str| name.ends_with(suffix))
1761}