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 11 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
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
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
36 changes: 24 additions & 12 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,9 +53,9 @@
})
}

const writeHandlerFile = async (ctx: PluginContext, { matchers, name }: NextDefinition) => {
const writeHandlerFile = async (ctx: PluginContext, { matchers, name, page }: NextDefinition) => {
const nextConfig = ctx.buildConfig
const handlerName = getHandlerName({ name })

Check failure on line 58 in src/build/functions/edge.ts

View workflow job for this annotation

GitHub Actions / Lint

'getHandlerName' was used before it was defined.
const handlerDirectory = join(ctx.edgeFunctionsDir, handlerName)
const handlerRuntimeDirectory = join(handlerDirectory, 'edge-runtime')

Expand All @@ -63,12 +63,11 @@
// 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(augmentMatchers(matchers, ctx)),
)
await writeFile(join(handlerRuntimeDirectory, 'matchers.json'), JSON.stringify(augmentedMatchers))

// The config is needed by the edge function to match and normalize URLs. To
// avoid shipping and parsing a large file at runtime, let's strip it down to
Expand All @@ -85,6 +84,17 @@
JSON.stringify(minimalNextConfig),
)

const isc = ctx.useFrameworksAPI
? `export const config = ${JSON.stringify({
name: name.endsWith('middleware')
? 'Next.js Middleware Handler'
: `Next.js Edge Handler: ${page}`,
pattern: augmentedMatchers.map((matcher) => matcher.regexp),
cache: name.endsWith('middleware') ? undefined : 'manual',
generator: `${ctx.pluginName}@${ctx.pluginVersion}`,
} satisfies IntegrationsConfig)};`
: ``

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

