Skip to main content

next_napi_bindings/next_api/
utils.rs

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