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: use Frameworks API #2547

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2729455
migrate blobs to frameworks api
pieh Jun 28, 2024
5a5fcb0
migrate serverless functions to frameworks api
pieh Jun 28, 2024
dda77b7
migrate edge functions to frameworks api
pieh Jul 3, 2024
173cefe
prep blobs handling to conditionally use frameworks api, but still be…
pieh Jul 3, 2024
96edce2
conditionally use frameworski api when generating serverless and edge…
pieh Jul 16, 2024
c9f0f41
memoize deployment config
pieh Jul 16, 2024
0c91ea6
try integration tests with recent build version
pieh Jul 16, 2024
869e448
integration tests handle frameworks api
pieh Jul 16, 2024
3f27a9a
test: add e2e test for pre-frameworks api
pieh Jul 16, 2024
9864c7c
don't introduce eslint disables
pieh Jul 16, 2024
1d49fff
test: add unit test for frameworks API dirs
pieh Jul 16, 2024
4817063
chore: update some code comments
pieh Jul 16, 2024
aec7ac2
fix lint
pieh Jul 16, 2024
f3b7306
add a helper for how edge function config is defined
pieh Jul 17, 2024
ab76a7d
Merge remote-tracking branch 'origin/main' into michalpiechowiak/frp-…
pieh Jul 22, 2024
7630ab4
add a helper for how serverless function config is defined
pieh Jul 23, 2024
6c7874c
Merge remote-tracking branch 'origin/main' into michalpiechowiak/frp-…
pieh Jul 23, 2024
4358a1c
Merge remote-tracking branch 'origin/main' into michalpiechowiak/frp-…
pieh Aug 21, 2024
ee47e8b
add next.deployStrategy otel attribute
pieh Aug 21, 2024
e3a806c
update new test assertion
pieh Aug 21, 2024
4424419
validate deployment setup to ensure we are testing what we think we a…
pieh Aug 22, 2024
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 .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ module.exports = {
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'error',
},
},
{
Expand Down
86 changes: 43 additions & 43 deletions src/build/advanced-api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,49 +35,6 @@ interface ApiBackgroundConfig {

type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig

export async function getAPIRoutesConfigs(ctx: PluginContext) {
const functionsConfigManifestPath = join(
ctx.publishDir,
'server',
'functions-config-manifest.json',
)
if (!existsSync(functionsConfigManifestPath)) {
// before https://github.com/vercel/next.js/pull/60163 this file might not have been produced if there were no API routes at all
return []
}

const functionsConfigManifest = JSON.parse(
await readFile(functionsConfigManifestPath, 'utf-8'),
) as FunctionsConfigManifest

const appDir = ctx.resolveFromSiteDir('.')
const pagesDir = join(appDir, 'pages')
const srcPagesDir = join(appDir, 'src', 'pages')
const { pageExtensions } = ctx.requiredServerFiles.config

return Promise.all(
Object.keys(functionsConfigManifest.functions).map(async (apiRoute) => {
const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions)

const sharedFields = {
apiRoute,
filePath,
config: {} as ApiConfig,
}

if (filePath) {
const config = await extractConfigFromFile(filePath, appDir)
return {
...sharedFields,
config,
}
}

return sharedFields
}),
)
}

// Next.js already defines a default `pageExtensions` array in its `required-server-files.json` file
// In case it gets `undefined`, this is a fallback
const SOURCE_FILE_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx']
Expand Down Expand Up @@ -186,3 +143,46 @@ const extractConfigFromFile = async (apiFilePath: string, appDir: string): Promi
return {}
}
}

export async function getAPIRoutesConfigs(ctx: PluginContext) {
const functionsConfigManifestPath = join(
ctx.publishDir,
'server',
'functions-config-manifest.json',
)
if (!existsSync(functionsConfigManifestPath)) {
// before https://github.com/vercel/next.js/pull/60163 this file might not have been produced if there were no API routes at all
return []
}

const functionsConfigManifest = JSON.parse(
await readFile(functionsConfigManifestPath, 'utf-8'),
) as FunctionsConfigManifest

const appDir = ctx.resolveFromSiteDir('.')
const pagesDir = join(appDir, 'pages')
const srcPagesDir = join(appDir, 'src', 'pages')
const { pageExtensions } = ctx.requiredServerFiles.config

return Promise.all(
Object.keys(functionsConfigManifest.functions).map(async (apiRoute) => {
const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions)

const sharedFields = {
apiRoute,
filePath,
config: {} as ApiConfig,
}

if (filePath) {
const config = await extractConfigFromFile(filePath, appDir)
return {
...sharedFields,
config,
}
}

return sharedFields
}),
)
}
7 changes: 2 additions & 5 deletions src/build/content/prerendered.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'

import { trace } from '@opentelemetry/api'
Expand All @@ -8,7 +8,6 @@ import { glob } from 'fast-glob'
import pLimit from 'p-limit'
import { satisfies } from 'semver'

