@netlify/zip-it-and-ship-it
Version:
127 lines (126 loc) • 5.78 kB
JavaScript
import { promises as fs } from 'fs';
import { basename, extname, dirname, join } from 'path';
import isPathInside from 'is-path-inside';
// @ts-expect-error(serhalp) -- Remove once https://github.com/schnittstabil/merge-options/pull/28 is merged, or replace
// this dependency.
import mergeOptions from 'merge-options';
import { z } from 'zod';
import { nodeBundler } from './runtimes/node/bundlers/types.js';
import { moduleFormat } from './runtimes/node/utils/module_format.js';
import { minimatch } from './utils/matching.js';
import { parseMemoryMB } from './utils/parse_memory.js';
// We only need the list of valid region codes here (to feed into Zod's
// `enum` for runtime validation). The natural shape would be a literal
// array, but TypeScript has no way to enforce that an array contains every
// member of a union type — it can only check that each element belongs to
// the union, not that none are missing.
//
// A Record keyed by the union, however, IS exhaustiveness-checked: with
// `satisfies Record<FunctionRegion, ...>`, TypeScript refuses to compile
// if any region in `FunctionRegion` is absent here, or if anything here
// isn't a valid `FunctionRegion`.
const FUNCTION_REGION_KEYS = {
cmh: null,
dub: null,
fra: null,
gru: null,
iad: null,
lhr: null,
nrt: null,
pdx: null,
sfo: null,
sin: null,
syd: null,
yul: null,
};
const FUNCTION_REGION_CODES = Object.keys(FUNCTION_REGION_KEYS);
// Accept any casing in source (`iad`, `IAD`, `Iad`) but normalize to lower
// case before validating and writing to the manifest.
const functionRegion = z.preprocess((input) => (typeof input === 'string' ? input.toLowerCase() : input), z.enum(FUNCTION_REGION_CODES));
const FUNCTION_MEMORY_MIN_MB = 1024;
const FUNCTION_MEMORY_MAX_MB = 4096;
const functionMemory = z.preprocess((input) => (typeof input === 'string' || typeof input === 'number' ? parseMemoryMB(input) : input), z.number().int().min(FUNCTION_MEMORY_MIN_MB).max(FUNCTION_MEMORY_MAX_MB));
const FUNCTION_VCPU_MIN = 0.5;
const FUNCTION_VCPU_MAX = 2;
const functionVcpu = z.number().min(FUNCTION_VCPU_MIN).max(FUNCTION_VCPU_MAX);
export const functionConfigShape = z.object({
background: z.boolean().optional().catch(undefined),
externalNodeModules: z.array(z.string()).optional().catch([]),
generator: z.string().optional().catch(undefined),
includedFiles: z.array(z.string()).optional().catch([]),
includedFilesBasePath: z.string().optional().catch(undefined),
ignoredNodeModules: z.array(z.string()).optional().catch([]),
name: z.string().optional().catch(undefined),
nodeBundler: nodeBundler.optional().catch(undefined),
nodeSourcemap: z.boolean().optional().catch(undefined),
nodeVersion: z.string().optional().catch(undefined),
memory: functionMemory.optional(),
region: functionRegion.optional(),
rustTargetDirectory: z.string().optional().catch(undefined),
schedule: z.string().optional().catch(undefined),
timeout: z.number().optional().catch(undefined),
vcpu: functionVcpu.optional(),
zipGo: z.boolean().optional().catch(undefined),
// Temporary configuration property, only meant to be used by the deploy
// configuration API. Once we start emitting ESM files for all ESM functions,
// we can remove this.
nodeModuleFormat: moduleFormat.optional().catch(undefined),
});
const refuseMemoryAndVcpu = (cfg) => !(cfg.memory !== undefined && cfg.vcpu !== undefined);
export const functionConfig = functionConfigShape.refine(refuseMemoryAndVcpu, {
message: '`memory` and `vcpu` cannot both be set.',
path: ['vcpu'],
});
const getConfigForFunction = async ({ config, configFileDirectories, func, }) => {
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 = configFileDirectories?.some((directory) => isPathInside(func.mainFile, directory));
if (!shouldReadConfigFile) {
return fromConfig;
}
const fromFile = await getFromFile(func);
return {
...fromConfig,
...fromFile,
};
};
const getFromMainConfig = ({ config, func, }) => {
if (!config) {
return {};
}
// It's safe to mutate the array because it's local to this function.
const matches = Object.keys(config)
.filter((expression) => minimatch(func.name, expression))
.map((expression) => {
const wildcardCount = [...expression].filter((char) => char === '*').length;
// The weight increases with the number of hardcoded (i.e. non-wildcard)
// characters — e.g. "netlify" has a higher weight than "net*". We do a
// subtraction of 1 if there is at least one wildcard character, so that
// "netlify" has a higher weight than "netlify*".
const weight = expression.length - wildcardCount - (wildcardCount === 0 ? 0 : 1);
return {
expression,
weight,
};
})
.sort(({ weight: weightA }, { weight: weightB }) => weightA - weightB)
.map(({ expression }) => config[expression]);
return mergeOptions.apply({ concatArrays: true, ignoreUndefined: true }, matches);
};
const getFromFile = async (func) => {
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);
if (configFile.version === 1) {
return configFile.config;
}
}
catch {
// no-op
}
return {};
};
export { getConfigForFunction };