next.js/crates/next-napi-bindings/src/next_api/utils.rs
utils.rs524 lines16.6 KB
use std::{
    future::Future,
    ops::Deref,
    sync::{Arc, LazyLock},
};

use anyhow::{Context, Result, anyhow};
use futures_util::TryFutureExt;
use napi::{
    JsFunction, JsObject, JsUnknown, NapiRaw, NapiValue, Status,
    bindgen_prelude::{Buffer, External, ToNapiValue},
    threadsafe_function::{ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use napi_derive::napi;
use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame};
use regex::Regex;
use rustc_hash::FxHashMap;
use serde::Serialize;
use turbo_rcstr::RcStr;
use turbo_tasks::{
    Effects, OperationVc, ReadRef, TaskId, TryJoinIterExt, Vc, VcValueType, take_effects,
};
use turbo_tasks_fs::FileContent;
use turbopack_core::{
    diagnostics::{Diagnostic, DiagnosticContextExt, PlainDiagnostic},
    issue::{
        CollectibleIssuesExt, IssueFilter, IssueSeverity, PlainIssue, PlainIssueSource,
        PlainSource, StyledString,
    },
    source_pos::SourcePos,
};

use crate::next_api::turbopack_ctx::NextTurbopackContext;

/// An [`OperationVc`] that can be passed back and forth to JS across the [`napi`][mod@napi]
/// boundary via [`External`].
///
/// It is a helper type to hold both a [`OperationVc`] and the [`NextTurbopackContext`]. Without
/// this, we'd need to pass both individually all over the place.
///
/// This napi-specific abstraction does not implement [`turbo_tasks::NonLocalValue`] or
/// [`turbo_tasks::OperationValue`] and should be dereferenced to an [`OperationVc`] before being
/// passed to a [`turbo_tasks::function`].
//
// TODO: If we add a tracing garbage collector to turbo-tasks, this should be tracked as a GC root.
#[derive(Clone)]
pub struct DetachedVc<T> {
    turbopack_ctx: NextTurbopackContext,
    /// The Vc. Must be unresolved, otherwise you are referencing an inactive operation.
    vc: OperationVc<T>,
}

impl<T> DetachedVc<T> {
    pub fn new(turbopack_ctx: NextTurbopackContext, vc: OperationVc<T>) -> Self {
        Self { turbopack_ctx, vc }
    }

    pub fn turbopack_ctx(&self) -> &NextTurbopackContext {
        &self.turbopack_ctx
    }
}

impl<T> Deref for DetachedVc<T> {
    type Target = OperationVc<T>;

    fn deref(&self) -> &Self::Target {
        &self.vc
    }
}

/// An opaque handle to the root of a turbo-tasks computation created by
/// [`turbo_tasks::TurboTasks::spawn_root_task`] that can be passed back and forth to JS across the
/// [`napi`][mod@napi] boundary via [`External`].
///
/// JavaScript code receiving this value **must** call [`root_task_dispose`] in a `try...finally`
/// block to avoid leaking root tasks.
///
/// This is used by [`subscribe`] to create a computation that re-executes when dependencies change.
//
// TODO: If we add a tracing garbage collector to turbo-tasks, this should be tracked as a GC root.
pub struct RootTask {
    turbopack_ctx: NextTurbopackContext,
    task_id: Option<TaskId>,
}

impl Drop for RootTask {
    fn drop(&mut self) {
        // TODO stop the root task
    }
}

#[napi]
pub fn root_task_dispose(
    #[napi(ts_arg_type = "{ __napiType: \"RootTask\" }")] mut root_task: External<RootTask>,
) -> napi::Result<()> {
    if let Some(task) = root_task.task_id.take() {
        root_task
            .turbopack_ctx
            .turbo_tasks()
            .dispose_root_task(task);
    }
    Ok(())
}

/// [Peeks] at the [`Issue`] held by the given source and returns it as a [`PlainDiagnostic`].
/// It does not [consume] any [`Issue`]s held by the source.
///
/// [Peeks]: turbo_tasks::CollectiblesSource::peek_collectibles
/// [`Issue`]: turbopack_core::issue::Issue
/// [consume]: turbo_tasks::CollectiblesSource::take_collectibles
pub async fn get_issues<T: Send>(
    source: OperationVc<T>,
    filter: Vc<IssueFilter>,
) -> Result<Arc<Vec<ReadRef<PlainIssue>>>> {
    Ok(Arc::new(
        source.peek_issues().get_plain_issues(filter).await?,
    ))
}

/// [Peeks] at the [`Diagnostic`]s held by the given source and returns it as a [`PlainDiagnostic`].
/// It does not [consume] any [`Diagnostic`]s held by the source.
///
/// [Peeks]: turbo_tasks::CollectiblesSource::peek_collectibles
/// [consume]: turbo_tasks::CollectiblesSource::take_collectibles
pub async fn get_diagnostics<T: Send>(
    source: OperationVc<T>,
) -> Result<Arc<Vec<ReadRef<PlainDiagnostic>>>> {
    let captured_diags = source.peek_diagnostics().await?;
    let mut diags = captured_diags
        .diagnostics
        .iter()
        .map(|d| d.into_plain())
        .try_join()
        .await?;

    diags.sort();

    Ok(Arc::new(diags))
}

/// Returns true if the file path refers to a Next.js/React internal file whose
/// source code frames would be unhelpful (e.g. large bundled vendored files).
///
/// Mirrors the JS `isInternal()` check from
/// `packages/next/src/shared/lib/is-internal.ts`.
fn is_internal(file_path: &str) -> bool {
    // Uses [/\\] so both Unix and Windows separators are matched without
    // needing to normalize the path
    static RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(
            r"(?x)
            # React vendored in Next.js dist/compiled (reactVendoredRe)
            [/\\]next[/\\]dist[/\\]compiled[/\\](?:react|react-dom|react-server-dom-webpack|react-server-dom-turbopack|scheduler)[/\\]
            # React in node_modules (reactNodeModulesRe)
            | node_modules[/\\](?:react|react-dom|scheduler)[/\\]
            # Next.js internals (nextInternalsRe)
            | node_modules[/\\]next[/\\]
            | [/\\]\.next[/\\]static[/\\]chunks[/\\]webpack\.js$
            | edge-runtime-webpack\.js$
            | webpack-runtime\.js$
            ",
        )
        .expect("is_internal regex must compile")
    });

    RE.is_match(file_path)
}

