Skip to content

Commit f2bf3c2

Browse files
committed
Add response handling in app route handler
1 parent 25ad33d commit f2bf3c2

File tree

4 files changed

+237
-136
lines changed

4 files changed

+237
-136
lines changed

packages/next/errors.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,5 +699,10 @@
699699
"698": "Next DevTools: Can't dispatch %s in this environment. This is a bug in Next.js",
700700
"699": "Next DevTools: App Dev Overlay is already mounted. This is a bug in Next.js",
701701
"700": "Next DevTools: Pages Dev Overlay is already mounted. This is a bug in Next.js",
702-
"701": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload."
702+
"701": "Invariant: app-route received invalid cache entry %s",
703+
"702": "Invariant: unexpected APP_ROUTE cache data",
704+
"703": "Route is configured with dynamic = error which cannot be statically generated.",
705+
"704": "Route is configured with dynamic = error be statically generated.",
706+
"705": "Route is configured with dynamic = error that cannot be statically generated.",
707+
"706": "Failed to persist Chrome DevTools workspace UUID. The Chrome DevTools Workspace needs to be reconnected after the next page reload."
703708
}

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

Lines changed: 215 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { patchFetch as _patchFetch } from '../../server/lib/patch-fetch'
88
import type { IncomingMessage, ServerResponse } from 'node:http'
99
import { getRequestMeta } from '../../server/request-meta'
1010
import { getTracer, type Span, SpanKind } from '../../server/lib/trace/tracer'
11-
import type { ServerOnInstrumentationRequestError } from '../../server/app-render/types'
1211
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
1312
import { NodeNextRequest, NodeNextResponse } from '../../server/base-http/node'
1413
import {
@@ -18,11 +17,17 @@ import {
1817
import { BaseServerSpan } from '../../server/lib/trace/constants'
1918
import { getRevalidateReason } from '../../server/instrumentation/utils'
2019
import { sendResponse } from '../../server/send-response'
21-
import { toNodeOutgoingHttpHeaders } from '../../server/web/utils'
20+
import {
21+
fromNodeOutgoingHttpHeaders,
22+
toNodeOutgoingHttpHeaders,
23+
} from '../../server/web/utils'
24+
import { decodePathParams } from '../../server/lib/router-utils/decode-path-params'
25+
import { getCacheControlHeader } from '../../server/lib/cache-control'
2226
import { INFINITE_CACHE, NEXT_CACHE_TAGS_HEADER } from '../../lib/constants'
2327
import {
2428
CachedRouteKind,
2529
type ResponseCacheEntry,
30+
type ResponseGenerator,
2631
} from '../../server/response-cache'
2732

2833
import * as userland from 'VAR_USERLAND'
@@ -107,46 +112,43 @@ export async function handler(
107112
const {
108113
buildId,
109114
params,
110-
parsedUrl,
111115
nextConfig,
116+
parsedUrl,
112117
prerenderManifest,
113118
routerServerContext,
114119
isOnDemandRevalidate,
120+
revalidateOnlyGenerated,
115121
} = prepareResult
116122

117-
const onInstrumentationRequestError =
118-
routeModule.instrumentationOnRequestError.bind(routeModule)
123+
const normalizedSrcPage = normalizeAppPath(srcPage)
119124

120-
const onError: ServerOnInstrumentationRequestError = (
121-
err,
122-
_,
123-
errorContext
124-
) => {
125-
if (routerServerContext?.logErrorWithOriginalStack) {
126-
routerServerContext.logErrorWithOriginalStack(err, 'app-dir')
127-
} else {
128-
console.error(err)
129-
}
130-
return onInstrumentationRequestError(
131-
req,
132-
err,
133-
{
134-
path: req.url || '/',
135-
headers: req.headers,
136-
method: req.method || 'GET',
137-
},
138-
errorContext
139-
)
125+
// TODO: rework this to not be necessary as a middleware
126+
// rewrite should not need to pass this context like this
127+
// maybe we rely on rewrite header instead
128+
let resolvedPathname = getRequestMeta(req, 'rewroteURL')
129+
130+
if (!resolvedPathname) {
131+
resolvedPathname = parsedUrl.pathname || '/'
140132
}
141133

142-
const pathname = parsedUrl.pathname || '/'
143-
const normalizedSrcPage = normalizeAppPath(srcPage)
134+
if (resolvedPathname === '/index') {
135+
resolvedPathname = '/'
136+
}
137+
resolvedPathname = decodePathParams(resolvedPathname)
138+
144139
let isIsr = Boolean(
145140
prerenderManifest.dynamicRoutes[normalizedSrcPage] ||
146-
prerenderManifest.routes[normalizedSrcPage] ||
147-
prerenderManifest.routes[pathname]
141+
prerenderManifest.routes[resolvedPathname]
148142
)
149143

144+
let cacheKey: string | null = null
145+
146+
if (isIsr && !routeModule.isDev) {
147+
cacheKey = resolvedPathname
148+
// ensure /index and / is normalized to one key
149+
cacheKey = cacheKey === '/index' ? '/' : cacheKey
150+
}
151+
150152
const supportsDynamicResponse: boolean =
151153
// If we're in development, we always support dynamic HTML
152154
routeModule.isDev === true ||
@@ -181,7 +183,13 @@ export async function handler(
181183
res.on('close', cb)
182184
},
183185
onAfterTaskError: undefined,
184-
onInstrumentationRequestError: onError,
186+
onInstrumentationRequestError: (error, _request, errorContext) =>
187+
routeModule.onRequestError(
188+
req,
189+
error,
190+
errorContext,
191+
routerServerContext
192+
),
185193
},
186194
sharedContext: {
187195
buildId,
@@ -238,14 +246,186 @@ export async function handler(
238246
})
239247
}
240248

241-
let response: Response
249+
const handleResponse = async (currentSpan?: Span) => {
250+
const responseGenerator: ResponseGenerator = async ({
251+
previousCacheEntry,
252+
}) => {
253+
try {
254+
if (
255+
!getRequestMeta(req, 'minimalMode') &&
256+
isOnDemandRevalidate &&
257+
revalidateOnlyGenerated &&
258+
!previousCacheEntry
259+
) {
260+
res.statusCode = 404
261+
// on-demand revalidate always sets this header
262+
res.setHeader('x-nextjs-cache', 'REVALIDATED')
263+
res.end('This page could not be found')
264+
return null
265+
}
266+
267+
const response = await invokeRouteModule(currentSpan)
268+
269+
;(req as any).fetchMetrics = (context.renderOpts as any).fetchMetrics
270+
let pendingWaitUntil = context.renderOpts.pendingWaitUntil
271+
272+
// Attempt using provided waitUntil if available
273+
// if it's not we fallback to sendResponse's handling
274+
if (pendingWaitUntil) {
275+
if (context.renderOpts.waitUntil) {
276+
context.renderOpts.waitUntil(pendingWaitUntil)
277+
pendingWaitUntil = undefined
278+
}
279+
}
280+
const cacheTags = context.renderOpts.collectedTags
281+
282+
// If the request is for a static response, we can cache it so long
283+
// as it's not edge.
284+
if (isIsr) {
285+
const blob = await response.blob()
286+
287+
// Copy the headers from the response.
288+
const headers = toNodeOutgoingHttpHeaders(response.headers)
289+
290+
if (cacheTags) {
291+
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
292+
}
293+
294+
if (!headers['content-type'] && blob.type) {
295+
headers['content-type'] = blob.type
296+
}
297+
298+
const revalidate =
299+
typeof context.renderOpts.collectedRevalidate === 'undefined' ||
300+
context.renderOpts.collectedRevalidate >= INFINITE_CACHE
301+
? false
302+
: context.renderOpts.collectedRevalidate
303+
304+
const expire =
305+
typeof context.renderOpts.collectedExpire === 'undefined' ||
306+
context.renderOpts.collectedExpire >= INFINITE_CACHE
307+
? undefined
308+
: context.renderOpts.collectedExpire
309+
310+
// Create the cache entry for the response.
311+
const cacheEntry: ResponseCacheEntry = {
312+
value: {
313+
kind: CachedRouteKind.APP_ROUTE,
314+
status: response.status,
315+
body: Buffer.from(await blob.arrayBuffer()),
316+
headers,
317+
},
318+
cacheControl: { revalidate, expire },
319+
}
320+
321+
return cacheEntry
322+
} else {
323+
// send response without caching if not ISR
324+
await sendResponse(
325+
nodeNextReq,
326+
nodeNextRes,
327+
response,
328+
context.renderOpts.pendingWaitUntil
329+
)
330+
return null
331+
}
332+
} catch (err) {
333+
// if this is a background revalidate we need to report
334+
// the request error here as it won't be bubbled
335+
if (previousCacheEntry?.isStale) {
336+
await routeModule.onRequestError(
337+
req,
338+
err,
339+
{
340+
routerKind: 'App Router',
341+
routePath: srcPage,
342+
routeType: 'route',
343+
revalidateReason: getRevalidateReason({
344+
isRevalidate,
345+
isOnDemandRevalidate,
346+
}),
347+
},
348+
routerServerContext
349+
)
350+
}
351+
throw err
352+
}
353+
}
354+
355+
const cacheEntry = await routeModule.handleResponse({
356+
req,
357+
nextConfig,
358+
cacheKey,
359+
routeKind: RouteKind.APP_ROUTE,
360+
isFallback: false,
361+
prerenderManifest,
362+
isRoutePPREnabled: false,
363+
isOnDemandRevalidate,
364+
revalidateOnlyGenerated,
365+
responseGenerator,
366+
waitUntil: ctx.waitUntil,
367+
})
368+
369+
// we don't create a cacheEntry for ISR
370+
if (!isIsr) {
371+
return null
372+
}
373+
374+
if (cacheEntry?.value?.kind !== CachedRouteKind.APP_ROUTE) {
375+
throw new Error(
376+
`Invariant: app-route received invalid cache entry ${cacheEntry?.value?.kind}`
377+
)
378+
}
379+
380+
if (!getRequestMeta(req, 'minimalMode')) {
381+
res.setHeader(
382+
'x-nextjs-cache',
383+
isOnDemandRevalidate
384+
? 'REVALIDATED'
385+
: cacheEntry.isMiss
386+
? 'MISS'
387+
: cacheEntry.isStale
388+
? 'STALE'
389+
: 'HIT'
390+
)
391+
}
392+
393+
const headers = fromNodeOutgoingHttpHeaders(cacheEntry.value.headers)
394+
395+
if (!(getRequestMeta(req, 'minimalMode') && isIsr)) {
396+
headers.delete(NEXT_CACHE_TAGS_HEADER)
397+
}
398+
399+
// If cache control is already set on the response we don't
400+
// override it to allow users to customize it via next.config
401+
if (
402+
cacheEntry.cacheControl &&
403+
!res.getHeader('Cache-Control') &&
404+
!headers.get('Cache-Control')
405+
) {
406+
headers.set(
407+
'Cache-Control',
408+
getCacheControlHeader(cacheEntry.cacheControl)
409+
)
410+
}
411+
412+
await sendResponse(
413+
nodeNextReq,
414+
nodeNextRes,
415+
new Response(cacheEntry.value.body, {
416+
headers,
417+
status: cacheEntry.value.status || 200,
418+
})
419+
)
420+
return null
421+
}
242422

243423
// TODO: activeSpan code path is for when wrapped by
244424
// next-server can be removed when this is no longer used
245425
if (activeSpan) {
246-
response = await invokeRouteModule(activeSpan)
426+
await handleResponse(activeSpan)
247427
} else {
248-
response = await tracer.withPropagatedContext(req.headers, () =>
428+
await tracer.withPropagatedContext(req.headers, () =>
249429
tracer.trace(
250430
BaseServerSpan.handleRequest,
251431
{
@@ -256,79 +436,14 @@ export async function handler(
256436
'http.target': req.url,
257437
},
258438
},
259-
invokeRouteModule
439+
handleResponse
260440
)
261441
)
262442
}
263-
264-
;(req as any).fetchMetrics = (context.renderOpts as any).fetchMetrics
265-
266-
const cacheTags = context.renderOpts.collectedTags
267-
268-
// If the request is for a static response, we can cache it so long
269-
// as it's not edge.
270-
if (isIsr) {
271-
const blob = await response.blob()
272-
273-
// Copy the headers from the response.
274-
const headers = toNodeOutgoingHttpHeaders(response.headers)
275-
276-
if (cacheTags) {
277-
headers[NEXT_CACHE_TAGS_HEADER] = cacheTags
278-
}
279-
280-
if (!headers['content-type'] && blob.type) {
281-
headers['content-type'] = blob.type
282-
}
283-
284-
const revalidate =
285-
typeof context.renderOpts.collectedRevalidate === 'undefined' ||
286-
context.renderOpts.collectedRevalidate >= INFINITE_CACHE
287-
? false
288-
: context.renderOpts.collectedRevalidate
289-
290-
const expire =
291-
typeof context.renderOpts.collectedExpire === 'undefined' ||
292-
context.renderOpts.collectedExpire >= INFINITE_CACHE
293-
? undefined
294-
: context.renderOpts.collectedExpire
295-
296-
// Create the cache entry for the response.
297-
const cacheEntry: ResponseCacheEntry = {
298-
value: {
299-
kind: CachedRouteKind.APP_ROUTE,
300-
status: response.status,
301-
body: Buffer.from(await blob.arrayBuffer()),
302-
headers,
303-
},
304-
cacheControl: { revalidate, expire },
305-
}
306-
307-
return cacheEntry
308-
}
309-
let pendingWaitUntil = context.renderOpts.pendingWaitUntil
310-
311-
// Attempt using provided waitUntil if available
312-
// if it's not we fallback to sendResponse's handling
313-
if (pendingWaitUntil) {
314-
if (context.renderOpts.waitUntil) {
315-
context.renderOpts.waitUntil(pendingWaitUntil)
316-
pendingWaitUntil = undefined
317-
}
318-
}
319-
320-
// Send the response now that we have copied it into the cache.
321-
await sendResponse(
322-
nodeNextReq,
323-
nodeNextRes,
324-
response,
325-
context.renderOpts.pendingWaitUntil
326-
)
327-
return null
328443
} catch (err) {
329444
// if we aren't wrapped by base-server handle here
330445
if (!activeSpan) {
331-
await onError(err, req, {
446+
await routeModule.onRequestError(req, err, {
332447
routerKind: 'App Router',
333448
routePath: normalizedSrcPage,
334449
routeType: 'route',

0 commit comments

Comments
 (0)