Skip to content

Commit

Permalink
feat: add support for the Frameworks API (#6735)
Browse files Browse the repository at this point in the history
* feat: add support for the Frameworks API

* chore: fix tests

* fix: oops
  • Loading branch information
eduardoboucas authored Jun 28, 2024
1 parent 73f4eb5 commit 6a48a38
Show file tree
Hide file tree
Showing 12 changed files with 161 additions and 21 deletions.
8 changes: 6 additions & 2 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js'
import { deploySite } from '../../utils/deploy/deploy-site.js'
import { getEnvelopeEnv } from '../../utils/env/index.js'
import { getFrameworksAPIPaths } from '../../utils/frameworks-api.js'
import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js'
import openBrowser from '../../utils/open-browser.js'
import BaseCommand from '../base-command.js'
Expand Down Expand Up @@ -460,12 +461,15 @@ const runDeploy = async ({
deployId = results.id

const internalFunctionsFolder = await getInternalFunctionsDir({ base: site.root, packagePath, ensureExists: true })
const frameworksAPIPaths = getFrameworksAPIPaths(site.root, packagePath)

await frameworksAPIPaths.functions.ensureExists()

// The order of the directories matter: zip-it-and-ship-it will prioritize
// functions from the rightmost directories. In this case, we want user
// functions to take precedence over internal functions.
const functionDirectories = [internalFunctionsFolder, functionsFolder].filter((folder): folder is string =>
Boolean(folder),
const functionDirectories = [internalFunctionsFolder, frameworksAPIPaths.functions.path, functionsFolder].filter(
(folder): folder is string => Boolean(folder),
)
const manifestPath = skipFunctionsCache ? null : await getFunctionsManifestPath({ base: site.root, packagePath })

Expand Down
5 changes: 5 additions & 0 deletions src/commands/serve/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.js'
import { UNLINKED_SITE_MOCK_ID, getDotEnvVariables, getSiteInformation, injectEnvVariables } from '../../utils/dev.js'
import { getEnvelopeEnv } from '../../utils/env/index.js'
import { getFrameworksAPIPaths } from '../../utils/frameworks-api.js'
import { getInternalFunctionsDir } from '../../utils/functions/functions.js'
import { ensureNetlifyIgnore } from '../../utils/gitignore.js'
import openBrowser from '../../utils/open-browser.js'
Expand Down Expand Up @@ -79,6 +80,10 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
packagePath: command.workspacePackage,
})

const frameworksAPIPaths = getFrameworksAPIPaths(site.root, command.workspacePackage)

await frameworksAPIPaths.functions.ensureExists()

let settings: ServerSettings
try {
settings = await detectServerSettings(devConfig, options, command)
Expand Down
21 changes: 16 additions & 5 deletions src/lib/functions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
warn,
watchDebounced,
} from '../../utils/command-helpers.js'
import { getFrameworksAPIPaths } from '../../utils/frameworks-api.js'
import { INTERNAL_FUNCTIONS_FOLDER, SERVE_FUNCTIONS_FOLDER } from '../../utils/functions/functions.js'
import type { BlobsContext } from '../blobs/blobs.js'
import { BACKGROUND_FUNCTIONS_WARNING } from '../log.js'
Expand All @@ -28,8 +29,9 @@ export const DEFAULT_FUNCTION_URL_EXPRESSION = /^\/.netlify\/(functions|builders
const TYPES_PACKAGE = '@netlify/functions'
const ZIP_EXTENSION = '.zip'

const isInternalFunction = (func: ListedFunction | NetlifyFunction) =>
func.mainFile.includes(getPathInProject([INTERNAL_FUNCTIONS_FOLDER]))
const isInternalFunction = (func: ListedFunction | NetlifyFunction, frameworksAPIFunctionsPath: string) =>
func.mainFile.includes(getPathInProject([INTERNAL_FUNCTIONS_FOLDER])) ||
func.mainFile.includes(frameworksAPIFunctionsPath)

/**
* @typedef {"buildError" | "extracted" | "loaded" | "missing-types-package" | "reloaded" | "reloading" | "removed"} FunctionEvent
Expand Down Expand Up @@ -61,6 +63,7 @@ export class FunctionsRegistry {
private projectRoot: string
private isConnected: boolean
private debug: boolean
private frameworksAPIPaths: ReturnType<typeof getFrameworksAPIPaths>

constructor({
blobsContext,
Expand All @@ -69,6 +72,7 @@ export class FunctionsRegistry {
// @ts-expect-error TS(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message
config,
debug = false,
frameworksAPIPaths,
isConnected = false,
// @ts-expect-error TS(7031) FIXME: Binding element 'logLambdaCompat' implicitly has a... Remove this comment to see the full error message
logLambdaCompat,
Expand All @@ -79,12 +83,19 @@ export class FunctionsRegistry {
settings,
// @ts-expect-error TS(7031) FIXME: Binding element 'timeouts' implicitly has an 'any'... Remove this comment to see the full error message
timeouts,
}: { projectRoot: string; debug?: boolean; isConnected?: boolean; blobsContext: BlobsContext } & object) {
}: {
projectRoot: string
debug?: boolean
frameworksAPIPaths: ReturnType<typeof getFrameworksAPIPaths>
isConnected?: boolean
blobsContext: BlobsContext
} & object) {
// @ts-expect-error TS(2339) FIXME: Property 'capabilities' does not exist on type 'Fu... Remove this comment to see the full error message
this.capabilities = capabilities
// @ts-expect-error TS(2339) FIXME: Property 'config' does not exist on type 'Function... Remove this comment to see the full error message
this.config = config
this.debug = debug
this.frameworksAPIPaths = frameworksAPIPaths
this.isConnected = isConnected
this.projectRoot = projectRoot
// @ts-expect-error TS(2339) FIXME: Property 'timeouts' does not exist on type 'Functi... Remove this comment to see the full error message
Expand Down Expand Up @@ -484,9 +495,9 @@ export class FunctionsRegistry {
functions
.filter(
(func) =>
isInternalFunction(func) &&
isInternalFunction(func, this.frameworksAPIPaths.functions.path) &&
this.functions.has(func.name) &&
!isInternalFunction(this.functions.get(func.name)!),
!isInternalFunction(this.functions.get(func.name)!, this.frameworksAPIPaths.functions.path),
)
.map((func) => func.name),
)
Expand Down
9 changes: 8 additions & 1 deletion src/lib/functions/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { $TSFixMe } from '../../commands/types.js'
import { NETLIFYDEVERR, NETLIFYDEVLOG, error as errorExit, log } from '../../utils/command-helpers.js'
import { UNLINKED_SITE_MOCK_ID } from '../../utils/dev.js'
import { isFeatureFlagEnabled } from '../../utils/feature-flags.js'
import { getFrameworksAPIPaths } from '../../utils/frameworks-api.js'
import {
CLOCKWORK_USERAGENT,
getFunctionsDistPath,
Expand Down Expand Up @@ -321,6 +322,7 @@ export const startFunctionsServer = async (
timeouts,
} = options
const internalFunctionsDir = await getInternalFunctionsDir({ base: site.root, packagePath: command.workspacePackage })
const frameworksAPIPaths = await getFrameworksAPIPaths(site.root, command.workspacePackage)
const functionsDirectories: string[] = []
let manifest

Expand Down Expand Up @@ -348,7 +350,11 @@ export const startFunctionsServer = async (
} else {
// The order of the function directories matters. Rightmost directories take
// precedence.
const sourceDirectories = [internalFunctionsDir, settings.functions].filter(Boolean)
const sourceDirectories: string[] = [
internalFunctionsDir,
frameworksAPIPaths.functions.path,
settings.functions,
].filter(Boolean)

functionsDirectories.push(...sourceDirectories)
}
Expand All @@ -371,6 +377,7 @@ export const startFunctionsServer = async (
capabilities,
config,
debug,
frameworksAPIPaths,
isConnected: Boolean(siteUrl),
logLambdaCompat: isFeatureFlagEnabled('cli_log_lambda_compat', siteInfo),
manifest,
Expand Down
1 change: 1 addition & 0 deletions src/utils/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const getFeatureFlagsFromSiteInfo = (siteInfo: {
...(siteInfo.feature_flags || {}),
// see https://github.com/netlify/pod-dev-foundations/issues/581#issuecomment-1731022753
zisi_golang_use_al2: isFeatureFlagEnabled('cli_golang_use_al2', siteInfo),
netlify_build_frameworks_api: true,
})

export type FeatureFlags = Record<string, boolean | string | number>
37 changes: 37 additions & 0 deletions src/utils/frameworks-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { mkdir } from 'fs/promises'
import { resolve } from 'node:path'

interface FrameworksAPIPath {
path: string
ensureExists: () => Promise<void>
}

/**
* Returns an object containing the paths for all the operations of the
* Frameworks API. Each key maps to an object containing a `path` property
* with the path of the operation and a `ensureExists` methos that creates
* the directory in case it doesn't exist.
*/
export const getFrameworksAPIPaths = (basePath: string, packagePath?: string) => {
const root = resolve(basePath, packagePath || '', '.netlify/v1')
const paths = {
root,
config: resolve(root, 'config.json'),
functions: resolve(root, 'functions'),
edgeFunctions: resolve(root, 'edge-functions'),
blobs: resolve(root, 'blobs'),
}

return Object.entries(paths).reduce(
(acc, [name, path]) => ({
...acc,
[name]: {
path,
ensureExists: async () => {
await mkdir(path, { recursive: true })
},
},
}),
{} as Record<keyof typeof paths, FrameworksAPIPath>,
)
}
10 changes: 5 additions & 5 deletions src/utils/run-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ const copyConfig = async (configPath, destinationFolder) => {
return newConfigPath
}

/**
* @param {string} basePath
*/
// @ts-expect-error TS(7006) FIXME: Parameter 'basePath' implicitly has an 'any' type.
const cleanInternalDirectory = async (basePath) => {
const cleanInternalDirectory = async (basePath?: string) => {
if (!basePath) {
return
}

const ops = [INTERNAL_FUNCTIONS_FOLDER, INTERNAL_EDGE_FUNCTIONS_FOLDER, 'netlify.toml'].map((name) => {
const fullPath = path.resolve(basePath, getPathInProject([name]))

Expand Down
30 changes: 27 additions & 3 deletions tests/integration/commands/deploy/deploy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,11 +468,14 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
})
})

test('should deploy functions from internal functions directory', async (t) => {
test('should deploy functions from internal functions directory and Frameworks API', async (t) => {
await withSiteBuilder(t, async (builder) => {
await builder
.withNetlifyToml({
config: {
build: {
command: 'node build.mjs',
},
functions: { directory: 'functions' },
},
})
Expand Down Expand Up @@ -506,6 +509,13 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
body: 'Internal 3',
}),
})
.withFunction({
config: { path: '/framework-function-1' },
path: 'framework-1.js',
pathPrefix: 'frameworks-api-seed/functions',
handler: async () => new Response('Frameworks API Function 1'),
runtimeAPIVersion: 2,
})
.withContentFile({
content: `
export default async () => new Response("Internal V2 API")
Expand All @@ -520,6 +530,18 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
`,
path: '.netlify/functions-internal/func-4.mjs',
})
.withContentFile({
content: `
import { cp, readdir } from "fs/promises";
import { resolve } from "path";
const seedPath = resolve("frameworks-api-seed");
const destPath = resolve(".netlify/v1");
await cp(seedPath, destPath, { recursive: true });
`,
path: 'build.mjs',
})
.build()

const { deploy_url: deployUrl } = await callCli(
Expand All @@ -531,19 +553,21 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
true,
)

const [response1, response2, response3, response4, response5] = await Promise.all([
const [response1, response2, response3, response4, response5, response6] = await Promise.all([
fetch(`${deployUrl}/.netlify/functions/func-1`).then((res) => res.text()),
fetch(`${deployUrl}/.netlify/functions/func-2`).then((res) => res.text()),
fetch(`${deployUrl}/.netlify/functions/func-3`).then((res) => res.text()),
fetch(`${deployUrl}/.netlify/functions/func-4`),
fetch(`${deployUrl}/internal-v2-func`).then((res) => res.text()),
fetch(`${deployUrl}/framework-function-1`).then((res) => res.text()),
])

t.expect(response1).toEqual('User 1')
t.expect(response2).toEqual('User 2')
t.expect(response3).toEqual('Internal 3')
t.expect(response4.status).toBe(404)
t.expect(response5, 'Internal V2 API')
t.expect(response5).toEqual('Internal V2 API')
t.expect(response6).toEqual('Frameworks API Function 1')
})
})

Expand Down
29 changes: 27 additions & 2 deletions tests/integration/commands/dev/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')('ntl serve should
await builder
.withNetlifyToml({
config: {
build: {
command: 'node build.mjs',
},
plugins: [{ package: './plugins/deployblobs' }],
},
})
Expand All @@ -29,6 +32,25 @@ test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')('ntl serve should
},
},
})
.withFunction({
config: { path: '/framework-function-1' },
path: 'framework-1.js',
pathPrefix: 'frameworks-api-seed/functions',
handler: async () => new Response('Frameworks API Function 1'),
runtimeAPIVersion: 2,
})
.withContentFile({
content: `
import { cp, readdir } from "fs/promises";
import { resolve } from "path";
const seedPath = resolve("frameworks-api-seed");
const destPath = resolve(".netlify/v1");
await cp(seedPath, destPath, { recursive: true });
`,
path: 'build.mjs',
})
.withContentFile({
path: 'netlify/functions/index.ts',
content: `
Expand All @@ -54,8 +76,11 @@ test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')('ntl serve should
.build()

await withDevServer({ cwd: builder.directory, serve: true }, async ({ url }) => {
const response = await fetch(new URL('/foo.txt', url)).then((res) => res.text())
t.expect(response).toEqual('foo')
const response1 = await fetch(new URL('/foo.txt', url))
t.expect(await response1.text()).toEqual('foo')

const response2 = await fetch(new URL('/framework-function-1', url))
t.expect(await response2.text()).toEqual('Frameworks API Function 1')
})
})
})
19 changes: 18 additions & 1 deletion tests/integration/utils/site-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { copyFile, mkdir, rm, unlink, writeFile } from 'fs/promises'
import os from 'os'
import path from 'path'
import process from 'process'
import { inspect } from 'util'

import slugify from '@sindresorhus/slugify'
import execa from 'execa'
Expand Down Expand Up @@ -73,20 +74,36 @@ export class SiteBuilder {
}

withFunction({
config,
esm = false,
handler,
path: filePath,
pathPrefix = 'functions',
runtimeAPIVersion,
}: {
config?: object
esm?: boolean
handler: any
path: string
pathPrefix?: string
runtimeAPIVersion?: number
}) {
const dest = path.join(this.directory, pathPrefix, filePath)
this.tasks.push(async () => {
await ensureDir(path.dirname(dest))
const file = esm ? `export const handler = ${handler.toString()}` : `exports.handler = ${handler.toString()}`

let file = ''

if (runtimeAPIVersion === 2) {
file = `const handler = ${handler.toString()}; export default handler;`

if (config) {
file += `export const config = ${inspect(config)};`
}
} else {
file = esm ? `export const handler = ${handler.toString()}` : `exports.handler = ${handler.toString()}`
}

await writeFile(dest, file)
})

Expand Down
Loading

2 comments on commit 6a48a38

@github-actions
Copy link

Choose a reason for hiding this comment

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

📊 Benchmark results

  • Dependency count: 1,213
  • Package size: 313 MB
  • Number of ts-expect-error directives: 976

@github-actions
Copy link

Choose a reason for hiding this comment

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

📊 Benchmark results

  • Dependency count: 1,213
  • Package size: 313 MB
  • Number of ts-expect-error directives: 976

Please sign in to comment.