Skip to content

Commit

Permalink
feat: load edge functions from Frameworks API in serve (#6738)
Browse files Browse the repository at this point in the history
* feat: load edge functions from Frameworks API in `serve`

* fix: fix typo

Co-authored-by: Philippe Serhal <philippe.serhal@gmail.com>

* fix: fix `pathPrefix` fallback

* chore: fix site builder

* chore: fix test

---------

Co-authored-by: Philippe Serhal <philippe.serhal@gmail.com>
  • Loading branch information
eduardoboucas and serhalp committed Jul 1, 2024
1 parent 3262995 commit 1c6903f
Show file tree
Hide file tree
Showing 12 changed files with 199 additions and 86 deletions.
2 changes: 2 additions & 0 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
warn,
} from '../utils/command-helpers.js'
import { FeatureFlags } from '../utils/feature-flags.js'
import { getFrameworksAPIPaths } from '../utils/frameworks-api.js'
import getGlobalConfig from '../utils/get-global-config.js'
import { getSiteByName } from '../utils/get-site.js'
import openBrowser from '../utils/open-browser.js'
Expand Down Expand Up @@ -665,6 +666,7 @@ export default class BaseCommand extends Command {
globalConfig,
// state of current site dir
state,
frameworksAPIPaths: getFrameworksAPIPaths(buildDir, this.workspacePackage),
}
debug(`${this.name()}:init`)('end')
}
Expand Down
11 changes: 6 additions & 5 deletions src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,16 +461,17 @@ 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()
await command.netlify.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, frameworksAPIPaths.functions.path, functionsFolder].filter(
(folder): folder is string => Boolean(folder),
)
const functionDirectories = [
internalFunctionsFolder,
command.netlify.frameworksAPIPaths.functions.path,
functionsFolder,
].filter((folder): folder is string => Boolean(folder))
const manifestPath = skipFunctionsCache ? null : await getFunctionsManifestPath({ base: site.root, packagePath })

const redirectsPath = `${deployFolder}/_redirects`
Expand Down
12 changes: 6 additions & 6 deletions src/commands/serve/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +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 { getFrameworksAPIPaths, getFrameworksAPIConfig } 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 All @@ -34,7 +34,7 @@ import BaseCommand from '../base-command.js'
import { type DevConfig } from '../dev/types.js'

export const serve = async (options: OptionValues, command: BaseCommand) => {
const { api, cachedConfig, config, repositoryRoot, site, siteInfo, state } = command.netlify
const { api, cachedConfig, config, frameworksAPIPaths, repositoryRoot, site, siteInfo, state } = command.netlify
config.dev = { ...config.dev }
config.build = { ...config.build }
const devConfig = {
Expand Down Expand Up @@ -80,8 +80,6 @@ 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
Expand Down Expand Up @@ -119,6 +117,8 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
env: {},
})

const mergedConfig = await getFrameworksAPIConfig(config, frameworksAPIPaths.config.path)

