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

feat: add output: export support for appDir #47022

Merged
merged 42 commits into from
Mar 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
967d669
fix: `output: export` support for `app`
styfle Mar 7, 2023
fd30e7f
add initial test
styfle Mar 7, 2023
af8963d
Change `.rsc` to `.txt`
styfle Mar 7, 2023
aac1058
Add if statement
styfle Mar 9, 2023
6785b0f
Handle undefined
styfle Mar 9, 2023
6c2130e
Fix isAppPath
styfle Mar 9, 2023
a238770
Optimize the code
styfle Mar 9, 2023
b781f70
Merge branch 'canary' into app-dir-output-export
styfle Mar 9, 2023
87858c7
Fix typo in test
styfle Mar 10, 2023
84f43ec
Fix a bug with str concat
styfle Mar 10, 2023
cfbd2e1
Add tests
styfle Mar 10, 2023
6ccf8c5
Refactor test
styfle Mar 10, 2023
7ffffe1
Add tests with `generateStaticParams()`
styfle Mar 10, 2023
e64914b
anchor
styfle Mar 10, 2023
0d0b254
Add delay
styfle Mar 10, 2023
4b646bf
Add tests for `dynamic` but somehow not working
styfle Mar 10, 2023
41aaf27
Minor change to test names
styfle Mar 10, 2023
d0ac0e8
Fix tests
styfle Mar 10, 2023
74ca9ff
Fix all teh things
styfle Mar 11, 2023
bcba14e
Add type
styfle Mar 11, 2023
316a297
Merge branch 'canary' into styfle/next-775-implement-output-export-fo…
styfle Mar 11, 2023
4915943
Fix server side check for config
styfle Mar 13, 2023
2059734
Fix client side tree-shaking for config
styfle Mar 13, 2023
d699b99
Avoid cloning url every time
styfle Mar 13, 2023
71bdbb9
Fix static file emit (favicon.ico)
styfle Mar 13, 2023
ab2018d
Fix typo in test
styfle Mar 13, 2023
5933150
Merge branch 'canary' into styfle/next-775-implement-output-export-fo…
ijjk Mar 13, 2023
c730401
Update tests to check file output
styfle Mar 14, 2023
bcdac1a
Exclude `/route`
styfle Mar 14, 2023
f9d4435
Handle `/route`
styfle Mar 14, 2023
2deda3f
Make it work with API Route Handlers
styfle Mar 14, 2023
d42c179
Loosen test comparison and fix `.body` exists
styfle Mar 14, 2023
f8d072b
Merge branch 'canary' into styfle/next-775-implement-output-export-fo…
styfle Mar 14, 2023
c4cf8b2
Revert app-render
styfle Mar 14, 2023
04a916d
Apply suggestion from code review
styfle Mar 14, 2023
4ca9e32
Use isAppPageRoute() helper
styfle Mar 14, 2023
696dc17
Change 'auto' to undefined
styfle Mar 14, 2023
bf05c8a
Add test for force-dynamic
styfle Mar 14, 2023
fa94a43
Merge branch 'canary' into styfle/next-775-implement-output-export-fo…
styfle Mar 14, 2023
c14bc93
Remove unused `?`
styfle Mar 14, 2023
1aa1d58
Fix tests and include page name
styfle Mar 14, 2023
57cd2b9
Separate cases between manifest missing vs manifest corrupt
styfle Mar 14, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,7 @@ export default async function build(
enableUndici: config.experimental.enableUndici,
locales: config.i18n?.locales,
defaultLocale: config.i18n?.defaultLocale,
nextConfigOutput: config.output,
})
)

