Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support force-static on App Routes #46693

Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@ -19,7 +19,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 @@ -30,7 +30,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 @@ -56,6 +56,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 @@ -194,6 +193,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 @@ -313,9 +449,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 @@ -336,90 +470,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