// Now we generate a second Blobs context object, this time with edge access
// for runtime access (i.e. from functions and edge functions).
const runtimeBlobsContext = await getBlobsContextWithEdgeAccess(blobsOptions)
Expand All @@ -128,7 +128,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
const functionsRegistry = await startFunctionsServer({
blobsContext: runtimeBlobsContext,
command,
config,
config: mergedConfig,
debug: options.debug,
loadDistFunctions: true,
settings,
Expand Down Expand Up @@ -164,7 +164,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
addonsUrls,
blobsContext: runtimeBlobsContext,
command,
config,
config: mergedConfig,
configPath: configPathOverride,
debug: options.debug,
disableEdgeFunctions: options.internalDisableEdgeFunctions,
Expand Down
3 changes: 3 additions & 0 deletions src/commands/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import type { NetlifyConfig } from "@netlify/build";
import type { NetlifyTOML } from '@netlify/build-info'
import type { NetlifyAPI } from 'netlify'

import type { FrameworksAPIPaths } from "../utils/frameworks-api.ts";
import StateConfig from '../utils/state-config.js'


// eslint-disable-next-line @typescript-eslint/no-explicit-any
type $TSFixMe = any;

Expand Down Expand Up @@ -69,4 +71,5 @@ export type NetlifyOptions = {
cachedConfig: Record<string, $TSFixMe> & { env: EnvironmentVariables }
globalConfig: $TSFixMe
state: StateConfig
frameworksAPIPaths: FrameworksAPIPaths
}
22 changes: 20 additions & 2 deletions src/lib/edge-functions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,22 @@ export class EdgeFunctionsRegistry {
}
}

const importMapPaths = [this.importMapFromTOML, this.importMapFromDeployConfig]

if (this.usesFrameworksAPI) {
const { edgeFunctionsImportMap } = this.command.netlify.frameworksAPIPaths

if (await edgeFunctionsImportMap.exists()) {
importMapPaths.push(edgeFunctionsImportMap.path)
}
}

const { functionsConfig, graph, npmSpecifiersWithExtraneousFiles, success } = await this.runIsolate(
this.functions,
this.env,
{
getFunctionsConfig: true,
importMapPaths: [this.importMapFromTOML, this.importMapFromDeployConfig].filter(nonNullable),
importMapPaths: importMapPaths.filter(nonNullable),
},
)

Expand Down Expand Up @@ -569,11 +579,13 @@ export class EdgeFunctionsRegistry {
}

private async scanForFunctions() {
const [internalFunctions, userFunctions] = await Promise.all([
const [frameworkFunctions, integrationFunctions, userFunctions] = await Promise.all([
this.usesFrameworksAPI ? this.bundler.find([this.command.netlify.frameworksAPIPaths.edgeFunctions.path]) : [],
this.bundler.find([this.internalDirectory]),
this.bundler.find(this.directories),
this.scanForDeployConfig(),
])
const internalFunctions = [...frameworkFunctions, ...integrationFunctions]
const functions = [...internalFunctions, ...userFunctions]
const newFunctions = functions.filter((func) => {
const functionExists = this.functions.some(
Expand Down Expand Up @@ -634,4 +646,10 @@ export class EdgeFunctionsRegistry {

this.directoryWatchers.set(this.projectDir, watcher)
}

// We only take into account edge functions from the Frameworks API in
// the `serve` command, since we don't run the build command in `dev`.
private get usesFrameworksAPI() {
return this.command.name() === 'serve'
}
}
15 changes: 11 additions & 4 deletions src/lib/functions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,15 +420,22 @@ export class FunctionsRegistry {
if (extname(func.mainFile) === ZIP_EXTENSION) {
const unzippedDirectory = await this.unzipFunction(func)

if (this.debug) {
FunctionsRegistry.logEvent('extracted', { func })
}

// If there's a manifest file, look up the function in order to extract
// the build data.
// @ts-expect-error TS(2339) FIXME: Property 'manifest' does not exist on type 'Functi... Remove this comment to see the full error message
const manifestEntry = (this.manifest?.functions || []).find((manifestFunc) => manifestFunc.name === func.name)

// We found a zipped function that does not have a corresponding entry in
// the manifest. This shouldn't happen, but we ignore the function in
// this case.
if (!manifestEntry) {
return
}

if (this.debug) {
FunctionsRegistry.logEvent('extracted', { func })
}

func.buildData = {
...manifestEntry?.buildData,
routes: manifestEntry?.routes,
Expand Down
6 changes: 2 additions & 4 deletions src/lib/functions/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ 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 @@ -322,7 +321,6 @@ 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 @@ -352,7 +350,7 @@ export const startFunctionsServer = async (
// precedence.
const sourceDirectories: string[] = [
internalFunctionsDir,
frameworksAPIPaths.functions.path,
command.netlify.frameworksAPIPaths.functions.path,
settings.functions,
].filter(Boolean)

Expand All @@ -377,7 +375,7 @@ export const startFunctionsServer = async (
capabilities,
config,
debug,
frameworksAPIPaths,
frameworksAPIPaths: command.netlify.frameworksAPIPaths,
isConnected: Boolean(siteUrl),
logLambdaCompat: isFeatureFlagEnabled('cli_log_lambda_compat', siteInfo),
manifest,
Expand Down
45 changes: 40 additions & 5 deletions src/utils/frameworks-api.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import { mkdir } from 'fs/promises'
import { access, mkdir, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

import { mergeConfigs } from '@netlify/config'

import type { NetlifyOptions } from '../commands/types.js'

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

export type FrameworksAPIPaths = ReturnType<typeof getFrameworksAPIPaths>

/**
* 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.
* Frameworks API. Each key maps to an object containing a `path` property with
* the path of the operation, an `exists` method that returns whether the path
* exists, and an `ensureExists` method that creates it in case it doesn't.
*/
export const getFrameworksAPIPaths = (basePath: string, packagePath?: string) => {
const root = resolve(basePath, packagePath || '', '.netlify/v1')
const edgeFunctions = resolve(root, 'edge-functions')
const paths = {
root,
config: resolve(root, 'config.json'),
functions: resolve(root, 'functions'),
edgeFunctions: resolve(root, 'edge-functions'),
edgeFunctions,
edgeFunctionsImportMap: resolve(edgeFunctions, 'import_map.json'),
blobs: resolve(root, 'blobs'),
}

Expand All @@ -30,8 +39,34 @@ export const getFrameworksAPIPaths = (basePath: string, packagePath?: string) =>
ensureExists: async () => {
await mkdir(path, { recursive: true })
},
exists: async () => {
try {
await access(path)

return true
} catch {
return false
}
},
},
}),
{} as Record<keyof typeof paths, FrameworksAPIPath>,
)
}

/**
* Merges a config object with any config options from the Frameworks API.
*/
export const getFrameworksAPIConfig = async (config: NetlifyOptions['config'], frameworksAPIConfigPath: string) => {
let frameworksAPIConfigFile: string | undefined

try {
frameworksAPIConfigFile = await readFile(frameworksAPIConfigPath, 'utf8')
} catch {
return config
}

const frameworksAPIConfig = JSON.parse(frameworksAPIConfigFile)

return mergeConfigs([frameworksAPIConfig, config], { concatenateArrays: true }) as NetlifyOptions['config']
}
23 changes: 22 additions & 1 deletion tests/integration/commands/deploy/deploy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,25 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
`,
path: 'build.mjs',
})
.withEdgeFunction({
config: {
path: '/framework-edge-function-1',
},
handler: `
import { greeting } from 'alias:util';
export default async () => new Response(greeting + ' from Frameworks API edge function 1');
`,
path: 'frameworks-api-seed/edge-functions',
})
.withContentFile({
content: `export const greeting = 'Hello'`,
path: 'frameworks-api-seed/edge-functions/lib/util.ts',
})
.withContentFile({
content: JSON.stringify({ imports: { 'alias:util': './lib/util.ts' } }),
path: 'frameworks-api-seed/edge-functions/import_map.json',
})
.build()

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

const [response1, response2, response3, response4, response5, response6] = await Promise.all([
const [response1, response2, response3, response4, response5, response6, response7] = 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()),
fetch(`${deployUrl}/framework-edge-function-1`).then((res) => res.text()),
])

t.expect(response1).toEqual('User 1')
Expand All @@ -568,6 +588,7 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
t.expect(response4.status).toBe(404)
t.expect(response5).toEqual('Internal V2 API')
t.expect(response6).toEqual('Frameworks API Function 1')
t.expect(response7).toEqual('Hello from Frameworks API edge function 1')
})
})

Expand Down
6 changes: 3 additions & 3 deletions tests/integration/commands/dev/dev-miscellaneous.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -994,8 +994,8 @@ describe.concurrent('commands/dev-miscellaneous', () => {
.withEdgeFunction({
config: { path: '/internal-1' },
handler: () => new Response('Hello from an internal function'),
internal: true,
name: 'internal',
path: '.netlify/edge-functions',
})
.build()

Expand All @@ -1012,8 +1012,8 @@ describe.concurrent('commands/dev-miscellaneous', () => {
.withEdgeFunction({
config: { path: '/internal-2' },
handler: () => new Response('Hello from an internal function'),
internal: true,
name: 'internal',
path: '.netlify/edge-functions',
})
.build()

Expand Down Expand Up @@ -1070,7 +1070,7 @@ describe.concurrent('commands/dev-miscellaneous', () => {
.withEdgeFunction({
handler: `import { yell } from "yeller"; export default async () => new Response(yell("Netlify"))`,
name: 'yell',
internal: true,
path: '.netlify/edge-functions',
})
// Internal import map
.withContentFiles([
Expand Down
Loading

2 comments on commit 1c6903f

@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.