/// Renders a code frame for a source location, if available.
///
/// This avoids transferring the full source file content across the NAPI
/// boundary just to call back into Rust for code frame rendering.
///
/// Because this accesses the terminal size, this function call should not be cached (e.g. in
/// turbo-tasks).
fn render_source_code_frame(source: &PlainIssueSource, file_path: &str) -> Result<Option<String>> {
    let Some((start, end)) = source.range else {
        return Ok(None);
    };

    if is_internal(file_path) {
        return Ok(None);
    }

    let content = match &*source.asset.content {
        FileContent::Content(c) => {
            let Ok(content) = c.content().to_str() else {
                return Ok(None);
            };
            content
        }
        FileContent::NotFound => return Ok(None),
    };

    // SourcePos is 0-indexed; Location is 1-indexed
    let location = CodeFrameLocation {
        start: Location {
            line: (start.line + 1) as usize,
            column: Some((start.column + 1) as usize),
        },
        end: Some(Location {
            line: (end.line + 1) as usize,
            column: Some((end.column + 1) as usize),
        }),
    };

    render_code_frame(
        &content,
        &location,
        &CodeFrameOptions {
            color: true,
            highlight_code: true,
            max_width: terminal_size::terminal_size()
                .map(|(w, _)| w.0 as usize)
                .unwrap_or(100),
            ..Default::default()
        },
    )
}

/// Renders a code frame for the issue's primary source location.
fn render_issue_code_frame(issue: &PlainIssue) -> Result<Option<String>> {
    let Some(source) = issue.source.as_ref() else {
        return Ok(None);
    };
    render_source_code_frame(source, &issue.file_path)
}

#[napi(object)]
pub struct NapiIssue {
    pub severity: String,
    pub stage: String,
    pub file_path: RcStr,
    pub title: serde_json::Value,
    pub description: Option<serde_json::Value>,
    pub detail: Option<serde_json::Value>,
    pub source: Option<NapiIssueSource>,
    pub additional_sources: Vec<NapiAdditionalIssueSource>,
    pub documentation_link: RcStr,
    pub import_traces: serde_json::Value,
    /// Pre-rendered code frame for the issue's source location, if available.
    /// Rendered in Rust to avoid transferring full source file content to JS.
    pub code_frame: Option<String>,
}

#[napi(object)]
pub struct NapiAdditionalIssueSource {
    pub description: RcStr,
    pub source: NapiIssueSource,
    /// Pre-rendered code frame for this additional source location, if available.
    pub code_frame: Option<String>,
}

