Skip to content

Commit 08ea8de

Browse files
authored
Migrate app route to handler interface (vercel#80008)
Continuation of vercel#78166 this implements the handler interface for app router route handlers. This does still return the cache entry for ISR route handlers for the incremental cache to handle but response handling is moved into the handler here. Validated against deploy tests vercel/vercel#13413 and https://github.com/vercel/next.js/actions/runs/15359464126/job/43224461919
1 parent 1dcc7fe commit 08ea8de

2 files changed

Lines changed: 327 additions & 137 deletions

File tree

packages/next/src/build/templates/app-route.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
import {
22
AppRouteRouteModule,
3+
type AppRouteRouteHandlerContext,
34
type AppRouteRouteModuleOptions,
45
} from '../../server/route-modules/app-route/module.compiled'
56
import { RouteKind } from '../../server/route-kind'
67
import { patchFetch as _patchFetch } from '../../server/lib/patch-fetch'
78

89
import * as userland from 'VAR_USERLAND'
10+
import {
11+
RouterServerContextSymbol,
12+
routerServerGlobal,
13+
} from '../../server/lib/router-utils/router-server-context'
14+
import type { IncomingMessage, ServerResponse } from 'node:http'
15+
import { getRequestMeta } from '../../server/request-meta'
16+
import { getTracer, type Span, SpanKind } from '../../server/lib/trace/tracer'
17+
import type { ServerOnInstrumentationRequestError } from '../../server/app-render/types'
18+
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
19+
import { NodeNextRequest, NodeNextResponse } from '../../server/base-http/node'
20+
import {
21+
NextRequestAdapter,
22+
signalFromNodeResponse,
23+
} from '../../server/web/spec-extension/adapters/next-request'
24+
import { BaseServerSpan } from '../../server/lib/trace/constants'
25+
import { getRevalidateReason } from '../../server/instrumentation/utils'
26+
import { sendResponse } from '../../server/send-response'
27+
import { toNodeOutgoingHttpHeaders } from '../../server/web/utils'
28+
import { INFINITE_CACHE, NEXT_CACHE_TAGS_HEADER } from '../../lib/constants'
29+
import {
30+
CachedRouteKind,
31+
type ResponseCacheEntry,
32+
} from '../../server/response-cache'
933

1034
// These are injected by the loader afterwards. This is injected as a variable
1135
// instead of a replacement because this could also be `undefined` instead of
@@ -50,3 +74,293 @@ export {
5074
serverHooks,
5175
patchFetch,
5276
}
77+
78+
export async function handler(
79+
req: IncomingMessage,
80+
res: ServerResponse,
81+
ctx: {
82+
waitUntil: (prom: Promise<void>) => void
83+
}
84+
) {
85+
let srcPage = 'VAR_DEFINITION_PAGE'
86+
87+
// turbopack doesn't normalize `/index` in the page name
88+
// so we need to to process dynamic routes properly
89+
// TODO: fix turbopack providing differing value from webpack
90+
if (process.env.TURBOPACK) {
91+
srcPage = srcPage.replace(/\/index$/, '') || '/'
92+
} else if (srcPage === '/index') {
93+
// we always normalize /index specifically
94+
srcPage = '/'
95+
}
96+
const multiZoneDraftMode = process.env
97+
.__NEXT_MULTI_ZONE_DRAFT_MODE as any as boolean
98+
99+
const prepareResult = await routeModule.prepare(req, res, {
100+
srcPage,
101+
multiZoneDraftMode,
102+
})
103+
104+
if (!prepareResult) {
105+
res.statusCode = 400
106+
res.end('Bad Request')
107+
ctx.waitUntil?.(Promise.resolve())
108+
return null
109+
}
110+
111+
const {
112+
buildId,
113+
params,
114+
parsedUrl,
115+
serverFilesManifest,
116+
prerenderManifest,
117+
isOnDemandRevalidate,
118+
} = prepareResult
119+
120+
const routerServerContext =
121+
routerServerGlobal[RouterServerContextSymbol]?.[
122+
process.env.__NEXT_RELATIVE_PROJECT_DIR || ''
123+
]
124+
125+
const onInstrumentationRequestError =
126+
routeModule.instrumentationOnRequestError.bind(routeModule)
127+
128+
const onError: ServerOnInstrumentationRequestError = (
129+
err,
130+
_,
131+
errorContext
132+
) => {
133+
if (routerServerContext?.logErrorWithOriginalStack) {
134+
routerServerContext.logErrorWithOriginalStack(err, 'app-dir')
135+
} else {
136+
console.error(err)
137+
}
138+
return onInstrumentationRequestError(
139+
req,
140+
err,
141+
{
142+
path: req.url || '/',
143+
headers: req.headers,
144+
method: req.method || 'GET',
145+
},
146+
errorContext
147+
)
148+
}
149+
150+
const nextConfig =
151+
routerServerContext?.nextConfig || serverFilesManifest.config
152+
153+
const pathname = parsedUrl.pathname || '/'
154+
const normalizedSrcPage = normalizeAppPath(srcPage)
155+
let isIsr = Boolean(
156+
prerenderManifest.dynamicRoutes[normalizedSrcPage] ||
157+
prerenderManifest.routes[normalizedSrcPage] ||
158+
prerenderManifest.routes[pathname]
159+
)
160+
161+
const supportsDynamicResponse: boolean =
162+
// If we're in development, we always support dynamic HTML
163+
routeModule.isDev === true ||
164+
// If this is not SSG or does not have static paths, then it supports
165+
// dynamic HTML.
166+
!isIsr
167+
168+
// This is a revalidation request if the request is for a static
169+
// page and it is not being resumed from a postponed render and
170+
// it is not a dynamic RSC request then it is a revalidation
171+
// request.
172+
const isRevalidate = isIsr && !supportsDynamicResponse
173+
174+
const method = req.method || 'GET'
175+
const tracer = getTracer()
176+
const activeSpan = tracer.getActiveScopeSpan()
177+
178+
const context: AppRouteRouteHandlerContext = {
179+
params,
180+
prerenderManifest,
181+
renderOpts: {
182+
experimental: {
183+
dynamicIO: Boolean(nextConfig.experimental.dynamicIO),
184+
authInterrupts: Boolean(nextConfig.experimental.authInterrupts),
185+
},
186+
supportsDynamicResponse,
187+
incrementalCache: getRequestMeta(req, 'incrementalCache'),
188+
cacheLifeProfiles: nextConfig.experimental?.cacheLife,
189+
isRevalidate,
190+
waitUntil: ctx.waitUntil,
191+
onClose: (cb) => {
192+
res.on('close', cb)
193+
},
194+
onAfterTaskError: undefined,
195+
onInstrumentationRequestError: onError,
196+
},
197+
sharedContext: {
198+
buildId,
199+
},
200+
}
201+
const nodeNextReq = new NodeNextRequest(req)
202+
const nodeNextRes = new NodeNextResponse(res)
203+
const nextReq = NextRequestAdapter.fromNodeNextRequest(
204+
nodeNextReq,
205+
signalFromNodeResponse(res)
206+
)
207+
208+
try {
209+
const invokeRouteModule = async (span?: Span) => {
210+
return routeModule.handle(nextReq, context).finally(() => {
211+
if (!span) return
212+
213+
span.setAttributes({
214+
'http.status_code': res.statusCode,
215+
'next.rsc': false,
216+
})
217+
218+
const rootSpanAttributes = tracer.getRootSpanAttributes()
219+
// We were unable to get attributes, probably OTEL is not enabled
220+
if (!rootSpanAttributes) {
221+
return
222+
}
223+
224+
if (
225+
rootSpanAttributes.get('next.span_type') !==
226+
BaseServerSpan.handleRequest
227+
) {
228+
console.warn(
229+
`Unexpected root span type '${rootSpanAttributes.get(
230+
'next.span_type'
231+
)}'. Please report this Next.js issue https://github.com/vercel/next.js`
232+
)
233+
return
234+
}
235+
236+
const route = rootSpanAttributes.get('next.route')
237+
if (route) {
238+
const name = `${method} ${route}`
239+
240+
span.setAttributes({
241+
'next.route': route,
242+
'http.route': route,
243+
'next.span_name': name,
244+
})
245+
span.updateName(name)
246+
} else {
247+
span.updateName(`${method} ${req.url}`)
248+
}
249+
})
250+
}
251+
252+
let response: Response
253+
254+
// TODO: activeSpan code path is for when wrapped by
255+
// next-server can be removed when this is no longer used
256+
if (activeSpan) {
257+
response = await invokeRouteModule(activeSpan)
258+
} else {
259+
response = await tracer.withPropagatedContext(req.headers, () =>
260+
tracer.trace(
261+
BaseServerSpan.handleRequest,
262+
{
263+
spanName: `${method} ${req.url}`,
264+
kind: SpanKind.SERVER,
265+
attributes: {
266+
'http.method': method,
267+
'http.target': req.url,
268+
},
269+
},
270+
invokeRouteModule
271+
)
272+
)
273+
}
274+
275+
;(req as any).fetchMetrics = (context.renderOpts as any).fetchMetrics
276+
277+
const cacheTags = context.renderOpts.collectedTags
278+
279+
// If the request is for a static response, we can cache it so long
280+
// as it's not edge.
281+
if (isIsr) {
282+
const blob = await response.blob()
283+
284+
// Copy the headers from the response.
285+
const headers = toNodeOutgoingHttpHeaders(response.headers)
286+
287+
if (cacheTags) {
288+
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
289+
}
290+
291+
if (!headers['content-type'] && blob.type) {
292+
headers['content-type'] = blob.type
293+
}
294+
295+
const revalidate =
296+
typeof context.renderOpts.collectedRevalidate === 'undefined' ||
297+
context.renderOpts.collectedRevalidate >= INFINITE_CACHE
298+
? false
299+
: context.renderOpts.collectedRevalidate
300+
301+
const expire =
302+
typeof context.renderOpts.collectedExpire === 'undefined' ||
303+
context.renderOpts.collectedExpire >= INFINITE_CACHE
304+
? undefined
305+
: context.renderOpts.collectedExpire
306+
307+
// Create the cache entry for the response.
308+
const cacheEntry: ResponseCacheEntry = {
309+
value: {
310+
kind: CachedRouteKind.APP_ROUTE,
311+
status: response.status,
312+
body: Buffer.from(await blob.arrayBuffer()),
313+
headers,
314+
},
315+
cacheControl: { revalidate, expire },
316+
}
317+
318+
return cacheEntry
319+
}
320+
let pendingWaitUntil = context.renderOpts.pendingWaitUntil
321+
322+
// Attempt using provided waitUntil if available
323+
// if it's not we fallback to sendResponse's handling
324+
if (pendingWaitUntil) {
325+
if (context.renderOpts.waitUntil) {
326+
context.renderOpts.waitUntil(pendingWaitUntil)
327+
pendingWaitUntil = undefined
328+
}
329+
}
330+
331+
// Send the response now that we have copied it into the cache.
332+
await sendResponse(
333+
nodeNextReq,
334+
nodeNextRes,
335+
response,
336+
context.renderOpts.pendingWaitUntil
337+
)
338+
return null
339+
} catch (err) {
340+
// if we aren't wrapped by base-server handle here
341+
if (!activeSpan) {
342+
await onError(err, req, {
343+
routerKind: 'App Router',
344+
routePath: normalizedSrcPage,
345+
routeType: 'route',
346+
revalidateReason: getRevalidateReason({
347+
isRevalidate,
348+
isOnDemandRevalidate,
349+
}),
350+
})
351+
}
352+
353+
// rethrow so that we can handle serving error page
354+
355+
// If this is during static generation, throw the error again.
356+
if (isIsr) throw err
357+
358+
// Otherwise, send a 500 response.
359+
await sendResponse(
360+
nodeNextReq,
361+
nodeNextRes,
362+
new Response(null, { status: 500 })
363+
)
364+
return null
365+
}
366+
}

0 commit comments

Comments
 (0)