Skip to content

Commit

Permalink
feat: use netlify:import-map specifier (#246)
Browse files Browse the repository at this point in the history
* feat: use `netlify:import-map` specifier

* refactor: conditionally set import map URL
  • Loading branch information
eduardoboucas committed Dec 9, 2022
1 parent e521d7e commit 9f2a947
Show file tree
Hide file tree
Showing 17 changed files with 106 additions and 141 deletions.
4 changes: 2 additions & 2 deletions 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 })
13 changes: 9 additions & 4 deletions deno/lib/stage2.ts
Expand Up @@ -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'

Expand Down Expand Up @@ -63,14 +63,18 @@ const getVirtualPath = (basePath: string, filePath: string) => {
return url
}

const stage2Loader = (basePath: string, functions: InputFunction[], externals: Set<string>) => {
const stage2Loader = (basePath: string, functions: InputFunction[], externals: Set<string>, importMapData?: string) => {
return async (specifier: string): Promise<LoadResponse | undefined> => {
if (specifier === STAGE2_SPECIFIER) {
const stage2Entry = getStage2Entry(basePath, functions)

return inlineModule(specifier, stage2Entry)
}

if (specifier === importMapSpecifier && importMapData !== undefined) {
return inlineModule(specifier, importMapData)
}

if (specifier === PUBLIC_SPECIFIER || externals.has(specifier)) {
return {
kind: 'external',
Expand All @@ -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)

Expand Down
1 change: 0 additions & 1 deletion node/bundle.ts
Expand Up @@ -7,5 +7,4 @@ export interface Bundle {
extension: string
format: BundleFormat
hash: string
importMapURL?: string
}
69 changes: 9 additions & 60 deletions 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'
Expand All @@ -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)
Expand All @@ -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()
})
Expand All @@ -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)
Expand Down Expand Up @@ -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'))
Expand All @@ -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'))

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 })
})
4 changes: 3 additions & 1 deletion node/bundler.ts
Expand Up @@ -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'
Expand Down Expand Up @@ -126,7 +128,7 @@ const bundle = async (
declarations,
distDirectory,
functions,
importMapURL: functionBundle.importMapURL,
importMap: importMapSpecifier,
layers: deployConfig.layers,
})

Expand Down
8 changes: 2 additions & 6 deletions node/config.test.ts
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 4 additions & 28 deletions 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'
Expand Down Expand Up @@ -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}`]

Expand All @@ -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 = () => {
Expand Down
38 changes: 31 additions & 7 deletions node/import_map.test.ts
Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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}'`,
)
})

0 comments on commit 9f2a947

Please sign in to comment.