Skip to content

Commit 4907044

Browse files
authored
Add source map support for server components/actions in the browser (#71042)
This PR adds support for showing the original server sources of server components and server actions in the browser's developer tools. To accomplish that, we're building on top of #69190, #70563, and #70564, in which we prepared the proper generation of the source maps. This PR completes the feature, by wiring up `findSourceMapURL` (see facebook/react#30741) with a new dev server middleware that serves those source map files to the browser. Another change we had to make is disabling the dead-code elimination of server actions in client bundles (see #70103), but only in development mode. This optimization is primarily intended for production bundles, so the change should not pose a problem. With that, it's now possible to jump directly into the server sources from the browser's dev tools, e.g. for component stacks of server logs that are replayed in the browser, or when inspecting server action props in the React DevTools.
1 parent a48680d commit 4907044

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+662
-377
lines changed

crates/napi/src/next_api/project.rs

Lines changed: 82 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use turbopack_core::{
2828
diagnostics::PlainDiagnostic,
2929
error::PrettyPrintError,
3030
issue::PlainIssue,
31-
source_map::Token,
31+
source_map::{SourceMap, Token},
3232
version::{PartialUpdate, TotalUpdate, Update, VersionState},
3333
SOURCE_MAP_PREFIX,
3434
};
@@ -1002,74 +1002,76 @@ pub struct StackFrame {
10021002
pub method_name: Option<String>,
10031003
}
10041004

1005+
pub async fn get_source_map(
1006+
container: Vc<ProjectContainer>,
1007+
file_path: String,
1008+
) -> Result<Option<Vc<SourceMap>>> {
1009+
let (file, module) = match Url::parse(&file_path) {
1010+
Ok(url) => match url.scheme() {
1011+
"file" => {
1012+
let path = urlencoding::decode(url.path())?.to_string();
1013+
let module = url.query_pairs().find(|(k, _)| k == "id");
1014+
(
1015+
path,
1016+
match module {
1017+
Some(module) => Some(urlencoding::decode(&module.1)?.into_owned().into()),
1018+
None => None,
1019+
},
1020+
)
1021+
}
1022+
_ => bail!("Unknown url scheme"),
1023+
},
1024+
Err(_) => (file_path.to_string(), None),
1025+
};
1026+
1027+
let Some(chunk_base) = file.strip_prefix(
1028+
&(format!(
1029+
"{}/{}/",
1030+
container.project().await?.project_path,
1031+
container.project().dist_dir().await?
1032+
)),
1033+
) else {
1034+
// File doesn't exist within the dist dir
1035+
return Ok(None);
1036+
};
1037+
1038+
let server_path = container.project().node_root().join(chunk_base.into());
1039+
1040+
let client_path = container
1041+
.project()
1042+
.client_relative_path()
1043+
.join(chunk_base.into());
1044+
1045+
let mut map = container
1046+
.get_source_map(server_path, module.clone())
1047+
.await?;
1048+
1049+
if map.is_none() {
1050+
// If the chunk doesn't exist as a server chunk, try a client chunk.
1051+
// TODO: Properly tag all server chunks and use the `isServer` query param.
1052+
// Currently, this is inaccurate as it does not cover RSC server
1053+
// chunks.
1054+
map = container.get_source_map(client_path, module).await?;
1055+
}
1056+
1057+
let map = map.context("chunk/module is missing a sourcemap")?;
1058+
1059+
Ok(Some(map))
1060+
}
1061+
10051062
#[napi]
10061063
pub async fn project_trace_source(
10071064
#[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External<ProjectInstance>,
10081065
frame: StackFrame,
10091066
) -> napi::Result<Option<StackFrame>> {
10101067
let turbo_tasks = project.turbo_tasks.clone();
1068+
let container = project.container;
10111069
let traced_frame = turbo_tasks
10121070
.run_once(async move {
1013-
let (file, module) = match Url::parse(&frame.file) {
1014-
Ok(url) => match url.scheme() {
1015-
"file" => {
1016-
let path = urlencoding::decode(url.path())?.to_string();
1017-
let module = url.query_pairs().find(|(k, _)| k == "id");
1018-
(
1019-
path,
1020-
match module {
1021-
Some(module) => {
1022-
Some(urlencoding::decode(&module.1)?.into_owned().into())
1023-
}
1024-
None => None,
1025-
},
1026-
)
1027-
}
1028-
_ => bail!("Unknown url scheme"),
1029-
},
1030-
Err(_) => (frame.file.to_string(), None),
1031-
};
1032-
1033-
let Some(chunk_base) = file.strip_prefix(
1034-
&(format!(
1035-
"{}/{}/",
1036-
project.container.project().await?.project_path,
1037-
project.container.project().dist_dir().await?
1038-
)),
1039-
) else {
1040-
// File doesn't exist within the dist dir
1071+
let Some(map) = get_source_map(container, frame.file).await? else {
10411072
return Ok(None);
10421073
};
10431074

1044-
let server_path = project
1045-
.container
1046-
.project()
1047-
.node_root()
1048-
.join(chunk_base.into());
1049-
1050-
let client_path = project
1051-
.container
1052-
.project()
1053-
.client_relative_path()
1054-
.join(chunk_base.into());
1055-
1056-
let mut map = project
1057-
.container
1058-
.get_source_map(server_path, module.clone())
1059-
.await?;
1060-
1061-
if map.is_none() {
1062-
// If the chunk doesn't exist as a server chunk, try a client chunk.
1063-
// TODO: Properly tag all server chunks and use the `isServer` query param.
1064-
// Currently, this is inaccurate as it does not cover RSC server
1065-
// chunks.
1066-
map = project
1067-
.container
1068-
.get_source_map(client_path, module)
1069-
.await?;
1070-
}
1071-
let map = map.context("chunk/module is missing a sourcemap")?;
1072-
10731075
let Some(line) = frame.line else {
10741076
return Ok(None);
10751077
};
@@ -1152,6 +1154,28 @@ pub async fn project_get_source_for_asset(
11521154
Ok(source)
11531155
}
11541156

1157+
#[napi]
1158+
pub async fn project_get_source_map(
1159+
#[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External<ProjectInstance>,
1160+
file_path: String,
1161+
) -> napi::Result<Option<String>> {
1162+
let turbo_tasks = project.turbo_tasks.clone();
1163+
let container = project.container;
1164+
1165+
let source_map = turbo_tasks
1166+
.run_once(async move {
1167+
let Some(map) = get_source_map(container, file_path).await? else {
1168+
return Ok(None);
1169+
};
1170+
1171+
Ok(Some(map.to_rope().await?.to_str()?.to_string()))
1172+
})
1173+
.await
1174+
.map_err(|e| napi::Error::from_reason(PrettyPrintError(&e).to_string()))?;
1175+
1176+
Ok(source_map)
1177+
}
1178+
11551179
/// Runs exit handlers for the project registered using the [`ExitHandler`] API.
11561180
#[napi]
11571181
pub async fn project_on_exit(

packages/next/src/build/swc/generated-native.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,10 @@ export interface StackFrame {
273273
column?: number
274274
methodName?: string
275275
}
276+
export function projectGetSourceMap(
277+
project: { __napiType: 'Project' },
278+
filePath: string
279+
): Promise<string | null>
276280
export function projectTraceSource(
277281
project: { __napiType: 'Project' },
278282
frame: StackFrame

packages/next/src/build/swc/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,10 @@ function bindingToApi(
757757
return binding.projectGetSourceForAsset(this._nativeProject, filePath)
758758
}
759759

760+
getSourceMap(filePath: string): Promise<string | null> {
761+
return binding.projectGetSourceMap(this._nativeProject, filePath)
762+
}
763+
760764
updateInfoSubscribe(aggregationMs: number) {
761765
return subscribe<TurbopackResult<UpdateMessage>>(true, async (callback) =>
762766
binding.projectUpdateInfoSubscribe(

packages/next/src/build/swc/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ export interface Project {
213213

214214
getSourceForAsset(filePath: string): Promise<string | null>
215215

216+
getSourceMap(filePath: string): Promise<string | null>
217+
216218
traceSource(
217219
stackFrame: TurbopackStackFrame
218220
): Promise<TurbopackStackFrame | null>

packages/next/src/build/webpack/loaders/next-flight-client-module-loader.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ const flightClientModuleLoader: webpack.LoaderDefinitionFunction =
1616
const buildInfo = getModuleBuildInfo(this._module)
1717
buildInfo.rsc = getRSCModuleInformation(source, false)
1818

19-
// This is a server action entry module in the client layer. We need to create
20-
// re-exports of "virtual modules" to expose the reference IDs to the client
21-
// separately so they won't be always in the same one module which is not
22-
// splittable.
23-
if (buildInfo.rsc.actionIds) {
19+
// This is a server action entry module in the client layer. We need to
20+
// create re-exports of "virtual modules" to expose the reference IDs to the
21+
// client separately so they won't be always in the same one module which is
22+
// not splittable. This server action module tree shaking is only applied in
23+
// production mode. In development mode, we want to preserve the original
24+
// modules (as transformed by SWC) to ensure that source mapping works.
25+
if (buildInfo.rsc.actionIds && process.env.NODE_ENV === 'production') {
2426
return Object.entries(buildInfo.rsc.actionIds)
2527
.map(([id, name]) => {
2628
return `export { ${name} } from 'next-flight-server-reference-proxy-loader?id=${id}&name=${name}!'`

packages/next/src/build/webpack/plugins/wellknown-errors-plugin/parseNotFoundError.ts

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,29 +51,40 @@ async function getSourceFrame(
5151
try {
5252
const loc =
5353
input.loc || input.dependencies.map((d: any) => d.loc).filter(Boolean)[0]
54-
const originalSource = input.module.originalSource()
55-
56-
const result = await createOriginalStackFrame({
57-
source: originalSource,
58-
rootDirectory: compilation.options.context!,
59-
modulePath: fileName,
60-
frame: {
61-
arguments: [],
62-
file: fileName,
63-
methodName: '',
64-
lineNumber: loc.start.line,
65-
column: loc.start.column,
66-
},
67-
})
68-
69-
return {
70-
frame: result?.originalCodeFrame ?? '',
71-
lineNumber: result?.originalStackFrame?.lineNumber?.toString() ?? '',
72-
column: result?.originalStackFrame?.column?.toString() ?? '',
54+
const module = input.module as webpack.Module
55+
const originalSource = module.originalSource()
56+
const sourceMap = originalSource?.map() ?? undefined
57+
58+
if (sourceMap) {
59+
const moduleId = compilation.chunkGraph.getModuleId(module)
60+
61+
const result = await createOriginalStackFrame({
62+
source: {
63+
type: 'bundle',
64+
sourceMap,
65+
compilation,
66+
moduleId,
67+
modulePath: fileName,
68+
},
69+
rootDirectory: compilation.options.context!,
70+
frame: {
71+
arguments: [],
72+
file: fileName,
73+
methodName: '',
74+
lineNumber: loc.start.line,
75+
column: loc.start.column,
76+
},
77+
})
78+
79+
return {
80+
frame: result?.originalCodeFrame ?? '',
81+
lineNumber: result?.originalStackFrame?.lineNumber?.toString() ?? '',
82+
column: result?.originalStackFrame?.column?.toString() ?? '',
83+
}
7384
}
74-
} catch {
75-
return { frame: '', lineNumber: '', column: '' }
76-
}
85+
} catch {}
86+
87+
return { frame: '', lineNumber: '', column: '' }
7788
}
7889

7990
function getFormattedFileName(
Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
// TODO: Will be implemented later.
2-
export function findSourceMapURL(_filename: string): string | null {
3-
return null
4-
}
1+
const basePath = process.env.__NEXT_ROUTER_BASEPATH || ''
2+
const pathname = `${basePath}/__nextjs_source-map`
3+
4+
export const findSourceMapURL =
5+
process.env.NODE_ENV === 'development'
6+
? function findSourceMapURL(filename: string): string | null {
7+
const url = new URL(pathname, document.location.origin)
8+
9+
url.searchParams.set(
10+
'filename',
11+
filename.replace(new RegExp(`^${document.location.origin}`), '')
12+
)
13+
14+
return url.href
15+
}
16+
: undefined

packages/next/src/client/app-index.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createFromReadableStream } from 'react-server-dom-webpack/client'
1111
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
1212
import { onRecoverableError } from './on-recoverable-error'
1313
import { callServer } from './app-call-server'
14+
import { findSourceMapURL } from './app-find-source-map-url'
1415
import {
1516
type AppRouterActionQueue,
1617
createMutableActionQueue,
@@ -20,9 +21,6 @@ import type { InitialRSCPayload } from '../server/app-render/types'
2021
import { createInitialRouterState } from './components/router-reducer/create-initial-router-state'
2122
import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runtime'
2223

23-
// Importing from dist so that we can define an alias if needed.
24-
import { findSourceMapURL } from 'next/dist/client/app-find-source-map-url'
25-
2624
/// <reference types="react-dom/experimental" />
2725

2826
const appElement: HTMLElement | Document | null = document

packages/next/src/client/components/react-dev-overlay/app/ReactDevOverlay.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BuildError } from '../internal/container/BuildError'
66
import { Errors } from '../internal/container/Errors'
77
import { StaticIndicator } from '../internal/container/StaticIndicator'
88
import type { SupportedErrorEvent } from '../internal/container/Errors'
9-
import { parseStack } from '../internal/helpers/parseStack'
9+
import { parseStack } from '../internal/helpers/parse-stack'
1010
import { Base } from '../internal/styles/Base'
1111
import { ComponentStyles } from '../internal/styles/ComponentStyles'
1212
import { CssReset } from '../internal/styles/CssReset'

packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
ACTION_VERSION_INFO,
1616
useErrorOverlayReducer,
1717
} from '../shared'
18-
import { parseStack } from '../internal/helpers/parseStack'
18+
import { parseStack } from '../internal/helpers/parse-stack'
1919
import ReactDevOverlay from './ReactDevOverlay'
2020
import { useErrorHandler } from '../internal/helpers/use-error-handler'
2121
import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler'

0 commit comments

Comments
 (0)