[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:
Gal Schlezinger 2023-01-26 12:16:53 +02:00 committed by GitHub
parent 8f05c0c07e
commit 35f68bc691
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 109 additions and 31 deletions

View file

@ -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)

View file

@ -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/
)
})
}
)

View file

@ -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.')
}

View file

@ -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')
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}

View file

@ -0,0 +1,7 @@
export async function fetcher(...args) {
return await anotherFetcher(...args)
}
async function anotherFetcher(...args) {
return await fetch(...args)
}