Skip to content
This repository has been archived by the owner on May 22, 2024. It is now read-only.

feat: add support to per-function configuration files #1030

Merged
merged 9 commits into from
Aug 17, 2022
70 changes: 66 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { promises as fs } from 'fs'
import { basename, extname, dirname, join } from 'path'

import mergeOptions from 'merge-options'

import type { FeatureFlags } from './feature_flags.js'
import { FunctionSource } from './function.js'
import type { NodeBundlerName } from './runtimes/node/bundlers/types.js'
import type { NodeVersionString } from './runtimes/node/index.js'
import { minimatch } from './utils/matching.js'

export interface FunctionConfig {
interface FunctionConfig {
externalNodeModules?: string[]
includedFiles?: string[]
includedFilesBasePath?: string
danez marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -19,16 +23,54 @@ export interface FunctionConfig {
zipGo?: boolean
}

interface FunctionConfigFile {
config: FunctionConfig
version: number
}

type GlobPattern = string

export type Config = Record<GlobPattern, FunctionConfig>
type Config = Record<GlobPattern, FunctionConfig>
type FunctionWithoutConfig = Omit<FunctionSource, 'config'>

const getConfigForFunction = async ({
config,
configFileDirectories,
func,
featureFlags,
}: {
config?: Config
configFileDirectories?: string[]
func: FunctionWithoutConfig
featureFlags: FeatureFlags
}): Promise<FunctionConfig> => {
const fromConfig = getFromMainConfig({ config, func })

// We try to read from a function config file if the function directory is
// inside one of `configFileDirectories`.
const shouldReadConfigFile =
featureFlags.project_deploy_configuration_api_use_per_function_configuration_files &&
danez marked this conversation as resolved.
Show resolved Hide resolved
configFileDirectories?.some((directory) => func.srcDir.startsWith(directory))
danez marked this conversation as resolved.
Show resolved Hide resolved

if (!shouldReadConfigFile) {
return fromConfig
}

const fromFile = await getFromFile(func)

return {
...fromConfig,
...fromFile,
}
}

export const getConfigForFunction = ({
const getFromMainConfig = ({
config,
func,
}: {
config?: Config
func: Omit<FunctionSource, 'config'>
configFileDirectories?: string[]
func: FunctionWithoutConfig
}): FunctionConfig => {
if (!config) {
return {}
Expand Down Expand Up @@ -56,3 +98,23 @@ export const getConfigForFunction = ({

return mergeOptions.apply({ concatArrays: true, ignoreUndefined: true }, matches)
}

const getFromFile = async (func: FunctionWithoutConfig): Promise<FunctionConfig> => {
const filename = `${basename(func.mainFile, extname(func.mainFile))}.json`
const configFilePath = join(dirname(func.mainFile), filename)

try {
const data = await fs.readFile(configFilePath, 'utf8')
const configFile = JSON.parse(data) as FunctionConfigFile

if (configFile.version === 1) {
danez marked this conversation as resolved.
Show resolved Hide resolved
return configFile.config
}
} catch {
// no-op
}

return {}
}

export { Config, FunctionConfig, FunctionWithoutConfig, getConfigForFunction }
1 change: 1 addition & 0 deletions src/feature_flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const defaultFlags: Record<string, boolean> = {
parseWithEsbuild: false,
traceWithNft: false,
zisi_pure_esm: false,
project_deploy_configuration_api_use_per_function_configuration_files: false,
}

export type FeatureFlag = keyof typeof defaultFlags
Expand Down
18 changes: 12 additions & 6 deletions src/runtimes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { extname, basename } from 'path'

import { Config, getConfigForFunction } from '../config.js'
import { Config, getConfigForFunction, FunctionWithoutConfig } from '../config.js'
import { defaultFlags, FeatureFlags } from '../feature_flags.js'
import { FunctionSource } from '../function.js'
import { FsCache } from '../utils/fs.js'
Expand All @@ -18,7 +18,7 @@ type FunctionMap = Map<string, FunctionSource>
type FunctionTuple = [string, FunctionSource]

// The same as `FunctionTuple` but functions don't have a `config` object yet.
type FunctionTupleWithoutConfig = [string, Omit<FunctionSource, 'config'>]
type FunctionTupleWithoutConfig = [string, FunctionWithoutConfig]

/**
* Finds functions for a list of paths using a specific runtime. The return
Expand Down Expand Up @@ -79,9 +79,10 @@ export const getFunctionsFromPaths = async (
paths: string[],
{
config,
configFileDirectories = [],
dedupe = false,
featureFlags = defaultFlags,
}: { config?: Config; dedupe?: boolean; featureFlags?: FeatureFlags } = {},
}: { config?: Config; configFileDirectories?: string[]; dedupe?: boolean; featureFlags?: FeatureFlags } = {},
): Promise<FunctionMap> => {
const fsCache = makeFsCache()

Expand All @@ -104,9 +105,12 @@ export const getFunctionsFromPaths = async (
remainingPaths: runtimePaths,
}
}, Promise.resolve({ functions: [], remainingPaths: paths } as { functions: FunctionTupleWithoutConfig[]; remainingPaths: string[] }))
const functionsWithConfig: FunctionTuple[] = functions.map(([name, func]) => [
const functionConfigs = await Promise.all(
functions.map(([, func]) => getConfigForFunction({ config, configFileDirectories, func, featureFlags })),
)
const functionsWithConfig: FunctionTuple[] = functions.map(([name, func], index) => [
name,
{ ...func, config: getConfigForFunction({ config, func }) },
{ ...func, config: functionConfigs[index] },
])

return new Map(functionsWithConfig)
Expand All @@ -125,10 +129,12 @@ export const getFunctionFromPath = async (
const func = await runtime.findFunctionInPath({ path, fsCache, featureFlags })

if (func) {
const functionConfig = await getConfigForFunction({ config, func: { ...func, runtime }, featureFlags })

return {
...func,
runtime,
config: getConfigForFunction({ config, func: { ...func, runtime } }),
config: functionConfig,
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface ZipFunctionOptions {
}

type ZipFunctionsOptions = ZipFunctionOptions & {
configFileDirectories?: string[]
manifest?: string
parallelLimit?: number
}
Expand All @@ -46,6 +47,7 @@ export const zipFunctions = async function (
archiveFormat = 'zip',
basePath,
config = {},
configFileDirectories,
featureFlags: inputFeatureFlags,
manifest,
parallelLimit = DEFAULT_PARALLEL_LIMIT,
Expand All @@ -57,7 +59,7 @@ export const zipFunctions = async function (
const featureFlags = getFlags(inputFeatureFlags)
const srcFolders = resolveFunctionsDirectories(relativeSrcFolders)
const [paths] = await Promise.all([listFunctionsDirectories(srcFolders), fs.mkdir(destFolder, { recursive: true })])
const functions = await getFunctionsFromPaths(paths, { config, dedupe: true, featureFlags })
const functions = await getFunctionsFromPaths(paths, { config, configFileDirectories, dedupe: true, featureFlags })
const results = await pMap(
functions.values(),
async (func) => {
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = () => true
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"config": {
"includedFiles": ["blog/*.md"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = () => true
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"config": {
"includedFiles": ["blog/*.md"]
},
"version": 3
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = () => true
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{
"config": {
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = () => true
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"config": {
"includedFiles": ["blog/*.md"]
}
"version": 3
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = () => true
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"config": {
"includedFiles": ["blog/*.md"]
},
"version": 1
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = () => true
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"config": {
"includedFiles": ["blog/*.md"]
},
"version": 1
}
128 changes: 128 additions & 0 deletions tests/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,32 @@ const getZipChecksum = async function (t, bundler) {
return sha1sum
}

test.before(async () => {
// Renaming malformed `.malformed-json` files to `.json`
await rename(
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-1', 'my-function-1.malformed-json'),
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-1', 'my-function-1.json'),
)
await rename(
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-2.malformed-json'),
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-2.json'),
)
})

test.after.always(async () => {
if (env.ZISI_KEEP_TEMP_DIRS === undefined) {
await del(`${tmpdir()}/zip-it-test-bundler-*`, { force: true })
}

// Renaming malformed `.json` files back to `.malformed-json`
await rename(
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-1', 'my-function-1.json'),
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-1', 'my-function-1.malformed-json'),
)
await rename(
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-2.json'),
join(FIXTURES_DIR, 'config-files-malformed-json', 'my-function-2.malformed-json'),
)
})

test.afterEach(() => {
Expand Down Expand Up @@ -2758,3 +2780,109 @@ test('listFunctionsFiles does not include wrong arch functions and warns', async

console.warn.restore()
})

testMany(
danez marked this conversation as resolved.
Show resolved Hide resolved
'Loads function configuration properties from a JSON file if the function is inside one of `configFileDirectories`',
['bundler_nft', 'bundler_esbuild', 'bundler_default'],
async (options, t) => {
const fixtureName = 'config-files-select-directories'
const pathInternal = join(fixtureName, '.netlify', 'functions-internal')
const pathUser = join(fixtureName, 'netlify', 'functions')
const opts = merge(options, {
basePath: join(FIXTURES_DIR, fixtureName),
configFileDirectories: [join(FIXTURES_DIR, pathInternal)],
featureFlags: { project_deploy_configuration_api_use_per_function_configuration_files: true },
})
const { files, tmpDir } = await zipFixture(t, [pathInternal, pathUser], {
length: 2,
opts,
})

const func1Entry = files.find(({ name }) => name === 'internal-function')
const func2Entry = files.find(({ name }) => name === 'user-function')

t.deepEqual(func1Entry.config.includedFiles, ['blog/*.md'])
t.is(func2Entry.config.includedFiles, undefined)

await unzipFiles(files, (path) => `${path}/../${basename(path)}_out`)

const functionPaths = [
join(tmpDir, 'internal-function.zip_out', 'internal-function.js'),
join(tmpDir, 'user-function.zip_out', 'user-function.js'),
]
const func1 = await importFunctionFile(functionPaths[0])
const func2 = await importFunctionFile(functionPaths[1])

t.true(func1.handler())
t.true(func2.handler())

t.true(await pathExists(`${tmpDir}/internal-function.zip_out/blog/one.md`))
t.false(await pathExists(`${tmpDir}/user-function.zip_out/blog/one.md`))
},
)

testMany(
'Ignores function configuration files with a missing or invalid `version` property',
['bundler_nft', 'bundler_esbuild', 'bundler_default'],
async (options, t) => {
const fixtureName = 'config-files-invalid-version'
const fixtureDir = join(FIXTURES_DIR, fixtureName)
const opts = merge(options, {
basePath: fixtureDir,
configFileDirectories: [fixtureDir],
featureFlags: { zisi_detect_esm: true },
})
const { files, tmpDir } = await zipFixture(t, fixtureName, {
length: 2,
opts,
})

await unzipFiles(files, (path) => `${path}/../${basename(path)}_out`)

const functionPaths = [
join(tmpDir, 'my-function-1.zip_out', 'my-function-1.js'),
join(tmpDir, 'my-function-2.zip_out', 'my-function-2.js'),
]
const func1 = await importFunctionFile(functionPaths[0])
const func2 = await importFunctionFile(functionPaths[1])

t.true(func1.handler())
t.true(func2.handler())
t.is(files[0].config.includedFiles, undefined)
t.is(files[1].config.includedFiles, undefined)
t.false(await pathExists(`${tmpDir}/my-function-1.zip_out/blog/one.md`))
},
)

testMany(
'Ignores function configuration files with malformed JSON',
['bundler_nft', 'bundler_esbuild', 'bundler_default'],
async (options, t) => {
const fixtureName = 'config-files-malformed-json'
const fixtureDir = join(FIXTURES_DIR, fixtureName)
const opts = merge(options, {
basePath: fixtureDir,
configFileDirectories: [fixtureDir],
featureFlags: { zisi_detect_esm: true },
})
const { files, tmpDir } = await zipFixture(t, fixtureName, {
length: 2,
opts,
})

await unzipFiles(files, (path) => `${path}/../${basename(path)}_out`)

const functionPaths = [
join(tmpDir, 'my-function-1.zip_out', 'my-function-1.js'),
join(tmpDir, 'my-function-2.zip_out', 'my-function-2.js'),
]
const func1 = await importFunctionFile(functionPaths[0])
const func2 = await importFunctionFile(functionPaths[1])

t.true(func1.handler())
t.true(func2.handler())
t.is(files[0].config.includedFiles, undefined)
t.is(files[1].config.includedFiles, undefined)
t.false(await pathExists(`${tmpDir}/my-function-1.zip_out/blog/one.md`))
},
)