Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use netlify:import-map specifier #246

Merged
merged 2 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions deno/bundle.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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}'`,
)
})