Expand Down Expand Up @@ -1466,6 +1467,7 @@ export default async function build(
isrFlushToDisk: config.experimental.isrFlushToDisk,
maxMemoryCacheSize:
config.experimental.isrMemoryCacheSize,
nextConfigOutput: config.output,
})
}
)
Expand Down
12 changes: 12 additions & 0 deletions packages/next/src/build/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,7 @@ export async function isPageStatic({
isrFlushToDisk,
maxMemoryCacheSize,
incrementalCacheHandlerPath,
nextConfigOutput,
}: {
page: string
distDir: string
Expand All @@ -1347,6 +1348,7 @@ export async function isPageStatic({
isrFlushToDisk?: boolean
maxMemoryCacheSize?: number
incrementalCacheHandlerPath?: string
nextConfigOutput: 'standalone' | 'export'
}): Promise<{
isStatic?: boolean
isAmpOnly?: boolean
Expand Down Expand Up @@ -1480,6 +1482,16 @@ export async function isPageStatic({
{}
)

if (nextConfigOutput === 'export') {
if (!appConfig.dynamic || appConfig.dynamic === 'auto') {
appConfig.dynamic = 'error'
} else if (appConfig.dynamic === 'force-dynamic') {
throw new Error(
`export const dynamic = "force-dynamic" on page "${page}" cannot be used with "output: export". See more info here: https://nextjs.org/docs/advanced-features/static-html-export`
)
}
}

if (appConfig.dynamic === 'force-dynamic') {
appConfig.revalidate = 0
}
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export function getDefineEnv({
}),
'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify(config.basePath),
'process.env.__NEXT_HAS_REWRITES': JSON.stringify(hasRewrites),
'process.env.__NEXT_CONFIG_OUTPUT': JSON.stringify(config.output),
'process.env.__NEXT_I18N_SUPPORT': JSON.stringify(!!config.i18n),
'process.env.__NEXT_I18N_DOMAINS': JSON.stringify(config.i18n?.domains),
'process.env.__NEXT_ANALYTICS_ID': JSON.stringify(config.analyticsId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,16 @@ export async function fetchServerResponse(
}

try {
const res = await fetch(url.toString(), {
let fetchUrl = url
if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
fetchUrl = new URL(url) // clone
if (fetchUrl.pathname.endsWith('/')) {
fetchUrl.pathname += 'index.txt'
} else {
fetchUrl.pathname += '.txt'
}
}
const res = await fetch(fetchUrl, {
// Backwards compat for older browsers. `same-origin` is the default in modern browsers.
credentials: 'same-origin',
headers,
Expand All @@ -44,8 +53,14 @@ export async function fetchServerResponse(
? urlToUrlWithoutFlightMarker(res.url)
: undefined

const isFlightResponse =
res.headers.get('content-type') === RSC_CONTENT_TYPE_HEADER
const contentType = res.headers.get('content-type') || ''
let isFlightResponse = contentType === RSC_CONTENT_TYPE_HEADER

if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
if (!isFlightResponse) {
isFlightResponse = contentType.startsWith('text/plain')
}
}

// If fetch returns something different than flight response handle it like a mpa navigation
if (!isFlightResponse) {
Expand Down
67 changes: 63 additions & 4 deletions packages/next/src/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
PRERENDER_MANIFEST,
SERVER_DIRECTORY,
SERVER_REFERENCE_MANIFEST,
APP_PATH_ROUTES_MANIFEST,
} from '../shared/lib/constants'
import loadConfig from '../server/config'
import { ExportPathMap, NextConfigComplete } from '../server/config-shared'
Expand All @@ -51,6 +52,9 @@ import {
overrideBuiltInReactPackages,
} from '../build/webpack/require-hook'
import { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin'
import { isAppRouteRoute } from '../lib/is-app-route-route'
import { isAppPageRoute } from '../lib/is-app-page-route'
import isError from '../lib/is-error'

loadRequireHook()
if (process.env.NEXT_PREBUNDLED_REACT) {
Expand Down Expand Up @@ -238,6 +242,23 @@ export default async function exportApp(
prerenderManifest = require(join(distDir, PRERENDER_MANIFEST))
} catch (_) {}

let appRoutePathManifest: Record<string, string> | undefined = undefined
try {
appRoutePathManifest = require(join(distDir, APP_PATH_ROUTES_MANIFEST))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use fs.exists here instead so that if there's an error in the manifest it doesn't accidentally fall through.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timneutkens Should I also make that change to the prerender manifest on line 241 above?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 57cd2b9

} catch (err) {
if (
isError(err) &&
(err.code === 'ENOENT' || err.code === 'MODULE_NOT_FOUND')
) {
// the manifest doesn't exist which will happen when using
// "pages" dir instead of "app" dir.
appRoutePathManifest = undefined
} else {
// the manifest is malformed (invalid json)
throw err
}
}

const excludedPrerenderRoutes = new Set<string>()
const pages = options.pages || Object.keys(pagesManifest)
const defaultPathMap: ExportPathMap = {}
Expand Down Expand Up @@ -269,6 +290,25 @@ export default async function exportApp(
defaultPathMap[page] = { page }
}

const mapAppRouteToPage = new Map<string, string>()
if (!options.buildExport && appRoutePathManifest) {
for (const [pageName, routePath] of Object.entries(
appRoutePathManifest
)) {
mapAppRouteToPage.set(routePath, pageName)
if (
isAppPageRoute(pageName) &&
!prerenderManifest?.routes[routePath] &&
!prerenderManifest?.dynamicRoutes[routePath]
) {
defaultPathMap[routePath] = {
page: pageName,
_isAppDir: true,
}
}
}
}

// Initialize the output directory
const outDir = options.outdir

Expand Down Expand Up @@ -711,7 +751,10 @@ export default async function exportApp(
await Promise.all(
Object.keys(prerenderManifest.routes).map(async (route) => {
const { srcRoute } = prerenderManifest!.routes[route]
const pageName = srcRoute || route
const appPageName = mapAppRouteToPage.get(srcRoute || '')
const pageName = appPageName || srcRoute || route
const isAppPath = Boolean(appPageName)
const isAppRouteHandler = appPageName && isAppRouteRoute(appPageName)

// returning notFound: true from getStaticProps will not
// output html/json files during the build
Expand All @@ -720,7 +763,7 @@ export default async function exportApp(
}
route = normalizePagePath(route)

const pagePath = getPagePath(pageName, distDir, undefined, false)
const pagePath = getPagePath(pageName, distDir, undefined, isAppPath)
const distPagesDir = join(
pagePath,
// strip leading / and then recurse number of nested dirs
Expand All @@ -733,6 +776,15 @@ export default async function exportApp(
)

const orig = join(distPagesDir, route)
const handlerSrc = `${orig}.body`
const handlerDest = join(outDir, route)

if (isAppRouteHandler && (await exists(handlerSrc))) {
await promises.mkdir(dirname(handlerDest), { recursive: true })
await promises.copyFile(handlerSrc, handlerDest)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to error here if custom headers are set in the .meta file or a custom status code as those won't work with next export

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I created NEXT-823 to track this 👍

return
}

const htmlDest = join(
outDir,
`${route}${
Expand All @@ -743,13 +795,20 @@ export default async function exportApp(
outDir,
`${route}.amp${subFolders ? `${sep}index` : ''}.html`
)
const jsonDest = join(pagesDataDir, `${route}.json`)
const jsonDest = isAppPath
? join(
outDir,
`${route}${
subFolders && route !== '/index' ? `${sep}index` : ''
}.txt`
huozhi marked this conversation as resolved.
Show resolved Hide resolved
)
: join(pagesDataDir, `${route}.json`)

await promises.mkdir(dirname(htmlDest), { recursive: true })
await promises.mkdir(dirname(jsonDest), { recursive: true })

const htmlSrc = `${orig}.html`
const jsonSrc = `${orig}.json`
const jsonSrc = `${orig}${isAppPath ? '.rsc' : '.json'}`

await promises.copyFile(htmlSrc, htmlDest)
await promises.copyFile(jsonSrc, jsonDest)
Expand Down
6 changes: 5 additions & 1 deletion packages/next/src/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,11 @@ export interface ExperimentalConfig {
}

export type ExportPathMap = {
[path: string]: { page: string; query?: Record<string, string | string[]> }
[path: string]: {
page: string
query?: Record<string, string | string[]>
_isAppDir?: boolean
}
}

/**
Expand Down
20 changes: 20 additions & 0 deletions test/integration/app-dir-export/app/another/[slug]/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Link from 'next/link'

export const dynamic = 'force-static'

export function generateStaticParams() {
return [{ slug: 'first' }, { slug: 'second' }]
}

export default function Page({ params }) {
return (
<main>
<h1>{params.slug}</h1>
<ul>
<li>
<Link href="/another">Visit another page</Link>
</li>
</ul>
</main>
)
}
26 changes: 26 additions & 0 deletions test/integration/app-dir-export/app/another/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Link from 'next/link'

export default function Another() {
return (
<main>
<h1>Another</h1>
<ul>
<li>
<Link href="/">Visit the home page</Link>
</li>
<li>
<Link href="/another">another page</Link>
</li>
<li>
<Link href="/another/first">another first page</Link>
</li>
<li>
<Link href="/another/second">another second page</Link>
</li>
<li>
<Link href="/image-import">image import page</Link>
</li>
</ul>
</main>
)
}
3 changes: 3 additions & 0 deletions test/integration/app-dir-export/app/api/json/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ answer: 42 })
}
3 changes: 3 additions & 0 deletions test/integration/app-dir-export/app/api/txt/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function GET() {
return new Response('this is plain text')
}
Binary file added test/integration/app-dir-export/app/favicon.ico
Binary file not shown.
18 changes: 18 additions & 0 deletions test/integration/app-dir-export/app/image-import/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Link from 'next/link'
import img from './test.png'

export default function ImageImport() {
return (
<main>
<h1>Image Import</h1>
<ul>
<li>
<Link href="/">Visit the home page</Link>
</li>
<li>
<a href={img.src}>View the image</a>
</li>
</ul>
</main>
)
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions test/integration/app-dir-export/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
26 changes: 26 additions & 0 deletions test/integration/app-dir-export/app/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Link from 'next/link'

export default function Home() {
return (
<main>
<h1>Home</h1>
<ul>
<li>
<Link href="/another">another no trailingslash</Link>
</li>
<li>
<Link href="/another/">another has trailingslash</Link>
</li>
<li>
<Link href="/another/first">another first page</Link>
</li>
<li>
<Link href="/another/second">another second page</Link>
</li>
<li>
<Link href="/image-import">image import page</Link>
</li>
</ul>
</main>
)
}
2 changes: 2 additions & 0 deletions test/integration/app-dir-export/app/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
13 changes: 13 additions & 0 deletions test/integration/app-dir-export/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
experimental: {
appDir: true,
},
generateBuildId() {
return 'test-build-id'
},
}

module.exports = nextConfig
Loading