impl From<&PlainIssue> for NapiIssue {
    fn from(issue: &PlainIssue) -> Self {
        Self {
            description: issue
                .description
                .as_ref()
                .map(|styled| serde_json::to_value(StyledStringSerialize::from(styled)).unwrap()),
            stage: issue.stage.to_string(),
            file_path: issue.file_path.clone(),
            detail: issue
                .detail
                .as_ref()
                .map(|styled| serde_json::to_value(StyledStringSerialize::from(styled)).unwrap()),
            documentation_link: issue.documentation_link.clone(),
            severity: issue.severity.as_str().to_string(),
            source: issue.source.as_ref().map(|source| source.into()),
            additional_sources: issue
                .additional_sources
                .iter()
                .map(|s| NapiAdditionalIssueSource {
                    description: s.description.clone(),
                    code_frame: render_source_code_frame(&s.source, &s.source.asset.file_path)
                        .unwrap_or_default(),
                    source: (&s.source).into(),
                })
                .collect(),
            title: serde_json::to_value(StyledStringSerialize::from(&issue.title)).unwrap(),
            import_traces: serde_json::to_value(&issue.import_traces).unwrap(),
            code_frame: render_issue_code_frame(issue).unwrap_or_default(),
        }
    }
}

#[derive(Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StyledStringSerialize<'a> {
    Line {
        value: Vec<StyledStringSerialize<'a>>,
    },
    Stack {
        value: Vec<StyledStringSerialize<'a>>,
    },
    Text {
        value: &'a str,
    },
    Code {
        value: &'a str,
    },
    Strong {
        value: &'a str,
    },
}

impl<'a> From<&'a StyledString> for StyledStringSerialize<'a> {
    fn from(value: &'a StyledString) -> Self {
        match value {
            StyledString::Line(parts) => StyledStringSerialize::Line {
                value: parts.iter().map(|p| p.into()).collect(),
            },
            StyledString::Stack(parts) => StyledStringSerialize::Stack {
                value: parts.iter().map(|p| p.into()).collect(),
            },
            StyledString::Text(string) => StyledStringSerialize::Text { value: string },
            StyledString::Code(string) => StyledStringSerialize::Code { value: string },
            StyledString::Strong(string) => StyledStringSerialize::Strong { value: string },
        }
    }
}

#[napi(object)]
pub struct NapiIssueSource {
    pub source: NapiSource,
    pub range: Option<NapiIssueSourceRange>,
}

impl From<&PlainIssueSource> for NapiIssueSource {
    fn from(
        PlainIssueSource {
            asset: source,
            range,
        }: &PlainIssueSource,
    ) -> Self {
        Self {
            source: (&**source).into(),
            range: range.as_ref().map(|range| range.into()),
        }
    }
}

#[napi(object)]
pub struct NapiIssueSourceRange {
    pub start: NapiSourcePos,
    pub end: NapiSourcePos,
}

impl From<&(SourcePos, SourcePos)> for NapiIssueSourceRange {
    fn from((start, end): &(SourcePos, SourcePos)) -> Self {
        Self {
            start: (*start).into(),
            end: (*end).into(),
        }
    }
}

#[napi(object)]
pub struct NapiSource {
    pub ident: RcStr,
    pub file_path: RcStr,
}

impl From<&PlainSource> for NapiSource {
    fn from(source: &PlainSource) -> Self {
        Self {
            ident: (*source.ident).clone(),
            file_path: (*source.file_path).clone(),
        }
    }
}

#[napi(object)]
pub struct NapiSourcePos {
    pub line: u32,
    pub column: u32,
}

impl From<SourcePos> for NapiSourcePos {
    fn from(pos: SourcePos) -> Self {
        Self {
            line: pos.line,
            column: pos.column,
        }
    }
}

#[napi(object)]
pub struct NapiDiagnostic {
    pub category: RcStr,
    pub name: RcStr,
    #[napi(ts_type = "Record<string, string>")]
    pub payload: FxHashMap<RcStr, RcStr>,
}

impl NapiDiagnostic {
    pub fn from(diagnostic: &PlainDiagnostic) -> Self {
        Self {
            category: diagnostic.category.clone(),
            name: diagnostic.name.clone(),
            payload: diagnostic
                .payload
                .iter()
                .map(|(k, v)| (k.clone(), v.clone()))
                .collect(),
        }
    }
}

