Skip to main content

turbopack_cli/dev/
mod.rs

1use std::{
2    env::current_dir,
3    future::{Future, join},
4    io::{Write, stdout},
5    net::{IpAddr, SocketAddr},
6    path::{MAIN_SEPARATOR, PathBuf},
7    sync::Arc,
8    time::{Duration, Instant},
9};
10
11use anyhow::{Context, Result};
12use owo_colors::OwoColorize;
13use rustc_hash::FxHashSet;
14use turbo_rcstr::{RcStr, rcstr};
15use turbo_tasks::{
16    NonLocalValue, OperationVc, ResolvedVc, TransientInstance, TurboTasks, UpdateInfo, Vc,
17    trace::TraceRawVcs,
18    util::{FormatBytes, FormatDuration},
19};
20use turbo_tasks_backend::{
21    BackendOptions, GitVersionInfo, StartupCacheState, StorageMode, TurboTasksBackend,
22    noop_backing_storage, turbo_backing_storage,
23};
24use turbo_tasks_fs::FileSystem;
25use turbo_tasks_malloc::TurboMalloc;
26use turbo_unix_path::join_path;
27use turbopack::evaluate_context::node_build_environment;
28use turbopack_cli_utils::issue::{ConsoleUi, LogOptions};
29use turbopack_core::{
30    issue::{IssueReporter, IssueSeverity},
31    resolve::parse::Request,
32    server_fs::ServerFileSystem,
33};
34use turbopack_dev_server::{
35    DevServer, DevServerBuilder, SourceProvider,
36    introspect::IntrospectionSource,
37    source::{
38        ContentSource, combined::CombinedContentSource, router::PrefixedRouterContentSource,
39        static_assets::StaticAssetsContentSource,
40    },
41};
42use turbopack_ecmascript_runtime::RuntimeType;
43use turbopack_env::dotenv::load_env;
44use turbopack_node::{child_process_backend, execution_context::ExecutionContext};
45use turbopack_nodejs::NodeJsChunkingContext;
46
47use self::web_entry_source::create_web_entry_source;
48use crate::{
49    arguments::DevArguments,
50    contexts::NodeEnv,
51    util::{
52        EntryRequest, NormalizedDirs, normalize_dirs, normalize_entries, output_fs, project_fs,
53    },
54};
55
56pub(crate) mod web_entry_source;
57
58pub struct TurbopackDevServerBuilder {
59    turbo_tasks: Arc<TurboTasks<TurboTasksBackend>>,
60    project_dir: RcStr,
61    root_dir: RcStr,
62    entry_requests: Vec<EntryRequest>,
63    eager_compile: bool,
64    hostname: Option<IpAddr>,
65    issue_reporter: Option<Box<dyn IssueReporterProvider>>,
66    port: Option<u16>,
67    browserslist_query: RcStr,
68    log_level: IssueSeverity,
69    show_all: bool,
70    log_detail: bool,
71    allow_retry: bool,
72}
73
74impl TurbopackDevServerBuilder {
75    pub fn new(
76        turbo_tasks: Arc<TurboTasks<TurboTasksBackend>>,
77        project_dir: RcStr,
78        root_dir: RcStr,
79    ) -> TurbopackDevServerBuilder {
80        TurbopackDevServerBuilder {
81            turbo_tasks,
82            project_dir,
83            root_dir,
84            entry_requests: vec![],
85            eager_compile: false,
86            hostname: None,
87            issue_reporter: None,
88            port: None,
89            browserslist_query: "last 1 Chrome versions, last 1 Firefox versions, last 1 Safari \
90                                 versions, last 1 Edge versions"
91                .into(),
92            log_level: IssueSeverity::Warning,
93            show_all: false,
94            log_detail: false,
95            allow_retry: false,
96        }
97    }
98
99    pub fn entry_request(mut self, entry_asset_path: EntryRequest) -> TurbopackDevServerBuilder {
100        self.entry_requests.push(entry_asset_path);
101        self
102    }
103
104    pub fn eager_compile(mut self, eager_compile: bool) -> TurbopackDevServerBuilder {
105        self.eager_compile = eager_compile;
106        self
107    }
108
109    pub fn hostname(mut self, hostname: IpAddr) -> TurbopackDevServerBuilder {
110        self.hostname = Some(hostname);
111        self
112    }
113
114    pub fn port(mut self, port: u16) -> TurbopackDevServerBuilder {
115        self.port = Some(port);
116        self
117    }
118
119    pub fn browserslist_query(mut self, browserslist_query: RcStr) -> TurbopackDevServerBuilder {
120        self.browserslist_query = browserslist_query;
121        self
122    }
123
124    pub fn log_level(mut self, log_level: IssueSeverity) -> TurbopackDevServerBuilder {
125        self.log_level = log_level;
126        self
127    }
128
129    pub fn show_all(mut self, show_all: bool) -> TurbopackDevServerBuilder {
130        self.show_all = show_all;
131        self
132    }
133
134    pub fn allow_retry(mut self, allow_retry: bool) -> TurbopackDevServerBuilder {
135        self.allow_retry = allow_retry;
136        self
137    }
138
139    pub fn log_detail(mut self, log_detail: bool) -> TurbopackDevServerBuilder {
140        self.log_detail = log_detail;
141        self
142    }
143
144    pub fn issue_reporter(
145        mut self,
146        issue_reporter: Box<dyn IssueReporterProvider>,
147    ) -> TurbopackDevServerBuilder {
148        self.issue_reporter = Some(issue_reporter);
149        self
150    }
151
152    /// Attempts to find an open port to bind.
153    fn find_port(&self, host: IpAddr, port: u16, max_attempts: u16) -> Result<DevServerBuilder> {
154        // max_attempts of 1 means we loop 0 times.
155        let max_attempts = max_attempts - 1;
156        let mut attempts = 0;
157        loop {
158            let current_port = port + attempts;
159            let addr = SocketAddr::new(host, current_port);
160            let listen_result = DevServer::listen(addr);
161
162            if let Err(e) = &listen_result
163                && self.allow_retry
164                && attempts < max_attempts
165            {
166                // Returned error from `listen` is not `std::io::Error` but `anyhow::Error`,
167                // so we need to access its source to check if it is
168                // `std::io::ErrorKind::AddrInUse`.
169                let should_retry = e
170                    .source()
171                    .and_then(|e| {
172                        e.downcast_ref::<std::io::Error>()
173                            .map(|e| e.kind() == std::io::ErrorKind::AddrInUse)
174                    })
175                    .unwrap_or(false);
176
177                if should_retry {
178                    println!(
179                        "{} - Port {} is in use, trying {} instead",
180                        "warn ".yellow(),
181                        current_port,
182                        current_port + 1
183                    );
184                    attempts += 1;
185                    continue;
186                }
187            }
188
189            return listen_result;
190        }
191    }
192
193    pub async fn build(self) -> Result<DevServer> {
194        let port = self.port.context("port must be set")?;
195        let host = self.hostname.context("hostname must be set")?;
196
197        let server = self.find_port(host, port, 10)?;
198
199        let turbo_tasks = self.turbo_tasks;
200        let project_dir: RcStr = self.project_dir;
201        let root_dir: RcStr = self.root_dir;
202        let eager_compile = self.eager_compile;
203        let show_all = self.show_all;
204        let log_detail: bool = self.log_detail;
205        let browserslist_query: RcStr = self.browserslist_query;
206        let log_args = TransientInstance::new(LogOptions {
207            current_dir: current_dir().unwrap(),
208            project_dir: PathBuf::from(project_dir.clone()),
209            show_all,
210            log_detail,
211            log_level: self.log_level,
212        });
213        let entry_requests = Arc::new(self.entry_requests);
214        let tasks = turbo_tasks.clone();
215        let issue_provider = self.issue_reporter.unwrap_or_else(|| {
216            // Initialize a ConsoleUi reporter if no custom reporter was provided
217            Box::new(move || Vc::upcast(ConsoleUi::new(log_args.clone())))
218        });
219
220        #[derive(Clone, TraceRawVcs, NonLocalValue)]
221        struct ServerSourceProvider {
222            root_dir: RcStr,
223            project_dir: RcStr,
224            entry_requests: Arc<Vec<EntryRequest>>,
225            eager_compile: bool,
226            browserslist_query: RcStr,
227        }
228        impl SourceProvider for ServerSourceProvider {
229            fn get_source(&self) -> OperationVc<Box<dyn ContentSource>> {
230                source(
231                    self.root_dir.clone(),
232                    self.project_dir.clone(),
233                    self.entry_requests.clone(),
234                    self.eager_compile,
235                    self.browserslist_query.clone(),
236                )
237            }
238        }
239        let source = ServerSourceProvider {
240            root_dir,
241            project_dir,
242            entry_requests,
243            eager_compile,
244            browserslist_query,
245        };
246
247        let issue_reporter_arc = Arc::new(move || issue_provider.get_issue_reporter());
248        Ok(server.serve(tasks, source, issue_reporter_arc))
249    }
250}
251
252#[turbo_tasks::function(operation, root)]
253async fn source(
254    root_dir: RcStr,
255    project_dir: RcStr,
256    entry_requests: Arc<Vec<EntryRequest>>,
257    eager_compile: bool,
258    browserslist_query: RcStr,
259) -> Result<Vc<Box<dyn ContentSource>>> {
260    let project_relative = project_dir.strip_prefix(&*root_dir).unwrap();
261    let project_relative: RcStr = project_relative
262        .strip_prefix(MAIN_SEPARATOR)
263        .unwrap_or(project_relative)
264        .replace(MAIN_SEPARATOR, "/")
265        .into();
266
267    let output_fs = output_fs(project_dir);
268    const OUTPUT_DIR: &str = ".turbopack/build";
269    let fs: Vc<Box<dyn FileSystem>> = project_fs(
270        root_dir,
271        /* watch= */ true,
272        join_path(project_relative.as_str(), OUTPUT_DIR)
273            .unwrap()
274            .into(),
275    );
276    let root_path = fs.root().owned().await?;
277    let project_path = root_path.join(&project_relative)?;
278
279    let env = load_env(root_path.clone());
280    let build_output_root = output_fs.root().await?.join(OUTPUT_DIR)?;
281
282    let build_output_root_to_root_path = project_path
283        .join(OUTPUT_DIR)?
284        .get_relative_path_to(&root_path)
285        .context("Project path is in root path")?;
286    let build_output_root_to_root_path = build_output_root_to_root_path;
287
288    let build_chunking_context = NodeJsChunkingContext::builder(
289        root_path.clone(),
290        build_output_root.clone(),
291        build_output_root_to_root_path,
292        build_output_root.clone(),
293        build_output_root.join("chunks")?,
294        build_output_root.join("assets")?,
295        node_build_environment().to_resolved().await?,
296        RuntimeType::Development,
297    )
298    .build();
299
300    let node_backend = child_process_backend();
301    let execution_context = ExecutionContext::new(
302        root_path.clone(),
303        Vc::upcast(build_chunking_context),
304        env,
305        node_backend,
306    );
307
308    let server_fs = Vc::upcast::<Box<dyn FileSystem>>(ServerFileSystem::new());
309    let server_root = server_fs.root().owned().await?;
310    let entry_requests = entry_requests
311        .iter()
312        .map(|r| match r {
313            EntryRequest::Relative(p) => Request::relative(
314                p.clone().into(),
315                Default::default(),
316                Default::default(),
317                false,
318            ),
319            EntryRequest::Module(m, p) => Request::module(
320                m.clone().into(),
321                p.clone().into(),
322                Default::default(),
323                Default::default(),
324            ),
325        })
326        .collect();
327
328    let web_source: ResolvedVc<Box<dyn ContentSource>> = create_web_entry_source(
329        root_path.clone(),
330        execution_context,
331        entry_requests,
332        server_root,
333        rcstr!("/ROOT"),
334        env,
335        eager_compile,
336        NodeEnv::Development.cell(),
337        Default::default(),
338        browserslist_query,
339    )
340    .to_resolved()
341    .await?;
342    let static_source = ResolvedVc::upcast(
343        StaticAssetsContentSource::new(Default::default(), project_path.join("public")?)
344            .to_resolved()
345            .await?,
346    );
347    let main_source = CombinedContentSource::new(vec![static_source, web_source])
348        .to_resolved()
349        .await?;
350    let introspect = ResolvedVc::upcast(
351        IntrospectionSource {
352            roots: FxHashSet::from_iter([ResolvedVc::upcast(main_source)]),
353        }
354        .resolved_cell(),
355    );
356    let main_source = ResolvedVc::upcast(main_source);
357    Ok(Vc::upcast(PrefixedRouterContentSource::new(
358        Default::default(),
359        vec![(rcstr!("__turbopack__"), introspect)],
360        *main_source,
361    )))
362}
363
364/// Start a devserver with the given args.
365pub async fn start_server(args: &DevArguments) -> Result<()> {
366    let start = Instant::now();
367
368    #[cfg(feature = "tokio_console")]
369    console_subscriber::init();
370
371    let NormalizedDirs {
372        project_dir,
373        root_dir,
374    } = normalize_dirs(&args.common.dir, &args.common.root)?;
375
376    let is_ci = std::env::var("CI").is_ok_and(|v| !v.is_empty());
377    let is_short_session = is_ci;
378
379    let tt = if args.common.persistent_caching {
380        let version_info = GitVersionInfo {
381            describe: env!("VERGEN_GIT_DESCRIBE"),
382            dirty: option_env!("CI").is_none_or(|v| v.is_empty())
383                && env!("VERGEN_GIT_DIRTY") == "true",
384        };
385        let cache_dir = args
386            .common
387            .cache_dir
388            .clone()
389            .unwrap_or_else(|| PathBuf::from(&*project_dir).join(".turbopack/cache"));
390        let (backing_storage, cache_state) =
391            turbo_backing_storage(&cache_dir, &version_info, is_ci, is_short_session, false)?;
392        let storage_mode = if std::env::var("TURBO_ENGINE_READ_ONLY").is_ok() {
393            StorageMode::ReadOnly
394        } else if is_ci || is_short_session {
395            StorageMode::ReadWriteOnShutdown
396        } else {
397            StorageMode::ReadWrite
398        };
399        let tt = TurboTasks::new(TurboTasksBackend::new(
400            BackendOptions {
401                storage_mode: Some(storage_mode),
402                ..Default::default()
403            },
404            backing_storage,
405        ));
406        if let StartupCacheState::Invalidated { reason_code } = cache_state {
407            eprintln!(
408                "{} - Turbopack cache was invalidated{}",
409                "warn ".yellow(),
410                reason_code
411                    .as_deref()
412                    .map(|r| format!(": {r}"))
413                    .unwrap_or_default()
414            );
415        }
416        tt
417    } else {
418        TurboTasks::new(TurboTasksBackend::new(
419            BackendOptions {
420                storage_mode: None,
421                ..Default::default()
422            },
423            noop_backing_storage(),
424        ))
425    };
426
427    let tt_clone = tt.clone();
428
429    let mut server = TurbopackDevServerBuilder::new(tt, project_dir, root_dir)
430        .eager_compile(args.eager_compile)
431        .hostname(args.hostname)
432        .port(args.port)
433        .log_detail(args.common.log_detail)
434        .show_all(args.common.show_all)
435        .log_level(
436            args.common
437                .log_level
438                .map_or_else(|| IssueSeverity::Warning, |l| l.0),
439        );
440
441    for entry in normalize_entries(&args.common.entries) {
442        server = server.entry_request(EntryRequest::Relative(entry))
443    }
444
445    #[cfg(feature = "serializable")]
446    {
447        server = server.allow_retry(args.allow_retry);
448    }
449
450    let server = server.build().await?;
451
452    {
453        let addr = &server.addr;
454        let hostname = if addr.ip().is_loopback() || addr.ip().is_unspecified() {
455            "localhost".to_string()
456        } else if addr.is_ipv6() {
457            // When using an IPv6 address, we need to surround the IP in brackets to
458            // distinguish it from the port's `:`.
459            format!("[{}]", addr.ip())
460        } else {
461            addr.ip().to_string()
462        };
463        let index_uri = match addr.port() {
464            443 => format!("https://{hostname}"),
465            80 => format!("http://{hostname}"),
466            port => format!("http://{hostname}:{port}"),
467        };
468        println!(
469            "{} - started server on {}, url: {}",
470            "ready".green(),
471            server.addr,
472            index_uri
473        );
474        if !args.no_open {
475            let _ = webbrowser::open(&index_uri);
476        }
477    }
478
479    let stats_future = async move {
480        if args.common.log_detail {
481            println!(
482                "{event_type} - initial compilation {start} ({memory})",
483                event_type = "event".purple(),
484                start = FormatDuration(start.elapsed()),
485                memory = FormatBytes(TurboMalloc::memory_usage())
486            );
487        }
488
489        let mut progress_counter = 0;
490        loop {
491            let update_future = profile_timeout(
492                tt_clone.as_ref(),
493                tt_clone.aggregated_update_info(Duration::from_millis(100), Duration::MAX),
494            );
495
496            if let Some(UpdateInfo {
497                duration,
498                tasks,
499                reasons,
500                ..
501            }) = update_future.await
502            {
503                progress_counter = 0;
504                match (args.common.log_detail, !reasons.is_empty()) {
505                    (true, true) => {
506                        println!(
507                            "\x1b[2K{event_type} - {reasons} {duration} ({tasks} tasks, {memory})",
508                            event_type = "event".purple(),
509                            duration = FormatDuration(duration),
510                            tasks = tasks,
511                            memory = FormatBytes(TurboMalloc::memory_usage())
512                        );
513                    }
514                    (true, false) => {
515                        println!(
516                            "\x1b[2K{event_type} - compilation {duration} ({tasks} tasks, \
517                             {memory})",
518                            event_type = "event".purple(),
519                            duration = FormatDuration(duration),
520                            tasks = tasks,
521                            memory = FormatBytes(TurboMalloc::memory_usage())
522                        );
523                    }
524                    (false, true) => {
525                        println!(
526                            "\x1b[2K{event_type} - {reasons} {duration}",
527                            event_type = "event".purple(),
528                            duration = FormatDuration(duration),
529                        );
530                    }
531                    (false, false) => {
532                        if duration > Duration::from_secs(1) {
533                            println!(
534                                "\x1b[2K{event_type} - compilation {duration}",
535                                event_type = "event".purple(),
536                                duration = FormatDuration(duration),
537                            );
538                        }
539                    }
540                }
541            } else {
542                progress_counter += 1;
543                if args.common.log_detail {
544                    print!(
545                        "\x1b[2K{event_type} - updating for {progress_counter}s... ({memory})\r",
546                        event_type = "event".purple(),
547                        memory = FormatBytes(TurboMalloc::memory_usage())
548                    );
549                } else {
550                    print!(
551                        "\x1b[2K{event_type} - updating for {progress_counter}s...\r",
552                        event_type = "event".purple(),
553                    );
554                }
555                let _ = stdout().lock().flush();
556            }
557        }
558    };
559
560    join!(stats_future, async { server.future.await.unwrap() }).await;
561
562    Ok(())
563}
564
565#[cfg(feature = "profile")]
566// When profiling, exits the process when no new updates have been received for
567// a given timeout and there are no more tasks in progress.
568async fn profile_timeout<T>(
569    tt: &TurboTasks<TurboTasksBackend>,
570    future: impl Future<Output = T>,
571) -> T {
572    /// How long to wait in between updates before force-exiting the process
573    /// during profiling.
574    const PROFILE_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
575
576    futures::pin_mut!(future);
577    loop {
578        match tokio::time::timeout(PROFILE_EXIT_TIMEOUT, &mut future).await {
579            Ok(res) => return res,
580            Err(_) => {
581                if tt.get_in_progress_count() == 0 {
582                    std::process::exit(0)
583                }
584            }
585        }
586    }
587}
588
589#[cfg(not(feature = "profile"))]
590fn profile_timeout<T>(
591    _tt: &TurboTasks<TurboTasksBackend>,
592    future: impl Future<Output = T>,
593) -> impl Future<Output = T> {
594    future
595}
596
597pub trait IssueReporterProvider: Send + Sync + 'static {
598    fn get_issue_reporter(&self) -> Vc<Box<dyn IssueReporter>>;
599}
600
601impl<T> IssueReporterProvider for T
602where
603    T: Fn() -> Vc<Box<dyn IssueReporter>> + Send + Sync + Clone + 'static,
604{
605    fn get_issue_reporter(&self) -> Vc<Box<dyn IssueReporter>> {
606        self()
607    }
608}