Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ssr): better DX with sourcemaps, breakpoints, error messages #3928

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"//": "READ .github/contributing.md to understand what to put under deps vs. devDeps!",
"dependencies": {
"@babel/code-frame": "^7.14.5",
"esbuild": "^0.12.8",
"postcss": "^8.3.4",
"resolve": "^1.20.0",
Expand All @@ -65,6 +66,7 @@
"@rollup/plugin-node-resolve": "13.0.0",
"@rollup/plugin-typescript": "^8.2.1",
"@rollup/pluginutils": "^4.1.0",
"@types/babel__code-frame": "^7.0.2",
"@types/clean-css": "^4.2.4",
"@types/convert-source-map": "^1.5.1",
"@types/debug": "^4.1.5",
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/node/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ export async function createServer(
},
ssrFixStacktrace(e) {
if (e.stack) {
e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
e.stack = ssrRewriteStacktrace(e, moduleGraph)
}
},
listen(port?: number, isRestart?: boolean) {
Expand Down
21 changes: 15 additions & 6 deletions packages/vite/src/node/server/sourcemap.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { promises as fs } from 'fs'
import path from 'path'
import { ModuleGraph } from './moduleGraph'

export async function injectSourcesContent(
map: { sources: string[]; sourcesContent?: string[]; sourceRoot?: string },
file: string
file: string,
moduleGraph?: ModuleGraph
): Promise<void> {
const sourceRoot = await fs.realpath(
path.resolve(path.dirname(file), map.sourceRoot || '')
)
map.sourcesContent = []
const needsContent = !map.sourcesContent
if (needsContent) {
map.sourcesContent = []
}
await Promise.all(
map.sources.filter(Boolean).map(async (sourcePath, i) => {
map.sourcesContent![i] = await fs.readFile(
path.resolve(sourceRoot, decodeURI(sourcePath)),
'utf-8'
)
const mod = await moduleGraph?.getModuleByUrl(sourcePath)
sourcePath = mod?.file || path.resolve(sourceRoot, decodeURI(sourcePath))
if (moduleGraph) {
map.sources[i] = sourcePath
}
if (needsContent) {
map.sourcesContent![i] = await fs.readFile(sourcePath, 'utf-8')
}
})
)
}
3 changes: 2 additions & 1 deletion packages/vite/src/node/server/transformRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ export async function transformRequest(
return (mod.ssrTransformResult = await ssrTransform(
code,
map as SourceMap,
url
url,
config.isProduction
))
} else {
return (mod.transformResult = {
Expand Down
76 changes: 49 additions & 27 deletions packages/vite/src/node/ssr/ssrModuleLoader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'fs'
import path from 'path'
import * as convertSourceMap from 'convert-source-map'
import { ViteDevServer } from '..'
import { cleanUrl, resolveFrom, unwrapId } from '../utils'
import { ssrRewriteStacktrace } from './ssrStacktrace'
Expand All @@ -11,6 +12,7 @@ import {
ssrDynamicImportKey
} from './ssrTransform'
import { transformRequest } from '../server/transformRequest'
import { injectSourcesContent } from '../server/sourcemap'

interface SSRContext {
global: NodeJS.Global
Expand Down Expand Up @@ -86,19 +88,19 @@ async function instantiateModule(
})
)

const ssrImportMeta = { url }
const { clearScreen, isProduction, logger, root } = server.config

const ssrImport = (dep: string) => {
if (isExternal(dep)) {
return nodeRequire(dep, mod.file, server.config.root)
return nodeRequire(dep, mod.file, root)
} else {
return moduleGraph.urlToModuleMap.get(unwrapId(dep))?.ssrModule
}
}

const ssrDynamicImport = (dep: string) => {
if (isExternal(dep)) {
return Promise.resolve(nodeRequire(dep, mod.file, server.config.root))
return Promise.resolve(nodeRequire(dep, mod.file, root))
} else {
// #3087 dynamic import vars is ignored at rewrite import path,
// so here need process relative path
Expand All @@ -123,32 +125,52 @@ async function instantiateModule(
}
}

const ssrImportMeta = { url }
const ssrArguments: Record<string, any> = {
global: context.global,
[ssrModuleExportsKey]: ssrModule,
[ssrImportMetaKey]: ssrImportMeta,
[ssrImportKey]: ssrImport,
[ssrDynamicImportKey]: ssrDynamicImport,
[ssrExportAllKey]: ssrExportAll
}

let ssrModuleImpl = isProduction
? result.code + `\n//# sourceURL=${mod.url}`
: `(0,function(${Object.keys(ssrArguments)}){\n${result.code}\n})`

const { map } = result
if (map?.mappings) {
if (mod.file) {
await injectSourcesContent(map, mod.file, moduleGraph)
Copy link
Member Author

@aleclarson aleclarson Jun 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly not sure if this call is needed in production. Someone needs to test with and without to see the difference (as it may affect stack traces?). Otherwise, in development, this ensures that changes to local modules will not appear while debugging an earlier version of a module.

}

ssrModuleImpl += `\n` + convertSourceMap.fromObject(map).toComment()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline sourcemap is appended in both production and development, as new Function still handles sourcemaps somewhat, but vm.runInThisContext actually respects breakpoints.

}

try {
new Function(
`global`,
ssrModuleExportsKey,
ssrImportMetaKey,
ssrImportKey,
ssrDynamicImportKey,
ssrExportAllKey,
result.code + `\n//# sourceURL=${mod.url}`
)(
context.global,
ssrModule,
ssrImportMeta,
ssrImport,
ssrDynamicImport,
ssrExportAll
)
let ssrModuleInit: Function
if (isProduction) {
// Use the faster `new Function` in production.
ssrModuleInit = new Function(...Object.keys(ssrArguments), ssrModuleImpl)
} else {
// Use the slower `vm.runInThisContext` for better sourcemap support.
const vm = require('vm') as typeof import('vm')
ssrModuleInit = vm.runInThisContext(ssrModuleImpl, {
filename: mod.file || mod.url,
columnOffset: 1,
displayErrors: false
})
}
ssrModuleInit(...Object.values(ssrArguments))
} catch (e) {
e.stack = ssrRewriteStacktrace(e.stack, moduleGraph)
server.config.logger.error(
`Error when evaluating SSR module ${url}:\n${e.stack}`,
{
timestamp: true,
clear: server.config.clearScreen
}
)
try {
e.stack = ssrRewriteStacktrace(e, moduleGraph)
} catch {}
logger.error(`Error when evaluating SSR module ${url}:\n\n${e.stack}`, {
timestamp: true,
clear: clearScreen
})
throw e
}

Expand Down
79 changes: 47 additions & 32 deletions packages/vite/src/node/ssr/ssrStacktrace.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,73 @@
import { codeFrameColumns, SourceLocation } from '@babel/code-frame'
import { SourceMapConsumer, RawSourceMap } from 'source-map'
import { ModuleGraph } from '../server/moduleGraph'
import fs from 'fs'

let offset: number
try {
new Function('throw new Error(1)')()
} catch (e) {
// in Node 12, stack traces account for the function wrapper.
// in Node 13 and later, the function wrapper adds two lines,
// which must be subtracted to generate a valid mapping
const match = /:(\d+):\d+\)$/.exec(e.stack.split('\n')[1])
offset = match ? +match[1] - 1 : 0
}
const stackFrameRE = /^ {4}at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?)\)?/

export function ssrRewriteStacktrace(
stack: string,
error: Error,
moduleGraph: ModuleGraph
): string {
return stack
.split('\n')
.map((line) => {
return line.replace(
/^ {4}at (?:(.+?)\s+\()?(?:(.+?):(\d+)(?::(\d+))?)\)?/,
(input, varName, url, line, column) => {
if (!url) return input

const mod = moduleGraph.urlToModuleMap.get(url)
const rawSourceMap = mod?.ssrTransformResult?.map

if (!rawSourceMap) {
return input
}
let code!: string
let location: SourceLocation | undefined

const stackFrames = error
.stack!.split('\n')
.slice(error.message.split('\n').length)
.map((line, i) => {
return line.replace(stackFrameRE, (input, varName, url, line, column) => {
if (!url) return input

const mod = moduleGraph.urlToModuleMap.get(url)
const rawSourceMap = mod?.ssrTransformResult?.map

if (rawSourceMap) {
const consumer = new SourceMapConsumer(
rawSourceMap as any as RawSourceMap
)

const pos = consumer.originalPositionFor({
line: Number(line) - offset,
line: Number(line),
column: Number(column),
bias: SourceMapConsumer.LEAST_UPPER_BOUND
bias: SourceMapConsumer.GREATEST_LOWER_BOUND
})

if (!pos.source) {
return input
if (pos.source) {
url = pos.source
line = pos.line
column = pos.column
}
}

if (i == 0 && fs.existsSync(url)) {
code = fs.readFileSync(url, 'utf8')
location = {
start: {
line: Number(line),
column: Number(column)
}
}
}

const source = `${pos.source}:${pos.line || 0}:${pos.column || 0}`
if (rawSourceMap) {
const source = `${url}:${line}:${column}`
if (!varName || varName === 'eval') {
return ` at ${source}`
} else {
return ` at ${varName} (${source})`
}
}
)
return input
})
})
.join('\n')

const message = location
? codeFrameColumns(code, location, {
highlightCode: true,
message: error.message
})
: error.message

return message + '\n\n' + stackFrames.join('\n')
}
43 changes: 31 additions & 12 deletions packages/vite/src/node/ssr/ssrTransform.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { removeMapFileComments } from 'convert-source-map'
import MagicString from 'magic-string'
import { SourceMap } from 'rollup'
import { TransformResult } from '../server/transformRequest'
Expand All @@ -24,12 +25,32 @@ export const ssrDynamicImportKey = `__vite_ssr_dynamic_import__`
export const ssrExportAllKey = `__vite_ssr_exportAll__`
export const ssrImportMetaKey = `__vite_ssr_import_meta__`

let offset: number
try {
new Function('throw new Error(1)')()
} catch (e) {
// in Node 12, stack traces account for the function wrapper.
// in Node 13 and later, the function wrapper adds two lines,
// which must be subtracted to generate a valid mapping
const match = /:(\d+):\d+\)$/.exec(e.stack.split('\n')[1])
offset = match ? +match[1] - 1 : 0
}

export async function ssrTransform(
code: string,
inMap: SourceMap | null,
url: string
url: string,
isProduction: boolean
): Promise<TransformResult | null> {
const s = new MagicString(code)
const s = new MagicString(removeMapFileComments(code))

// SSR modules are wrapped with `new Function()` before they're executed,
// so we need to shift the line mappings. These empty lines are removed
// before the module is wrapped.
const lineOffset = isProduction ? offset : 1
if (lineOffset > 0) {
s.prependLeft(0, '\n'.repeat(lineOffset))
}

const ast = parser.parse(code, {
sourceType: 'module',
Expand Down Expand Up @@ -178,23 +199,21 @@ export async function ssrTransform(
}
})

let map = s.generateMap({ hires: true })
let map = s.generateMap({
hires: true,
source: url,
includeContent: true
})

if (inMap && inMap.mappings && inMap.sources.length > 0) {
map = combineSourcemaps(url, [
{
...map,
sources: inMap.sources,
sourcesContent: inMap.sourcesContent
} as RawSourceMap,
map as RawSourceMap,
inMap as RawSourceMap
]) as SourceMap
} else {
map.sources = [url]
map.sourcesContent = [code]
}

return {
code: s.toString(),
code: s.toString().slice(lineOffset),
map,
deps: [...deps]
}
Expand Down