Skip to content

Commit 1c6903f

Browse files
feat: load edge functions from Frameworks API in serve (#6738)
* feat: load edge functions from Frameworks API in `serve` * fix: fix typo Co-authored-by: Philippe Serhal <[email protected]> * fix: fix `pathPrefix` fallback * chore: fix site builder * chore: fix test --------- Co-authored-by: Philippe Serhal <[email protected]>
1 parent 3262995 commit 1c6903f

File tree

12 files changed

+199
-86
lines changed

12 files changed

+199
-86
lines changed

src/commands/base-command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
warn,
3636
} from '../utils/command-helpers.js'
3737
import { FeatureFlags } from '../utils/feature-flags.js'
38+
import { getFrameworksAPIPaths } from '../utils/frameworks-api.js'
3839
import getGlobalConfig from '../utils/get-global-config.js'
3940
import { getSiteByName } from '../utils/get-site.js'
4041
import openBrowser from '../utils/open-browser.js'
@@ -665,6 +666,7 @@ export default class BaseCommand extends Command {
665666
globalConfig,
666667
// state of current site dir
667668
state,
669+
frameworksAPIPaths: getFrameworksAPIPaths(buildDir, this.workspacePackage),
668670
}
669671
debug(`${this.name()}:init`)('end')
670672
}

src/commands/deploy/deploy.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,16 +461,17 @@ const runDeploy = async ({
461461
deployId = results.id
462462

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

466-
await frameworksAPIPaths.functions.ensureExists()
465+
await command.netlify.frameworksAPIPaths.functions.ensureExists()
467466

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

476477
const redirectsPath = `${deployFolder}/_redirects`

src/commands/serve/serve.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import detectServerSettings, { getConfigWithPlugins } from '../../utils/detect-server-settings.js'
2424
import { UNLINKED_SITE_MOCK_ID, getDotEnvVariables, getSiteInformation, injectEnvVariables } from '../../utils/dev.js'
2525
import { getEnvelopeEnv } from '../../utils/env/index.js'
26-
import { getFrameworksAPIPaths } from '../../utils/frameworks-api.js'
26+
import { getFrameworksAPIPaths, getFrameworksAPIConfig } from '../../utils/frameworks-api.js'
2727
import { getInternalFunctionsDir } from '../../utils/functions/functions.js'
2828
import { ensureNetlifyIgnore } from '../../utils/gitignore.js'
2929
import openBrowser from '../../utils/open-browser.js'
@@ -34,7 +34,7 @@ import BaseCommand from '../base-command.js'
3434
import { type DevConfig } from '../dev/types.js'
3535

3636
export const serve = async (options: OptionValues, command: BaseCommand) => {
37-
const { api, cachedConfig, config, repositoryRoot, site, siteInfo, state } = command.netlify
37+
const { api, cachedConfig, config, frameworksAPIPaths, repositoryRoot, site, siteInfo, state } = command.netlify
3838
config.dev = { ...config.dev }
3939
config.build = { ...config.build }
4040
const devConfig = {
@@ -80,8 +80,6 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
8080
packagePath: command.workspacePackage,
8181
})
8282

83-
const frameworksAPIPaths = getFrameworksAPIPaths(site.root, command.workspacePackage)
84-
8583
await frameworksAPIPaths.functions.ensureExists()
8684

8785
let settings: ServerSettings
@@ -119,6 +117,8 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
119117
env: {},
120118
})
121119