Expand All @@ -102,7 +112,7 @@
{ name, files, wasm }: NextDefinition,
) => {
const srcDir = join(ctx.standaloneDir, ctx.nextDistDir)
const destDir = join(ctx.edgeFunctionsDir, getHandlerName({ name }))

Check failure on line 115 in src/build/functions/edge.ts

View workflow job for this annotation

GitHub Actions / Lint

'getHandlerName' was used before it was defined.

const edgeRuntimeDir = join(ctx.pluginDir, 'edge-runtime')
const shimPath = join(edgeRuntimeDir, 'shim/index.js')
Expand Down Expand Up @@ -174,10 +184,12 @@
]
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.useFrameworksAPI) {
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 @@ -103,7 +103,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 @@ -141,7 +143,9 @@ export const createServerHandler = async (ctx: PluginContext) => {
await copyNextServerCode(ctx)
await copyNextDependencies(ctx)
await copyHandlerDependencies(ctx)
await writeHandlerManifest(ctx)
if (!ctx.useFrameworksAPI) {
await writeHandlerManifest(ctx)
}
await writePackageMetadata(ctx)
await writeHandlerFile(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'))
})
76 changes: 65 additions & 11 deletions src/build/plugin-context.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { join, relative, resolve } from 'node:path'
import { dirname, join, relative, resolve } from 'node:path'
import { join as posixJoin } from 'node:path/posix'
import { fileURLToPath } from 'node:url'

Expand All @@ -15,6 +15,8 @@ import type { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middlew
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
import { satisfies } from 'semver'

import { encodeBlobKey } from '../shared/blobkey.js'

const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
const PLUGIN_DIR = join(MODULE_DIR, '../..')
const DEFAULT_PUBLISH_DIR = '.next'
Expand Down Expand Up @@ -137,36 +139,84 @@ export class PluginContext {

/**
* Absolute path of the directory that will be deployed to the blob store
* frameworks api: `.netlify/v1/blobs/deploy`
* region aware: `.netlify/deploy/v1/blobs/deploy`
* default: `.netlify/blobs/deploy`
*/
get blobDir(): string {
if (this.useRegionalBlobs) {
return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy')
switch (this.blobsStrategy) {
case 'frameworks-api':
return this.resolveFromPackagePath('.netlify/v1/blobs/deploy')
case 'regional':
return this.resolveFromPackagePath('.netlify/deploy/v1/blobs/deploy')
case 'legacy':
default:
return this.resolveFromPackagePath('.netlify/blobs/deploy')
}
}

return this.resolveFromPackagePath('.netlify/blobs/deploy')
async setBlob(key: string, value: string) {
switch (this.blobsStrategy) {
case 'frameworks-api': {
const path = join(this.blobDir, await encodeBlobKey(key), 'blob')
await mkdir(dirname(path), { recursive: true })
await writeFile(path, value, 'utf-8')
return
}
case 'regional':
case 'legacy':
default: {
const path = join(this.blobDir, await encodeBlobKey(key))
await writeFile(path, value, 'utf-8')
}
}
}

get buildVersion(): string {
return this.constants.NETLIFY_BUILD_VERSION || 'v0.0.0'
}

get useRegionalBlobs(): boolean {
if (!(this.featureFlags || {})['next-runtime-regional-blobs']) {
return false
#useFrameworksAPI: PluginContext['useFrameworksAPI'] | null = null
get useFrameworksAPI(): boolean {
if (this.#useFrameworksAPI === null) {
// Defining RegExp pattern in edge function inline config is only supported since Build 29.50.5 / CLI 17.32.1
const REQUIRED_BUILD_VERSION = '>=29.50.5'
this.#useFrameworksAPI = satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, {
includePrerelease: true,
})
}

return this.#useFrameworksAPI
}

#blobsStrategy: PluginContext['blobsStrategy'] | null = null
get blobsStrategy(): 'legacy' | 'regional' | 'frameworks-api' {
if (this.#blobsStrategy === null) {
if (this.useFrameworksAPI) {
this.#blobsStrategy = 'frameworks-api'
} else {
// Region-aware blobs are only available as of CLI v17.23.5 (i.e. Build v29.41.5)
const REQUIRED_BUILD_VERSION = '>=29.41.5'
this.#blobsStrategy = satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, {
includePrerelease: true,
})
? 'regional'
: 'legacy'
}
}

// Region-aware blobs are only available as of CLI v17.23.5 (i.e. Build v29.41.5)
const REQUIRED_BUILD_VERSION = '>=29.41.5'
return satisfies(this.buildVersion, REQUIRED_BUILD_VERSION, { includePrerelease: true })
return this.#blobsStrategy
}

/**
* Absolute path of the directory containing the files for the serverless lambda function
* `.netlify/functions-internal`
*/
get serverFunctionsDir(): string {
if (this.useFrameworksAPI) {
return this.resolveFromPackagePath('.netlify/v1/functions')
}

return this.resolveFromPackagePath('.netlify/functions-internal')
}

Expand Down Expand Up @@ -194,6 +244,10 @@ export class PluginContext {
* `.netlify/edge-functions`
*/
get edgeFunctionsDir(): string {
if (this.useFrameworksAPI) {
return this.resolveFromPackagePath('.netlify/v1/edge-functions')
}

return this.resolveFromPackagePath('.netlify/edge-functions')
}

Expand Down
5 changes: 5 additions & 0 deletions src/build/templates/handler-monorepo.tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,9 @@ export default async function (req, context) {
export const config = {
path: '/*',
preferStatic: true,
name: 'Next.js Server Handler',
generator: '{{generator}}',
nodeBundler: 'none',
includedFiles: ['**'],
includedFilesBasePath: '{{serverHandlerRootDir}}',
}
5 changes: 5 additions & 0 deletions src/build/templates/handler.tmpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,9 @@ export default async function handler(req, context) {
export const config = {
path: '/*',
preferStatic: true,
name: 'Next.js Server Handler',
generator: '{{generator}}',
nodeBundler: 'none',
includedFiles: ['**'],
includedFilesBasePath: '{{serverHandlerRootDir}}',
}
28 changes: 28 additions & 0 deletions tests/e2e/cli-before-frameworks-api-support.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import { test } from '../utils/playwright-helpers.js'

test('should serve 404 page when requesting non existing page (no matching route) if site is deployed with CLI not supporting frameworks API', async ({
page,
cliBeforeFrameworksAPISupport,
}) => {
// 404 page is built and uploaded to blobs at build time
// when Next.js serves 404 it will try to fetch it from the blob store
// if request handler function is unable to get from blob store it will
// fail request handling and serve 500 error.
// This implicitly tests that request handler function is able to read blobs
// that are uploaded as part of site deploy.
// This also tests if edge middleware is working.

const response = await page.goto(new URL('non-existing', cliBeforeFrameworksAPISupport.url).href)
const headers = response?.headers() || {}
expect(response?.status()).toBe(404)

expect(await page.textContent('h1')).toBe('404')

expect(headers['netlify-cdn-cache-control']).toBe(
'no-cache, no-store, max-age=0, must-revalidate',
)
expect(headers['cache-control']).toBe('no-cache,no-store,max-age=0,must-revalidate')

expect(headers['x-hello-from-middleware']).toBe('hello')
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server'

export function middleware() {
const response: NextResponse = NextResponse.next()

response.headers.set('x-hello-from-middleware', 'hello')

return response
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
}

module.exports = nextConfig
16 changes: 16 additions & 0 deletions tests/fixtures/cli-before-frameworks-api-support/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "old-cli",
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "next build",
"dev": "next dev",
"build": "next build"
},
"dependencies": {
"next": "latest",
"netlify-cli": "17.32.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
Loading
Loading