Skip to main content

next_napi_bindings/next_api/
utils.rs

1use std::{
2    future::Future,
3    ops::Deref,
4    sync::{Arc, LazyLock},
5};
6
7use anyhow::{Context, Result, anyhow};
8use futures_util::TryFutureExt;
9use napi::{
10    JsFunction, JsObject, JsUnknown, NapiRaw, NapiValue, Status,
11    bindgen_prelude::{Buffer, External, ToNapiValue},
12    threadsafe_function::{ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode},
13};
14use napi_derive::napi;
15use next_code_frame::{
16    CodeFrameColorMode, CodeFrameLocation, CodeFrameOptions, Location, render_code_frame,
17};
18use regex::Regex;
19use rustc_hash::FxHashMap;
20use serde::Serialize;
21use turbo_rcstr::RcStr;
22use turbo_tasks::{Effects, OperationVc, ReadRef, TaskId, Vc, VcValueType, take_effects};
23use turbo_tasks_fs::FileContent;
24use turbopack_core::{
25    issue::{
26        CollectibleIssuesExt, IssueFilter, IssueSeverity, PlainIssue, PlainIssueSource,
27        PlainSource, StyledString,
28    },
29    source_pos::SourcePos,
30};
31
32use crate::next_api::turbopack_ctx::NextTurbopackContext;
33
34/// An [`OperationVc`] that can be passed back and forth to JS across the [`napi`][mod@napi]
35/// boundary via [`External`].
36///
37/// It is a helper type to hold both a [`OperationVc`] and the [`NextTurbopackContext`]. Without
38/// this, we'd need to pass both individually all over the place.
39///
40/// This napi-specific abstraction does not implement [`turbo_tasks::NonLocalValue`] or
41/// [`turbo_tasks::OperationValue`] and should be dereferenced to an [`OperationVc`] before being
42/// passed to a [`turbo_tasks::function`].
43//
44// TODO: If we add a tracing garbage collector to turbo-tasks, this should be tracked as a GC root.
45#[derive(Clone)]
46pub struct DetachedVc<T> {
47    turbopack_ctx: NextTurbopackContext,
48    /// The Vc. Must be unresolved, otherwise you are referencing an inactive operation.
49    vc: OperationVc<T>,
50}
51
52impl<T> DetachedVc<T> {
53    pub fn new(turbopack_ctx: NextTurbopackContext, vc: OperationVc<T>) -> Self {
54        Self { turbopack_ctx, vc }
55    }
56
57    pub fn turbopack_ctx(&self) -> &NextTurbopackContext {
58        &self.turbopack_ctx
59    }
60}
61
62impl<T> Deref for DetachedVc<T> {
63    type Target = OperationVc<T>;
64
65    fn deref(&self) -> &Self::Target {
66        &self.vc
67    }
68}
69
70/// An opaque handle to the root of a turbo-tasks computation created by
71/// [`turbo_tasks::TurboTasks::spawn_root_task`] that can be passed back and forth to JS across the
72/// [`napi`][mod@napi] boundary via [`External`].
73///
74/// JavaScript code receiving this value **must** call [`root_task_dispose`] in a `try...finally`
75/// block to avoid leaking root tasks.
76///
77/// This is used by [`subscribe`] to create a computation that re-executes when dependencies change.
78//
79// TODO: If we add a tracing garbage collector to turbo-tasks, this should be tracked as a GC root.
80pub struct RootTask {
81    turbopack_ctx: NextTurbopackContext,
82    task_id: Option<TaskId>,
83}
84
85impl Drop for RootTask {
86    fn drop(&mut self) {
87        // TODO stop the root task
88    }
89}
90
91#[napi]
92pub fn root_task_dispose(
93    #[napi(ts_arg_type = "{ __napiType: \"RootTask\" }")] mut root_task: External<RootTask>,
94) -> napi::Result<()> {
95    if let Some(task) = root_task.task_id.take() {
96        root_task
97            .turbopack_ctx
98            .turbo_tasks()
99            .dispose_root_task(task);
100    }
101    Ok(())
102}
103
104/// [Peeks] at the [`Issue`]s held by the given source and returns them as [`PlainIssue`]s.
105/// It does not [consume] any [`Issue`]s held by the source.
106///
107/// [Peeks]: turbo_tasks::CollectiblesSource::peek_collectibles
108/// [`Issue`]: turbopack_core::issue::Issue
109/// [consume]: turbo_tasks::CollectiblesSource::take_collectibles
110pub async fn get_issues<T: Send>(
111    source: OperationVc<T>,
112    filter: &IssueFilter,
113) -> Result<Arc<Vec<ReadRef<PlainIssue>>>> {
114    Ok(Arc::new(
115        source.peek_issues().get_plain_issues(filter).await?,
116    ))
117}
118
119/// Returns true if the file path refers to a Next.js/React internal file whose
120/// source code frames would be unhelpful (e.g. large bundled vendored files).
121///
122/// Mirrors the JS `isInternal()` check from
123/// `packages/next/src/shared/lib/is-internal.ts`.
124fn is_internal(file_path: &str) -> bool {
125    // Uses [/\\] so both Unix and Windows separators are matched without
126    // needing to normalize the path
127    static RE: LazyLock<Regex> = LazyLock::new(|| {
128        Regex::new(
129            r"(?x)
130            # React vendored in Next.js dist/compiled (reactVendoredRe)
131            [/\\]next[/\\]dist[/\\]compiled[/\\](?:react|react-dom|react-server-dom-webpack|react-server-dom-turbopack|scheduler)[/\\]
132            # React in node_modules (reactNodeModulesRe)
133            | node_modules[/\\](?:react|react-dom|scheduler)[/\\]
134            # Next.js internals (nextInternalsRe)
135            | node_modules[/\\]next[/\\]
136            | [/\\]\.next[/\\]static[/\\]chunks[/\\]webpack\.js$
137            | edge-runtime-webpack\.js$
138            | webpack-runtime\.js$
139            ",
140        )
141        .expect("is_internal regex must compile")
142    });
143
144    RE.is_match(file_path)
145}
146
147/// Renders a code frame for a source location, if available.
148///
149/// This avoids transferring the full source file content across the NAPI
150/// boundary just to call back into Rust for code frame rendering.
151///
152/// Because this accesses the terminal size, this function call should not be cached (e.g. in
153/// turbo-tasks).
154fn render_source_code_frame(
155    severity: IssueSeverity,
156    source: &PlainIssueSource,
157    file_path: &str,
158) -> Result<Option<String>> {
159    let Some((start, end)) = source.range else {
160        return Ok(None);
161    };
162
163    if is_internal(file_path) {
164        return Ok(None);
165    }
166
167    let content = match &*source.asset.content {
168        FileContent::Content(c) => {
169            let Ok(content) = c.content().to_str() else {
170                return Ok(None);
171            };
172            content
173        }
174        FileContent::NotFound => return Ok(None),
175    };
176
177    // SourcePos is 0-indexed; Location is 1-indexed
178    let location = CodeFrameLocation {
179        start: Location {
180            line: (start.line + 1) as usize,
181            column: Some((start.column + 1) as usize),
182        },
183        end: Some(Location {
184            line: (end.line + 1) as usize,
185            column: Some((end.column + 1) as usize),
186        }),
187    };
188
189    render_code_frame(
190        &content,
191        &location,
192        &CodeFrameOptions {
193            color: match severity {
194                IssueSeverity::Bug | IssueSeverity::Fatal | IssueSeverity::Error => {
195                    CodeFrameColorMode::Error
196                }
197                IssueSeverity::Warning => CodeFrameColorMode::Warning,
198                IssueSeverity::Hint
199                | IssueSeverity::Note
200                | IssueSeverity::Suggestion
201                | IssueSeverity::Info => CodeFrameColorMode::Info,
202            },
203            highlight_code: true,
204            max_width: terminal_size::terminal_size()
205                .map(|(w, _)| w.0 as usize)
206                .unwrap_or(100),
207            ..Default::default()
208        },
209    )
210}
211
212/// Renders a code frame for the issue's primary source location.
213fn render_issue_code_frame(issue: &PlainIssue) -> Result<Option<String>> {
214    let Some(source) = issue.source.as_ref() else {
215        return Ok(None);
216    };
217    render_source_code_frame(issue.severity, source, &issue.file_path)
218}
219
220#[napi(object)]
221pub struct NapiIssue {
222    pub severity: String,
223    pub stage: String,
224    pub file_path: RcStr,
225    pub title: serde_json::Value,
226    pub description: Option<serde_json::Value>,
227    pub detail: Option<serde_json::Value>,
228    pub source: Option<NapiIssueSource>,
229    pub additional_sources: Vec<NapiAdditionalIssueSource>,
230    pub documentation_link: RcStr,
231    pub import_traces: serde_json::Value,
232    /// Pre-rendered code frame for the issue's source location, if available.
233    /// Rendered in Rust to avoid transferring full source file content to JS.
234    pub code_frame: Option<String>,
235}
236
237#[napi(object)]
238pub struct NapiAdditionalIssueSource {
239    pub description: RcStr,
240    pub source: NapiIssueSource,
241    /// Pre-rendered code frame for this additional source location, if available.
242    pub code_frame: Option<String>,
243}
244
245impl From<&PlainIssue> for NapiIssue {
246    fn from(issue: &PlainIssue) -> Self {
247        Self {
248            description: issue
249                .description
250                .as_ref()
251                .map(|styled| serde_json::to_value(StyledStringSerialize::from(styled)).unwrap()),
252            stage: issue.stage.to_string(),
253            file_path: issue.file_path.clone(),
254            detail: issue
255                .detail
256                .as_ref()
257                .map(|styled| serde_json::to_value(StyledStringSerialize::from(styled)).unwrap()),
258            documentation_link: issue.documentation_link.clone(),
259            severity: issue.severity.as_str().to_string(),
260            source: issue.source.as_ref().map(|source| source.into()),
261            additional_sources: issue
262                .additional_sources
263                .iter()
264                .map(|s| NapiAdditionalIssueSource {
265                    description: s.description.clone(),
266                    code_frame: render_source_code_frame(
267                        issue.severity,
268                        &s.source,
269                        &s.source.asset.file_path,
270                    )
271                    .unwrap_or_default(),
272                    source: (&s.source).into(),
273                })
274                .collect(),
275            title: serde_json::to_value(StyledStringSerialize::from(&issue.title)).unwrap(),
276            import_traces: serde_json::to_value(&issue.import_traces).unwrap(),
277            code_frame: render_issue_code_frame(issue).unwrap_or_default(),
278        }
279    }
280}
281
282#[derive(Serialize)]
283#[serde(tag = "type", rename_all = "camelCase")]
284pub enum StyledStringSerialize<'a> {
285    Line {
286        value: Vec<StyledStringSerialize<'a>>,
287    },
288    Stack {
289        value: Vec<StyledStringSerialize<'a>>,
290    },
291    Text {
292        value: &'a str,
293    },
294    Code {
295        value: &'a str,
296    },
297    Strong {
298        value: &'a str,
299    },
300}
301
302impl<'a> From<&'a StyledString> for StyledStringSerialize<'a> {
303    fn from(value: &'a StyledString) -> Self {
304        match value {
305            StyledString::Line(parts) => StyledStringSerialize::Line {
306                value: parts.iter().map(|p| p.into()).collect(),
307            },
308            StyledString::Stack(parts) => StyledStringSerialize::Stack {
309                value: parts.iter().map(|p| p.into()).collect(),
310            },
311            StyledString::Text(string) => StyledStringSerialize::Text { value: string },
312            StyledString::Code(string) => StyledStringSerialize::Code { value: string },
313            StyledString::Strong(string) => StyledStringSerialize::Strong { value: string },
314        }
315    }
316}
317
318#[napi(object)]
319pub struct NapiIssueSource {
320    pub source: NapiSource,
321    pub range: Option<NapiIssueSourceRange>,
322}
323
324impl From<&PlainIssueSource> for NapiIssueSource {
325    fn from(
326        PlainIssueSource {
327            asset: source,
328            range,
329        }: &PlainIssueSource,
330    ) -> Self {
331        Self {
332            source: (&**source).into(),
333            range: range.as_ref().map(|range| range.into()),
334        }
335    }
336}
337
338#[napi(object)]
339pub struct NapiIssueSourceRange {
340    pub start: NapiSourcePos,
341    pub end: NapiSourcePos,
342}
343
344impl From<&(SourcePos, SourcePos)> for NapiIssueSourceRange {
345    fn from((start, end): &(SourcePos, SourcePos)) -> Self {
346        Self {
347            start: (*start).into(),
348            end: (*end).into(),
349        }
350    }
351}
352
353#[napi(object)]
354pub struct NapiSource {
355    pub ident: RcStr,
356    pub file_path: RcStr,
357}
358
359impl From<&PlainSource> for NapiSource {
360    fn from(source: &PlainSource) -> Self {
361        Self {
362            ident: source.ident.clone(),
363            file_path: source.file_path.clone(),
364        }
365    }
366}
367
368#[napi(object)]
369pub struct NapiSourcePos {
370    pub line: u32,
371    pub column: u32,
372}
373
374impl From<SourcePos> for NapiSourcePos {
375    fn from(pos: SourcePos) -> Self {
376        Self {
377            line: pos.line,
378            column: pos.column,
379        }
380    }
381}
382
383#[napi(object)]
384pub struct NapiUsedFeature {
385    pub feature_name: RcStr,
386    /// How many times it was used, typically this means how often it was imported.
387    pub invocation_count: u32,
388}
389
390impl NapiUsedFeature {
391    pub fn new(feature_name: RcStr, invocation_count: u32) -> Self {
392        Self {
393            feature_name,
394            invocation_count,
395        }
396    }
397}
398
399pub struct TurbopackResult<T: ToNapiValue> {
400    pub result: T,
401    pub issues: Vec<NapiIssue>,
402}
403
404impl<T: ToNapiValue> ToNapiValue for TurbopackResult<T> {
405    unsafe fn to_napi_value(
406        env: napi::sys::napi_env,
407        val: Self,
408    ) -> napi::Result<napi::sys::napi_value> {
409        let mut obj = unsafe { napi::Env::from_raw(env).create_object()? };
410
411        let result = unsafe {
412            let result = T::to_napi_value(env, val.result)?;
413            JsUnknown::from_raw(env, result)?
414        };
415        if matches!(result.get_type()?, napi::ValueType::Object) {
416            // SAFETY: We know that result is an object, so we can cast it to a JsObject
417            let result = unsafe { result.cast::<JsObject>() };
418
419            for key in JsObject::keys(&result)? {
420                let value: JsUnknown = result.get_named_property(&key)?;
421                obj.set_named_property(&key, value)?;
422            }
423        }
424
425        obj.set_named_property("issues", val.issues)?;
426
427        Ok(unsafe { obj.raw() })
428    }
429}
430
431pub fn subscribe<T: 'static + Send + Sync, F: Future<Output = Result<T>> + Send, V: ToNapiValue>(
432    ctx: NextTurbopackContext,
433    func: JsFunction,
434    handler: impl 'static + Sync + Send + Clone + Fn() -> F,
435    mapper: impl 'static + Sync + Send + FnMut(ThreadSafeCallContext<T>) -> napi::Result<Vec<V>>,
436) -> napi::Result<External<RootTask>> {
437    let func: ThreadsafeFunction<T> = func.create_threadsafe_function(0, mapper)?;
438    let task_id = ctx.turbo_tasks().spawn_root_task({
439        let ctx = ctx.clone();
440        move || {
441            let ctx = ctx.clone();
442            let handler = handler.clone();
443            let func = func.clone();
444            async move {
445                let result = handler()
446                    .or_else(|e| ctx.throw_turbopack_internal_result(&e))
447                    .await;
448
449                let status = func.call(result, ThreadsafeFunctionCallMode::NonBlocking);
450                if !matches!(status, Status::Ok) {
451                    let error = anyhow!("Error calling JS function: {}", status);
452                    eprintln!("{error}");
453                    return Err::<Vc<()>, _>(error);
454                }
455                Ok(Default::default())
456            }
457        }
458    });
459    Ok(External::new(RootTask {
460        turbopack_ctx: ctx,
461        task_id: Some(task_id),
462    }))
463}
464
465// Await the source and return fatal issues if there are any, otherwise
466// propagate any actual error results.
467pub async fn strongly_consistent_catch_collectables<R: VcValueType + Send>(
468    source_op: OperationVc<R>,
469    filter: &IssueFilter,
470) -> Result<(
471    Option<ReadRef<R>>,
472    Arc<Vec<ReadRef<PlainIssue>>>,
473    Arc<Effects>,
474)> {
475    let result = source_op.read_strongly_consistent().await;
476    let issues = get_issues(source_op, filter).await?;
477    let effects = Arc::new(take_effects(source_op).await?);
478
479    let result = if result.is_err() && issues.iter().any(|i| i.severity <= IssueSeverity::Error) {
480        None
481    } else {
482        Some(result?)
483    };
484
485    Ok((result, issues, effects))
486}
487
488#[napi]
489pub fn expand_next_js_template(
490    content: Buffer,
491    template_path: String,
492    next_package_dir_path: String,
493    #[napi(ts_arg_type = "Record<string, string>")] replacements: FxHashMap<String, String>,
494    #[napi(ts_arg_type = "Record<string, string>")] injections: FxHashMap<String, String>,
495    #[napi(ts_arg_type = "Record<string, string | null>")] imports: FxHashMap<
496        String,
497        Option<String>,
498    >,
499) -> napi::Result<String> {
500    Ok(next_taskless::expand_next_js_template(
501        str::from_utf8(&content).context("template content must be valid utf-8")?,
502        &template_path,
503        &next_package_dir_path,
504        replacements.iter().map(|(k, v)| (&**k, &**v)),
505        injections.iter().map(|(k, v)| (&**k, &**v)),
506        imports.iter().map(|(k, v)| (&**k, v.as_deref())),
507    )?)
508}