-
Notifications
You must be signed in to change notification settings - Fork 86
feat: add blob storage #2287
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 blob storage #2287
Changes from 99 commits
68908b0
3fe87c5
2fe2c83
407da47
01f44e3
a75b428
79acf0b
7db5872
8b34135
a6941f2
90333d4
bba2391
a9faf28
a20f8c4
8c769af
271624d
7361ef8
4497b56
ae7f9b6
58a62b3
4d45ddf
110816e
171b5a7
9f47f42
1aa8ab1
6e1b151
e3b5a74
a30ffba
4a509fe
048a0c2
d856aec
0725388
3604f11
893b5ee
26346da
6feaac3
913ba1b
b464472
6d4f1be
9e44536
c2dc32d
e6eb363
1639999
ed3cb71
183b7aa
391feaf
e664884
cc866a1
dfc5f21
946dac8
bce39ce
217bd9a
5af0a8a
dc26736
eb469b9
f316e5f
0a97c3b
18fc31b
aae443c
f0e5f6d
a9eac3b
d1d6d00
609945e
7edf24c
55a5699
3ef276b
cd15334
4dd2961
6fd1266
9a2f9b3
f1cb1e3
98a2bc2
4a5cfb5
a5f66b7
ce8c746
eb69bcc
5eb29b7
cc41ab3
4059344
da0edf3
296f50c
9863fa8
fbf1952
75d17c3
f0c92dc
4b75d4e
6e88f67
d54e062
30075f7
f7f511f
f7147d7
eb8674f
78a1a08
a7f6696
913a6d2
b1a4dad
8dfab95
2cc9633
43860c6
500e462
6e56190
54fa413
1538fec
3be1496
da170da
3a0a011
b9ff1a6
b7946c1
ef2ec91
7c91a71
1b40b4b
2d13071
18adedc
bad1b2b
4849ec8
2375994
65875bd
2664912
a118b9e
299418c
0e4a3b7
41cade6
fa32780
7b270ad
de16614
200738f
20aff69
fb5fd25
663b343
b5f9a51
f1201dd
dffcc01
659f693
08008f2
4ccfd0e
ae664e8
0506a30
b6ee3a4
5ab0b49
a17498a
ebb3abd
4226d39
49519fc
bc5afd3
e0449b5
6cb125c
d54f824
297320e
468ecc3
d6b8646
dcc1e77
dec7888
e62e879
248dea6
5f46053
9b150af
e41a370
aa3c09d
edefc1b
310a547
293ed48
393a92e
1d24c2b
3275332
1d840ae
24956d1
9c71569
c815d64
f3a5e0d
69071d5
b5ff0ab
c5687c6
189bc12
ef67939
ed18202
25a45bd
bb643a2
5320e7f
b4d343e
3662003
e050866
ad426cb
1fe2413
4ee7225
6a25768
219215b
1ae3e29
811daf7
dcd1c76
45a5548
a40be22
7c67874
4868f7e
ed2843f
542642b
108dca6
bc75e8f
686050a
05fd540
2489637
548b092
57e9bb3
5d3d456
4850a9d
a81452b
84d020b
4662234
316ca06
30cdeb0
01d3058
3074b31
b38586d
d5b818a
4027de1
c9e173c
b704128
239e044
563a189
3f3deb1
12c4723
ff4d8c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -93,4 +93,5 @@ module.exports = { | |
}, | ||
}, | ||
], | ||
ignorePatterns: ['packages/runtime/src/blob.js'], | ||
} |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
src/blob.js |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
import type { NetlifyConfig } from '@netlify/build/types' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know why, but for |
||
import destr from 'destr' | ||
import { readJSON, writeJSON } from 'fs-extra' | ||
import { existsSync, readJSON, writeJSON } from 'fs-extra' | ||
import type { Header } from 'next/dist/lib/load-custom-routes' | ||
import type { NextConfigComplete } from 'next/dist/server/config-shared' | ||
import { join, dirname, relative } from 'pathe' | ||
|
@@ -34,18 +34,52 @@ const defaultFailBuild = (message: string, { error }): never => { | |
throw new Error(`${message}\n${error && error.stack}`) | ||
} | ||
|
||
const addIncrementalCacheHandlerPath = ( | ||
incrementalCacheHandlerPath: string, | ||
experimentalConfig: NextConfig['experimental'], | ||
) => { | ||
// This check is needed for now because if blob storage isn't available, this file will not have been created | ||
if (incrementalCacheHandlerPath !== undefined && existsSync(incrementalCacheHandlerPath)) { | ||
experimentalConfig.incrementalCacheHandlerPath = incrementalCacheHandlerPath | ||
} | ||
|
||
return experimentalConfig | ||
} | ||
|
||
export const getNextConfig = async function getNextConfig({ | ||
publish, | ||
failBuild = defaultFailBuild, | ||
incrementalCacheHandlerPath, | ||
}: { | ||
publish: string | ||
failBuild?: (message: string, { error }) => never | ||
incrementalCacheHandlerPath?: string | ||
}): Promise<NextConfig> { | ||
try { | ||
const { config, appDir, ignore }: RequiredServerFiles = await readJSON(join(publish, 'required-server-files.json')) | ||
const requiredServerFiles: RequiredServerFiles = await readJSON(join(publish, 'required-server-files.json')) | ||
const { config, appDir, ignore } = requiredServerFiles | ||
|
||
if (!config) { | ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment | ||
// @ts-ignore | ||
return failBuild('Error loading your Next config') | ||
} | ||
|
||
if ('incrementalCacheHandlerPath' in config.experimental) { | ||
pieh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
console.warn( | ||
"Your Next.js configuration has the experimental incrementalCacheHandlerPath option set. It will be overridden by Netlify's internal cache.", | ||
) | ||
} | ||
|
||
// For more info, see https://nextjs.org/docs/app/api-reference/next-config-js/incrementalCacheHandlerPath | ||
// ./cache-handler.js will be copied to the root or the .next build folder | ||
await writeJSON(join(publish, 'required-server-files.json'), { | ||
...requiredServerFiles, | ||
config: { | ||
...config, | ||
experimental: addIncrementalCacheHandlerPath(incrementalCacheHandlerPath, config.experimental), | ||
}, | ||
}) | ||
const routesManifest: RoutesManifest = await readJSON(join(publish, ROUTES_MANIFEST_FILE)) | ||
|
||
// If you need access to other manifest files, you can add them here as well | ||
|
@@ -115,7 +149,7 @@ export const configureHandlerFunctions = async ({ | |
ignore: Array<string> | ||
apiLambdas: APILambda[] | ||
ssrLambdas: SSRLambda[] | ||
splitApiRoutes: boolean | ||
splitApiRoutes: unknown | ||
}) => { | ||
const config = await getRequiredServerFiles(publish) | ||
const files = config.files || [] | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,17 @@ | ||
import { cpus } from 'os' | ||
|
||
import { Blobs } from '@netlify/blobs/dist/src/main' | ||
import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build/types' | ||
import { build } from '@netlify/esbuild' | ||
import bridgeFile from '@vercel/node-bridge' | ||
import chalk from 'chalk' | ||
import destr from 'destr' | ||
import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON, stat } from 'fs-extra' | ||
import { PrerenderManifest } from 'next/dist/build' | ||
import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON, stat, readFile } from 'fs-extra' | ||
import mime from 'mime-types' | ||
import type { PrerenderManifest } from 'next/dist/build' | ||
import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config' | ||
import { outdent } from 'outdent' | ||
import pLimit from 'p-limit' | ||
import { join, relative, resolve, dirname, basename, extname } from 'pathe' | ||
import glob from 'tiny-glob' | ||
|
||
|
@@ -21,12 +27,13 @@ import { | |
API_FUNCTION_NAME, | ||
LAMBDA_WARNING_SIZE, | ||
} from '../constants' | ||
import { BlobISRPage, getHashedKey } from '../templates/blobStorage' | ||
import { getApiHandler } from '../templates/getApiHandler' | ||
import { getHandler } from '../templates/getHandler' | ||
import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver' | ||
|
||
import { ApiConfig, extractConfigFromFile, isEdgeConfig } from './analysis' | ||
import { getRequiredServerFiles } from './config' | ||
import { getRequiredServerFiles, NextConfig } from './config' | ||
import { getDependenciesOfFile, getServerFile, getSourceFileForPage } from './files' | ||
import { writeFunctionConfiguration } from './functionsMetaData' | ||
import { pack } from './pack' | ||
|
@@ -157,6 +164,16 @@ export const generateFunctions = async ( | |
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'), | ||
join(functionsDir, functionName, 'handlerUtils.js'), | ||
) | ||
// need to copy the blob storage helper over to be available on request time | ||
// the odb needs access to the blob storage | ||
// we have to bundle it to not miss any files on the odb then | ||
await build({ | ||
entryPoints: [join(__dirname, '..', '..', 'lib', 'templates', 'blobStorage.js')], | ||
outfile: join(functionsDir, functionName, 'blobStorage.js'), | ||
bundle: true, | ||
platform: 'node', | ||
}) | ||
|
||
await writeFunctionConfiguration({ functionName, functionTitle, functionsDir }) | ||
|
||
const nfInternalFiles = await glob(join(functionsDir, functionName, '**')) | ||
|
@@ -364,42 +381,161 @@ const changeExtension = (file: string, extension: string) => { | |
return join(dirname(file), base + extension) | ||
} | ||
|
||
const getSSRDependencies = async (publish: string): Promise<string[]> => { | ||
const getPrerenderManifest = async (publish: string) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I extracted getting the prerenderManifest out of the prerender content functions because the job of those functions are to iterated through the content of the prerendermanifest, not load it. |
||
const prerenderManifest: PrerenderManifest = await readJSON(join(publish, 'prerender-manifest.json')) | ||
|
||
return [ | ||
...Object.entries(prerenderManifest.routes).flatMap(([route, ssgRoute]) => { | ||
if (ssgRoute.initialRevalidateSeconds === false) { | ||
return [] | ||
} | ||
return prerenderManifest | ||
} | ||
|
||
if (ssgRoute.dataRoute.endsWith('.rsc')) { | ||
return [ | ||
join(publish, 'server', 'app', ssgRoute.dataRoute), | ||
join(publish, 'server', 'app', changeExtension(ssgRoute.dataRoute, '.html')), | ||
] | ||
/** | ||
* Warms up the cache with prerendered content | ||
* | ||
* @param options | ||
* @param options.netliBlob - the blob storage instance | ||
* @param options.prerenderManifest - the prerender manifest | ||
* @param options.publish - the publish directory | ||
* | ||
*/ | ||
const setPrerenderedBlobStoreContent = async ({ | ||
netliBlob, | ||
prerenderManifest, | ||
publish, | ||
i18n, | ||
}: { | ||
i18n: NextConfig['i18n'] | ||
netliBlob: Blobs | ||
prerenderManifest: PrerenderManifest | ||
publish: string | ||
}): Promise<void> => { | ||
// *.rsc, *.json and *.html files can be found in the .next build artifacts folder, | ||
// | ||
// e.g. app router build artifacts from the default demo site | ||
// demos/default/.next/server/app/blog/rob/second-post.html | ||
// demos/default/.next/server/app/blog/rob/second-post.rsc | ||
// | ||
// e.g. pages router build artifacts from the default demo site | ||
// demos/default/.next/server/pages/en/getStaticProps/1.html | ||
// demos/default/.next/server/pages/en/getStaticProps/1.json | ||
const limit = pLimit(Math.max(2, cpus().length)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
const blobCalls = Object.entries(prerenderManifest.routes).map(([route, ssgRoute]) => | ||
limit(async () => { | ||
const routerTypeSubPath = ssgRoute.dataRoute.endsWith('.rsc') ? 'app' : 'pages' | ||
const dataFilePath = join(publish, 'server', routerTypeSubPath, ssgRoute.dataRoute) | ||
|
||
try { | ||
// Page data for an app router page is an RSC serialized format, i.e. a string, | ||
// or a JSON file for the pages router. | ||
const pageData = | ||
routerTypeSubPath === 'app' | ||
? await readFile(dataFilePath, 'utf8') | ||
: JSON.parse(await readFile(join(publish, 'server', routerTypeSubPath, `${route}.json`), 'utf8')) | ||
|
||
const htmlFilePath = join(publish, 'server', routerTypeSubPath, `${route}.html`) | ||
const html = await readFile(htmlFilePath, 'utf8') | ||
|
||
// TODO: once implemented in blob storage API | ||
// We need to remove the leading slash from the route so that the call to the blob storage | ||
// does not generate a 405 error. | ||
// It's currently under consideration to support this in the blob storage API. | ||
const pageRoute = route.replace(new RegExp(`^${i18n.defaultLocale}/`), '') | ||
const pageBlob: BlobISRPage = { | ||
value: html, | ||
headers: { | ||
'content-type': 'text/html', | ||
}, | ||
lastModified: Date.now(), | ||
} | ||
let { dataRoute } = ssgRoute | ||
const dataBlob: BlobISRPage = { | ||
value: pageData, | ||
headers: { | ||
'content-type': mime.lookup(dataFilePath), | ||
}, | ||
lastModified: Date.now(), | ||
} | ||
|
||
// for the index route we have to replace it with the language as this is the url that will be requested | ||
if (pageRoute === i18n.defaultLocale) { | ||
dataRoute = dataRoute.replace(/index\.json$/, `${i18n.defaultLocale}.json`) | ||
} | ||
|
||
console.log('[SET KEY]:', pageRoute, getHashedKey(pageRoute)) | ||
console.log('[SET KEY]:', dataRoute, getHashedKey(pageRoute), { ssgRoute }) | ||
return Promise.all([ | ||
netliBlob.setJSON(getHashedKey(pageRoute), pageBlob), | ||
netliBlob.setJSON(getHashedKey(dataRoute), dataBlob), | ||
]) | ||
} catch { | ||
// noop | ||
// gracefully fall back to not having it in the blob storage and the ISR ODB handler needs to let the | ||
// request fall through to the next server to generate the page nothing we can serve then. | ||
} | ||
}), | ||
) | ||
|
||
const trimmedPath = route === '/' ? 'index' : route.slice(1) | ||
await Promise.all(blobCalls) | ||
} | ||
|
||
const getPrerenderedContent = (prerenderManifest: PrerenderManifest, publish: string): string[] => [ | ||
...Object.entries(prerenderManifest.routes).flatMap(([route, ssgRoute]) => { | ||
if (ssgRoute.initialRevalidateSeconds === false) { | ||
return [] | ||
} | ||
|
||
if (ssgRoute.dataRoute.endsWith('.rsc')) { | ||
return [ | ||
join(publish, 'server', 'pages', `${trimmedPath}.html`), | ||
join(publish, 'server', 'pages', `${trimmedPath}.json`), | ||
join(publish, 'server', 'app', ssgRoute.dataRoute), | ||
join(publish, 'server', 'app', changeExtension(ssgRoute.dataRoute, '.html')), | ||
] | ||
}), | ||
join(publish, '**', '*.html'), | ||
join(publish, 'static-manifest.json'), | ||
] | ||
} | ||
} | ||
|
||
export const getSSRLambdas = async (publish: string): Promise<SSRLambda[]> => { | ||
const trimmedPath = route === '/' ? 'index' : route.slice(1) | ||
return [ | ||
join(publish, 'server', 'pages', `${trimmedPath}.html`), | ||
join(publish, 'server', 'pages', `${trimmedPath}.json`), | ||
] | ||
}), | ||
join(publish, '**', '*.html'), | ||
join(publish, 'static-manifest.json'), | ||
] | ||
|
||
// TODO: get a build feature flag set up for blob storage | ||
export const getSSRLambdas = async ({ | ||
publish, | ||
i18n, | ||
netliBlob, | ||
}: { | ||
i18n: NextConfig['i18n'] | ||
publish: string | ||
netliBlob?: Blobs | ||
}): Promise<SSRLambda[]> => { | ||
const commonDependencies = await getCommonDependencies(publish) | ||
const ssrRoutes = await getSSRRoutes(publish) | ||
|
||
// TODO: for now, they're the same - but we should separate them | ||
const nonOdbRoutes = ssrRoutes | ||
const odbRoutes = ssrRoutes | ||
|
||
const ssrDependencies = await getSSRDependencies(publish) | ||
const prerenderManifest = await getPrerenderManifest(publish) | ||
let ssrDependencies: Awaited<ReturnType<typeof getPrerenderedContent>> | ||
|
||
if (netliBlob) { | ||
console.log('using blob storage') | ||
ssrDependencies = [] | ||
|
||
try { | ||
console.log('warming up the cache with prerendered content') | ||
await setPrerenderedBlobStoreContent({ netliBlob, prerenderManifest, publish, i18n }) | ||
} catch (error) { | ||
console.error('Unable to store prerendered content in blob storage', error) | ||
|
||
throw error | ||
} | ||
} else { | ||
// We only want prerendered content stored in the lambda if we aren't using blob storage | ||
ssrDependencies = getPrerenderedContent(prerenderManifest, publish) | ||
} | ||
|
||
return [ | ||
{ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO: THis fork is already deprecated we should use esbuild directly! (follow up PR)