mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
[turbopack] Render code frames on the turbopack side (#90534)
Not that error rendering performance is critical, but it is silly to pass frame information from turbopack to js just to have js pass the same information back to native code to draw a frame.
This commit is contained in:
Generated
+2
@@ -4650,12 +4650,14 @@ dependencies = [
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"rand 0.10.0",
|
||||
"regex",
|
||||
"rustc-hash 2.1.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"supports-hyperlinks",
|
||||
"swc_core",
|
||||
"terminal_hyperlink",
|
||||
"terminal_size",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-chrome",
|
||||
|
||||
@@ -468,6 +468,7 @@ strsim = "0.11.1"
|
||||
swc_sourcemap = "9.3.4"
|
||||
syn = "2.0.100"
|
||||
tempfile = "3.20.0"
|
||||
terminal_size = "0.3.0"
|
||||
thiserror = "1.0.48"
|
||||
thread_local = "1.1.8"
|
||||
tokio = "1.43.0"
|
||||
|
||||
@@ -73,6 +73,7 @@ rustc-hash = { workspace = true }
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
supports-hyperlinks = "3.1.0"
|
||||
terminal_size = { workspace = true }
|
||||
terminal_hyperlink = "0.1.0"
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
@@ -80,6 +81,7 @@ tracing-chrome = "0.7.2"
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
|
||||
swc_core = { workspace = true, features = [
|
||||
"base",
|
||||
|
||||
@@ -8,6 +8,9 @@ use napi::{
|
||||
threadsafe_function::{ThreadSafeCallContext, ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use next_code_frame::{CodeFrameLocation, CodeFrameOptions, Location, render_code_frame};
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::Serialize;
|
||||
use turbo_tasks::{
|
||||
@@ -124,6 +127,89 @@ pub async fn get_diagnostics<T: Send>(
|
||||
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: Lazy<Regex> = Lazy::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 the issue's 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_issue_code_frame(issue: &PlainIssue) -> Result<Option<String>> {
|
||||
let Some(source) = issue.source.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some((start, end)) = source.range else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if is_internal(&issue.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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct NapiIssue {
|
||||
pub severity: String,
|
||||
@@ -135,6 +221,9 @@ pub struct NapiIssue {
|
||||
pub source: Option<NapiIssueSource>,
|
||||
pub documentation_link: String,
|
||||
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>,
|
||||
}
|
||||
|
||||
impl From<&PlainIssue> for NapiIssue {
|
||||
@@ -155,6 +244,7 @@ impl From<&PlainIssue> for NapiIssue {
|
||||
source: issue.source.as_ref().map(|source| source.into()),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,20 +323,12 @@ impl From<&(SourcePos, SourcePos)> for NapiIssueSourceRange {
|
||||
#[napi(object)]
|
||||
pub struct NapiSource {
|
||||
pub ident: String,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&PlainSource> for NapiSource {
|
||||
fn from(source: &PlainSource) -> Self {
|
||||
Self {
|
||||
ident: source.ident.to_string(),
|
||||
content: match &*source.content {
|
||||
FileContent::Content(content) => match content.content().to_str() {
|
||||
Ok(str) => Some(str.into_owned()),
|
||||
Err(_) => None,
|
||||
},
|
||||
FileContent::NotFound => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -514,6 +514,11 @@ export interface NapiIssue {
|
||||
source?: NapiIssueSource
|
||||
documentationLink: string
|
||||
importTraces: any
|
||||
/**
|
||||
* Pre-rendered code frame for the issue's source location, if available.
|
||||
* Rendered in Rust to avoid transferring full source file content to JS.
|
||||
*/
|
||||
codeFrame?: string
|
||||
}
|
||||
export interface NapiIssueSource {
|
||||
source: NapiSource
|
||||
@@ -525,7 +530,6 @@ export interface NapiIssueSourceRange {
|
||||
}
|
||||
export interface NapiSource {
|
||||
ident: string
|
||||
content?: string
|
||||
}
|
||||
export interface NapiSourcePos {
|
||||
line: number
|
||||
|
||||
@@ -121,7 +121,6 @@ export interface Issue {
|
||||
source?: {
|
||||
source: {
|
||||
ident: string
|
||||
content?: string
|
||||
}
|
||||
range?: {
|
||||
start: {
|
||||
@@ -140,6 +139,8 @@ export interface Issue {
|
||||
}
|
||||
documentationLink: string
|
||||
importTraces?: PlainTraceItem[][]
|
||||
/** Pre-rendered code frame from the Rust NAPI layer */
|
||||
codeFrame?: string
|
||||
}
|
||||
export interface PlainTraceItem {
|
||||
fsName: string
|
||||
|
||||
@@ -11,6 +11,8 @@ export const nextInternalsRe =
|
||||
|
||||
export default function isInternal(file: string | null) {
|
||||
if (!file) return false
|
||||
// NOTE: native code has a version of the same logic in crates/next-napi-bindings/src/next_api/utils.rs
|
||||
// keep them in sync.
|
||||
|
||||
return (
|
||||
nextInternalsRe.test(file) ||
|
||||
|
||||
@@ -112,6 +112,18 @@ https://nextjs.org/docs/messages/module-not-found
|
||||
`)
|
||||
})
|
||||
|
||||
it('includes pre-rendered code frame from Rust', () => {
|
||||
const issue: Issue = {
|
||||
...baseIssue,
|
||||
importTraces: [],
|
||||
codeFrame:
|
||||
' 1 | const x = 1;\n> 2 | const y = unknown;\n | ^^^^^^^\n 3 | const z = 3;',
|
||||
}
|
||||
const output = formatIssue(issue)
|
||||
expect(output).toContain('const y = unknown')
|
||||
expect(output).toContain('^^^^^^^')
|
||||
})
|
||||
|
||||
it('handles missing layers in traces', () => {
|
||||
const trace: PlainTraceItem[] = [
|
||||
traceItem('src/app/page.ts'),
|
||||
|
||||
@@ -6,12 +6,10 @@ import type {
|
||||
} from '../../../build/swc/types'
|
||||
|
||||
import { bold, green, magenta, red } from '../../../lib/picocolors'
|
||||
import isInternal from '../is-internal'
|
||||
import { deobfuscateText } from '../magic-identifier'
|
||||
import type { EntryKey } from './entry-key'
|
||||
import * as Log from '../../../build/output/log'
|
||||
import type { NextConfigComplete } from '../../../server/config-shared'
|
||||
import { codeFrameColumns } from '../errors/code-frame'
|
||||
|
||||
type IssueKey = `${Issue['severity']}-${Issue['filePath']}-${string}-${string}`
|
||||
export type IssuesMap = Map<IssueKey, Issue>
|
||||
@@ -128,32 +126,8 @@ export function formatIssue(issue: Issue) {
|
||||
}
|
||||
message += '\n'
|
||||
|
||||
if (
|
||||
source?.range &&
|
||||
source.source.content &&
|
||||
// ignore Next.js/React internals, as these can often be huge bundled files.
|
||||
!isInternal(filePath)
|
||||
) {
|
||||
const { start, end } = source.range
|
||||
|
||||
// TODO(lukesandberg): move codeFrame formatting into turbopack, it would be more efficient than passing the source back and forth
|
||||
const frame = codeFrameColumns(
|
||||
source.source.content,
|
||||
{
|
||||
start: {
|
||||
line: start.line + 1,
|
||||
column: start.column + 1,
|
||||
},
|
||||
end: {
|
||||
line: end.line + 1,
|
||||
column: end.column + 1,
|
||||
},
|
||||
},
|
||||
{ color: true }
|
||||
)
|
||||
if (frame) {
|
||||
message += frame.trimEnd() + '\n\n'
|
||||
}
|
||||
if (issue.codeFrame) {
|
||||
message += issue.codeFrame.trimEnd() + '\n\n'
|
||||
}
|
||||
|
||||
if (description) {
|
||||
|
||||
Reference in New Issue
Block a user