Skip to content

Commit

Permalink
Support force-static on App Routes (#46693)
Browse files Browse the repository at this point in the history
Supports `export const dynamic = 'force-static'` on App Routes. This strips pieces of information from the request object to ensure subsequent requests don't accidentally cache user specific data:

- Removes `searchParams`
- Removes `headers`
- Removes `cookies`
- Removes `host`
- Removes `port`
- Removes `protocol`

[NEXT-682](https://linear.app/vercel/issue/NEXT-682/force-static-doesnt-work-with-app-routes)
  • Loading branch information
wyattjoh committed Mar 2, 2023
1 parent e7ee310 commit 3229ed7
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 93 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ export interface AsyncStorageWrapper<Store extends {}, Context extends {}> {
wrap<Result>(
storage: AsyncLocalStorage<Store>,
context: Context,
callback: () => Result
callback: (store: Store) => Result
): Result
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class RequestAsyncStorageWrapper
public wrap<Result>(
storage: AsyncLocalStorage<RequestStore>,
context: RequestContext,
callback: () => Result
callback: (store: RequestStore) => Result
): Result {
return RequestAsyncStorageWrapper.wrap(storage, context, callback)
}
Expand All @@ -60,7 +60,7 @@ export class RequestAsyncStorageWrapper
public static wrap<Result>(
storage: AsyncLocalStorage<RequestStore>,
{ req, res, renderOpts }: RequestContext,
callback: () => Result
callback: (store: RequestStore) => Result
): Result {
// Reads of this are cached on the `req` object, so this should resolve
// instantly. There's no need to pass this data down from a previous
Expand Down Expand Up @@ -105,6 +105,6 @@ export class RequestAsyncStorageWrapper
previewData,
}

return storage.run(store, callback)
return storage.run(store, callback, store)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class StaticGenerationAsyncStorageWrapper
public wrap<Result>(
storage: AsyncLocalStorage<StaticGenerationStore>,
context: RequestContext,
callback: () => Result
callback: (store: StaticGenerationStore) => Result
): Result {
return StaticGenerationAsyncStorageWrapper.wrap(storage, context, callback)
}
Expand All @@ -31,7 +31,7 @@ export class StaticGenerationAsyncStorageWrapper
public static wrap<Result>(
storage: AsyncLocalStorage<StaticGenerationStore>,
{ pathname, renderOpts }: RequestContext,
callback: () => Result
callback: (store: StaticGenerationStore) => Result
): Result {
/**
* Rules of Static & Dynamic HTML:
Expand All @@ -58,6 +58,6 @@ export class StaticGenerationAsyncStorageWrapper
}
;(renderOpts as any).store = store

return storage.run(store, callback)
return storage.run(store, callback, store)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ import type { ModuleLoader } from '../helpers/module-loader/module-loader'
import { RouteHandler } from './route-handler'
import * as Log from '../../../build/output/log'
import { patchFetch } from '../../lib/patch-fetch'
import {
StaticGenerationAsyncStorage,
StaticGenerationStore,
} from '../../../client/components/static-generation-async-storage'
import { StaticGenerationAsyncStorage } from '../../../client/components/static-generation-async-storage'
import { StaticGenerationAsyncStorageWrapper } from '../../async-storage/static-generation-async-storage-wrapper'
import { IncrementalCache } from '../../lib/incremental-cache'
import { AppConfig } from '../../../build/utils'
import { RequestCookies } from 'next/dist/compiled/@edge-runtime/cookies'
import { NextURL } from '../../web/next-url'

// TODO-APP: This module has a dynamic require so when bundling for edge it causes issues.
const NodeModuleLoader =
Expand Down Expand Up @@ -195,6 +194,143 @@ export async function sendResponse(
}
}

function cleanURL(urlString: string): string {
const url = new URL(urlString)
url.host = 'localhost:3000'
url.search = ''
url.protocol = 'http'
return url.toString()
}

function proxyRequest(req: NextRequest, module: AppRouteModule): NextRequest {
function handleNextUrlBailout(prop: string | symbol) {
switch (prop) {
case 'search':
case 'searchParams':
case 'toString':
case 'href':
case 'origin':
module.staticGenerationBailout(`nextUrl.${prop as string}`)
return
default:
return
}
}

const cache: {
url?: string
toString?: () => string
headers?: Headers
cookies?: RequestCookies
searchParams?: URLSearchParams
} = {}

const handleForceStatic = (url: string, prop: string) => {
switch (prop) {
case 'search':
return ''
case 'searchParams':
if (!cache.searchParams) cache.searchParams = new URLSearchParams()

return cache.searchParams
case 'url':
case 'href':
if (!cache.url) cache.url = cleanURL(url)

return cache.url
case 'toJSON':
case 'toString':
if (!cache.url) cache.url = cleanURL(url)
if (!cache.toString) cache.toString = () => cache.url!

return cache.toString
case 'headers':
if (!cache.headers) cache.headers = new Headers()

return cache.headers
case 'cookies':
if (!cache.headers) cache.headers = new Headers()
if (!cache.cookies) cache.cookies = new RequestCookies(cache.headers)

return cache.cookies
case 'clone':
if (!cache.url) cache.url = cleanURL(url)

return () => new NextURL(cache.url!)
default:
break
}
}

const wrappedNextUrl = new Proxy(req.nextUrl, {
get(target, prop) {
handleNextUrlBailout(prop)

if (
module.handlers.dynamic === 'force-static' &&
typeof prop === 'string'
) {
const result = handleForceStatic(target.href, prop)
if (result !== undefined) return result
}

return (target as any)[prop]
},
set(target, prop, value) {
handleNextUrlBailout(prop)
;(target as any)[prop] = value
return true
},
})

const handleReqBailout = (prop: string | symbol) => {
switch (prop) {
case 'headers':
module.headerHooks.headers()
return
// if request.url is accessed directly instead of
// request.nextUrl we bail since it includes query
// values that can be relied on dynamically
case 'url':
case 'body':
case 'blob':
case 'json':
case 'text':
case 'arrayBuffer':
case 'formData':
module.staticGenerationBailout(`request.${prop}`)
return
default:
return
}
}

return new Proxy(req, {
get(target, prop) {
handleReqBailout(prop)

if (prop === 'nextUrl') {
return wrappedNextUrl
}

if (
module.handlers.dynamic === 'force-static' &&
typeof prop === 'string'
) {
const result = handleForceStatic(target.url, prop)
if (result !== undefined) return result
}

return (target as any)[prop]
},
set(target, prop, value) {
handleReqBailout(prop)
;(target as any)[prop] = value
return true
},
})
}

export class AppRouteRouteHandler implements RouteHandler<AppRouteRouteMatch> {
constructor(
private readonly requestAsyncLocalStorageWrapper: AsyncStorageWrapper<
Expand Down Expand Up @@ -314,9 +450,7 @@ export class AppRouteRouteHandler implements RouteHandler<AppRouteRouteMatch> {
supportsDynamicHTML: false,
},
},
() => {
const _req = (request ? request : wrapRequest(req)) as NextRequest

(staticGenerationStore) => {
// We can currently only statically optimize if only GET/HEAD
// are used as a Prerender can't be used conditionally based
// on the method currently
Expand All @@ -337,90 +471,33 @@ export class AppRouteRouteHandler implements RouteHandler<AppRouteRouteMatch> {
)
}

const staticGenerationStore =
staticGenerationAsyncStorage.getStore() ||
({} as StaticGenerationStore)

const dynamicConfig = module.handlers.dynamic
const revalidateConfig = module.handlers.revalidate
let defaultRevalidate: number | false = false

if (dynamicConfig === 'force-dynamic') {
module.staticGenerationBailout(`dynamic = 'force-dynamic'`)
}
if (typeof revalidateConfig !== 'undefined') {
defaultRevalidate = revalidateConfig

if (typeof staticGenerationStore.revalidate === 'undefined') {
staticGenerationStore.revalidate = defaultRevalidate
}
switch (module.handlers.dynamic) {
case 'force-dynamic':
staticGenerationStore.forceDynamic = true
module.staticGenerationBailout(`dynamic = 'force-dynamic'`)
break
case 'force-static':
staticGenerationStore.forceStatic = true
break
default:
// TODO: implement
break
}

const handleNextUrlBailout = (prop: string | symbol) => {
if (
[
'search',
'searchParams',
'toString',
'href',
'origin',
].includes(prop as string)
) {
module.staticGenerationBailout(`nextUrl.${prop as string}`)
}
if (typeof staticGenerationStore.revalidate === 'undefined') {
staticGenerationStore.revalidate =
module.handlers.revalidate ?? false
}

const wrappedNextUrl = new Proxy(_req.nextUrl, {
get(target, prop) {
handleNextUrlBailout(prop)
return (target as any)[prop]
},
set(target, prop, value) {
handleNextUrlBailout(prop)
;(target as any)[prop] = value
return true
},
})

const handleReqBailout = (prop: string | symbol) => {
if (prop === 'headers') {
return module.headerHooks.headers()
}
// if request.url is accessed directly instead of
// request.nextUrl we bail since it includes query
// values that can be relied on dynamically
if (prop === 'url') {
module.staticGenerationBailout(`request.${prop as string}`)
}
if (
[
'body',
'blob',
'json',
'text',
'arrayBuffer',
'formData',
].includes(prop as string)
) {
module.staticGenerationBailout(`request.${prop as string}`)
}
}
// Wrap the request so we can add additional functionality to cases
// that might change it's output or affect the rendering.
const wrappedRequest = proxyRequest(
// TODO: investigate why/how this cast is necessary/possible
(request ? request : wrapRequest(req)) as NextRequest,
module
)

const wrappedReq = new Proxy(_req, {
get(target, prop) {
handleReqBailout(prop)
if (prop === 'nextUrl') {
return wrappedNextUrl
}
return (target as any)[prop]
},
set(target, prop, value) {
handleReqBailout(prop)
;(target as any)[prop] = value
return true
},
})
return handle(wrappedReq, { params })
return handle(wrappedRequest, { params })
}
)
)
Expand Down
29 changes: 29 additions & 0 deletions test/e2e/app-dir/app-routes/app-custom-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,35 @@ createNextDescribe(
})
})

describe('dynamic = "force-static"', () => {
it('strips search, headers, and domain from request', async () => {
const res = await next.fetch('/dynamic?query=true', {
headers: {
accept: 'application/json',
cookie: 'session=true',
},
})

const url = 'http://localhost:3000/dynamic'

expect(res.status).toEqual(200)
expect(await res.json()).toEqual({
nextUrl: {
href: url,
search: '',
searchParams: null,
clone: url,
},
req: {
url,
headers: null,
},
headers: null,
cookies: null,
})
})
})

if (isNextDev) {
describe('lowercase exports', () => {
it.each([
Expand Down

0 comments on commit 3229ed7

Please sign in to comment.