@netlify/edge-bundler
Advanced tools
Comparing version 8.13.0 to 8.13.1
@@ -73,3 +73,3 @@ import { promises as fs } from 'fs'; | ||
// deploy configuration API and the in-source configuration. | ||
const declarations = mergeDeclarations(tomlDeclarations, userFunctionsWithConfig, internalFunctionsWithConfig, deployConfig.declarations); | ||
const declarations = mergeDeclarations(tomlDeclarations, userFunctionsWithConfig, internalFunctionsWithConfig, deployConfig.declarations, featureFlags); | ||
const internalFunctionConfig = createFunctionConfig({ | ||
@@ -76,0 +76,0 @@ internalFunctionsWithConfig, |
import { FunctionConfig, Path } from './config.js'; | ||
import { FeatureFlags } from './feature_flags.js'; | ||
interface BaseDeclaration { | ||
@@ -17,4 +18,4 @@ cache?: string; | ||
export type Declaration = DeclarationWithPath | DeclarationWithPattern; | ||
export declare const mergeDeclarations: (tomlDeclarations: Declaration[], userFunctionsConfig: Record<string, FunctionConfig>, internalFunctionsConfig: Record<string, FunctionConfig>, deployConfigDeclarations: Declaration[]) => Declaration[]; | ||
export declare const mergeDeclarations: (tomlDeclarations: Declaration[], userFunctionsConfig: Record<string, FunctionConfig>, internalFunctionsConfig: Record<string, FunctionConfig>, deployConfigDeclarations: Declaration[], featureFlags?: FeatureFlags) => Declaration[]; | ||
export declare const parsePattern: (pattern: string) => string; | ||
export {}; |
import regexpAST from 'regexp-tree'; | ||
export const mergeDeclarations = (tomlDeclarations, userFunctionsConfig, internalFunctionsConfig, deployConfigDeclarations) => { | ||
export const mergeDeclarations = (tomlDeclarations, userFunctionsConfig, internalFunctionsConfig, deployConfigDeclarations, featureFlags = {}) => { | ||
const functionsVisited = new Set(); | ||
let declarations = getDeclarationsFromInput(deployConfigDeclarations, internalFunctionsConfig, functionsVisited); | ||
// eslint-disable-next-line unicorn/prefer-ternary | ||
if (featureFlags.edge_functions_correct_order) { | ||
declarations = [ | ||
// INTEGRATIONS | ||
// 1. Declarations from the integrations deploy config | ||
...getDeclarationsFromInput(deployConfigDeclarations, internalFunctionsConfig, functionsVisited), | ||
// 2. Declarations from the integrations ISC | ||
...createDeclarationsFromFunctionConfigs(internalFunctionsConfig, functionsVisited), | ||
// USER | ||
// 3. Declarations from the users toml config | ||
...getDeclarationsFromInput(tomlDeclarations, userFunctionsConfig, functionsVisited), | ||
// 4. Declarations from the users ISC | ||
...createDeclarationsFromFunctionConfigs(userFunctionsConfig, functionsVisited), | ||
]; | ||
} | ||
else { | ||
declarations = [ | ||
...getDeclarationsFromInput(tomlDeclarations, userFunctionsConfig, functionsVisited), | ||
...getDeclarationsFromInput(deployConfigDeclarations, internalFunctionsConfig, functionsVisited), | ||
...createDeclarationsFromFunctionConfigs(internalFunctionsConfig, functionsVisited), | ||
...createDeclarationsFromFunctionConfigs(userFunctionsConfig, functionsVisited), | ||
]; | ||
} | ||
return declarations; | ||
}; | ||
const getDeclarationsFromInput = (inputDeclarations, functionConfigs, functionsVisited) => { | ||
var _a; | ||
const declarations = []; | ||
const functionsVisited = new Set(); | ||
// We start by iterating over all the declarations in the TOML file and in | ||
// the deploy configuration file. For any declaration for which we also have | ||
// a function configuration object, we replace the path because that object | ||
// takes precedence. | ||
for (const declaration of [...tomlDeclarations, ...deployConfigDeclarations]) { | ||
const config = userFunctionsConfig[declaration.function] || internalFunctionsConfig[declaration.function]; | ||
// For any declaration for which we also have a function configuration object, | ||
// we replace the path because that object takes precedence. | ||
for (const declaration of inputDeclarations) { | ||
const config = functionConfigs[declaration.function]; | ||
if (!config) { | ||
@@ -31,6 +56,8 @@ // If no config is found, add the declaration as is. | ||
} | ||
// Finally, we must create declarations for functions that are not declared | ||
// in the TOML at all. | ||
for (const name in { ...internalFunctionsConfig, ...userFunctionsConfig }) { | ||
const { cache, path } = internalFunctionsConfig[name] || userFunctionsConfig[name]; | ||
return declarations; | ||
}; | ||
const createDeclarationsFromFunctionConfigs = (functionConfigs, functionsVisited) => { | ||
const declarations = []; | ||
for (const name in functionConfigs) { | ||
const { cache, path } = functionConfigs[name]; | ||
// If we have a path specified, create a declaration for each path. | ||
@@ -37,0 +64,0 @@ if (!functionsVisited.has(name) && path) { |
import { test, expect } from 'vitest'; | ||
import { mergeDeclarations } from './declaration.js'; | ||
const deployConfigDeclarations = []; | ||
test('Deploy config takes precedence over user config', () => { | ||
test('Ensure the order of edge functions with FF', () => { | ||
const deployConfigDeclarations = [ | ||
{ function: 'framework-a', path: '/path1' }, | ||
{ function: 'framework-b', path: '/path2' }, | ||
{ function: 'framework-manifest-a', path: '/path1' }, | ||
{ function: 'framework-manifest-c', path: '/path3' }, | ||
{ function: 'framework-manifest-b', path: '/path2' }, | ||
]; | ||
const tomlConfig = [ | ||
{ function: 'user-a', path: '/path1' }, | ||
{ function: 'user-b', path: '/path2' }, | ||
{ function: 'user-toml-a', path: '/path1' }, | ||
{ function: 'user-toml-c', path: '/path3' }, | ||
{ function: 'user-toml-b', path: '/path2' }, | ||
]; | ||
const userFuncConfig = { | ||
'user-c': { path: ['/path1', '/path2'] }, | ||
'user-isc-c': { path: ['/path1', '/path2'] }, | ||
}; | ||
const internalFuncConfig = { | ||
'framework-c': { path: ['/path1', '/path2'] }, | ||
'framework-isc-c': { path: ['/path1', '/path2'] }, | ||
}; | ||
expect(mergeDeclarations(tomlConfig, userFuncConfig, internalFuncConfig, deployConfigDeclarations)).toMatchSnapshot(); | ||
expect(mergeDeclarations(tomlConfig, userFuncConfig, internalFuncConfig, deployConfigDeclarations, { | ||
edge_functions_correct_order: true, | ||
})).toMatchSnapshot(); | ||
}); | ||
test('Ensure the order of edge functions without FF', () => { | ||
const deployConfigDeclarations = [ | ||
{ function: 'framework-manifest-a', path: '/path1' }, | ||
{ function: 'framework-manifest-c', path: '/path3' }, | ||
{ function: 'framework-manifest-b', path: '/path2' }, | ||
]; | ||
const tomlConfig = [ | ||
{ function: 'user-toml-a', path: '/path1' }, | ||
{ function: 'user-toml-c', path: '/path3' }, | ||
{ function: 'user-toml-b', path: '/path2' }, | ||
]; | ||
const userFuncConfig = { | ||
'user-isc-c': { path: ['/path1', '/path2'] }, | ||
}; | ||
const internalFuncConfig = { | ||
'framework-isc-c': { path: ['/path1', '/path2'] }, | ||
}; | ||
expect(mergeDeclarations(tomlConfig, userFuncConfig, internalFuncConfig, deployConfigDeclarations, { | ||
edge_functions_correct_order: false, | ||
})).toMatchSnapshot(); | ||
}); | ||
test('In-source config takes precedence over netlify.toml config', () => { | ||
@@ -22,0 +47,0 @@ const tomlConfig = [ |
declare const defaultFlags: { | ||
edge_functions_correct_order: boolean; | ||
edge_functions_fail_unsupported_regex: boolean; | ||
edge_functions_invalid_config_throw: boolean; | ||
edge_functions_manifest_validate_slash: boolean; | ||
}; | ||
@@ -9,7 +9,7 @@ type FeatureFlag = keyof typeof defaultFlags; | ||
declare const getFlags: (input?: Record<string, boolean>, flags?: { | ||
edge_functions_correct_order: boolean; | ||
edge_functions_fail_unsupported_regex: boolean; | ||
edge_functions_invalid_config_throw: boolean; | ||
edge_functions_manifest_validate_slash: boolean; | ||
}) => FeatureFlags; | ||
export { defaultFlags, getFlags }; | ||
export type { FeatureFlag, FeatureFlags }; |
const defaultFlags = { | ||
edge_functions_correct_order: false, | ||
edge_functions_fail_unsupported_regex: false, | ||
edge_functions_invalid_config_throw: false, | ||
edge_functions_manifest_validate_slash: false, | ||
}; | ||
@@ -6,0 +6,0 @@ const getFlags = (input = {}, flags = defaultFlags) => Object.entries(flags).reduce((result, [key, defaultValue]) => ({ |
import { EdgeFunction } from './edge_function.js'; | ||
export declare const removeDuplicatesByExtension: (functions: string[]) => string[]; | ||
declare const findFunctions: (directories: string[]) => Promise<EdgeFunction[]>; | ||
export { findFunctions }; |
import { promises as fs } from 'fs'; | ||
import { basename, extname, join } from 'path'; | ||
import { basename, extname, join, parse } from 'path'; | ||
import { nonNullable } from './utils/non_nullable.js'; | ||
const ALLOWED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']); | ||
// the order of the allowed extensions is also the order we remove duplicates | ||
// with a lower index meaning a higher precedence over the others | ||
const ALLOWED_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx']; | ||
export const removeDuplicatesByExtension = (functions) => { | ||
const seen = new Map(); | ||
return Object.values(functions.reduce((acc, path) => { | ||
const { ext, name } = parse(path); | ||
const extIndex = ALLOWED_EXTENSIONS.indexOf(ext); | ||
if (!seen.has(name) || seen.get(name) > extIndex) { | ||
seen.set(name, extIndex); | ||
return { ...acc, [name]: path }; | ||
} | ||
return acc; | ||
}, {})); | ||
}; | ||
const findFunctionInDirectory = async (directory) => { | ||
const name = basename(directory); | ||
const candidatePaths = [...ALLOWED_EXTENSIONS] | ||
.flatMap((extension) => [`${name}${extension}`, `index${extension}`]) | ||
.map((filename) => join(directory, filename)); | ||
const candidatePaths = ALLOWED_EXTENSIONS.flatMap((extension) => [`${name}${extension}`, `index${extension}`]).map((filename) => join(directory, filename)); | ||
let functionPath; | ||
@@ -38,3 +50,3 @@ for (const candidatePath of candidatePaths) { | ||
const extension = extname(path); | ||
if (ALLOWED_EXTENSIONS.has(extension)) { | ||
if (ALLOWED_EXTENSIONS.includes(extension)) { | ||
return { name: basename(path, extension), path }; | ||
@@ -46,3 +58,3 @@ } | ||
try { | ||
items = await fs.readdir(baseDirectory); | ||
items = await fs.readdir(baseDirectory).then(removeDuplicatesByExtension); | ||
} | ||
@@ -49,0 +61,0 @@ catch { |
import { FeatureFlags } from '../../feature_flags.js'; | ||
import ManifestValidationError from './error.js'; | ||
export declare const validateManifest: (manifestData: unknown, featureFlags?: FeatureFlags) => void; | ||
export declare const validateManifest: (manifestData: unknown, _featureFlags?: FeatureFlags) => void; | ||
export { ManifestValidationError }; |
@@ -7,3 +7,3 @@ import Ajv from 'ajv'; | ||
let manifestValidator; | ||
const initializeValidator = (featureFlags) => { | ||
const initializeValidator = () => { | ||
if (manifestValidator === undefined) { | ||
@@ -14,3 +14,3 @@ const ajv = new Ajv({ allErrors: true }); | ||
// checks if the pattern string starts with ^ and ends with $ | ||
const normalizedPatternRegex = featureFlags.edge_functions_manifest_validate_slash ? /^\^\/.*\$$/ : /^\^.*\$$/; | ||
const normalizedPatternRegex = /^\^.*\$$/; | ||
ajv.addFormat('regexPattern', { | ||
@@ -24,4 +24,5 @@ validate: (data) => normalizedPatternRegex.test(data), | ||
// throws on validation error | ||
export const validateManifest = (manifestData, featureFlags = {}) => { | ||
const validate = initializeValidator(featureFlags); | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
export const validateManifest = (manifestData, _featureFlags = {}) => { | ||
const validate = initializeValidator(); | ||
const valid = validate(manifestData); | ||
@@ -28,0 +29,0 @@ if (!valid) { |
import chalk from 'chalk'; | ||
import { test, expect, describe, beforeEach, vi } from 'vitest'; | ||
import { test, expect, describe } from 'vitest'; | ||
import { validateManifest, ManifestValidationError } from './index.js'; | ||
@@ -95,13 +95,6 @@ // We need to disable all color outputs for the tests as they are different on different platforms, CI, etc. | ||
describe('route', () => { | ||
let freshValidateManifest; | ||
beforeEach(async () => { | ||
// reset all modules, to get a fresh AJV validator for FF changes | ||
vi.resetModules(); | ||
const indexImport = await import('./index.js'); | ||
freshValidateManifest = indexImport.validateManifest; | ||
}); | ||
test('should throw on additional property', () => { | ||
const manifest = getBaseManifest(); | ||
manifest.routes[0].foo = 'bar'; | ||
expect(() => freshValidateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
expect(() => validateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
}); | ||
@@ -111,18 +104,8 @@ test('should throw on invalid pattern', () => { | ||
manifest.routes[0].pattern = '/^/hello/?$/'; | ||
expect(() => freshValidateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
expect(() => validateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
}); | ||
test('should not throw on missing beginning slash without FF', () => { | ||
const manifest = getBaseManifest(); | ||
manifest.routes[0].pattern = '^hello/?$'; | ||
expect(() => freshValidateManifest(manifest, { edge_functions_manifest_validate_slash: false })).not.toThrowError(); | ||
}); | ||
test('should throw on missing beginning slash with FF', () => { | ||
const manifest = getBaseManifest(); | ||
manifest.routes[0].pattern = '^hello/?$'; | ||
expect(() => freshValidateManifest(manifest, { edge_functions_manifest_validate_slash: true })).toThrowErrorMatchingSnapshot(); | ||
}); | ||
test('should throw on missing function', () => { | ||
const manifest = getBaseManifest(); | ||
delete manifest.routes[0].function; | ||
expect(() => freshValidateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
expect(() => validateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
}); | ||
@@ -132,3 +115,3 @@ test('should throw on missing pattern', () => { | ||
delete manifest.routes[0].pattern; | ||
expect(() => freshValidateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
expect(() => validateManifest(manifest)).toThrowErrorMatchingSnapshot(); | ||
}); | ||
@@ -135,0 +118,0 @@ }); |
@@ -19,3 +19,3 @@ const bundlesSchema = { | ||
format: 'regexPattern', | ||
errorMessage: 'pattern needs to be a regex that starts with ^ followed by / and ends with $ without any additional slashes before and afterwards', | ||
errorMessage: 'pattern must be a regex that starts with ^ and ends with $ (e.g. ^/blog/[d]{4}$)', | ||
}, | ||
@@ -35,3 +35,3 @@ generator: { type: 'string' }, | ||
format: 'regexPattern', | ||
errorMessage: 'excluded_patterns needs to be an array of regex that starts with ^ followed by / and ends with $ without any additional slashes before and afterwards', | ||
errorMessage: 'excluded_patterns must be an array of regex that starts with ^ and ends with $ (e.g. ^/blog/[d]{4}$)', | ||
}, | ||
@@ -38,0 +38,0 @@ }, |
{ | ||
"name": "@netlify/edge-bundler", | ||
"version": "8.13.0", | ||
"version": "8.13.1", | ||
"description": "Intelligently prepare Netlify Edge Functions for deployment", | ||
@@ -61,3 +61,3 @@ "type": "module", | ||
"@types/uuid": "^9.0.0", | ||
"@vitest/coverage-c8": "^0.29.7", | ||
"@vitest/coverage-c8": "^0.30.0", | ||
"archiver": "^5.3.1", | ||
@@ -70,4 +70,4 @@ "chalk": "^4.1.2", | ||
"tar": "^6.1.11", | ||
"typescript": "^4.5.4", | ||
"vitest": "^0.29.7" | ||
"typescript": "^5.0.0", | ||
"vitest": "^0.30.0" | ||
}, | ||
@@ -74,0 +74,0 @@ "engines": { |
3076795
151
7811