pub struct TurbopackResult<T: ToNapiValue> {
    pub result: T,
    pub issues: Vec<NapiIssue>,
    pub diagnostics: Vec<NapiDiagnostic>,
}

impl<T: ToNapiValue> ToNapiValue for TurbopackResult<T> {
    unsafe fn to_napi_value(
        env: napi::sys::napi_env,
        val: Self,
    ) -> napi::Result<napi::sys::napi_value> {
        let mut obj = unsafe { napi::Env::from_raw(env).create_object()? };

        let result = unsafe {
            let result = T::to_napi_value(env, val.result)?;
            JsUnknown::from_raw(env, result)?
        };
        if matches!(result.get_type()?, napi::ValueType::Object) {
            // SAFETY: We know that result is an object, so we can cast it to a JsObject
            let result = unsafe { result.cast::<JsObject>() };

            for key in JsObject::keys(&result)? {
                let value: JsUnknown = result.get_named_property(&key)?;
                obj.set_named_property(&key, value)?;
            }
        }

        obj.set_named_property("issues", val.issues)?;
        obj.set_named_property("diagnostics", val.diagnostics)?;

        Ok(unsafe { obj.raw() })
    }
}

pub fn subscribe<T: 'static + Send + Sync, F: Future<Output = Result<T>> + Send, V: ToNapiValue>(
    ctx: NextTurbopackContext,
    func: JsFunction,
    handler: impl 'static + Sync + Send + Clone + Fn() -> F,
    mapper: impl 'static + Sync + Send + FnMut(ThreadSafeCallContext<T>) -> napi::Result<Vec<V>>,
) -> napi::Result<External<RootTask>> {
    let func: ThreadsafeFunction<T> = func.create_threadsafe_function(0, mapper)?;
    let task_id = ctx.turbo_tasks().spawn_root_task({
        let ctx = ctx.clone();
        move || {
            let ctx = ctx.clone();
            let handler = handler.clone();
            let func = func.clone();
            async move {
                let result = handler()
                    .or_else(|e| ctx.throw_turbopack_internal_result(&e))
                    .await;

                let status = func.call(result, ThreadsafeFunctionCallMode::NonBlocking);
                if !matches!(status, Status::Ok) {
                    let error = anyhow!("Error calling JS function: {}", status);
                    eprintln!("{error}");
                    return Err::<Vc<()>, _>(error);
                }
                Ok(Default::default())
            }
        }
    });
    Ok(External::new(RootTask {
        turbopack_ctx: ctx,
        task_id: Some(task_id),
    }))
}

// Await the source and return fatal issues if there are any, otherwise
// propagate any actual error results.
pub async fn strongly_consistent_catch_collectables<R: VcValueType + Send>(
    source_op: OperationVc<R>,
    filter: Vc<IssueFilter>,
) -> Result<(
    Option<ReadRef<R>>,
    Arc<Vec<ReadRef<PlainIssue>>>,
    Arc<Vec<ReadRef<PlainDiagnostic>>>,
    Arc<Effects>,
)> {
    let result = source_op.read_strongly_consistent().await;
    let issues = get_issues(source_op, filter).await?;
    let diagnostics = get_diagnostics(source_op).await?;
    let effects = Arc::new(take_effects(source_op).await?);

    let result = if result.is_err() && issues.iter().any(|i| i.severity <= IssueSeverity::Error) {
        None
    } else {
        Some(result?)
    };

    Ok((result, issues, diagnostics, effects))
}

#[napi]
pub fn expand_next_js_template(
    content: Buffer,
    template_path: String,
    next_package_dir_path: String,
    #[napi(ts_arg_type = "Record<string, string>")] replacements: FxHashMap<String, String>,
    #[napi(ts_arg_type = "Record<string, string>")] injections: FxHashMap<String, String>,
    #[napi(ts_arg_type = "Record<string, string | null>")] imports: FxHashMap<
        String,
        Option<String>,
    >,
) -> napi::Result<String> {
    Ok(next_taskless::expand_next_js_template(
        str::from_utf8(&content).context("template content must be valid utf-8")?,
        &template_path,
        &next_package_dir_path,
        replacements.iter().map(|(k, v)| (&**k, &**v)),
        injections.iter().map(|(k, v)| (&**k, &**v)),
        imports.iter().map(|(k, v)| (&**k, v.as_deref())),
    )?)
}
Quest for Codev2.0.0
/
SIGN IN