import { encodeBlobKey } from '../../shared/blobkey.js'
import type {
CachedFetchValue,
NetlifyCachedAppPageValue,
Expand All @@ -31,13 +30,11 @@ const writeCacheEntry = async (
lastModified: number,
ctx: PluginContext,
): Promise<void> => {
const path = join(ctx.blobDir, await encodeBlobKey(route))
const entry = JSON.stringify({
lastModified,
value,
} satisfies NetlifyCacheHandlerValue)

await writeFile(path, entry, 'utf-8')
await ctx.setBlob(route, entry)
}

/**
Expand Down
48 changes: 24 additions & 24 deletions src/build/content/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ function isError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error
}

/**
* Generates a copy of the middleware manifest without any middleware in it. We
* do this because we'll run middleware in an edge function, and we don't want
* to run it again in the server handler.
*/
const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) => {
await mkdir(dirname(destPath), { recursive: true })

const data = await readFile(sourcePath, 'utf8')
const manifest = JSON.parse(data)

// TODO: Check for `manifest.version` and write an error to the system log
// when we find a value that is not equal to 2. This will alert us in case
// Next.js starts using a new format for the manifest and we're writing
// one with the old version.
const newManifest = {
...manifest,
middleware: {},
}
const newData = JSON.stringify(newManifest)

await writeFile(destPath, newData)
}

/**
* Copy App/Pages Router Javascript needed by the server handler
*/
Expand Down Expand Up @@ -311,30 +335,6 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise<void> =>
})
}

/**
* Generates a copy of the middleware manifest without any middleware in it. We
* do this because we'll run middleware in an edge function, and we don't want
* to run it again in the server handler.
*/
const replaceMiddlewareManifest = async (sourcePath: string, destPath: string) => {
await mkdir(dirname(destPath), { recursive: true })

const data = await readFile(sourcePath, 'utf8')
const manifest = JSON.parse(data)

// TODO: Check for `manifest.version` and write an error to the system log
// when we find a value that is not equal to 2. This will alert us in case
// Next.js starts using a new format for the manifest and we're writing
// one with the old version.
const newManifest = {
...manifest,
middleware: {},
}
const newData = JSON.stringify(newManifest)

await writeFile(destPath, newData)
}

export const verifyHandlerDirStructure = async (ctx: PluginContext) => {
const runConfig = JSON.parse(await readFile(join(ctx.serverHandlerDir, RUN_CONFIG), 'utf-8'))

Expand Down
5 changes: 2 additions & 3 deletions src/build/content/static.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { existsSync } from 'node:fs'
import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { cp, mkdir, readFile, rename, rm } from 'node:fs/promises'
import { basename, join } from 'node:path'

import { trace } from '@opentelemetry/api'
import { wrapTracer } from '@opentelemetry/api/experimental'
import glob from 'fast-glob'

import { encodeBlobKey } from '../../shared/blobkey.js'
import { PluginContext } from '../plugin-context.js'
import { verifyNetlifyForms } from '../verification.js'

Expand All @@ -33,7 +32,7 @@ export const copyStaticContent = async (ctx: PluginContext): Promise<void> => {
.map(async (path): Promise<void> => {
const html = await readFile(join(srcDir, path), 'utf-8')
verifyNetlifyForms(ctx, html)
await writeFile(join(destDir, await encodeBlobKey(path)), html, 'utf-8')
await ctx.setBlob(path, html)
}),
)
} catch (error) {
Expand Down
56 changes: 37 additions & 19 deletions src/build/functions/edge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'

import type { Manifest, ManifestFunction } from '@netlify/edge-functions'
import type { IntegrationsConfig, Manifest, ManifestFunction } from '@netlify/edge-functions'
import { glob } from 'fast-glob'
import type { EdgeFunctionDefinition as NextDefinition } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
import { pathToRegexp } from 'path-to-regexp'
Expand Down Expand Up @@ -53,7 +53,23 @@ const augmentMatchers = (
})
}

const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
const getHandlerName = ({ name }: Pick<NextDefinition, 'name'>): string =>
`${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}`

const getEdgeFunctionSharedConfig = (
ctx: PluginContext,
{ name, page }: Pick<NextDefinition, 'name' | 'page'>,
) => {
return {
name: name.endsWith('middleware')
? 'Next.js Middleware Handler'
: `Next.js Edge Handler: ${page}`,
cache: name.endsWith('middleware') ? undefined : ('manual' as const),
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
}
}

const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: NextDefinition) => {
const nextConfig = ctx.buildConfig
const handlerName = getHandlerName({ name })
const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
Expand All @@ -63,6 +79,8 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
// Netlify Edge Functions and the Next.js edge runtime.
await copyRuntime(ctx, handlerDirectory)

const augmentedMatchers = augmentMatchers(matchers, ctx)

// Writing a file with the matchers that should trigger this function. We'll
// read this file from the function at runtime.
await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(matchers))
Expand All @@ -82,6 +100,14 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
JSON.stringify(minimalNextConfig),
)

