Skip to content

Commit 891bff1

Browse files
authored
chore: reorganize directory structure for clarity (#34)
* chore: big reorg * chore: simplify content handling * chore: add comments * feat: another refactor * fix: content filtering logic * chore: promise all style * chore: update eslint override path * chore: update readme * fix: update missed server content paths * chore: update FetchCacheValue type * chore: make content type logic more explicit
1 parent 45559e2 commit 891bff1

19 files changed

+298
-333
lines changed

.eslintrc.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ module.exports = {
2020
overrides: [
2121
...overrides,
2222
{
23-
files: ['src/handlers/**'],
23+
files: ['src/run/handlers/**'],
2424
rules: {
2525
'max-statements': ['error', 30],
2626
'import/no-anonymous-default-export': 'off',

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ How to add new integration test scenarios to the application:
1616
`.next` folder.
1717
4. Add your test
1818

19-
> Currently the tests require a built version of the `dist/handlers/cache.cjs` so you need to run
20-
> `npm run build` before executing the integration tests.
19+
> Currently the tests require a built version of the `dist/run/handlers/cache.cjs` so you need to
20+
> run `npm run build` before executing the integration tests.

src/build/cache.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NetlifyPluginConstants, NetlifyPluginUtils } from '@netlify/build'
2+
import { ensureDir, move, pathExists } from 'fs-extra/esm'
3+
import { BUILD_DIR } from './constants.js'
4+
5+
/**
6+
* Move the Next.js build output from the publish dir to a temp dir
7+
*/
8+
export const moveBuildOutput = async (
9+
{ PUBLISH_DIR }: NetlifyPluginConstants,
10+
utils: NetlifyPluginUtils,
11+
): Promise<void> => {
12+
if (!(await pathExists(PUBLISH_DIR))) {
13+
utils.build.failBuild(
14+
'Your publish directory does not exist. Please check your netlify.toml file.',
15+
)
16+
}
17+
18+
// move the build output to a temp dir
19+
await move(PUBLISH_DIR, `${BUILD_DIR}/.next`, { overwrite: true })
20+
21+
// recreate the publish dir so we can move the static content back
22+
await ensureDir(PUBLISH_DIR)
23+
}

src/build/config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Enable Next.js standalone mode at build time
3+
*/
4+
export const setBuildConfig = () => {
5+
process.env.NEXT_PRIVATE_STANDALONE = 'true'
6+
}

src/helpers/constants.ts renamed to src/build/constants.ts

-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export const PLUGIN_DIR = resolve(`${MODULE_DIR}../..`)
66
export const WORKING_DIR = process.cwd()
77

88
export const BUILD_DIR = `${WORKING_DIR}/.netlify`
9-
export const RUN_DIR = WORKING_DIR
109

1110
export const SERVER_FUNCTIONS_DIR = `${BUILD_DIR}/functions-internal`
1211
export const SERVER_HANDLER_NAME = '___netlify-server-handler'

src/build/content/prerendered.ts

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { getDeployStore } from '@netlify/blobs'
2+
import { NetlifyPluginConstants } from '@netlify/build'
3+
import { globby } from 'globby'
4+
import { readFile } from 'node:fs/promises'
5+
import { cpus } from 'os'
6+
import pLimit from 'p-limit'
7+
import { parse, ParsedPath } from 'path'
8+
import { BUILD_DIR } from '../constants.js'
9+
10+
type CacheEntry = {
11+
key: string
12+
value: {
13+
lastModified: number
14+
value: PageCacheValue | RouteCacheValue | FetchCacheValue
15+
}
16+
}
17+
18+
type PageCacheValue = {
19+
kind: 'PAGE'
20+
html: string
21+
pageData: string
22+
headers?: { [k: string]: string }
23+
status?: number
24+
}
25+
26+
type RouteCacheValue = {
27+
kind: 'ROUTE'
28+
body: string
29+
headers?: { [k: string]: string }
30+
status?: number
31+
}
32+
33+
type FetchCacheValue = {
34+
kind: 'FETCH'
35+
data: {
36+
headers: { [k: string]: string }
37+
body: string
38+
url: string
39+
status?: number
40+
tags?: string[]
41+
}
42+
}
43+
44+
// static prerendered pages content with JSON data
45+
const isPage = ({ dir, name, ext }: ParsedPath, paths: string[]) => {
46+
return dir.startsWith('server/pages') && ext === '.html' && paths.includes(`${dir}/${name}.json`)
47+
}
48+
// static prerendered app content with RSC data
49+
const isApp = ({ dir, ext }: ParsedPath) => {
50+
return dir.startsWith('server/app') && ext === '.html'
51+
}
52+
// static prerendered app route handler
53+
const isRoute = ({ dir, ext }: ParsedPath) => {
54+
return dir.startsWith('server/app') && ext === '.body'
55+
}
56+
// fetch cache data
57+
const isFetch = ({ dir }: ParsedPath) => {
58+
return dir.startsWith('cache/fetch-cache')
59+
}
60+
61+
/**
62+
* Transform content file paths into cache entries for the blob store
63+
*/
64+
const buildPrerenderedContentEntries = async (cwd: string): Promise<Promise<CacheEntry>[]> => {
65+
const paths = await globby(
66+
[`cache/fetch-cache/*`, `server/+(app|pages)/**/*.+(html|body|json)`],
67+
{
68+
cwd,
69+
extglob: true,
70+
},
71+
)
72+
73+
return paths
74+
.map(parse)
75+
.filter((path: ParsedPath) => {
76+
return isPage(path, paths) || isApp(path) || isRoute(path) || isFetch(path)
77+
})
78+
.map(async (path: ParsedPath): Promise<CacheEntry> => {
79+
const { dir, name, ext } = path
80+
const key = `${dir}/${name}`
81+
let value
82+
83+
if (isPage(path, paths)) {
84+
value = {
85+
kind: 'PAGE',
86+
html: await readFile(`${cwd}/${key}.html`, 'utf-8'),
87+
pageData: JSON.parse(await readFile(`${cwd}/${key}.json`, 'utf-8')),
88+
} satisfies PageCacheValue
89+
}
90+
91+
if (isApp(path)) {
92+
value = {
93+
kind: 'PAGE',
94+
html: await readFile(`${cwd}/${key}.html`, 'utf-8'),
95+
pageData: await readFile(`${cwd}/${key}.rsc`, 'utf-8'),
96+
...JSON.parse(await readFile(`${cwd}/${key}.meta`, 'utf-8')),
97+
} satisfies PageCacheValue
98+
}
99+
100+
if (isRoute(path)) {
101+
value = {
102+
kind: 'ROUTE',
103+
body: await readFile(`${cwd}/${key}.body`, 'utf-8'),
104+
...JSON.parse(await readFile(`${cwd}/${key}.meta`, 'utf-8')),
105+
} satisfies RouteCacheValue
106+
}
107+
108+
if (isFetch(path)) {
109+
value = {
110+
kind: 'FETCH',
111+
data: JSON.parse(await readFile(`${cwd}/${key}`, 'utf-8')),
112+
} satisfies FetchCacheValue
113+
}
114+
115+
return {
116+
key,
117+
value: {
118+
lastModified: Date.now(),
119+
value,
120+
},
121+
}
122+
})
123+
}
124+
125+
/**
126+
* Upload prerendered content to the blob store and remove it from the bundle
127+
*/
128+
export const uploadPrerenderedContent = async ({
129+
NETLIFY_API_TOKEN,
130+
NETLIFY_API_HOST,
131+
SITE_ID,
132+
}: NetlifyPluginConstants) => {
133+
// initialize the blob store
134+
const blob = getDeployStore({
135+
deployID: process.env.DEPLOY_ID,
136+
siteID: SITE_ID,
137+
token: NETLIFY_API_TOKEN,
138+
apiURL: `https://${NETLIFY_API_HOST}`,
139+
})
140+
// limit concurrent uploads to 2x the number of CPUs
141+
const limit = pLimit(Math.max(2, cpus().length))
142+
143+
// read prerendered content and build JSON key/values for the blob store
144+
const entries = await Promise.allSettled(
145+
await buildPrerenderedContentEntries(`${BUILD_DIR}/.next/standalone/.next`),
146+
)
147+
entries.forEach((result) => {
148+
if (result.status === 'rejected') {
149+
console.error(`Unable to read prerendered content: ${result.reason.message}`)
150+
}
151+
})
152+
153+
// upload JSON content data to the blob store
154+
const uploads = await Promise.allSettled(
155+
entries
156+
.filter((entry) => entry.status === 'fulfilled')
157+
.map((entry: PromiseSettledResult<CacheEntry>) => {
158+
const result = entry as PromiseFulfilledResult<CacheEntry>
159+
const { key, value } = result.value
160+
return limit(() => blob.setJSON(key, value))
161+
}),
162+
)
163+
uploads.forEach((upload, index) => {
164+
if (upload.status === 'rejected') {
165+
const result = entries[index] as PromiseFulfilledResult<CacheEntry>
166+
console.error(`Unable to store ${result.value.key}: ${upload.reason.message}`)
167+
}
168+
})
169+
}

src/build/content/server.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { copy } from 'fs-extra/esm'
2+
import { globby } from 'globby'
3+
4+
/**
5+
* Copy App/Pages Router Javascript needed by the server handler
6+
*/
7+
export const copyServerContent = async (src: string, dest: string): Promise<Promise<void>[]> => {
8+
const paths = await globby([`*`, `server/*`, `server/chunks/*`, `server/+(app|pages)/**/*.js`], {
9+
cwd: src,
10+
extglob: true,
11+
})
12+
13+
return paths.map((path: string) => copy(`${src}/${path}`, `${dest}/${path}`))
14+
}

src/build/content/static.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NetlifyPluginConstants } from '@netlify/build'
2+
import { copy, pathExists } from 'fs-extra/esm'
3+
import { globby } from 'globby'
4+
import { ParsedPath, parse } from 'node:path'
5+
import { BUILD_DIR, WORKING_DIR } from '../constants.js'
6+
7+
/**
8+
* Copy static pages (HTML without associated JSON data)
9+
*/
10+
const copyStaticPages = async (src: string, dest: string): Promise<Promise<void>[]> => {
11+
const paths = await globby([`server/pages/**/*.+(html|json)`], {
12+
cwd: src,
13+
extglob: true,
14+
})
15+
16+
return (
17+
paths
18+
.map(parse)
19+
// keep only static files that do not have JSON data
20+
.filter(({ dir, name }: ParsedPath) => !paths.includes(`${dir}/${name}.json`))
21+
.map(({ dir, base }: ParsedPath) =>
22+
copy(`${src}/${dir}/${base}`, `${dest}/${dir.replace(/^server\/(app|pages)/, '')}/${base}`),
23+
)
24+
)
25+
}
26+
27+
/**
28+
* Move static content to the publish dir so it is uploaded to the CDN
29+
*/
30+
export const copyStaticContent = async ({ PUBLISH_DIR }: NetlifyPluginConstants): Promise<void> => {
31+
await Promise.all([
32+
// static pages
33+
Promise.all(await copyStaticPages(`${BUILD_DIR}/.next/standalone/.next`, PUBLISH_DIR)),
34+
// static assets
35+
copy(`${BUILD_DIR}/.next/static/`, `${PUBLISH_DIR}/_next/static`),
36+
// public assets
37+
(await pathExists(`${WORKING_DIR}/public/`)) && copy(`${WORKING_DIR}/public/`, PUBLISH_DIR),
38+
])
39+
}

src/build/functions/edge.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { emptyDir } from 'fs-extra/esm'
2+
import { EDGE_HANDLER_DIR } from '../constants.js'
3+
4+
/**
5+
* Create a Netlify edge function to run the Next.js server
6+
*/
7+
export const createEdgeHandler = async () => {
8+
// reset the handler directory
9+
await emptyDir(EDGE_HANDLER_DIR)
10+
}
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
import { writeFile } from 'fs/promises'
2-
31
import { nodeFileTrace } from '@vercel/nft'
42
import { copy, emptyDir, readJson, writeJSON } from 'fs-extra/esm'
5-
6-
import {
7-
BUILD_DIR,
8-
EDGE_HANDLER_DIR,
9-
PLUGIN_DIR,
10-
SERVER_HANDLER_DIR,
11-
SERVER_HANDLER_NAME,
12-
} from './constants.js'
13-
import { findServerContent } from './files.js'
3+
import { writeFile } from 'fs/promises'
4+
import { BUILD_DIR, PLUGIN_DIR, SERVER_HANDLER_DIR, SERVER_HANDLER_NAME } from '../constants.js'
5+
import { copyServerContent } from '../content/server.js'
146

157
const pkg = await readJson(`${PLUGIN_DIR}/package.json`)
168

@@ -23,7 +15,7 @@ export const createServerHandler = async () => {
2315

2416
// trace the handler dependencies
2517
const { fileList } = await nodeFileTrace(
26-
[`${PLUGIN_DIR}/dist/handlers/server.js`, `${PLUGIN_DIR}/dist/handlers/cache.cjs`],
18+
[`${PLUGIN_DIR}/dist/run/handlers/server.js`, `${PLUGIN_DIR}/dist/run/handlers/cache.cjs`],
2719
{ base: PLUGIN_DIR, ignore: ['package.json', 'node_modules/next/**'] },
2820
)
2921

@@ -33,16 +25,11 @@ export const createServerHandler = async () => {
3325
)
3426

3527
// copy the next.js standalone build output to the handler directory
36-
const content = await findServerContent(`${BUILD_DIR}/.next/standalone/.next`)
3728
await Promise.all(
38-
content.map((paths) => copy(paths.absolute, `${SERVER_HANDLER_DIR}/.next/${paths.handler}`)),
29+
await copyServerContent(`${BUILD_DIR}/.next/standalone/.next`, `${SERVER_HANDLER_DIR}/.next`),
3930
)
4031
await copy(`${BUILD_DIR}/.next/standalone/node_modules`, `${SERVER_HANDLER_DIR}/node_modules`)
4132

42-
// TODO: @robs checkout why this is needed but in my simple integration test the `.next` folder is missing
43-
44-
await copy(`${BUILD_DIR}/.next`, `${SERVER_HANDLER_DIR}/.next`)
45-
4633
// create the handler metadata file
4734
await writeJSON(`${SERVER_HANDLER_DIR}/${SERVER_HANDLER_NAME}.json`, {
4835
config: {
@@ -67,14 +54,6 @@ export const createServerHandler = async () => {
6754
// write the root handler file
6855
await writeFile(
6956
`${SERVER_HANDLER_DIR}/${SERVER_HANDLER_NAME}.js`,
70-
`import handler from './dist/handlers/server.js';export default handler;export const config = {path:'/*'}`,
57+
`import handler from './dist/run/handlers/server.js';export default handler;export const config = {path:'/*'}`,
7158
)
7259
}
73-
74-
/**
75-
* Create a Netlify edge function to run the Next.js server
76-
*/
77-
export const createEdgeHandler = async () => {
78-
// reset the handler directory
79-
await emptyDir(EDGE_HANDLER_DIR)
80-
}

0 commit comments

Comments
 (0)