From 1c6903faf1acca743119d3519617247543f52bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Mon, 1 Jul 2024 10:21:10 +0100 Subject: [PATCH] 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 * fix: fix `pathPrefix` fallback * chore: fix site builder * chore: fix test --------- Co-authored-by: Philippe Serhal --- src/commands/base-command.ts | 2 + src/commands/deploy/deploy.ts | 11 +- src/commands/serve/serve.ts | 12 +- src/commands/types.d.ts | 3 + src/lib/edge-functions/registry.ts | 22 ++- src/lib/functions/registry.ts | 15 +- src/lib/functions/server.ts | 6 +- src/utils/frameworks-api.ts | 45 +++++- .../commands/deploy/deploy.test.js | 23 +++- .../commands/dev/dev-miscellaneous.test.js | 6 +- tests/integration/commands/dev/serve.test.ts | 129 +++++++++++------- tests/integration/utils/site-builder.ts | 11 +- 12 files changed, 199 insertions(+), 86 deletions(-) diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index f3a30eba883..32631b4d0de 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -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' @@ -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') } diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index ea4b864bcf8..24c51fffb11 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -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` diff --git a/src/commands/serve/serve.ts b/src/commands/serve/serve.ts index 17491da7f53..6c63f3ef911 100644 --- a/src/commands/serve/serve.ts +++ b/src/commands/serve/serve.ts @@ -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' @@ -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 = { @@ -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 @@ -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) @@ -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, @@ -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, diff --git a/src/commands/types.d.ts b/src/commands/types.d.ts index ccc22349032..dce9224fe7c 100644 --- a/src/commands/types.d.ts +++ b/src/commands/types.d.ts @@ -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; @@ -69,4 +71,5 @@ export type NetlifyOptions = { cachedConfig: Record & { env: EnvironmentVariables } globalConfig: $TSFixMe state: StateConfig + frameworksAPIPaths: FrameworksAPIPaths } diff --git a/src/lib/edge-functions/registry.ts b/src/lib/edge-functions/registry.ts index a736431f532..3aa60421fdc 100644 --- a/src/lib/edge-functions/registry.ts +++ b/src/lib/edge-functions/registry.ts @@ -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), }, ) @@ -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( @@ -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' + } } diff --git a/src/lib/functions/registry.ts b/src/lib/functions/registry.ts index bfad5740048..15af41ab744 100644 --- a/src/lib/functions/registry.ts +++ b/src/lib/functions/registry.ts @@ -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, diff --git a/src/lib/functions/server.ts b/src/lib/functions/server.ts index 8bd1d370961..41974583788 100644 --- a/src/lib/functions/server.ts +++ b/src/lib/functions/server.ts @@ -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, @@ -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 @@ -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) @@ -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, diff --git a/src/utils/frameworks-api.ts b/src/utils/frameworks-api.ts index 449dc69f1dc..b50c286a479 100644 --- a/src/utils/frameworks-api.ts +++ b/src/utils/frameworks-api.ts @@ -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 + exists: () => Promise } +export type FrameworksAPIPaths = ReturnType + /** * 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'), } @@ -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, ) } + +/** + * 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'] +} diff --git a/tests/integration/commands/deploy/deploy.test.js b/tests/integration/commands/deploy/deploy.test.js index 20405a850da..839fe0700f6 100644 --- a/tests/integration/commands/deploy/deploy.test.js +++ b/tests/integration/commands/deploy/deploy.test.js @@ -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( @@ -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') @@ -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') }) }) diff --git a/tests/integration/commands/dev/dev-miscellaneous.test.js b/tests/integration/commands/dev/dev-miscellaneous.test.js index 21d8e24d62e..5042b154239 100644 --- a/tests/integration/commands/dev/dev-miscellaneous.test.js +++ b/tests/integration/commands/dev/dev-miscellaneous.test.js @@ -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() @@ -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() @@ -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([ diff --git a/tests/integration/commands/dev/serve.test.ts b/tests/integration/commands/dev/serve.test.ts index 2e19cd5f692..d94f0de73e4 100644 --- a/tests/integration/commands/dev/serve.test.ts +++ b/tests/integration/commands/dev/serve.test.ts @@ -6,41 +6,62 @@ import { withSiteBuilder } from '../../utils/site-builder.js' setupFixtureTests('plugin-changing-publish-dir', { devServer: { serve: true } }, () => { test('ntl serve should respect plugins changing publish dir', async ({ devServer }) => { - const response = await fetch(`http://localhost:${devServer.port}/`) + const response = await fetch(`http://localhost:${devServer?.port}/`) expect(response.status).toBe(200) }) }) -test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')('ntl serve should upload file-based blobs', async (t) => { - await withSiteBuilder(t, async (builder) => { - await builder - .withNetlifyToml({ - config: { - build: { - command: 'node build.mjs', +test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')( + 'ntl serve should respect blobs, functions, and edge functions generated by integrations and with the Frameworks API', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder + .withNetlifyToml({ + config: { + build: { + command: 'node build.mjs', + }, + plugins: [{ package: './plugins/deployblobs' }], }, - plugins: [{ package: './plugins/deployblobs' }], - }, - }) - .withBuildPlugin({ - name: 'deployblobs', - plugin: { - async onBuild() { - const { mkdir, writeFile } = require('node:fs/promises') - await mkdir('.netlify/blobs/deploy', { recursive: true }) - await writeFile('.netlify/blobs/deploy/foo.txt', 'foo') + }) + .withBuildPlugin({ + name: 'deployblobs', + plugin: { + async onBuild() { + const { mkdir, writeFile } = require('node:fs/promises') + await mkdir('.netlify/blobs/deploy', { recursive: true }) + await writeFile('.netlify/blobs/deploy/foo.txt', 'foo') + }, }, - }, - }) - .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: ` + }) + .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, + }) + .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', + }) + .withContentFile({ + content: ` import { cp, readdir } from "fs/promises"; import { resolve } from "path"; @@ -49,11 +70,11 @@ test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')('ntl serve should await cp(seedPath, destPath, { recursive: true }); `, - path: 'build.mjs', - }) - .withContentFile({ - path: 'netlify/functions/index.ts', - content: ` + path: 'build.mjs', + }) + .withContentFile({ + path: 'netlify/functions/index.ts', + content: ` import { getDeployStore } from "@netlify/blobs"; export default async (request: Request) => { @@ -63,24 +84,28 @@ test.skipIf(process.env.NETLIFY_TEST_DISABLE_LIVE === 'true')('ntl serve should }; export const config = { path: "/*" }; `, - }) - .withContentFile({ - path: 'package.json', - content: JSON.stringify({ - dependencies: { - '@netlify/blobs': '*', - }, - }), - }) - .withCommand({ command: ['npm', 'install'] }) - .build() + }) + .withContentFile({ + path: 'package.json', + content: JSON.stringify({ + dependencies: { + '@netlify/blobs': '*', + }, + }), + }) + .withCommand({ command: ['npm', 'install'] }) + .build() + + await withDevServer({ cwd: builder.directory, serve: true }, async ({ url }) => { + const response1 = await fetch(new URL('/foo.txt', url)) + t.expect(await response1.text()).toEqual('foo') - await withDevServer({ cwd: builder.directory, serve: true }, async ({ url }) => { - 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') - const response2 = await fetch(new URL('/framework-function-1', url)) - t.expect(await response2.text()).toEqual('Frameworks API Function 1') + const response3 = await fetch(new URL('/framework-edge-function-1', url)) + t.expect(await response3.text()).toEqual('Hello from Frameworks API edge function 1') + }) }) - }) -}) + }, +) diff --git a/tests/integration/utils/site-builder.ts b/tests/integration/utils/site-builder.ts index 7f56fe44314..b89b1597ac2 100644 --- a/tests/integration/utils/site-builder.ts +++ b/tests/integration/utils/site-builder.ts @@ -113,20 +113,23 @@ export class SiteBuilder { withEdgeFunction({ config, handler, - internal = false, + imports = '', name = 'function', + path: edgeFunctionsDirectory = 'netlify/edge-functions', pathPrefix = '', }: { config?: any handler: string | Function - internal?: boolean + imports?: string name?: string + path?: string pathPrefix?: string }) { - const edgeFunctionsDirectory = internal ? '.netlify/edge-functions' : 'netlify/edge-functions' const dest = path.join(this.directory, pathPrefix, edgeFunctionsDirectory, `${name}.js`) this.tasks.push(async () => { - let content = typeof handler === 'string' ? handler : `export default ${handler.toString()}` + let content = `${imports};` + + content += typeof handler === 'string' ? handler : `export default ${handler.toString()}` if (config) { content += `;export const config = ${JSON.stringify(config)}`