const isc =
ctx.edgeFunctionsConfigStrategy === 'inline'
? `export const config = ${JSON.stringify({
...getEdgeFunctionSharedConfig(ctx, { name, page }),
pattern: augmentedMatchers.map((matcher) => matcher.regexp),
} satisfies IntegrationsConfig)};`
: ``

// Writing the function entry file. It wraps the middleware code with the
// compatibility layer mentioned above.
await writeFile(
Expand All @@ -90,7 +116,7 @@ const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefi
import {handleMiddleware} from './edge-runtime/middleware.ts';
import handler from './server/${name}.js';
export default (req, context) => handleMiddleware(req, context, handler);
`,
${isc}`,
)
}

Expand Down Expand Up @@ -136,26 +162,16 @@ const createEdgeHandler = async (ctx: PluginContext, definition: NextDefinition)
await writeHandlerFile(ctx, definition)
}

const getHandlerName = ({ name }: Pick<NextDefinition, 'name'>): string =>
`${EDGE_HANDLER_NAME}-${name.replace(/\W/g, '-')}`

const buildHandlerDefinition = (
ctx: PluginContext,
{ name, matchers, page }: NextDefinition,
): Array<ManifestFunction> => {
const fun = getHandlerName({ name })
const funName = name.endsWith('middleware')
? 'Next.js Middleware Handler'
: `Next.js Edge Handler: ${page}`
const cache = name.endsWith('middleware') ? undefined : ('manual' as const)
const generator = `${ctx.pluginName}@${ctx.pluginVersion}`

return augmentMatchers(matchers, ctx).map((matcher) => ({
...getEdgeFunctionSharedConfig(ctx, { name, page }),
function: fun,
name: funName,
pattern: matcher.regexp,
cache,
generator,
}))
}

Expand All @@ -171,10 +187,12 @@ export const createEdgeHandlers = async (ctx: PluginContext) => {
]
await Promise.all(nextDefinitions.map((def) => createEdgeHandler(ctx, def)))

const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))
const netlifyManifest: Manifest = {
version: 1,
functions: netlifyDefinitions,
if (ctx.edgeFunctionsConfigStrategy === 'manifest') {
const netlifyDefinitions = nextDefinitions.flatMap((def) => buildHandlerDefinition(ctx, def))
const netlifyManifest: Manifest = {
version: 1,
functions: netlifyDefinitions,
}
await writeEdgeManifest(ctx, netlifyManifest)
}
await writeEdgeManifest(ctx, netlifyManifest)
}
8 changes: 6 additions & 2 deletions src/build/functions/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ const getHandlerFile = async (ctx: PluginContext): Promise<string> => {
const templatesDir = join(ctx.pluginDir, 'dist/build/templates')

const templateVariables: Record<string, string> = {
'{{useRegionalBlobs}}': ctx.useRegionalBlobs.toString(),
'{{useRegionalBlobs}}': (ctx.blobsStrategy !== 'legacy').toString(),
'{{generator}}': `${ctx.pluginName}@${ctx.pluginVersion}`,
'{{serverHandlerRootDir}}': ctx.serverHandlerRootDir,
}
// In this case it is a monorepo and we need to use a own template for it
// as we have to change the process working directory
Expand Down Expand Up @@ -143,7 +145,9 @@ export const createServerHandler = async (ctx: PluginContext) => {
await copyNextServerCode(ctx)
await copyNextDependencies(ctx)
await copyHandlerDependencies(ctx)
await writeHandlerManifest(ctx)
if (ctx.serverHandlerConfigStrategy === 'manifest') {
await writeHandlerManifest(ctx)
}
await writeHandlerFile(ctx)

await verifyHandlerDirStructure(ctx)
Expand Down
17 changes: 17 additions & 0 deletions src/build/plugin-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,20 @@ test('should use deploy configuration blobs directory when @netlify/build versio

expect(ctx.blobDir).toBe(join(cwd, '.netlify/deploy/v1/blobs/deploy'))
})

test('should use frameworks API directories when @netlify/build version supports it', () => {
const { cwd } = mockFileSystem({
'.next/required-server-files.json': JSON.stringify({
config: { distDir: '.next' },
relativeAppDir: '',
} as RequiredServerFilesManifest),
})

const ctx = new PluginContext({
constants: { NETLIFY_BUILD_VERSION: '29.50.5' },
} as unknown as NetlifyPluginOptions)

expect(ctx.blobDir).toBe(join(cwd, '.netlify/v1/blobs/deploy'))
expect(ctx.edgeFunctionsDir).toBe(join(cwd, '.netlify/v1/edge-functions'))
expect(ctx.serverFunctionsDir).toBe(join(cwd, '.netlify/v1/functions'))
})
Loading
Loading