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