Skip to main content

next_napi_bindings/next_api/
turbopack_ctx.rs

1//! Utilities for constructing and using the [`NextTurbopackContext`] type.
2
3use std::{
4    env,
5    fs::OpenOptions,
6    io::{self, BufRead, Write},
7    path::PathBuf,
8    sync::{Arc, LazyLock, Mutex},
9    time::Instant,
10};
11
12use anyhow::Result;
13use napi::{Env, JsFunction, bindgen_prelude::Promise, threadsafe_function::ThreadsafeFunction};
14use napi_derive::napi;
15use owo_colors::OwoColorize;
16use serde::Serialize;
17use terminal_hyperlink::Hyperlink;
18use turbo_tasks::{
19    PrettyPrintError, TurboTasks, TurboTasksCallApi,
20    backend::TurboTasksExecutionError,
21    message_queue::{CompilationEvent, Severity},
22};
23use turbo_tasks_backend::{
24    BackendOptions, EvictionMode, GitVersionInfo, StartupCacheState, TurboTasksBackend,
25    db_invalidation::invalidation_reasons, noop_backing_storage, turbo_backing_storage,
26};
27
28pub type NextTurboTasks = Arc<TurboTasks<TurboTasksBackend>>;
29
30/// A value often wrapped in [`napi::bindgen_prelude::External`] that retains the [TurboTasks]
31/// instance used by Next.js, and [various napi helpers that are passed to us from
32/// JavaScript][NapiNextTurbopackCallbacks].
33///
34/// This is not a [`turbo_tasks::value`], and should only be used within the top-level napi layer.
35/// It should not be passed to a [`turbo_tasks::function`]. For serializable information about the
36/// project, use the [`next_api::project::Project`] type instead.
37///
38/// This type is a wrapper around an [`Arc`] and is therefore cheaply cloneable. It is [`Send`] and
39/// [`Sync`].
40#[derive(Clone)]
41pub struct NextTurbopackContext {
42    inner: Arc<NextTurboContextInner>,
43}
44
45struct NextTurboContextInner {
46    turbo_tasks: NextTurboTasks,
47    napi_callbacks: NapiNextTurbopackCallbacks,
48}
49
50impl NextTurbopackContext {
51    pub fn new(turbo_tasks: NextTurboTasks, napi_callbacks: NapiNextTurbopackCallbacks) -> Self {
52        NextTurbopackContext {
53            inner: Arc::new(NextTurboContextInner {
54                turbo_tasks,
55                napi_callbacks,
56            }),
57        }
58    }
59
60    pub fn turbo_tasks(&self) -> &NextTurboTasks {
61        &self.inner.turbo_tasks
62    }
63
64    /// Constructs and throws a `TurbopackInternalError` from within JavaScript. This type is
65    /// defined within Next.js, and passed via [`NapiNextTurbopackCallbacks`]. This should be called
66    /// at the top level (a `napi` function) and only for errors that are not expected to occur an
67    /// indicate a bug in Turbopack or Next.js.
68    ///
69    /// This may log anonymized information about the error to our telemetry service (via the
70    /// JS callback). It may log to stderr and write a log file to disk (in Rust), subject to
71    /// throttling.
72    ///
73    /// The caller should exit immediately with the returned [`napi::Error`] after calling this, as
74    /// it sets a pending exception.
75    ///
76    /// The returned future does not depend on the lifetime of `&self` or `&err`, making it easier
77    /// to compose with [`futures_util::TryFutureExt`] and similar utilities.
78    pub fn throw_turbopack_internal_error(
79        &self,
80        err: &anyhow::Error,
81    ) -> impl Future<Output = napi::Error> + use<> {
82        let this = self.clone();
83        let message = PrettyPrintError(err).to_string();
84        let downcast_root_cause_err = err.root_cause().downcast_ref::<TurboTasksExecutionError>();
85        let panic_location =
86            if let Some(TurboTasksExecutionError::Panic(p)) = downcast_root_cause_err {
87                p.location.clone()
88            } else {
89                None
90            };
91
92        log_internal_error_and_inform(err);
93
94        async move {
95            this.inner
96                .napi_callbacks
97                .throw_turbopack_internal_error
98                .call_async::<()>(Ok(TurbopackInternalErrorOpts {
99                    message,
100                    anonymized_location: panic_location,
101                }))
102                .await
103                .expect_err("throwTurbopackInternalError must throw an error")
104        }
105    }
106
107    /// A utility method that calls [`NextTurbopackContext::throw_turbopack_internal_error`] and
108    /// wraps the [`napi::Error`] in a [`napi::Result`].
109    ///
110    /// The returned future does not depend on the lifetime of `&self` or `&err`, making it easier
111    /// to compose with [`futures_util::TryFutureExt::or_else`].
112    ///
113    /// The returned type uses a generic (`T`), but should be a never type (`!`) once that nightly
114    /// feature is stabilized.
115    pub fn throw_turbopack_internal_result<T>(
116        &self,
117        err: &anyhow::Error,
118    ) -> impl Future<Output = napi::Result<T>> + use<T> {
119        let err_fut = self.throw_turbopack_internal_error(err);
120        async move { Err(err_fut.await) }
121    }
122
123    /// Calls the `onBeforeDeferredEntries` callback in Node.js if one was provided.
124    pub async fn on_before_deferred_entries(&self) -> napi::Result<()> {
125        if let Some(callback) = &self.inner.napi_callbacks.on_before_deferred_entries {
126            let promise = callback.call_async::<Promise<()>>(Ok(())).await?;
127            promise.await?;
128        }
129        Ok(())
130    }
131}
132
133/// A version of [`NapiNextTurbopackCallbacks`] that can accepted as an argument to a napi function.
134///
135/// This can be converted into a [`NapiNextTurbopackCallbacks`] with
136/// [`NapiNextTurbopackCallbacks::from_js`].
137#[napi(object)]
138pub struct NapiNextTurbopackCallbacksJsObject {
139    /// Called when we've encountered a bug in Turbopack and not in the user's code. Constructs and
140    /// throws a `TurbopackInternalError` type. Logs to anonymized telemetry.
141    ///
142    /// As a result of the use of `ErrorStrategy::CalleeHandled`, the first argument is an error if
143    /// there's a runtime conversion error. This should never happen, but if it does, the function
144    /// can throw it instead.
145    #[napi(ts_type = "(conversionError: Error | null, opts: TurbopackInternalErrorOpts) => never")]
146    pub throw_turbopack_internal_error: JsFunction,
147
148    /// Called before deferred entries are processed in a production build.
149    #[napi(ts_type = "() => Promise<void>")]
150    pub on_before_deferred_entries: Option<JsFunction>,
151}
152
153/// A collection of helper JavaScript functions passed into
154/// [`crate::next_api::project::project_new`] and stored in the [`NextTurbopackContext`].
155///
156/// This type is [`Send`] and [`Sync`]. Callbacks are wrapped in [`ThreadsafeFunction`].
157pub struct NapiNextTurbopackCallbacks {
158    // It's a little nasty to use a `ThreadsafeFunction` for this, but we don't expect exceptions
159    // to be a hot codepath.
160    //
161    // More ideally, we'd convert the error type in the JS thread after the execution of the future
162    // when resolving the JS `Promise` object. However, doing that would add a lot more boilerplate
163    // to all of our async entrypoints, and would be complicated by `FunctionRef` being `!Send` (I
164    // think it could be `Send`, as long as `napi::Env` is checked at call-time, which it should be
165    // anyways).
166    throw_turbopack_internal_error: ThreadsafeFunction<TurbopackInternalErrorOpts>,
167    on_before_deferred_entries: Option<ThreadsafeFunction<()>>,
168}
169
170/// Arguments for `NapiNextTurbopackCallbacks::throw_turbopack_internal_error`.
171#[napi(object)]
172pub struct TurbopackInternalErrorOpts {
173    pub message: String,
174    pub anonymized_location: Option<String>,
175}
176
177impl NapiNextTurbopackCallbacks {
178    pub fn from_js(env: &Env, obj: NapiNextTurbopackCallbacksJsObject) -> napi::Result<Self> {
179        let mut throw_turbopack_internal_error: ThreadsafeFunction<TurbopackInternalErrorOpts> =
180            obj.throw_turbopack_internal_error
181                .create_threadsafe_function(0, |ctx| {
182                    // Avoid unpacking the struct into positional arguments, we really want to make
183                    // sure we don't incorrectly order arguments and accidentally log a potentially
184                    // PII-containing message in anonymized telemetry.
185                    Ok(vec![ctx.value])
186                })?;
187        // Unref so this ThreadsafeFunction doesn't keep the Node.js event loop alive
188        // after shutdown.
189        let _ = throw_turbopack_internal_error.unref(env);
190
191        let on_before_deferred_entries = obj
192            .on_before_deferred_entries
193            .map(|callback| {
194                let mut f = callback.create_threadsafe_function(0, |_| Ok::<Vec<()>, _>(vec![]))?;
195                let _ = f.unref(env);
196                Ok::<_, napi::Error>(f)
197            })
198            .transpose()?;
199
200        Ok(NapiNextTurbopackCallbacks {
201            throw_turbopack_internal_error,
202            on_before_deferred_entries,
203        })
204    }
205}
206
207/// Returns the cache version `describe` string for the given Next.js version, of the form
208/// `v<next_version>-<git_short_sha>` (e.g. `v16.0.1-canary.13-94e9fa6`).
209pub fn cache_describe(next_version: &str) -> String {
210    format!("v{next_version}-{}", env!("VERGEN_GIT_SHA"))
211}
212
213/// Returns version info derived from the supplied Next.js version and compile-time git metadata.
214///
215/// The `dirty` flag is only set when not running in CI (`CI` env var unset at build time) and the
216/// working tree was dirty at build time.
217pub fn git_version_info(describe: &str) -> GitVersionInfo<'_> {
218    GitVersionInfo {
219        describe,
220        dirty: option_env!("CI").is_none_or(|value| value.is_empty())
221            && env!("VERGEN_GIT_DIRTY") == "true",
222    }
223}
224
225/// Turbopack's memory eviction strategy for the persistent cache, mirroring the
226/// `experimental.turbopackMemoryEviction` config option.
227///
228/// This is a napi-facing mirror of [`EvictionMode`] (the backend crate can't
229/// depend on napi). Keep the variants in sync; the `From` impl below is
230/// exhaustive, so adding a variant to one enum forces updating the other.
231#[napi(string_enum = "lowercase")]
232#[derive(Debug, PartialEq, Eq)]
233pub enum MemoryEvictionMode {
234    /// Never evict.
235    Off,
236    /// Evict after a snapshot only once enough memory has been allocated since
237    /// the last eviction to justify the cost of restoring evicted tasks.
238    Auto,
239    /// After every snapshot, evict all evictable tasks from memory, reloading
240    /// them from disk on demand.
241    Full,
242}
243
244impl From<MemoryEvictionMode> for EvictionMode {
245    fn from(mode: MemoryEvictionMode) -> Self {
246        match mode {
247            MemoryEvictionMode::Off => EvictionMode::Off,
248            MemoryEvictionMode::Auto => EvictionMode::Auto,
249            MemoryEvictionMode::Full => EvictionMode::Full,
250        }
251    }
252}
253
254pub fn create_turbo_tasks(
255    output_path: PathBuf,
256    next_version: &str,
257    persistent_caching: bool,
258    dependency_tracking: bool,
259    is_ci: bool,
260    is_short_session: bool,
261    skip_compaction: bool,
262    turbopack_memory_eviction: MemoryEvictionMode,
263) -> Result<NextTurboTasks> {
264    Ok(if persistent_caching {
265        let describe = cache_describe(next_version);
266        let version_info = git_version_info(&describe);
267        let (backing_storage, cache_state) = turbo_backing_storage(
268            &output_path.join("cache").join("turbopack"),
269            &version_info,
270            is_ci,
271            is_short_session,
272            skip_compaction,
273        )?;
274        let tt = TurboTasks::new(TurboTasksBackend::new(
275            BackendOptions {
276                storage_mode: Some(if std::env::var("TURBO_ENGINE_READ_ONLY").is_ok() {
277                    turbo_tasks_backend::StorageMode::ReadOnly
278                } else if is_ci || is_short_session {
279                    turbo_tasks_backend::StorageMode::ReadWriteOnShutdown
280                } else {
281                    turbo_tasks_backend::StorageMode::ReadWrite
282                }),
283                dependency_tracking,
284                num_workers: Some(tokio::runtime::Handle::current().metrics().num_workers()),
285                eviction_mode: EvictionMode::from(turbopack_memory_eviction),
286                ..Default::default()
287            },
288            backing_storage,
289        ));
290        if let StartupCacheState::Invalidated { reason_code } = cache_state {
291            tt.send_compilation_event(Arc::new(StartupCacheInvalidationEvent { reason_code }));
292        }
293        tt
294    } else {
295        TurboTasks::new(TurboTasksBackend::new(
296            BackendOptions {
297                storage_mode: None,
298                dependency_tracking,
299                ..Default::default()
300            },
301            noop_backing_storage(),
302        ))
303    })
304}
305
306#[derive(Serialize)]
307struct StartupCacheInvalidationEvent {
308    reason_code: Option<String>,
309}
310
311impl CompilationEvent for StartupCacheInvalidationEvent {
312    fn type_name(&self) -> &'static str {
313        "StartupCacheInvalidationEvent"
314    }
315
316    fn severity(&self) -> Severity {
317        Severity::Warning
318    }
319
320    fn message(&self) -> String {
321        let reason_msg = match self.reason_code.as_deref() {
322            Some(invalidation_reasons::PANIC) => {
323                " because we previously detected an internal error in Turbopack"
324            }
325            Some(invalidation_reasons::USER_REQUEST) => " as the result of a user request",
326            _ => "", // ignore unknown reasons
327        };
328        format!(
329            "Turbopack's filesystem cache has been deleted{reason_msg}. Builds or page loads may \
330             be slower as a result."
331        )
332    }
333
334    fn to_json(&self) -> String {
335        serde_json::to_string(self).unwrap()
336    }
337}
338
339static LOG_THROTTLE: Mutex<Option<Instant>> = Mutex::new(None);
340static LOG_DIVIDER: &str = "---------------------------";
341static PANIC_LOG: LazyLock<PathBuf> = LazyLock::new(|| {
342    let mut path = env::temp_dir();
343    path.push(format!("next-panic-{:x}.log", rand::random::<u128>()));
344    path
345});
346
347/// Log the error to stderr and write a log file to disk, subject to throttling.
348//
349// TODO: Now that we're passing the error to a JS callback, handle this logic in Next.js using the
350// logger there instead of writing directly to stderr.
351pub fn log_internal_error_and_inform(internal_error: &anyhow::Error) {
352    if cfg!(debug_assertions)
353        || env::var("SWC_DEBUG") == Ok("1".to_string())
354        || env::var("CI").is_ok_and(|v| !v.is_empty())
355        // Next's run-tests unsets CI and sets NEXT_TEST_CI
356        || env::var("NEXT_TEST_CI").is_ok_and(|v| !v.is_empty())
357    {
358        eprintln!(
359            "{}: An unexpected Turbopack error occurred:\n{}",
360            "FATAL".red().bold(),
361            PrettyPrintError(internal_error)
362        );
363        return;
364    }
365
366    // hold open this mutex guard to prevent concurrent writes to the file!
367    let mut last_error_time = LOG_THROTTLE.lock().unwrap();
368    if let Some(last_error_time) = last_error_time.as_ref()
369        && last_error_time.elapsed().as_secs() < 1
370    {
371        // Throttle panic logging to once per second
372        return;
373    }
374    *last_error_time = Some(Instant::now());
375
376    let size = std::fs::metadata(PANIC_LOG.as_path()).map(|m| m.len());
377    if let Ok(size) = size
378        && size > 512 * 1024
379    {
380        // Truncate the earliest error from log file if it's larger than 512KB
381        let new_lines = {
382            let log_read = OpenOptions::new()
383                .read(true)
384                .open(PANIC_LOG.as_path())
385                .unwrap_or_else(|_| panic!("Failed to open {}", PANIC_LOG.to_string_lossy()));
386
387            io::BufReader::new(&log_read)
388                .lines()
389                .skip(1)
390                .skip_while(|line| match line {
391                    Ok(line) => !line.starts_with(LOG_DIVIDER),
392                    Err(_) => false,
393                })
394                .collect::<Vec<_>>()
395        };
396
397        let mut log_write = OpenOptions::new()
398            .create(true)
399            .truncate(true)
400            .write(true)
401            .open(PANIC_LOG.as_path())
402            .unwrap_or_else(|_| panic!("Failed to open {}", PANIC_LOG.to_string_lossy()));
403
404        for line in new_lines {
405            match line {
406                Ok(line) => {
407                    writeln!(log_write, "{line}").unwrap();
408                }
409                Err(_) => {
410                    break;
411                }
412            }
413        }
414    }
415
416    let mut log_file = OpenOptions::new()
417        .create(true)
418        .append(true)
419        .open(PANIC_LOG.as_path())
420        .unwrap_or_else(|_| panic!("Failed to open {}", PANIC_LOG.to_string_lossy()));
421
422    let internal_error_str: String = PrettyPrintError(internal_error).to_string();
423    writeln!(log_file, "{}\n{}", LOG_DIVIDER, internal_error_str).unwrap();
424
425    let title = format!(
426        "Turbopack Error: {}",
427        internal_error_str.lines().next().unwrap_or("Unknown")
428    );
429    let version_str = format!(
430        "Turbopack version: `{}`\nNext.js version: `{}`",
431        env!("VERGEN_GIT_SHA"),
432        env!("NEXTJS_VERSION")
433    );
434    let bug_report_url = format!(
435        "https://bugs.nextjs.org/search?category=turbopack-error-report&title={}&body={}&labels=Turbopack,Turbopack%20Panic%20Backtrace",
436        urlencoding::encode(&title),
437        urlencoding::encode(&format!(
438            "{}\n\nError message:\n```\n{}\n```",
439            version_str, internal_error_str
440        ))
441    );
442    let bug_report_message = if supports_hyperlinks::supports_hyperlinks() {
443        "clicking here.".hyperlink(&bug_report_url)
444    } else {
445        format!("clicking here: {}", bug_report_url)
446    };
447
448    eprintln!(
449        "\n-----\n{}: An unexpected Turbopack error occurred. A panic log has been written to \
450         {}.\n\nTo help make Turbopack better, report this error by {}\n-----\n",
451        "FATAL".red().bold(),
452        PANIC_LOG.to_string_lossy(),
453        bug_report_message
454    );
455}