120+
const mergedConfig = await getFrameworksAPIConfig(config, frameworksAPIPaths.config.path)
121+
122122
// Now we generate a second Blobs context object, this time with edge access
123123
// for runtime access (i.e. from functions and edge functions).
124124
const runtimeBlobsContext = await getBlobsContextWithEdgeAccess(blobsOptions)
@@ -128,7 +128,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
128128
const functionsRegistry = await startFunctionsServer({
129129
blobsContext: runtimeBlobsContext,
130130
command,
131-
config,
131+
config: mergedConfig,
132132
debug: options.debug,
133133
loadDistFunctions: true,
134134
settings,
@@ -164,7 +164,7 @@ export const serve = async (options: OptionValues, command: BaseCommand) => {
164164
addonsUrls,
165165
blobsContext: runtimeBlobsContext,
166166
command,
167-
config,
167+
config: mergedConfig,
168168
configPath: configPathOverride,
169169
debug: options.debug,
170170
disableEdgeFunctions: options.internalDisableEdgeFunctions,

src/commands/types.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { NetlifyConfig } from "@netlify/build";
22
import type { NetlifyTOML } from '@netlify/build-info'
33
import type { NetlifyAPI } from 'netlify'
44

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

8+
79
// eslint-disable-next-line @typescript-eslint/no-explicit-any
810
type $TSFixMe = any;
911

@@ -69,4 +71,5 @@ export type NetlifyOptions = {
6971
cachedConfig: Record<string, $TSFixMe> & { env: EnvironmentVariables }
7072
globalConfig: $TSFixMe
7173
state: StateConfig
74+
frameworksAPIPaths: FrameworksAPIPaths
7275
}

src/lib/edge-functions/registry.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,12 +527,22 @@ export class EdgeFunctionsRegistry {
527527
}
528528
}
529529

530+
const importMapPaths = [this.importMapFromTOML, this.importMapFromDeployConfig]
531+
532+
if (this.usesFrameworksAPI) {
533+
const { edgeFunctionsImportMap } = this.command.netlify.frameworksAPIPaths
534+
535+
if (await edgeFunctionsImportMap.exists()) {
536+
importMapPaths.push(edgeFunctionsImportMap.path)
537+
}
538+
}
539+
530540
const { functionsConfig, graph, npmSpecifiersWithExtraneousFiles, success } = await this.runIsolate(
531541
this.functions,
532542
this.env,
533543
{
534544
getFunctionsConfig: true,
535-
importMapPaths: [this.importMapFromTOML, this.importMapFromDeployConfig].filter(nonNullable),
545+
importMapPaths: importMapPaths.filter(nonNullable),
536546
},
537547
)
538548

@@ -569,11 +579,13 @@ export class EdgeFunctionsRegistry {
569579
}
570580

571581
private async scanForFunctions() {
572-
const [internalFunctions, userFunctions] = await Promise.all([
582+
const [frameworkFunctions, integrationFunctions, userFunctions] = await Promise.all([
583+
this.usesFrameworksAPI ? this.bundler.find([this.command.netlify.frameworksAPIPaths.edgeFunctions.path]) : [],
573584
this.bundler.find([this.internalDirectory]),
574585
this.bundler.find(this.directories),
575586
this.scanForDeployConfig(),
576587
])
588+
const internalFunctions = [...frameworkFunctions, ...integrationFunctions]
577589
const functions = [...internalFunctions, ...userFunctions]
578590
const newFunctions = functions.filter((func) => {
579591
const functionExists = this.functions.some(
@@ -634,4 +646,10 @@ export class EdgeFunctionsRegistry {
634646

635647
this.directoryWatchers.set(this.projectDir, watcher)
636648
}
649+
650+
// We only take into account edge functions from the Frameworks API in
651+
// the `serve` command, since we don't run the build command in `dev`.
652+
private get usesFrameworksAPI() {
653+
return this.command.name() === 'serve'
654+
}
637655
}

src/lib/functions/registry.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -420,15 +420,22 @@ export class FunctionsRegistry {
420420
if (extname(func.mainFile) === ZIP_EXTENSION) {
421421
const unzippedDirectory = await this.unzipFunction(func)
422422

423-
if (this.debug) {
424-
FunctionsRegistry.logEvent('extracted', { func })
425-
}
426-
427423
// If there's a manifest file, look up the function in order to extract
428424
// the build data.
429425
// @ts-expect-error TS(2339) FIXME: Property 'manifest' does not exist on type 'Functi... Remove this comment to see the full error message
430426
const manifestEntry = (this.manifest?.functions || []).find((manifestFunc) => manifestFunc.name === func.name)
431427

428+
// We found a zipped function that does not have a corresponding entry in
429+
// the manifest. This shouldn't happen, but we ignore the function in
430+
// this case.
431+
if (!manifestEntry) {
432+
return
433+
}
434+
435+
if (this.debug) {
436+
FunctionsRegistry.logEvent('extracted', { func })
437+
}
438+
432439
func.buildData = {
433440
...manifestEntry?.buildData,
434441
routes: manifestEntry?.routes,

src/lib/functions/server.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import type { $TSFixMe } from '../../commands/types.js'
1212
import { NETLIFYDEVERR, NETLIFYDEVLOG, error as errorExit, log } from '../../utils/command-helpers.js'
1313
import { UNLINKED_SITE_MOCK_ID } from '../../utils/dev.js'
1414
import { isFeatureFlagEnabled } from '../../utils/feature-flags.js'
15-
import { getFrameworksAPIPaths } from '../../utils/frameworks-api.js'
1615
import {
1716
CLOCKWORK_USERAGENT,
1817
getFunctionsDistPath,
@@ -322,7 +321,6 @@ export const startFunctionsServer = async (
322321
timeouts,
323322
} = options
324323
const internalFunctionsDir = await getInternalFunctionsDir({ base: site.root, packagePath: command.workspacePackage })
325-
const frameworksAPIPaths = await getFrameworksAPIPaths(site.root, command.workspacePackage)
326324
const functionsDirectories: string[] = []
327325
let manifest
328326

@@ -352,7 +350,7 @@ export const startFunctionsServer = async (
352350
// precedence.
353351
const sourceDirectories: string[] = [
354352
internalFunctionsDir,
355-
frameworksAPIPaths.functions.path,
353+
command.netlify.frameworksAPIPaths.functions.path,
356354
settings.functions,
357355
].filter(Boolean)
358356

@@ -377,7 +375,7 @@ export const startFunctionsServer = async (
377375
capabilities,
378376
config,
379377
debug,
380-
frameworksAPIPaths,
378+
frameworksAPIPaths: command.netlify.frameworksAPIPaths,
381379
isConnected: Boolean(siteUrl),
382380
logLambdaCompat: isFeatureFlagEnabled('cli_log_lambda_compat', siteInfo),
383381
manifest,

src/utils/frameworks-api.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
1-
import { mkdir } from 'fs/promises'
1+
import { access, mkdir, readFile } from 'node:fs/promises'
22
import { resolve } from 'node:path'
33

4+
import { mergeConfigs } from '@netlify/config'
5+
6+
import type { NetlifyOptions } from '../commands/types.js'
7+
48
interface FrameworksAPIPath {
59
path: string
610
ensureExists: () => Promise<void>
11+
exists: () => Promise<boolean>
712
}
813

14+
export type FrameworksAPIPaths = ReturnType<typeof getFrameworksAPIPaths>
15+
916
/**
1017
* Returns an object containing the paths for all the operations of the
11-
* Frameworks API. Each key maps to an object containing a `path` property
12-
* with the path of the operation and a `ensureExists` methos that creates
13-
* the directory in case it doesn't exist.
18+
* Frameworks API. Each key maps to an object containing a `path` property with
19+
* the path of the operation, an `exists` method that returns whether the path
20+
* exists, and an `ensureExists` method that creates it in case it doesn't.
1421
*/
1522
export const getFrameworksAPIPaths = (basePath: string, packagePath?: string) => {
1623
const root = resolve(basePath, packagePath || '', '.netlify/v1')
24+
const edgeFunctions = resolve(root, 'edge-functions')
1725
const paths = {
1826
root,
1927
config: resolve(root, 'config.json'),
2028
functions: resolve(root, 'functions'),
21-
edgeFunctions: resolve(root, 'edge-functions'),
29+
edgeFunctions,
30+
edgeFunctionsImportMap: resolve(edgeFunctions, 'import_map.json'),
2231
blobs: resolve(root, 'blobs'),
2332
}
2433

@@ -30,8 +39,34 @@ export const getFrameworksAPIPaths = (basePath: string, packagePath?: string) =>
3039
ensureExists: async () => {
3140
await mkdir(path, { recursive: true })
3241
},
42+
exists: async () => {
43+
try {
44+
await access(path)
45+
46+
return true
47+
} catch {
48+
return false
49+
}
50+
},
3351
},
3452
}),
3553
{} as Record<keyof typeof paths, FrameworksAPIPath>,
3654
)
3755
}
56+
57+
/**
58+
* Merges a config object with any config options from the Frameworks API.
59+
*/
60+
export const getFrameworksAPIConfig = async (config: NetlifyOptions['config'], frameworksAPIConfigPath: string) => {
61+
let frameworksAPIConfigFile: string | undefined
62+
63+
try {
64+
frameworksAPIConfigFile = await readFile(frameworksAPIConfigPath, 'utf8')
65+
} catch {
66+
return config
67+
}
68+
69+
const frameworksAPIConfig = JSON.parse(frameworksAPIConfigFile)
70+
71+
return mergeConfigs([frameworksAPIConfig, config], { concatenateArrays: true }) as NetlifyOptions['config']
72+
}

tests/integration/commands/deploy/deploy.test.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,25 @@ describe.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true').concurrent('co
542542
`,
543543
path: 'build.mjs',
544544
})
545+
.withEdgeFunction({
546+
config: {
547+
path: '/framework-edge-function-1',
548+
},
549+
handler: `
550+
import { greeting } from 'alias:util';
551+
552+
export default async () => new Response(greeting + ' from Frameworks API edge function 1');
553+
`,
554+
path: 'frameworks-api-seed/edge-functions',
555+
})
556+
.withContentFile({
557+
content: `export const greeting = 'Hello'`,
558+
path: 'frameworks-api-seed/edge-functions/lib/util.ts',
559+
})
560+
.withContentFile({
561+
content: JSON.stringify({ imports: { 'alias:util': './lib/util.ts' } }),
562+
path: 'frameworks-api-seed/edge-functions/import_map.json',
563+
})
545564
.build()
546565

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

556-
const [response1, response2, response3, response4, response5, response6] = await Promise.all([
575+
const [response1, response2, response3, response4, response5, response6, response7] = await Promise.all([
557576
fetch(`${deployUrl}/.netlify/functions/func-1`).then((res) => res.text()),
558577
fetch(`${deployUrl}/.netlify/functions/func-2`).then((res) => res.text()),
559578
fetch(`${deployUrl}/.netlify/functions/func-3`).then((res) => res.text()),
560579
fetch(`${deployUrl}/.netlify/functions/func-4`),
561580
fetch(`${deployUrl}/internal-v2-func`).then((res) => res.text()),
562581
fetch(`${deployUrl}/framework-function-1`).then((res) => res.text()),
582+
fetch(`${deployUrl}/framework-edge-function-1`).then((res) => res.text()),
563583
])
564584

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

tests/integration/commands/dev/dev-miscellaneous.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -994,8 +994,8 @@ describe.concurrent('commands/dev-miscellaneous', () => {
994994
.withEdgeFunction({
995995
config: { path: '/internal-1' },
996996
handler: () => new Response('Hello from an internal function'),
997-
internal: true,
998997
name: 'internal',
998+
path: '.netlify/edge-functions',
999999
})
10001000
.build()
10011001

@@ -1012,8 +1012,8 @@ describe.concurrent('commands/dev-miscellaneous', () => {
10121012
.withEdgeFunction({
10131013
config: { path: '/internal-2' },
10141014
handler: () => new Response('Hello from an internal function'),
1015-
internal: true,
10161015
name: 'internal',
1016+
path: '.netlify/edge-functions',
10171017
})
10181018
.build()
10191019

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

0 commit comments

Comments
 (0)