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