diff --git a/node/bundler.test.ts b/node/bundler.test.ts index 385b9b9a..72efac38 100644 --- a/node/bundler.test.ts +++ b/node/bundler.test.ts @@ -1,7 +1,6 @@ import { promises as fs } from 'fs' import { join, resolve } from 'path' import process from 'process' -import { pathToFileURL } from 'url' import { deleteAsync } from 'del' import tmp from 'tmp-promise' @@ -11,6 +10,7 @@ import { fixturesDir } from '../test/util.js' import { BundleError } from './bundle_error.js' import { bundle, BundleOptions } from './bundler.js' +import { isNodeError } from './utils/error.js' test('Produces a JavaScript bundle and a manifest file', async () => { const sourceDirectory = resolve(fixturesDir, 'with_import_maps', 'functions') @@ -23,14 +23,7 @@ test('Produces a JavaScript bundle and a manifest file', async () => { ] const result = await bundle([sourceDirectory], tmpDir.path, declarations, { basePath: fixturesDir, - importMaps: [ - { - baseURL: pathToFileURL(join(fixturesDir, 'import-map.json')), - imports: { - 'alias:helper': pathToFileURL(join(fixturesDir, 'helper.ts')).toString(), - }, - }, - ], + configPath: join(sourceDirectory, 'config.json'), }) const generatedFiles = await fs.readdir(tmpDir.path) @@ -60,17 +53,10 @@ test('Produces only a ESZIP bundle when the `edge_functions_produce_eszip` featu ] const result = await bundle([sourceDirectory], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(sourceDirectory, 'config.json'), featureFlags: { edge_functions_produce_eszip: true, }, - importMaps: [ - { - baseURL: pathToFileURL(join(fixturesDir, 'import-map.json')), - imports: { - 'alias:helper': pathToFileURL(join(fixturesDir, 'helper.ts')).toString(), - }, - }, - ], }) const generatedFiles = await fs.readdir(tmpDir.path) @@ -99,17 +85,10 @@ test('Uses the vendored eszip module instead of fetching it from deno.land', asy ] const result = await bundle([sourceDirectory], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(sourceDirectory, 'config.json'), featureFlags: { edge_functions_produce_eszip: true, }, - importMaps: [ - { - baseURL: pathToFileURL(join(fixturesDir, 'import-map.json')), - imports: { - 'alias:helper': pathToFileURL(join(fixturesDir, 'helper.ts')).toString(), - }, - }, - ], }) const generatedFiles = await fs.readdir(tmpDir.path) @@ -205,14 +184,7 @@ test('Uses the cache directory as the `DENO_DIR` value if the `edge_functions_ca const options: BundleOptions = { basePath: fixturesDir, cacheDirectory: cacheDir.path, - importMaps: [ - { - baseURL: pathToFileURL(join(fixturesDir, 'import-map.json')), - imports: { - 'alias:helper': pathToFileURL(join(fixturesDir, 'helper.ts')).toString(), - }, - }, - ], + configPath: join(sourceDirectory, 'config.json'), } // Run #1, feature flag off: The directory should not be populated. @@ -259,17 +231,10 @@ test('Supports import maps with relative paths', async () => { ] const result = await bundle([sourceDirectory], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(sourceDirectory, 'config.json'), featureFlags: { edge_functions_produce_eszip: true, }, - importMaps: [ - { - baseURL: pathToFileURL(join(fixturesDir, 'import-map.json')), - imports: { - 'alias:helper': './helper.ts', - }, - }, - ], }) const generatedFiles = await fs.readdir(tmpDir.path) @@ -324,8 +289,7 @@ test('Ignores any user-defined `deno.json` files', async () => { `The file at '${denoConfigPath} would be overwritten by this test. Please move the file to a different location and try again.'`, ) } catch (error) { - // @ts-expect-error Error is not typed - if (error.code !== 'ENOENT') { + if (isNodeError(error) && error.code !== 'ENOENT') { throw error } } @@ -335,17 +299,10 @@ test('Ignores any user-defined `deno.json` files', async () => { expect(() => bundle([join(fixtureDir, 'functions')], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(fixtureDir, 'functions', 'config.json'), featureFlags: { edge_functions_produce_eszip: true, }, - importMaps: [ - { - baseURL: pathToFileURL(join(fixturesDir, 'import-map.json')), - imports: { - 'alias:helper': pathToFileURL(join(fixturesDir, 'helper.ts')).toString(), - }, - }, - ], }), ).not.toThrow() @@ -364,10 +321,10 @@ test('Processes a function that imports a custom layer', async () => { const layer = { name: 'test', flag: 'edge-functions-layer-test' } const result = await bundle([sourceDirectory], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(sourceDirectory, 'config.json'), featureFlags: { edge_functions_produce_eszip: true, }, - layers: [layer], }) const generatedFiles = await fs.readdir(tmpDir.path) @@ -386,3 +343,36 @@ test('Processes a function that imports a custom layer', async () => { await fs.rmdir(tmpDir.path, { recursive: true }) }) + +test('Loads declarations and import maps from the deploy configuration', async () => { + const fixtureDir = resolve(fixturesDir, 'with_deploy_config') + const tmpDir = await tmp.dir() + const declarations = [ + { + function: 'func1', + path: '/func1', + }, + ] + const directories = [join(fixtureDir, 'netlify', 'edge-functions'), join(fixtureDir, '.netlify', 'edge-functions')] + const result = await bundle(directories, tmpDir.path, declarations, { + basePath: fixtureDir, + configPath: join(fixtureDir, '.netlify', 'edge-functions', 'config.json'), + featureFlags: { + edge_functions_produce_eszip: true, + }, + }) + const generatedFiles = await fs.readdir(tmpDir.path) + + expect(result.functions.length).toBe(2) + expect(generatedFiles.length).toBe(2) + + const manifestFile = await fs.readFile(resolve(tmpDir.path, 'manifest.json'), 'utf8') + const manifest = JSON.parse(manifestFile) + const { bundles } = manifest + + expect(bundles.length).toBe(1) + expect(bundles[0].format).toBe('eszip2') + expect(generatedFiles.includes(bundles[0].asset)).toBe(true) + + await fs.rmdir(tmpDir.path, { recursive: true }) +}) diff --git a/node/bundler.ts b/node/bundler.ts index 9613ed95..9446be6f 100644 --- a/node/bundler.ts +++ b/node/bundler.ts @@ -8,13 +8,13 @@ import { DenoBridge, DenoOptions, OnAfterDownloadHook, OnBeforeDownloadHook } fr import type { Bundle } from './bundle.js' import { FunctionConfig, getFunctionConfig } from './config.js' import { Declaration, getDeclarationsFromConfig } from './declaration.js' +import { load as loadDeployConfig } from './deploy_config.js' import { EdgeFunction } from './edge_function.js' import { FeatureFlags, getFlags } from './feature_flags.js' import { findFunctions } from './finder.js' import { bundle as bundleESZIP } from './formats/eszip.js' import { bundle as bundleJS } from './formats/javascript.js' -import { ImportMap, ImportMapFile } from './import_map.js' -import { Layer } from './layer.js' +import { ImportMap } from './import_map.js' import { getLogger, LogFunction } from './logger.js' import { writeManifest } from './manifest.js' import { ensureLatestTypes } from './types.js' @@ -22,11 +22,10 @@ import { ensureLatestTypes } from './types.js' interface BundleOptions { basePath?: string cacheDirectory?: string + configPath?: string debug?: boolean distImportMapPath?: string featureFlags?: FeatureFlags - importMaps?: ImportMapFile[] - layers?: Layer[] onAfterDownload?: OnAfterDownloadHook onBeforeDownload?: OnBeforeDownloadHook systemLogger?: LogFunction @@ -83,11 +82,10 @@ const bundle = async ( { basePath: inputBasePath, cacheDirectory, + configPath, debug, distImportMapPath, featureFlags: inputFeatureFlags, - importMaps, - layers, onAfterDownload, onBeforeDownload, systemLogger, @@ -117,9 +115,16 @@ const bundle = async ( // to create the bundle artifacts and rename them later. const buildID = uuidv4() - // Creating an ImportMap instance with any import maps supplied by the user, - // if any. - const importMap = new ImportMap(importMaps) + // Loading any configuration options from the deploy configuration API, if it + // exists. + const deployConfig = await loadDeployConfig(configPath, logger) + + const importMap = new ImportMap() + + if (deployConfig.importMap) { + importMap.add(deployConfig.importMap) + } + const functions = await findFunctions(sourceDirectories) const functionBundle = await createBundle({ basePath, @@ -154,16 +159,16 @@ const bundle = async ( {} as Record, ) - // Creating a final declarations array by combining the TOML entries with the - // function configuration objects. - const declarations = getDeclarationsFromConfig(tomlDeclarations, functionsWithConfig) + // Creating a final declarations array by combining the TOML file with the + // deploy configuration API and the in-source configuration. + const declarations = getDeclarationsFromConfig(tomlDeclarations, functionsWithConfig, deployConfig) const manifest = await writeManifest({ bundles: [functionBundle], declarations, distDirectory, functions, - layers, + layers: deployConfig.layers, }) if (distImportMapPath) { diff --git a/node/config.test.ts b/node/config.test.ts index 3970f8d0..4783207b 100644 --- a/node/config.test.ts +++ b/node/config.test.ts @@ -153,10 +153,10 @@ test('Ignores function paths from the in-source `config` function if the feature const declarations: Declaration[] = [] const result = await bundle([internalDirectory, userDirectory], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(internalDirectory, 'config.json'), featureFlags: { edge_functions_produce_eszip: true, }, - importMaps: [importMapFile], }) const generatedFiles = await fs.readdir(tmpDir.path) @@ -191,11 +191,11 @@ test('Loads function paths from the in-source `config` function', async () => { ] const result = await bundle([internalDirectory, userDirectory], tmpDir.path, declarations, { basePath: fixturesDir, + configPath: join(internalDirectory, 'config.json'), featureFlags: { edge_functions_config_export: true, edge_functions_produce_eszip: true, }, - importMaps: [importMapFile], }) const generatedFiles = await fs.readdir(tmpDir.path) diff --git a/node/declaration.test.ts b/node/declaration.test.ts index 3503fb2b..20b94dae 100644 --- a/node/declaration.test.ts +++ b/node/declaration.test.ts @@ -3,6 +3,12 @@ import { test, expect } from 'vitest' import { FunctionConfig } from './config.js' import { getDeclarationsFromConfig } from './declaration.js' +// TODO: Add tests with the deploy config. +const deployConfig = { + declarations: [], + layers: [], +} + test('In source config takes precedence over netlify.toml config', () => { const tomlConfig = [ { function: 'geolocation', path: '/geo', cache: 'off' }, @@ -19,7 +25,7 @@ test('In source config takes precedence over netlify.toml config', () => { { function: 'json', path: '/json', cache: 'off' }, ] - const declarations = getDeclarationsFromConfig(tomlConfig, funcConfig) + const declarations = getDeclarationsFromConfig(tomlConfig, funcConfig, deployConfig) expect(declarations).toEqual(expectedDeclarations) }) @@ -40,7 +46,7 @@ test("Declarations don't break if no in source config is provided", () => { { function: 'json', path: '/json', cache: 'manual' }, ] - const declarations = getDeclarationsFromConfig(tomlConfig, funcConfig) + const declarations = getDeclarationsFromConfig(tomlConfig, funcConfig, deployConfig) expect(declarations).toEqual(expectedDeclarations) }) @@ -63,10 +69,9 @@ test('In source config works independent of the netlify.toml file if a path is d const expectedDeclarationsWithoutISCPath = [{ function: 'geolocation', path: '/geo', cache: 'off' }] - const declarationsWithISCPath = getDeclarationsFromConfig(tomlConfig, funcConfigWithPath) - - const declarationsWithoutISCPath = getDeclarationsFromConfig(tomlConfig, funcConfigWithoutPath) - + const declarationsWithISCPath = getDeclarationsFromConfig(tomlConfig, funcConfigWithPath, deployConfig) expect(declarationsWithISCPath).toEqual(expectedDeclarationsWithISCPath) + + const declarationsWithoutISCPath = getDeclarationsFromConfig(tomlConfig, funcConfigWithoutPath, deployConfig) expect(declarationsWithoutISCPath).toEqual(expectedDeclarationsWithoutISCPath) }) diff --git a/node/declaration.ts b/node/declaration.ts index d52ee7bc..e8fc340a 100644 --- a/node/declaration.ts +++ b/node/declaration.ts @@ -1,4 +1,5 @@ import { FunctionConfig } from './config.js' +import type { DeployConfig } from './deploy_config.js' interface BaseDeclaration { cache?: string @@ -19,19 +20,19 @@ type Declaration = DeclarationWithPath | DeclarationWithPattern export const getDeclarationsFromConfig = ( tomlDeclarations: Declaration[], functionsConfig: Record, + deployConfig: DeployConfig, ) => { const declarations: Declaration[] = [] const functionsVisited: Set = new Set() - // We start by iterating over all the TOML declarations. For any declaration - // for which we also have a function configuration object, we replace the - // defined config (currently path or cache or both) because that object takes - // precedence. - for (const declaration of tomlDeclarations) { + // 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, ...deployConfig.declarations]) { const config = functionsConfig[declaration.function] ?? {} functionsVisited.add(declaration.function) - declarations.push({ ...declaration, ...config }) } diff --git a/node/deploy_config.test.ts b/node/deploy_config.test.ts new file mode 100644 index 00000000..e37331b5 --- /dev/null +++ b/node/deploy_config.test.ts @@ -0,0 +1,60 @@ +import { promises as fs } from 'fs' +import { join } from 'path' +import { cwd } from 'process' +import { pathToFileURL } from 'url' + +import tmp from 'tmp-promise' +import { test, expect } from 'vitest' + +import { load } from './deploy_config.js' +import { getLogger } from './logger.js' + +const logger = getLogger(console.log) + +test('Returns an empty config object if there is no file at the given path', async () => { + const mockPath = join(cwd(), 'some-directory', `a-file-that-does-not-exist-${Date.now()}.json`) + const config = await load(mockPath, logger) + + expect(config.declarations).toEqual([]) + expect(config.layers).toEqual([]) +}) + +test('Returns a config object with declarations, layers, and import map', async () => { + const importMapFile = await tmp.file() + const importMap = { + imports: { + 'https://deno.land/': 'https://black.hole/', + }, + } + + await fs.writeFile(importMapFile.path, JSON.stringify(importMap)) + + const configFile = await tmp.file() + const config = { + functions: [ + { + function: 'func1', + path: '/func1', + }, + ], + layers: [ + { + name: 'layer1', + flag: 'edge_functions_layer1_url', + local: 'https://some-url.netlify.app/mod.ts', + }, + ], + import_map: importMapFile.path, + version: 1, + } + + await fs.writeFile(configFile.path, JSON.stringify(config)) + + const parsedConfig = await load(configFile.path, logger) + + expect(parsedConfig.declarations).toEqual(config.functions) + expect(parsedConfig.layers).toEqual(config.layers) + expect(parsedConfig.importMap).toBeTruthy() + expect(parsedConfig.importMap?.baseURL).toEqual(pathToFileURL(importMapFile.path)) + expect(parsedConfig.importMap?.imports).toEqual(importMap.imports) +}) diff --git a/node/deploy_config.ts b/node/deploy_config.ts new file mode 100644 index 00000000..fa8facc2 --- /dev/null +++ b/node/deploy_config.ts @@ -0,0 +1,71 @@ +import { promises as fs } from 'fs' +import { dirname, resolve } from 'path' + +import type { Declaration } from './declaration.js' +import { ImportMapFile, readFile as readImportMap } from './import_map.js' +import type { Layer } from './layer.js' +import type { Logger } from './logger.js' +import { isNodeError } from './utils/error.js' + +/* eslint-disable camelcase */ +interface DeployConfigFile { + functions?: Declaration[] + import_map?: string + layers?: Layer[] + version: number +} +/* eslint-enable camelcase */ + +export interface DeployConfig { + declarations: Declaration[] + importMap?: ImportMapFile + layers: Layer[] +} + +export const load = async (path: string | undefined, logger: Logger): Promise => { + if (path === undefined) { + return { + declarations: [], + layers: [], + } + } + + try { + const data = await fs.readFile(path, 'utf8') + const config = JSON.parse(data) as DeployConfigFile + + return parse(config, path) + } catch (error) { + if (isNodeError(error) && error.code !== 'ENOENT') { + logger.system('Error while parsing internal edge functions manifest:', error) + } + } + + return { + declarations: [], + layers: [], + } +} + +const parse = async (data: DeployConfigFile, path: string): Promise => { + if (data.version !== 1) { + throw new Error(`Unsupported file version: ${data.version}`) + } + + const config = { + declarations: data.functions ?? [], + layers: data.layers ?? [], + } + + if (data.import_map) { + const importMapPath = resolve(dirname(path), data.import_map) + const importMap = await readImportMap(importMapPath) + + return { + ...config, + importMap, + } + } + + return config +} diff --git a/node/import_map.ts b/node/import_map.ts index bb19e78a..f4f4db93 100644 --- a/node/import_map.ts +++ b/node/import_map.ts @@ -1,6 +1,7 @@ import { Buffer } from 'buffer' import { promises as fs } from 'fs' import { dirname } from 'path' +import { pathToFileURL } from 'url' import { parse } from '@import-maps/resolve' @@ -14,13 +15,34 @@ interface ImportMapFile { scopes?: Record> } +// ImportMap can take several import map files and merge them into a final +// import map object, also adding the internal imports in the right order. class ImportMap { - imports: Record + files: ImportMapFile[] constructor(files: ImportMapFile[] = []) { - let imports: ImportMap['imports'] = {} + this.files = [] files.forEach((file) => { + this.add(file) + }) + } + + static resolve(importMapFile: ImportMapFile) { + const { baseURL, ...importMap } = importMapFile + const parsedImportMap = parse(importMap, baseURL) + + return parsedImportMap + } + + add(file: ImportMapFile) { + this.files.push(file) + } + + getContents() { + let imports: Record = {} + + this.files.forEach((file) => { const importMap = ImportMap.resolve(file) imports = { ...imports, ...importMap.imports } @@ -33,20 +55,8 @@ class ImportMap { imports[specifier] = url }) - - this.imports = imports - } - - static resolve(importMapFile: ImportMapFile) { - const { baseURL, ...importMap } = importMapFile - const parsedImportMap = parse(importMap, baseURL) - - return parsedImportMap - } - - getContents() { const contents = { - imports: this.imports, + imports, } return JSON.stringify(contents) @@ -67,5 +77,26 @@ class ImportMap { } } -export { ImportMap } +const readFile = async (path: string): Promise => { + const baseURL = pathToFileURL(path) + + try { + const data = await fs.readFile(path, 'utf8') + const importMap = JSON.parse(data) + + return { + ...importMap, + baseURL, + } + } catch { + // no-op + } + + return { + baseURL, + imports: {}, + } +} + +export { ImportMap, readFile } export type { ImportMapFile } diff --git a/node/server/server.ts b/node/server/server.ts index 89758de9..63a71104 100644 --- a/node/server/server.ts +++ b/node/server/server.ts @@ -157,7 +157,7 @@ const serve = async ({ // Creating an ImportMap instance with any import maps supplied by the user, // if any. - const importMap = new ImportMap(importMaps) + const importMap = new ImportMap(importMaps ?? []) const flags = ['--allow-all', '--unstable', `--import-map=${importMap.toDataURL()}`, '--no-config'] if (certificatePath) { diff --git a/node/utils/error.ts b/node/utils/error.ts new file mode 100644 index 00000000..e2f78147 --- /dev/null +++ b/node/utils/error.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isNodeError = (error: any): error is NodeJS.ErrnoException => error instanceof Error diff --git a/test/fixtures/with_config/.netlify/edge-functions/config.json b/test/fixtures/with_config/.netlify/edge-functions/config.json new file mode 100644 index 00000000..bb85fe48 --- /dev/null +++ b/test/fixtures/with_config/.netlify/edge-functions/config.json @@ -0,0 +1,10 @@ +{ + "functions": [ + { + "function": "func2", + "path": "/func2" + } + ], + "import_map": "import_map.json", + "version": 1 +} diff --git a/test/fixtures/with_config/.netlify/edge-functions/import_map.json b/test/fixtures/with_config/.netlify/edge-functions/import_map.json new file mode 100644 index 00000000..aaef1126 --- /dev/null +++ b/test/fixtures/with_config/.netlify/edge-functions/import_map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "alias:helper": "../../../helper.ts" + } +} diff --git a/test/fixtures/with_deploy_config/.netlify/edge-functions/config.json b/test/fixtures/with_deploy_config/.netlify/edge-functions/config.json new file mode 100644 index 00000000..bb85fe48 --- /dev/null +++ b/test/fixtures/with_deploy_config/.netlify/edge-functions/config.json @@ -0,0 +1,10 @@ +{ + "functions": [ + { + "function": "func2", + "path": "/func2" + } + ], + "import_map": "import_map.json", + "version": 1 +} diff --git a/test/fixtures/with_deploy_config/.netlify/edge-functions/func2.ts b/test/fixtures/with_deploy_config/.netlify/edge-functions/func2.ts new file mode 100644 index 00000000..39095454 --- /dev/null +++ b/test/fixtures/with_deploy_config/.netlify/edge-functions/func2.ts @@ -0,0 +1,9 @@ +import { greet } from 'alias:helper' + +import { echo } from '../../util.ts' + +export default async () => { + const greeting = greet(echo('Jane Doe')) + + return new Response(greeting) +} diff --git a/test/fixtures/with_deploy_config/.netlify/edge-functions/import_map.json b/test/fixtures/with_deploy_config/.netlify/edge-functions/import_map.json new file mode 100644 index 00000000..4f6c1dac --- /dev/null +++ b/test/fixtures/with_deploy_config/.netlify/edge-functions/import_map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "alias:helper": "../../util.ts" + } +} diff --git a/test/fixtures/with_deploy_config/netlify/edge-functions/func1.ts b/test/fixtures/with_deploy_config/netlify/edge-functions/func1.ts new file mode 100644 index 00000000..55764a8a --- /dev/null +++ b/test/fixtures/with_deploy_config/netlify/edge-functions/func1.ts @@ -0,0 +1,3 @@ +import { echo } from '../../util.ts' + +export default async () => new Response(echo('Jane Doe')) diff --git a/test/fixtures/with_deploy_config/util.ts b/test/fixtures/with_deploy_config/util.ts new file mode 100644 index 00000000..bbaf8c35 --- /dev/null +++ b/test/fixtures/with_deploy_config/util.ts @@ -0,0 +1,2 @@ +export const greet = (name: string) => `Hello, ${name}!` +export const echo = (name: string) => name diff --git a/test/fixtures/with_import_maps/functions/config.json b/test/fixtures/with_import_maps/functions/config.json new file mode 100644 index 00000000..8a2e49ba --- /dev/null +++ b/test/fixtures/with_import_maps/functions/config.json @@ -0,0 +1,4 @@ +{ + "import_map": "import_map.json", + "version": 1 +} diff --git a/test/fixtures/with_import_maps/functions/import_map.json b/test/fixtures/with_import_maps/functions/import_map.json new file mode 100644 index 00000000..7c467239 --- /dev/null +++ b/test/fixtures/with_import_maps/functions/import_map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "alias:helper": "../../helper.ts" + } +} diff --git a/test/fixtures/with_layers/functions/config.json b/test/fixtures/with_layers/functions/config.json new file mode 100644 index 00000000..92c6e0cf --- /dev/null +++ b/test/fixtures/with_layers/functions/config.json @@ -0,0 +1,4 @@ +{ + "layers": [{ "name": "test", "flag": "edge-functions-layer-test" }], + "version": 1 +}