Skip to content

Commit

Permalink
Add source map support for server components/actions in the browser (v…
Browse files Browse the repository at this point in the history
…ercel#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 vercel#69190, vercel#70563, and vercel#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 vercel#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.
  • Loading branch information
unstubbable authored Oct 11, 2024
1 parent a48680d commit 4907044
Show file tree
Hide file tree
Showing 44 changed files with 662 additions and 377 deletions.
140 changes: 82 additions & 58 deletions crates/napi/src/next_api/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use turbopack_core::{
diagnostics::PlainDiagnostic,
error::PrettyPrintError,
issue::PlainIssue,
source_map::Token,
source_map::{SourceMap, Token},
version::{PartialUpdate, TotalUpdate, Update, VersionState},
SOURCE_MAP_PREFIX,
};
Expand Down Expand Up @@ -1002,74 +1002,76 @@ pub struct StackFrame {
pub method_name: Option<String>,
}

pub async fn get_source_map(
container: Vc<ProjectContainer>,
file_path: String,
) -> Result<Option<Vc<SourceMap>>> {
let (file, module) = match Url::parse(&file_path) {
Ok(url) => match url.scheme() {
"file" => {
let path = urlencoding::decode(url.path())?.to_string();
let module = url.query_pairs().find(|(k, _)| k == "id");
(
path,
match module {
Some(module) => Some(urlencoding::decode(&module.1)?.into_owned().into()),
None => None,
},
)
}
_ => bail!("Unknown url scheme"),
},
Err(_) => (file_path.to_string(), None),
};

let Some(chunk_base) = file.strip_prefix(
&(format!(
"{}/{}/",
container.project().await?.project_path,
container.project().dist_dir().await?
)),
) else {
// File doesn't exist within the dist dir
return Ok(None);
};

let server_path = container.project().node_root().join(chunk_base.into());

let client_path = container
.project()
.client_relative_path()
.join(chunk_base.into());

let mut map = container
.get_source_map(server_path, module.clone())
.await?;

if map.is_none() {
// If the chunk doesn't exist as a server chunk, try a client chunk.
// TODO: Properly tag all server chunks and use the `isServer` query param.
// Currently, this is inaccurate as it does not cover RSC server
// chunks.
map = container.get_source_map(client_path, module).await?;
}

let map = map.context("chunk/module is missing a sourcemap")?;

Ok(Some(map))
}

#[napi]
pub async fn project_trace_source(
#[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External<ProjectInstance>,
frame: StackFrame,
) -> napi::Result<Option<StackFrame>> {
let turbo_tasks = project.turbo_tasks.clone();
let container = project.container;
let traced_frame = turbo_tasks
.run_once(async move {
let (file, module) = match Url::parse(&frame.file) {
Ok(url) => match url.scheme() {
"file" => {
let path = urlencoding::decode(url.path())?.to_string();
let module = url.query_pairs().find(|(k, _)| k == "id");
(
path,
match module {
Some(module) => {
Some(urlencoding::decode(&module.1)?.into_owned().into())
}
None => None,
},
)
}
_ => bail!("Unknown url scheme"),
},
Err(_) => (frame.file.to_string(), None),
};

let Some(chunk_base) = file.strip_prefix(
&(format!(
"{}/{}/",
project.container.project().await?.project_path,
project.container.project().dist_dir().await?
)),
) else {
// File doesn't exist within the dist dir
let Some(map) = get_source_map(container, frame.file).await? else {
return Ok(None);
};

let server_path = project
.container
.project()
.node_root()
.join(chunk_base.into());

let client_path = project
.container
.project()
.client_relative_path()
.join(chunk_base.into());

let mut map = project
.container
.get_source_map(server_path, module.clone())
.await?;

if map.is_none() {
// If the chunk doesn't exist as a server chunk, try a client chunk.
// TODO: Properly tag all server chunks and use the `isServer` query param.
// Currently, this is inaccurate as it does not cover RSC server
// chunks.
map = project
.container
.get_source_map(client_path, module)
.await?;
}
let map = map.context("chunk/module is missing a sourcemap")?;

let Some(line) = frame.line else {
return Ok(None);
};
Expand Down Expand Up @@ -1152,6 +1154,28 @@ pub async fn project_get_source_for_asset(
Ok(source)
}

#[napi]
pub async fn project_get_source_map(
#[napi(ts_arg_type = "{ __napiType: \"Project\" }")] project: External<ProjectInstance>,
file_path: String,
) -> napi::Result<Option<String>> {
let turbo_tasks = project.turbo_tasks.clone();
let container = project.container;

let source_map = turbo_tasks
.run_once(async move {
let Some(map) = get_source_map(container, file_path).await? else {
return Ok(None);
};

Ok(Some(map.to_rope().await?.to_str()?.to_string()))
})
.await
.map_err(|e| napi::Error::from_reason(PrettyPrintError(&e).to_string()))?;

Ok(source_map)
}

/// Runs exit handlers for the project registered using the [`ExitHandler`] API.
#[napi]
pub async fn project_on_exit(
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/build/swc/generated-native.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ export interface StackFrame {
column?: number
methodName?: string
}
export function projectGetSourceMap(
project: { __napiType: 'Project' },
filePath: string
): Promise<string | null>
export function projectTraceSource(
project: { __napiType: 'Project' },
frame: StackFrame
Expand Down
4 changes: 4 additions & 0 deletions packages/next/src/build/swc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,10 @@ function bindingToApi(
return binding.projectGetSourceForAsset(this._nativeProject, filePath)
}

getSourceMap(filePath: string): Promise<string | null> {
return binding.projectGetSourceMap(this._nativeProject, filePath)
}

updateInfoSubscribe(aggregationMs: number) {
return subscribe<TurbopackResult<UpdateMessage>>(true, async (callback) =>
binding.projectUpdateInfoSubscribe(
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/build/swc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export interface Project {

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

getSourceMap(filePath: string): Promise<string | null>

traceSource(
stackFrame: TurbopackStackFrame
): Promise<TurbopackStackFrame | null>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ const flightClientModuleLoader: webpack.LoaderDefinitionFunction =
const buildInfo = getModuleBuildInfo(this._module)
buildInfo.rsc = getRSCModuleInformation(source, false)

// This is a server action entry module in the client layer. We need to create
// re-exports of "virtual modules" to expose the reference IDs to the client
// separately so they won't be always in the same one module which is not
// splittable.
if (buildInfo.rsc.actionIds) {
// This is a server action entry module in the client layer. We need to
// create re-exports of "virtual modules" to expose the reference IDs to the
// client separately so they won't be always in the same one module which is
// not splittable. This server action module tree shaking is only applied in
// production mode. In development mode, we want to preserve the original
// modules (as transformed by SWC) to ensure that source mapping works.
if (buildInfo.rsc.actionIds && process.env.NODE_ENV === 'production') {
return Object.entries(buildInfo.rsc.actionIds)
.map(([id, name]) => {
return `export { ${name} } from 'next-flight-server-reference-proxy-loader?id=${id}&name=${name}!'`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,29 +51,40 @@ async function getSourceFrame(
try {
const loc =
input.loc || input.dependencies.map((d: any) => d.loc).filter(Boolean)[0]
const originalSource = input.module.originalSource()

const result = await createOriginalStackFrame({
source: originalSource,
rootDirectory: compilation.options.context!,
modulePath: fileName,
frame: {
arguments: [],
file: fileName,
methodName: '',
lineNumber: loc.start.line,
column: loc.start.column,
},
})

return {
frame: result?.originalCodeFrame ?? '',
lineNumber: result?.originalStackFrame?.lineNumber?.toString() ?? '',
column: result?.originalStackFrame?.column?.toString() ?? '',
const module = input.module as webpack.Module
const originalSource = module.originalSource()
const sourceMap = originalSource?.map() ?? undefined

if (sourceMap) {
const moduleId = compilation.chunkGraph.getModuleId(module)

const result = await createOriginalStackFrame({
source: {
type: 'bundle',
sourceMap,
compilation,
moduleId,
modulePath: fileName,
},
rootDirectory: compilation.options.context!,
frame: {
arguments: [],
file: fileName,
methodName: '',
lineNumber: loc.start.line,
column: loc.start.column,
},
})

return {
frame: result?.originalCodeFrame ?? '',
lineNumber: result?.originalStackFrame?.lineNumber?.toString() ?? '',
column: result?.originalStackFrame?.column?.toString() ?? '',
}
}
} catch {
return { frame: '', lineNumber: '', column: '' }
}
} catch {}

return { frame: '', lineNumber: '', column: '' }
}

function getFormattedFileName(
Expand Down
20 changes: 16 additions & 4 deletions packages/next/src/client/app-find-source-map-url.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
// TODO: Will be implemented later.
export function findSourceMapURL(_filename: string): string | null {
return null
}
const basePath = process.env.__NEXT_ROUTER_BASEPATH || ''
const pathname = `${basePath}/__nextjs_source-map`

export const findSourceMapURL =
process.env.NODE_ENV === 'development'
? function findSourceMapURL(filename: string): string | null {
const url = new URL(pathname, document.location.origin)

url.searchParams.set(
'filename',
filename.replace(new RegExp(`^${document.location.origin}`), '')
)

return url.href
}
: undefined
4 changes: 1 addition & 3 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createFromReadableStream } from 'react-server-dom-webpack/client'
import { HeadManagerContext } from '../shared/lib/head-manager-context.shared-runtime'
import { onRecoverableError } from './on-recoverable-error'
import { callServer } from './app-call-server'
import { findSourceMapURL } from './app-find-source-map-url'
import {
type AppRouterActionQueue,
createMutableActionQueue,
Expand All @@ -20,9 +21,6 @@ import type { InitialRSCPayload } from '../server/app-render/types'
import { createInitialRouterState } from './components/router-reducer/create-initial-router-state'
import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runtime'

// Importing from dist so that we can define an alias if needed.
import { findSourceMapURL } from 'next/dist/client/app-find-source-map-url'

/// <reference types="react-dom/experimental" />

const appElement: HTMLElement | Document | null = document
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BuildError } from '../internal/container/BuildError'
import { Errors } from '../internal/container/Errors'
import { StaticIndicator } from '../internal/container/StaticIndicator'
import type { SupportedErrorEvent } from '../internal/container/Errors'
import { parseStack } from '../internal/helpers/parseStack'
import { parseStack } from '../internal/helpers/parse-stack'
import { Base } from '../internal/styles/Base'
import { ComponentStyles } from '../internal/styles/ComponentStyles'
import { CssReset } from '../internal/styles/CssReset'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
ACTION_VERSION_INFO,
useErrorOverlayReducer,
} from '../shared'
import { parseStack } from '../internal/helpers/parseStack'
import { parseStack } from '../internal/helpers/parse-stack'
import ReactDevOverlay from './ReactDevOverlay'
import { useErrorHandler } from '../internal/helpers/use-error-handler'
import { RuntimeErrorHandler } from '../internal/helpers/runtime-error-handler'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {
import { LeftRightDialogHeader } from '../components/LeftRightDialogHeader'
import { Overlay } from '../components/Overlay'
import { Toast } from '../components/Toast'
import { getErrorByType } from '../helpers/getErrorByType'
import type { ReadyRuntimeError } from '../helpers/getErrorByType'
import { getErrorByType } from '../helpers/get-error-by-type'
import type { ReadyRuntimeError } from '../helpers/get-error-by-type'
import { noop as css } from '../helpers/noop-template'
import { CloseIcon } from '../icons/CloseIcon'
import { RuntimeError } from './RuntimeError'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react'
import { CodeFrame } from '../../components/CodeFrame'
import type { ReadyRuntimeError } from '../../helpers/getErrorByType'
import type { ReadyRuntimeError } from '../../helpers/get-error-by-type'
import { noop as css } from '../../helpers/noop-template'
import { groupStackFramesByFramework } from '../../helpers/group-stack-frames-by-framework'
import { GroupedStackFrames } from './GroupedStackFrames'
Expand Down
Loading

0 comments on commit 4907044

Please sign in to comment.