diff --git a/deno/bundle.ts b/deno/bundle.ts index ba2e1345..530564c3 100644 --- a/deno/bundle.ts +++ b/deno/bundle.ts @@ -1,6 +1,6 @@ import { writeStage2 } from './lib/stage2.ts' const [payload] = Deno.args -const { basePath, destPath, externals, functions, importMapURL } = JSON.parse(payload) +const { basePath, destPath, externals, functions, importMapData } = JSON.parse(payload) -await writeStage2({ basePath, destPath, externals, functions, importMapURL }) +await writeStage2({ basePath, destPath, externals, functions, importMapData }) diff --git a/deno/lib/stage2.ts b/deno/lib/stage2.ts index c6c796d6..5c10fbb4 100644 --- a/deno/lib/stage2.ts +++ b/deno/lib/stage2.ts @@ -3,7 +3,7 @@ import { build, LoadResponse } from 'https://deno.land/x/eszip@v0.28.0/mod.ts' import * as path from 'https://deno.land/std@0.127.0/path/mod.ts' import type { InputFunction, WriteStage2Options } from '../../shared/stage2.ts' -import { virtualRoot } from '../../shared/consts.ts' +import { importMapSpecifier, virtualRoot } from '../../shared/consts.ts' import { PUBLIC_SPECIFIER, STAGE2_SPECIFIER } from './consts.ts' import { inlineModule, loadFromVirtualRoot, loadWithRetry } from './common.ts' @@ -63,7 +63,7 @@ const getVirtualPath = (basePath: string, filePath: string) => { return url } -const stage2Loader = (basePath: string, functions: InputFunction[], externals: Set) => { +const stage2Loader = (basePath: string, functions: InputFunction[], externals: Set, importMapData?: string) => { return async (specifier: string): Promise => { if (specifier === STAGE2_SPECIFIER) { const stage2Entry = getStage2Entry(basePath, functions) @@ -71,6 +71,10 @@ const stage2Loader = (basePath: string, functions: InputFunction[], externals: S return inlineModule(specifier, stage2Entry) } + if (specifier === importMapSpecifier && importMapData !== undefined) { + return inlineModule(specifier, importMapData) + } + if (specifier === PUBLIC_SPECIFIER || externals.has(specifier)) { return { kind: 'external', @@ -86,8 +90,9 @@ const stage2Loader = (basePath: string, functions: InputFunction[], externals: S } } -const writeStage2 = async ({ basePath, destPath, externals, functions, importMapURL }: WriteStage2Options) => { - const loader = stage2Loader(basePath, functions, new Set(externals)) +const writeStage2 = async ({ basePath, destPath, externals, functions, importMapData }: WriteStage2Options) => { + const importMapURL = importMapData ? importMapSpecifier : undefined + const loader = stage2Loader(basePath, functions, new Set(externals), importMapData) const bytes = await build([STAGE2_SPECIFIER], loader, importMapURL) const directory = path.dirname(destPath) diff --git a/node/bundle.ts b/node/bundle.ts index e578cb5e..522130a7 100644 --- a/node/bundle.ts +++ b/node/bundle.ts @@ -7,5 +7,4 @@ export interface Bundle { extension: string format: BundleFormat hash: string - importMapURL?: string } diff --git a/node/bundler.test.ts b/node/bundler.test.ts index 3405712f..f7cd2c5b 100644 --- a/node/bundler.test.ts +++ b/node/bundler.test.ts @@ -1,12 +1,12 @@ 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' import { test, expect } from 'vitest' +import { importMapSpecifier } from '../shared/consts.js' import { useFixture } from '../test/util.js' import { BundleError } from './bundle_error.js' @@ -30,9 +30,7 @@ test('Produces an ESZIP bundle', async () => { const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) @@ -43,7 +41,7 @@ test('Produces an ESZIP bundle', async () => { expect(bundles[0].format).toBe('eszip2') expect(generatedFiles.includes(bundles[0].asset)).toBe(true) - expect(importMapURL).toBe('file:///root/.netlify/edge-functions-dist/import_map.json') + expect(importMapURL).toBe(importMapSpecifier) await cleanup() }) @@ -64,9 +62,7 @@ test('Uses the vendored eszip module instead of fetching it from deno.land', asy const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) @@ -165,9 +161,7 @@ test('Uses the cache directory as the `DENO_DIR` value if the `edge_functions_ca const outFiles1 = await fs.readdir(distPath) expect(result1.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(outFiles1.length).toBe(3) + expect(outFiles1.length).toBe(2) try { await fs.readdir(join(cacheDir.path, 'deno_dir')) @@ -185,9 +179,7 @@ test('Uses the cache directory as the `DENO_DIR` value if the `edge_functions_ca const outFiles2 = await fs.readdir(distPath) expect(result2.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(outFiles2.length).toBe(3) + expect(outFiles2.length).toBe(2) const denoDir2 = await fs.readdir(join(cacheDir.path, 'deno_dir')) @@ -212,9 +204,7 @@ test('Supports import maps with relative paths', async () => { const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) @@ -299,9 +289,7 @@ test('Processes a function that imports a custom layer', async () => { const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) @@ -332,9 +320,7 @@ test('Loads declarations and import maps from the deploy configuration', async ( const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(2) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) @@ -346,40 +332,3 @@ test('Loads declarations and import maps from the deploy configuration', async ( await cleanup() }) - -test('Uses an absolute URL for the import map when the dist directory is not a child of the base path', async () => { - const { basePath, cleanup } = await useFixture('with_import_maps') - const { path: distPath } = await tmp.dir() - const declarations = [ - { - function: 'func1', - path: '/func1', - }, - ] - const sourceDirectory = join(basePath, 'functions') - const result = await bundle([sourceDirectory], distPath, declarations, { - basePath, - configPath: join(sourceDirectory, 'config.json'), - }) - const generatedFiles = await fs.readdir(distPath) - - expect(result.functions.length).toBe(1) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) - - const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') - const manifest = JSON.parse(manifestFile) - expect(() => validateManifest(manifest)).not.toThrowError() - const { bundles, import_map: importMapURL } = manifest - - expect(bundles.length).toBe(1) - expect(bundles[0].format).toBe('eszip2') - expect(generatedFiles.includes(bundles[0].asset)).toBe(true) - - const importMapPath = join(distPath, 'import_map.json') - expect(importMapURL).toBe(pathToFileURL(importMapPath).toString()) - - await cleanup() - await fs.rm(distPath, { recursive: true }) -}) diff --git a/node/bundler.ts b/node/bundler.ts index 311c871e..67cc4cc2 100644 --- a/node/bundler.ts +++ b/node/bundler.ts @@ -4,6 +4,8 @@ import { join } from 'path' import commonPathPrefix from 'common-path-prefix' import { v4 as uuidv4 } from 'uuid' +import { importMapSpecifier } from '../shared/consts.js' + import { DenoBridge, DenoOptions, OnAfterDownloadHook, OnBeforeDownloadHook } from './bridge.js' import type { Bundle } from './bundle.js' import { FunctionConfig, getFunctionConfig } from './config.js' @@ -126,7 +128,7 @@ const bundle = async ( declarations, distDirectory, functions, - importMapURL: functionBundle.importMapURL, + importMap: importMapSpecifier, layers: deployConfig.layers, }) diff --git a/node/config.test.ts b/node/config.test.ts index 7e4936d0..1acd9d4d 100644 --- a/node/config.test.ts +++ b/node/config.test.ts @@ -158,9 +158,7 @@ test('Ignores function paths from the in-source `config` function if the feature const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(6) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) @@ -198,9 +196,7 @@ test('Loads function paths from the in-source `config` function', async () => { const generatedFiles = await fs.readdir(distPath) expect(result.functions.length).toBe(6) - - // ESZIP, manifest and import map. - expect(generatedFiles.length).toBe(3) + expect(generatedFiles.length).toBe(2) const manifestFile = await fs.readFile(resolve(distPath, 'manifest.json'), 'utf8') const manifest = JSON.parse(manifestFile) diff --git a/node/formats/eszip.ts b/node/formats/eszip.ts index 57d35fa6..7265da84 100644 --- a/node/formats/eszip.ts +++ b/node/formats/eszip.ts @@ -1,5 +1,4 @@ -import { join, relative } from 'path' -import { pathToFileURL } from 'url' +import { join } from 'path' import { virtualRoot } from '../../shared/consts.js' import type { WriteStage2Options } from '../../shared/stage2.js' @@ -38,13 +37,13 @@ const bundleESZIP = async ({ const extension = '.eszip' const destPath = join(distDirectory, `${buildID}${extension}`) const { bundler, importMap: bundlerImportMap } = getESZIPPaths() - const importMapURL = await createUserImportMap(importMap, basePath, distDirectory) + const importMapData = JSON.stringify(importMap.getContents(basePath, virtualRoot)) const payload: WriteStage2Options = { basePath, destPath, externals, functions, - importMapURL, + importMapData, } const flags = ['--allow-all', '--no-config', `--import-map=${bundlerImportMap}`] @@ -60,30 +59,7 @@ const bundleESZIP = async ({ const hash = await getFileHash(destPath) - return { extension, format: BundleFormat.ESZIP2, hash, importMapURL } -} - -// Takes an import map, writes it to a file on disk, and gets its URL relative -// to the ESZIP root (i.e. using the virtual root prefix). -const createUserImportMap = async (importMap: ImportMap, basePath: string, distDirectory: string) => { - const destPath = join(distDirectory, 'import_map.json') - - await importMap.writeToFile(destPath) - - const virtualPath = relative(basePath, destPath) - - // If the dist directory is not a child of the base path, we can't represent - // the relative path as a file URL (because something like 'file://../foo' is - // not valid). This should never happen, but it's best to leave the absolute - // path untransformed to avoid getting a build error due to a missing import - // map. - if (virtualPath.startsWith('..')) { - return pathToFileURL(destPath).toString() - } - - const importMapURL = new URL(virtualPath, virtualRoot) - - return importMapURL.toString() + return { extension, format: BundleFormat.ESZIP2, hash } } const getESZIPPaths = () => { diff --git a/node/import_map.test.ts b/node/import_map.test.ts index bafe1f7a..b699fbca 100644 --- a/node/import_map.test.ts +++ b/node/import_map.test.ts @@ -22,14 +22,14 @@ test('Handles import maps with full URLs without specifying a base URL', () => { } const map = new ImportMap([inputFile1, inputFile2]) - const { imports } = JSON.parse(map.getContents()) + const { imports } = map.getContents() expect(imports['netlify:edge']).toBe('https://edge.netlify.com/v1/index.ts') expect(imports['alias:jamstack']).toBe('https://jamstack.org/') expect(imports['alias:pets']).toBe('https://petsofnetlify.com/') }) -test('Resolves relative paths to absolute paths if a root path is not provided', () => { +test('Resolves relative paths to absolute paths if a base path is not provided', () => { const basePath = join(cwd(), 'my-cool-site', 'import-map.json') const inputFile1 = { baseURL: pathToFileURL(basePath), @@ -39,14 +39,14 @@ test('Resolves relative paths to absolute paths if a root path is not provided', } const map = new ImportMap([inputFile1]) - const { imports } = JSON.parse(map.getContents()) + const { imports } = map.getContents() const expectedPath = join(cwd(), 'my-cool-site', 'heart', 'pets') expect(imports['netlify:edge']).toBe('https://edge.netlify.com/v1/index.ts') expect(imports['alias:pets']).toBe(`${pathToFileURL(expectedPath).toString()}/`) }) -test('Transforms relative paths so that they use the root path as a base', () => { +test('Transforms relative paths so that they become relative to the base path', () => { const basePath = join(cwd(), 'my-cool-site', 'import-map.json') const inputFile1 = { baseURL: pathToFileURL(basePath), @@ -55,9 +55,33 @@ test('Transforms relative paths so that they use the root path as a base', () => }, } + // Without a prefix. + const map1 = new ImportMap([inputFile1]) + const { imports: imports1 } = map1.getContents(cwd()) + + expect(imports1['netlify:edge']).toBe('https://edge.netlify.com/v1/index.ts') + expect(imports1['alias:pets']).toBe('file:///my-cool-site/heart/pets/') + + // With a prefix. + const map2 = new ImportMap([inputFile1]) + const { imports: imports2 } = map2.getContents(cwd(), 'file:///root/') + + expect(imports2['netlify:edge']).toBe('https://edge.netlify.com/v1/index.ts') + expect(imports2['alias:pets']).toBe('file:///root/my-cool-site/heart/pets/') +}) + +test('Throws when an import map uses a relative path to reference a file outside of the base path', () => { + const basePath = join(cwd(), 'my-cool-site') + const inputFile1 = { + baseURL: pathToFileURL(join(basePath, 'import_map.json')), + imports: { + 'alias:file': '../file.js', + }, + } + const map = new ImportMap([inputFile1]) - const { imports } = JSON.parse(map.getContents(cwd())) - expect(imports['netlify:edge']).toBe('https://edge.netlify.com/v1/index.ts') - expect(imports['alias:pets']).toBe('./my-cool-site/heart/pets') + expect(() => map.getContents(basePath)).toThrowError( + `Import map cannot reference '${join(cwd(), 'file.js')}' as it's outside of the base directory '${basePath}'`, + ) }) diff --git a/node/import_map.ts b/node/import_map.ts index 12041514..d5e4b310 100644 --- a/node/import_map.ts +++ b/node/import_map.ts @@ -1,6 +1,6 @@ import { Buffer } from 'buffer' import { promises as fs } from 'fs' -import { dirname, isAbsolute, posix, relative, sep } from 'path' +import { dirname, posix, relative, sep } from 'path' import { fileURLToPath, pathToFileURL } from 'url' import { parse } from '@import-maps/resolve' @@ -30,7 +30,7 @@ class ImportMap { // Transforms an import map by making any relative paths use a different path // as a base. - static resolve(importMapFile: ImportMapFile, rootPath?: string) { + static resolve(importMapFile: ImportMapFile, basePath?: string, prefix = 'file://') { const { baseURL, ...importMap } = importMapFile const parsedImportMap = parse(importMap, baseURL) const { imports = {} } = parsedImportMap @@ -45,14 +45,28 @@ class ImportMap { } // If this is a file URL, we might want to transform it to use another - // root path, as long as that root path is defined. - if (url.protocol === 'file:' && rootPath !== undefined) { + // base path, as long as one is provided. + if (url.protocol === 'file:' && basePath !== undefined) { + const path = fileURLToPath(url) + const relativePath = relative(basePath, path) + + if (relativePath.startsWith('..')) { + throw new Error(`Import map cannot reference '${path}' as it's outside of the base directory '${basePath}'`) + } + // We want to use POSIX paths for the import map regardless of the OS // we're building in. - const path = relative(rootPath, fileURLToPath(url)).split(sep).join(posix.sep) - const value = isAbsolute(path) ? path : `.${posix.sep}${path}` + let normalizedPath = relativePath.split(sep).join(posix.sep) + + // If the original URL had a trailing slash, ensure the normalized path + // has one too. + if (normalizedPath !== '' && url.pathname.endsWith(posix.sep) && !normalizedPath.endsWith(posix.sep)) { + normalizedPath += posix.sep + } + + const newURL = new URL(normalizedPath, prefix) - newImports[specifier] = value + newImports[specifier] = newURL.toString() return } @@ -67,11 +81,11 @@ class ImportMap { this.files.push(file) } - getContents(rootPath?: string) { + getContents(basePath?: string, prefix?: string) { let imports: Record = {} this.files.forEach((file) => { - const importMap = ImportMap.resolve(file, rootPath) + const importMap = ImportMap.resolve(file, basePath, prefix) imports = { ...imports, ...importMap.imports } }) @@ -83,15 +97,15 @@ class ImportMap { imports[specifier] = url }) - const contents = { + + return { imports, } - - return JSON.stringify(contents) } toDataURL() { - const encodedImportMap = Buffer.from(this.getContents()).toString('base64') + const data = JSON.stringify(this.getContents()) + const encodedImportMap = Buffer.from(data).toString('base64') return `data:application/json;base64,${encodedImportMap}` } @@ -103,7 +117,7 @@ class ImportMap { const contents = this.getContents(distDirectory) - await fs.writeFile(path, contents) + await fs.writeFile(path, JSON.stringify(contents)) } } diff --git a/node/manifest.ts b/node/manifest.ts index 80e8f8c5..047398c1 100644 --- a/node/manifest.ts +++ b/node/manifest.ts @@ -15,7 +15,7 @@ interface GenerateManifestOptions { bundles?: Bundle[] declarations?: Declaration[] functions: EdgeFunction[] - importMapURL?: string + importMap?: string layers?: Layer[] } @@ -40,7 +40,7 @@ const generateManifest = ({ bundles = [], declarations = [], functions, - importMapURL, + importMap, layers = [], }: GenerateManifestOptions) => { const preCacheRoutes: Route[] = [] @@ -77,7 +77,7 @@ const generateManifest = ({ post_cache_routes: postCacheRoutes.filter(nonNullable), bundler_version: getPackageVersion(), layers, - import_map: importMapURL, + import_map: importMap, } return manifest @@ -105,7 +105,7 @@ interface WriteManifestOptions { declarations: Declaration[] distDirectory: string functions: EdgeFunction[] - importMapURL?: string + importMap?: string layers?: Layer[] } @@ -114,10 +114,10 @@ const writeManifest = async ({ declarations = [], distDirectory, functions, - importMapURL, + importMap, layers, }: WriteManifestOptions) => { - const manifest = generateManifest({ bundles, declarations, functions, importMapURL, layers }) + const manifest = generateManifest({ bundles, declarations, functions, importMap, layers }) const manifestPath = join(distDirectory, 'manifest.json') await fs.writeFile(manifestPath, JSON.stringify(manifest)) diff --git a/node/server/server.ts b/node/server/server.ts index c9843edf..8fac952b 100644 --- a/node/server/server.ts +++ b/node/server/server.ts @@ -156,7 +156,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/shared/consts.ts b/shared/consts.ts index b08c9d4c..acd5f46e 100644 --- a/shared/consts.ts +++ b/shared/consts.ts @@ -1 +1,2 @@ +export const importMapSpecifier = 'netlify:import-map' export const virtualRoot = 'file:///root/' diff --git a/shared/stage2.ts b/shared/stage2.ts index 61ce362e..64b859ac 100644 --- a/shared/stage2.ts +++ b/shared/stage2.ts @@ -8,5 +8,5 @@ export interface WriteStage2Options { destPath: string externals: string[] functions: InputFunction[] - importMapURL?: string + importMapData?: string } diff --git a/test/fixtures/with_import_maps/functions/func1.ts b/test/fixtures/with_import_maps/functions/func1.ts index d9174f1b..ec9cb017 100644 --- a/test/fixtures/with_import_maps/functions/func1.ts +++ b/test/fixtures/with_import_maps/functions/func1.ts @@ -1,9 +1,9 @@ import { greet } from 'alias:helper' - +import { yell } from 'util/helper.ts' import { echo } from '../helper.ts' export default async () => { - const greeting = greet(echo('Jane Doe')) + const greeting = yell(greet(echo('Jane Doe'))) return new Response(greeting) } diff --git a/test/fixtures/with_import_maps/functions/import_map.json b/test/fixtures/with_import_maps/functions/import_map.json index f7bdf228..e6b1f951 100644 --- a/test/fixtures/with_import_maps/functions/import_map.json +++ b/test/fixtures/with_import_maps/functions/import_map.json @@ -1,5 +1,6 @@ { "imports": { - "alias:helper": "../helper.ts" + "alias:helper": "../helper.ts", + "util/": "../" } } diff --git a/test/fixtures/with_import_maps/helper.ts b/test/fixtures/with_import_maps/helper.ts index bbaf8c35..0415c46e 100644 --- a/test/fixtures/with_import_maps/helper.ts +++ b/test/fixtures/with_import_maps/helper.ts @@ -1,2 +1,3 @@ export const greet = (name: string) => `Hello, ${name}!` export const echo = (name: string) => name +export const yell = (message: string) => message.toUpperCase() diff --git a/test/util.ts b/test/util.ts index 37f7a49f..1bc2b350 100644 --- a/test/util.ts +++ b/test/util.ts @@ -2,13 +2,13 @@ import { promises as fs } from 'fs' import { join, resolve } from 'path' import { fileURLToPath } from 'url' -import cpy from 'cpy' import tmp from 'tmp-promise' import { getLogger } from '../node/logger.js' -// eslint-disable-next-line @typescript-eslint/no-empty-function -const testLogger = getLogger(() => {}) +const testLogger = getLogger(() => { + // no-op +}) const url = new URL(import.meta.url) const dirname = fileURLToPath(url) @@ -18,13 +18,10 @@ const useFixture = async (fixtureName: string) => { const tmpDir = await tmp.dir() const cleanup = () => fs.rmdir(tmpDir.path, { recursive: true }) const fixtureDir = resolve(fixturesDir, fixtureName) - - await cpy(`${fixtureDir}/**`, tmpDir.path) - const distPath = join(tmpDir.path, '.netlify', 'edge-functions-dist') return { - basePath: tmpDir.path, + basePath: fixtureDir, cleanup, distPath, }