Edge Functions silently falling back to Nodejs Runtime
See original GitHub issueVerify canary release
- I verified that the issue exists in the latest Next.js canary release
Provide environment information
PS C:\Users\drake\GitHub\@saeris\edge-fail> yarn next info
Operating System:
Platform: win32
Arch: x64
Version: Windows 10 Pro
Binaries:
Node: 18.4.0
npm: N/A
Yarn: N/A
pnpm: 7.4.0
Relevant packages:
next: 12.2.3-canary.10
eslint-config-next: N/A
react: 18.2.0
react-dom: 18.2.0
warn - Latest canary version not detected, detected: "12.2.3-canary.10", newest: "12.2.3-canary.9".
Please try the latest canary version (`npm install next@canary`) to confirm the issue still exists before creating a new issue.
Read more - https://nextjs.org/docs/messages/opening-an-issue
What browser are you using? (if relevant)
N/A
How are you deploying your application? (if relevant)
Vercel
Describe the Bug
I first noticed this behavior when upgrading a Nextjs 12.1 project to 12.2, having followed the migration guide to convert my Nodejs API routes to the new Edge function runtime. Quickly I found out that my routes weren’t behaving as expected, as I was getting an error from the following line:
const { searchParams, origin } = new URL(req.url);
Here I am following the recommendation from the middleware migration guide here (Please note, it has been mentioned in the docs that the Edge Runtime and Middleware share the same function signature and runtime API). This pattern is also mentioned in the official Edge Functions docs. This results in a runtime error because req.url
is not a complete url, it’s only the pathname.
error - TypeError [ERR_INVALID_URL]: Invalid URL
at new NodeError (node:internal/errors:388:5)
at URL.onParseError (node:internal/url:564:9)
at new URL (node:internal/url:644:5)
at handler (webpack-internal:///(api)/./src/pages/api/check.ts:27:40)
at Object.apiResolver (C:\Users\drake\GitHub\@saeris\worbik\node_modules\next\dist\server\api-utils\node.js:179:15)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async DevServer.runApi (C:\Users\drake\GitHub\@saeris\worbik\node_modules\next\dist\server\next-server.js:381:9)
at async Object.fn (C:\Users\drake\GitHub\@saeris\worbik\node_modules\next\dist\server\base-server.js:497:37)
at async Router.execute (C:\Users\drake\GitHub\@saeris\worbik\node_modules\next\dist\server\router.js:213:36)
at async DevServer.run (C:\Users\drake\GitHub\@saeris\worbik\node_modules\next\dist\server\base-server.js:616:29) {
input: '/api/check?guess=5inrvareifaodavmlLbaolklbA&challenge=5inrvaeifarodavmlLbaolklbA199',
code: 'ERR_INVALID_URL',
page: '/api/check'
}
This lead me to investigate further and so I put together a minimal edge route to log out the NextRequest
and NextFetchEvent
arguments to see what I was getting back from them:
import type { NextMiddleware } from "next/server";
export const config = {
runtime: `experimental-edge`,
};
const handler: NextMiddleware = (req, event) => {
// req is a NextRequest as the types indicate
// event should be a NextFetchEvent, instead it's a ServerResponse
// eslint-disable-next-line no-console
console.log({ req, event, url: req.url });
return new Response();
};
export default handler;
In the minimal reproduction I’ve provided, running yarn dev
and then visiting localhost:3000/api/edge
will lead to the following to be logged in your terminal:
PS C:\Users\drake\GitHub\@saeris\edge-fail> yarn dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
warn - You have enabled experimental feature (images) in next.config.js.
warn - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk.
event - compiled client and server successfully in 296 ms (153 modules)
wait - compiling /api/edge...
event - compiled successfully in 20 ms (31 modules)
{
req: IncomingMessage {
_readableState: ReadableState {
objectMode: false,
highWaterMark: 16384,
buffer: BufferList { head: null, tail: null, length: 0 },
length: 0,
pipes: [],
flowing: true,
ended: true,
endEmitted: true,
reading: false,
constructed: true,
sync: true,
needReadable: false,
emittedReadable: false,
readableListening: false,
resumeScheduled: false,
errorEmitted: false,
emitClose: true,
autoDestroy: true,
destroyed: true,
errored: null,
closed: true,
closeEmitted: true,
defaultEncoding: 'utf8',
awaitDrainWriters: null,
multiAwaitDrain: false,
readingMore: true,
dataEmitted: false,
decoder: null,
encoding: null,
[Symbol(kPaused)]: false
},
_events: [Object: null prototype] {},
_eventsCount: 0,
_maxListeners: undefined,
socket: Socket {
connecting: false,
_hadError: false,
_parent: null,
_host: null,
_readableState: [ReadableState],
_events: [Object: null prototype],
_eventsCount: 8,
_maxListeners: undefined,
_writableState: [WritableState],
allowHalfOpen: true,
_sockname: null,
_pendingData: null,
_pendingEncoding: '',
server: [Server],
_server: [Server],
parser: [HTTPParser],
on: [Function: socketListenerWrap],
addListener: [Function: socketListenerWrap],
prependListener: [Function: socketListenerWrap],
setEncoding: [Function: socketSetEncoding],
_paused: false,
_httpMessage: [ServerResponse],
[Symbol(async_id_symbol)]: 2744,
[Symbol(kHandle)]: [TCP],
[Symbol(lastWriteQueueSize)]: 0,
[Symbol(timeout)]: null,
[Symbol(kBuffer)]: null,
[Symbol(kBufferCb)]: null,
[Symbol(kBufferGen)]: null,
[Symbol(kCapture)]: false,
[Symbol(kSetNoDelay)]: false,
[Symbol(kSetKeepAlive)]: false,
[Symbol(kSetKeepAliveInitialDelay)]: 0,
[Symbol(kBytesRead)]: 0,
[Symbol(kBytesWritten)]: 0
},
httpVersionMajor: 1,
httpVersionMinor: 1,
httpVersion: '1.1',
complete: true,
rawHeaders: [
'Host',
'localhost:3000',
'Connection',
'keep-alive',
'Cache-Control',
'max-age=0',
'sec-ch-ua',
'" Not A;Brand";v="99", "Chromium";v="102", "Microsoft Edge";v="102"',
'sec-ch-ua-mobile',
'?0',
'sec-ch-ua-platform',
'"Windows"',
'DNT',
'1',
'Upgrade-Insecure-Requests',
'1',
'User-Agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.44',
'Accept',
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Sec-Fetch-Site',
'none',
'Sec-Fetch-Mode',
'navigate',
'Sec-Fetch-User',
'?1',
'Sec-Fetch-Dest',
'document',
'Accept-Encoding',
'gzip, deflate, br',
'Accept-Language',
'en-US,en;q=0.9'
],
rawTrailers: [],
aborted: false,
upgrade: false,
url: '/api/edge',
method: 'GET',
statusCode: null,
statusMessage: null,
client: Socket {
connecting: false,
_hadError: false,
_parent: null,
_host: null,
_readableState: [ReadableState],
_events: [Object: null prototype],
_eventsCount: 8,
_maxListeners: undefined,
_writableState: [WritableState],
allowHalfOpen: true,
_sockname: null,
_pendingData: null,
_pendingEncoding: '',
server: [Server],
_server: [Server],
parser: [HTTPParser],
on: [Function: socketListenerWrap],
addListener: [Function: socketListenerWrap],
prependListener: [Function: socketListenerWrap],
setEncoding: [Function: socketSetEncoding],
_paused: false,
_httpMessage: [ServerResponse],
[Symbol(async_id_symbol)]: 2744,
[Symbol(kHandle)]: [TCP],
[Symbol(lastWriteQueueSize)]: 0,
[Symbol(timeout)]: null,
[Symbol(kBuffer)]: null,
[Symbol(kBufferCb)]: null,
[Symbol(kBufferGen)]: null,
[Symbol(kCapture)]: false,
[Symbol(kSetNoDelay)]: false,
[Symbol(kSetKeepAlive)]: false,
[Symbol(kSetKeepAliveInitialDelay)]: 0,
[Symbol(kBytesRead)]: 0,
[Symbol(kBytesWritten)]: 0
},
_consuming: false,
_dumped: false,
cookies: [Getter/Setter],
query: {},
previewData: [Getter/Setter],
preview: [Getter/Setter],
body: '',
[Symbol(kCapture)]: false,
[Symbol(kHeaders)]: {
host: 'localhost:3000',
connection: 'keep-alive',
'cache-control': 'max-age=0',
'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="102", "Microsoft Edge";v="102"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
dnt: '1',
'upgrade-insecure-requests': '1',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.124 Safari/537.36 Edg/102.0.1245.44',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'sec-fetch-site': 'none',
'sec-fetch-mode': 'navigate',
'sec-fetch-user': '?1',
'sec-fetch-dest': 'document',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'en-US,en;q=0.9'
},
[Symbol(kHeadersCount)]: 32,
[Symbol(kTrailers)]: null,
[Symbol(kTrailersCount)]: 0,
[Symbol(NextRequestMeta)]: {
__NEXT_INIT_URL: 'http://localhost:3000/api/edge',
__NEXT_INIT_QUERY: {},
_protocol: 'http',
__nextHadTrailingSlash: false,
__nextIsLocaleDomain: false
}
},
event: <ref *1> ServerResponse {
_events: [Object: null prototype] {
finish: [Function: bound resOnFinish],
pipe: [Function]
},
_eventsCount: 2,
_maxListeners: undefined,
outputData: [],
outputSize: 0,
writable: true,
destroyed: false,
_last: false,
chunkedEncoding: false,
shouldKeepAlive: true,
maxRequestsOnConnectionReached: false,
_defaultKeepAlive: true,
useChunkedEncodingByDefault: true,
sendDate: true,
_removedConnection: false,
_removedContLen: false,
_removedTE: false,
_contentLength: null,
_hasBody: true,
_trailer: '',
finished: false,
_headerSent: false,
_closed: false,
socket: Socket {
connecting: false,
_hadError: false,
_parent: null,
_host: null,
_readableState: [ReadableState],
_events: [Object: null prototype],
_eventsCount: 8,
_maxListeners: undefined,
_writableState: [WritableState],
allowHalfOpen: true,
_sockname: null,
_pendingData: null,
_pendingEncoding: '',
server: [Server],
_server: [Server],
parser: [HTTPParser],
on: [Function: socketListenerWrap],
addListener: [Function: socketListenerWrap],
prependListener: [Function: socketListenerWrap],
setEncoding: [Function: socketSetEncoding],
_paused: false,
_httpMessage: [Circular *1],
[Symbol(async_id_symbol)]: 2744,
[Symbol(kHandle)]: [TCP],
[Symbol(lastWriteQueueSize)]: 0,
[Symbol(timeout)]: null,
[Symbol(kBuffer)]: null,
[Symbol(kBufferCb)]: null,
[Symbol(kBufferGen)]: null,
[Symbol(kCapture)]: false,
[Symbol(kSetNoDelay)]: false,
[Symbol(kSetKeepAlive)]: false,
[Symbol(kSetKeepAliveInitialDelay)]: 0,
[Symbol(kBytesRead)]: 0,
[Symbol(kBytesWritten)]: 0
},
_header: null,
_keepAliveTimeout: 5000,
_onPendingData: [Function: bound updateOutgoingData],
req: IncomingMessage {
_readableState: [ReadableState],
_events: [Object: null prototype] {},
_eventsCount: 0,
_maxListeners: undefined,
socket: [Socket],
httpVersionMajor: 1,
httpVersionMinor: 1,
httpVersion: '1.1',
complete: true,
rawHeaders: [Array],
rawTrailers: [],
aborted: false,
upgrade: false,
url: '/api/edge',
method: 'GET',
statusCode: null,
statusMessage: null,
client: [Socket],
_consuming: false,
_dumped: false,
cookies: [Getter/Setter],
query: {},
previewData: [Getter/Setter],
preview: [Getter/Setter],
body: '',
[Symbol(kCapture)]: false,
[Symbol(kHeaders)]: [Object],
[Symbol(kHeadersCount)]: 32,
[Symbol(kTrailers)]: null,
[Symbol(kTrailersCount)]: 0,
[Symbol(NextRequestMeta)]: [Object]
},
_sent100: false,
_expect_continue: false,
statusCode: 200,
flush: [Function: flush],
write: [Function (anonymous)],
end: [Function (anonymous)],
on: [Function: on],
writeHead: [Function: writeHead],
status: [Function (anonymous)],
send: [Function (anonymous)],
json: [Function (anonymous)],
redirect: [Function (anonymous)],
setPreviewData: [Function (anonymous)],
clearPreviewData: [Function (anonymous)],
revalidate: [Function (anonymous)],
unstable_revalidate: [Function (anonymous)],
[Symbol(kCapture)]: false,
[Symbol(kNeedDrain)]: false,
[Symbol(corked)]: 0,
[Symbol(kOutHeaders)]: null,
[Symbol(kUniqueHeaders)]: null
},
url: '/api/edge'
}
As you can see, clearly we’re not getting the expected data types here. The above matches what you would expect from the standard Nodejs runtime, where NextRequest
extends IncomingMessage
and NextResponse
extends ServerResponse
. Additionally as you can see, req.url
was logged as /api/edge
, which explains why the code I wrote earlier to extract request parameters resulted in a runtime error.
From here the next steps I tried were to run yarn why next
to determine if I hadn’t actually upgraded to 12.2. Nothing appeared to be wrong there. So to be safe, I nuked my node_modules
, yarn lockfile, etc and reinstalled. Same issue. Another developer suggested I try using another 12.2 feature to verify that I was indeed running it, so I enabled next/future/image
in my next.config.js
, which you can see from the console output above I’m properly getting warned about on startup.
It was from this point I started putting together a minimal, because as far as I can tell I’m doing all I need to in order to opt into using the Edge runtime. With that together, I was able to reproduce this failure on two additional machines (Windows 10 and macOS Monterey) as well as inside of GitHub Codespaces and asking another engineer to confirm.
Expected Behavior
I just want to be able to use the Edge runtime for my API routes and have a clear understanding of why the documented configuration instructions aren’t working. 😭
Link to reproduction
https://github.com/Saeris/edge-fail
To Reproduce
Please refer to the linked repository README for additional details
Steps to Reproduce
- Install deps:
yarn
- Check Nextjs version:
yarn why next
- Start dev server:
yarn dev
- Review console output, should have warning for experimental Nextjs feature (images), verifying the server is ^v12.2.0. Missing is a warning about experimental edge runtime.
- Visit api route: http://localhost:3000/api/edge
- Review console output, should be following shape:
{
req: IncomingMessage {
...
},
event: ServerResponse {
...
},
url: '/api/edge'
}
Issue Analytics
- State:
- Created a year ago
- Comments:5 (2 by maintainers)
Top GitHub Comments
I’d need to see some convincing perf data to change my mind about this one. Everything I’ve read up to now suggests either the opposite is true or that the difference is so small (<1%) between browser environments that it doesn’t matter. The perf cost adds up with the more interpolations there are in your string, so in a case such as this, where there are no interpolations, you’d never see that overhead accrue.
Perf concerns aside, while I may be an outlier here in terms of my preference for backticks, it doesn’t seem unreasonable to me that others could encounter the same problem and be caught unaware of the internal behavior Nextjs has for evaluating config settings. I’m fully aware that they are different data types, but it’s difficult to know exactly when that distinction is going to make a difference, as it so rarely does in this case. From the user perspective, if it’s valid JS then it should just work. At best, it could be inferred that an exported config object will be evaluated as JSON and if it’s not serializable as such, then it could have unexpected behavior.
Of course, documentation would clear up this ambiguity. Might be worth considering, if it does not exist already, adding a
NextAPIConfig
type that mentions this as part of its JSDoc comment. I’d imagine it would rarely get used but that would be a good place to put it. Same goes for any other configs that share this static analysis behavior.I am able to reproduce the bug. I suspect it is caused by Next.js’ built-in static analysis (The
runtime
config is determined at the build time).In the meantime, you can work around it by changing the quote:
Update
https://github.com/vercel/next.js/blob/56e760a20397c4e1b0f16edac895cc62f78428de/packages/next/build/analysis/extract-const-value.ts#L130
I have located the issue. It seems that Next.js is not able to statically extract value from
TemplateLiteral
. I am now implementing a fix, and once the test case passed I will submit a PR.Update
I have submitted a PR.