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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"find-up": "^5.0.0",
"glob": "^8.0.3",
"is-builtin-module": "^3.1.0",
"is-path-inside": "^3.0.3",
"junk": "^3.1.0",
"locate-path": "^6.0.0",
"merge-options": "^3.0.4",
Expand Down
71 changes: 67 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { promises as fs } from 'fs'
import { basename, extname, dirname, join } from 'path'

import isPathInside from 'is-path-inside'
import mergeOptions from 'merge-options'

import type { FeatureFlags } from './feature_flags.js'
import { FunctionSource } from './function.js'
import type { NodeBundlerType } 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 +24,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) => isPathInside(func.mainFile, directory))

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 +99,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
27 changes: 25 additions & 2 deletions src/runtimes/node/bundlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { FunctionConfig } from '../../../config.js'
import { FeatureFlags } from '../../../feature_flags.js'
import { detectEsModule } from '../utils/detect_es_module.js'

import esbuildBundler from './esbuild/index.js'
import nftBundler from './nft/index.js'
import noBundler from './none/index.js'
import { NodeBundler, NodeBundlerType } from './types.js'
import zisiBundler from './zisi/index.js'

Expand All @@ -18,17 +20,38 @@ export const getBundler = (name: NodeBundlerType): NodeBundler => {
case NodeBundlerType.ZISI:
return zisiBundler

case NodeBundlerType.NONE:
return noBundler

default:
throw new Error(`Unsupported Node bundler: ${name}`)
}
}

export const getBundlerName = async ({
config: { nodeBundler },
extension,
featureFlags,
mainFile,
}: {
config: FunctionConfig
extension: string
featureFlags: FeatureFlags
mainFile: string
}): Promise<NodeBundlerType> => {
if (nodeBundler) {
return nodeBundler
}

return await getDefaultBundler({ extension, featureFlags, mainFile })
}

// We use ZISI as the default bundler, except for certain extensions, for which
// esbuild is the only option.
export const getDefaultBundler = async ({
const getDefaultBundler = async ({
extension,
mainFile,
featureFlags,
mainFile,
}: {
extension: string
mainFile: string
Expand Down
109 changes: 109 additions & 0 deletions src/runtimes/node/bundlers/none/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { dirname, extname, normalize } from 'path'

import { FunctionBundlingUserError } from '../../../../utils/error.js'
import { RuntimeType } from '../../../runtime.js'
import { getBasePath } from '../../utils/base_path.js'
import { filterExcludedPaths, getPathsOfIncludedFiles } from '../../utils/included_files.js'
import { ModuleFormat } from '../../utils/module_format.js'
import { getNodeSupportMatrix } from '../../utils/node_version.js'
import { getPackageJsonIfAvailable } from '../../utils/package_json.js'
import { BundleFunction, GetSrcFilesFunction, NodeBundlerType } from '../types.js'

/**
* This bundler is a simple no-op bundler, that does no bundling at all.
* It returns the detected moduleFormat and the mainFile + includedFiles from the config.
*/

// Uses the same algorithm as the ESM_FILE_FORMAT resolver from Node.js to get
// the right module format for a function based on its main file.
// See https://nodejs.org/api/esm.html#resolver-algorithm-specification.
const getModuleFormat = async function (mainFile: string): Promise<ModuleFormat> {
const extension = extname(mainFile)

if (extension === '.mjs') {
return ModuleFormat.ESM
}

if (extension === '.cjs') {
return ModuleFormat.COMMONJS
}

const packageJson = await getPackageJsonIfAvailable(dirname(mainFile))

if (packageJson.type === 'module') {
return ModuleFormat.ESM
}

return ModuleFormat.COMMONJS
}

export const getSrcFiles: GetSrcFilesFunction = async function ({ config, mainFile }) {
const { includedFiles = [], includedFilesBasePath } = config
const { excludePatterns, paths: includedFilePaths } = await getPathsOfIncludedFiles(
includedFiles,
includedFilesBasePath,
)
const includedPaths = filterExcludedPaths(includedFilePaths, excludePatterns)

return { srcFiles: [mainFile, ...includedPaths], includedFiles: includedPaths }
}

const bundle: BundleFunction = async ({
basePath,
config,
extension,
featureFlags,
filename,
mainFile,
name,
pluginsModulesPath,
runtime,
srcDir,
srcPath,
stat,
}) => {
const { srcFiles, includedFiles } = await getSrcFiles({
basePath,
config: {
...config,
includedFilesBasePath: config.includedFilesBasePath || basePath,
},
extension,
featureFlags,
filename,
mainFile,
name,
pluginsModulesPath,
runtime,
srcDir,
srcPath,
stat,
})
const dirnames = srcFiles.map((filePath) => normalize(dirname(filePath)))
const moduleFormat = await getModuleFormat(mainFile)
const nodeSupport = getNodeSupportMatrix(config.nodeVersion)

if (moduleFormat === ModuleFormat.ESM && !nodeSupport.esm) {
throw new FunctionBundlingUserError(
`Function file is an ES module, which the Node.js version specified in the config (${config.nodeVersion}) does not support. ES modules are supported as of version 14 of Node.js.`,
{
functionName: name,
runtime: RuntimeType.JAVASCRIPT,
bundler: NodeBundlerType.NONE,
},
)
}

return {
basePath: getBasePath(dirnames),
includedFiles,
inputs: srcFiles,
mainFile,
moduleFormat,
srcFiles,
}
}

const bundler = { bundle, getSrcFiles }

export default bundler
2 changes: 1 addition & 1 deletion src/runtimes/node/bundlers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const enum NodeBundlerType {
ESBUILD_ZISI = 'esbuild_zisi',
NFT = 'nft',
ZISI = 'zisi',
SKIP = 'skip',
NONE = 'none',
}

// TODO: Create a generic warning type
Expand Down