[edge] improve fetch
stack traces in edge runtime (#44750)
Right now, when doing the following in an Edge API: ```typescript export default async () => { await fetch("https://hello.world"); }; ``` The stack trace generated does not contain the actual line of code that caused the error. This gives a bad developer experience when working with `next dev`. This PR fixes that for this specific use case and adds a test to make sure there's no regression. For `next start`, there's also a small change, that needs to be pushed upstream to `edge-runtime`. In order to run user code in the Edge Runtime, we call `vm.evaluate(code: string)`. However, if we embrace the `options` from the signature of `vm.runInContext(code, ctx, options)`, we can pass in the filename so the stack trace is correct. ## Bug - [ ] Related issues linked using `fixes #number` - [x] Integration tests added - [ ] Errors have a helpful link attached, see [`contributing.md`](https://github.com/vercel/next.js/blob/canary/contributing.md)
This commit is contained in:
parent
8f05c0c07e
commit
35f68bc691
6 changed files with 109 additions and 31 deletions
|
@ -15,6 +15,7 @@ import { pick } from '../../../lib/pick'
|
|||
import { fetchInlineAsset } from './fetch-inline-assets'
|
||||
import type { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin'
|
||||
import { UnwrapPromise } from '../../../lib/coalesced-function'
|
||||
import { runInContext } from 'vm'
|
||||
|
||||
const WEBPACK_HASH_REGEX =
|
||||
/__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g
|
||||
|
@ -218,6 +219,7 @@ Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation`),
|
|||
|
||||
const __fetch = context.fetch
|
||||
context.fetch = async (input, init = {}) => {
|
||||
const callingError = new Error('[internal]')
|
||||
const assetResponse = await fetchInlineAsset({
|
||||
input,
|
||||
assets: options.edgeFunctionEntry.assets,
|
||||
|
@ -238,30 +240,35 @@ Learn More: https://nextjs.org/docs/messages/edge-dynamic-code-evaluation`),
|
|||
init.headers.set(`user-agent`, `Next.js Middleware`)
|
||||
}
|
||||
|
||||
if (typeof input === 'object' && 'url' in input) {
|
||||
return __fetch(input.url, {
|
||||
...pick(input, [
|
||||
'method',
|
||||
'body',
|
||||
'cache',
|
||||
'credentials',
|
||||
'integrity',
|
||||
'keepalive',
|
||||
'mode',
|
||||
'redirect',
|
||||
'referrer',
|
||||
'referrerPolicy',
|
||||
'signal',
|
||||
]),
|
||||
...init,
|
||||
headers: {
|
||||
...Object.fromEntries(input.headers),
|
||||
...Object.fromEntries(init.headers),
|
||||
},
|
||||
})
|
||||
}
|
||||
const response =
|
||||
typeof input === 'object' && 'url' in input
|
||||
? __fetch(input.url, {
|
||||
...pick(input, [
|
||||
'method',
|
||||
'body',
|
||||
'cache',
|
||||
'credentials',
|
||||
'integrity',
|
||||
'keepalive',
|
||||
'mode',
|
||||
'redirect',
|
||||
'referrer',
|
||||
'referrerPolicy',
|
||||
'signal',
|
||||
]),
|
||||
...init,
|
||||
headers: {
|
||||
...Object.fromEntries(input.headers),
|
||||
...Object.fromEntries(init.headers),
|
||||
},
|
||||
})
|
||||
: __fetch(String(input), init)
|
||||
|
||||
return __fetch(String(input), init)
|
||||
return await response.catch((err) => {
|
||||
callingError.message = err.message
|
||||
err.stack = callingError.stack
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
const __Request = context.Request
|
||||
|
@ -343,27 +350,31 @@ export async function getModuleContext(options: ModuleContextOptions): Promise<{
|
|||
paths: Map<string, string>
|
||||
warnedEvals: Set<string>
|
||||
}> {
|
||||
let moduleContext:
|
||||
let lazyModuleContext:
|
||||
| UnwrapPromise<ReturnType<typeof getModuleContextShared>>
|
||||
| undefined
|
||||
|
||||
if (options.useCache) {
|
||||
moduleContext =
|
||||
lazyModuleContext =
|
||||
moduleContexts.get(options.moduleName) ||
|
||||
(await getModuleContextShared(options))
|
||||
}
|
||||
|
||||
if (!moduleContext) {
|
||||
moduleContext = await createModuleContext(options)
|
||||
moduleContexts.set(options.moduleName, moduleContext)
|
||||
if (!lazyModuleContext) {
|
||||
lazyModuleContext = await createModuleContext(options)
|
||||
moduleContexts.set(options.moduleName, lazyModuleContext)
|
||||
}
|
||||
|
||||
const moduleContext = lazyModuleContext
|
||||
|
||||
const evaluateInContext = (filepath: string) => {
|
||||
if (!moduleContext!.paths.has(filepath)) {
|
||||
if (!moduleContext.paths.has(filepath)) {
|
||||
const content = readFileSync(filepath, 'utf-8')
|
||||
try {
|
||||
moduleContext?.runtime.evaluate(content)
|
||||
moduleContext!.paths.set(filepath, content)
|
||||
runInContext(content, moduleContext.runtime.context, {
|
||||
filename: filepath,
|
||||
})
|
||||
moduleContext.paths.set(filepath, content)
|
||||
} catch (error) {
|
||||
if (options.useCache) {
|
||||
moduleContext?.paths.delete(options.moduleName)
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import { createNextDescribe } from 'e2e-utils'
|
||||
import webdriver from 'next-webdriver'
|
||||
import {
|
||||
hasRedbox,
|
||||
getRedboxSource,
|
||||
getRedboxDescription,
|
||||
} from 'next-test-utils'
|
||||
|
||||
createNextDescribe(
|
||||
'fetch failures have good stack traces in edge runtime',
|
||||
{
|
||||
files: __dirname,
|
||||
},
|
||||
({ next, isNextStart, isNextDev }) => {
|
||||
it('when awaiting `fetch` using an unknown domain, stack traces are preserved', async () => {
|
||||
const browser = await webdriver(next.appPort, '/api/unknown-domain')
|
||||
|
||||
if (isNextStart) {
|
||||
expect(next.cliOutput).toMatch(/at.+\/pages\/api\/unknown-domain.js/)
|
||||
} else if (isNextDev) {
|
||||
expect(next.cliOutput).toContain('src/fetcher.js')
|
||||
|
||||
expect(await hasRedbox(browser, true)).toBe(true)
|
||||
const source = await getRedboxSource(browser)
|
||||
|
||||
expect(source).toContain('async function anotherFetcher(...args)')
|
||||
expect(source).toContain(`fetch(...args)`)
|
||||
|
||||
const description = await getRedboxDescription(browser)
|
||||
expect(description).toEqual('TypeError: fetch failed')
|
||||
}
|
||||
})
|
||||
|
||||
it('when returning `fetch` using an unknown domain, stack traces are preserved', async () => {
|
||||
await webdriver(next.appPort, '/api/unknown-domain-no-await')
|
||||
expect(next.cliOutput).toMatch(
|
||||
/at.+\/pages\/api\/unknown-domain-no-await.js/
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
|
@ -0,0 +1,9 @@
|
|||
export const config = { runtime: 'edge' }
|
||||
|
||||
export default async function UnknownDomainEndpoint() {
|
||||
fetch('http://an.unknown.domain.nextjs').catch((err) => {
|
||||
console.error(`stack is:`, err.stack)
|
||||
})
|
||||
|
||||
return new Response('okay.')
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { fetcher } from '../../src/fetcher'
|
||||
|
||||
export const config = { runtime: 'edge' }
|
||||
|
||||
export default async function UnknownDomainEndpoint() {
|
||||
await fetcher('http://an.unknown.domain.nextjs')
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <p>hello world</p>
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export async function fetcher(...args) {
|
||||
return await anotherFetcher(...args)
|
||||
}
|
||||
|
||||
async function anotherFetcher(...args) {
|
||||
return await fetch(...args)
|
||||
}
|
Loading…
Reference in a new issue