Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions packages/edge-bundler/deno/extract.ts

This file was deleted.

6 changes: 3 additions & 3 deletions packages/edge-bundler/node/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import semver from 'semver'
import tmp, { DirectoryResult } from 'tmp-promise'
import { test, expect } from 'vitest'

import { DenoBridge, DENO_VERSION_RANGE } from './bridge.js'
import { DenoBridge, LEGACY_DENO_VERSION_RANGE } from './bridge.js'
import { getPlatformTarget } from './platform.js'

const require = createRequire(import.meta.url)
const archiver = require('archiver')

const getMockDenoBridge = function (tmpDir: DirectoryResult, mockBinaryOutput: string) {
const latestVersion = semver.minVersion(DENO_VERSION_RANGE)?.version ?? ''
const latestVersion = semver.minVersion(LEGACY_DENO_VERSION_RANGE)?.version ?? ''
const data = new PassThrough()
const archive = archiver('zip', { zlib: { level: 9 } })

Expand Down Expand Up @@ -139,7 +139,7 @@ test('Does inherit environment variables if `extendEnv` is not set', async () =>

test('Provides actionable error message when downloaded binary cannot be executed', async () => {
const tmpDir = await tmp.dir()
const latestVersion = semver.minVersion(DENO_VERSION_RANGE)?.version ?? ''
const latestVersion = semver.minVersion(LEGACY_DENO_VERSION_RANGE)?.version ?? ''
const data = new PassThrough()
const archive = archiver('zip', { zlib: { level: 9 } })

Expand Down
8 changes: 4 additions & 4 deletions packages/edge-bundler/node/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { getBinaryExtension } from './platform.js'

const DENO_VERSION_FILE = 'version.txt'

export const LEGACY_DENO_VERSION_RANGE = '1.39.0 - 2.2.4'

// When updating DENO_VERSION_RANGE, ensure that the deno version
// on the netlify/buildbot build image satisfies this range!
// https://github.com/netlify/buildbot/blob/f9c03c9dcb091d6570e9d0778381560d469e78ad/build-image/noble/Dockerfile#L410
export const DENO_VERSION_RANGE = '1.39.0 - 2.2.4'

const NEXT_DENO_VERSION_RANGE = '^2.4.2'
const DENO_VERSION_RANGE = '^2.4.2'

export type OnBeforeDownloadHook = () => void | Promise<void>
export type OnAfterDownloadHook = (error?: Error) => void | Promise<void>
Expand Down Expand Up @@ -75,7 +75,7 @@ export class DenoBridge {
options.featureFlags?.edge_bundler_generate_tarball ||
options.featureFlags?.edge_bundler_deno_v2

this.versionRange = options.versionRange ?? (useNextDeno ? NEXT_DENO_VERSION_RANGE : DENO_VERSION_RANGE)
this.versionRange = options.versionRange ?? (useNextDeno ? DENO_VERSION_RANGE : LEGACY_DENO_VERSION_RANGE)
}

private async downloadBinary() {
Expand Down
41 changes: 34 additions & 7 deletions packages/edge-bundler/node/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,8 +626,8 @@ test('Loads JSON modules with `with` attribute', async () => {
await rm(vendorDirectory.path, { force: true, recursive: true })
})

test('Emits a system log when import assertions are used', async () => {
const { basePath, cleanup, distPath } = await useFixture('with_import_assert')
test('Is backwards compatible with Deno 1.x', async () => {
const { basePath, cleanup, distPath } = await useFixture('with_deno_1x_features')
const sourceDirectory = join(basePath, 'functions')
const vendorDirectory = await tmp.dir()
const systemLogger = vi.fn()
Expand All @@ -643,18 +643,45 @@ test('Emits a system log when import assertions are used', async () => {

const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
const manifest = JSON.parse(manifestFile)
const bundlePath = join(distPath, manifest.bundles[0].asset)
const { func1 } = await runESZIP(bundlePath, vendorDirectory.path)

expect(func1).toBe(`{"foo":"bar"}`)
expect(systemLogger).toHaveBeenCalledWith(
`Edge function uses import assertions: ${join(sourceDirectory, 'func1.ts')}`,
)
expect(manifest.routes[0]).toEqual({
function: 'func1',
pattern: '^/with-import-assert/?$',
pattern: '^/with-import-assert-ts/?$',
excluded_patterns: [],
path: '/with-import-assert-ts',
})

expect(systemLogger).toHaveBeenCalledWith(
`Edge function uses import assertions: ${join(sourceDirectory, 'func2.js')}`,
)
expect(manifest.routes[1]).toEqual({
function: 'func2',
pattern: '^/with-import-assert-js/?$',
excluded_patterns: [],
path: '/with-import-assert-js',
})

expect(systemLogger).toHaveBeenCalledWith(
`Edge function uses the window global: ${join(sourceDirectory, 'func3.ts')}`,
)
expect(manifest.routes[2]).toEqual({
function: 'func3',
pattern: '^/with-window-global-ts/?$',
excluded_patterns: [],
path: '/with-window-global-ts',
})

expect(systemLogger).toHaveBeenCalledWith(
`Edge function uses the window global: ${join(sourceDirectory, 'func4.js')}`,
)
expect(manifest.routes[3]).toEqual({
function: 'func4',
pattern: '^/with-window-global-js/?$',
excluded_patterns: [],
path: '/with-import-assert',
path: '/with-window-global-js',
})

await cleanup()
Expand Down
116 changes: 51 additions & 65 deletions packages/edge-bundler/node/bundler.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
import { promises as fs } from 'fs'
import { join, relative } from 'path'
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 {
DenoBridge,
DenoOptions,
OnAfterDownloadHook,
OnBeforeDownloadHook,
LEGACY_DENO_VERSION_RANGE,
} from './bridge.js'
import type { Bundle } from './bundle.js'
import { FunctionConfig, getFunctionConfig } from './config.js'
import { Declaration, mergeDeclarations } 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, extension as eszipExtension, extract as extractESZIP } from './formats/eszip.js'
import { bundle as bundleESZIP } from './formats/eszip.js'
import { bundle as bundleTarball } from './formats/tarball.js'
import { ImportMap } from './import_map.js'
import { getLogger, LogFunction, Logger } from './logger.js'
import { writeManifest } from './manifest.js'
import { vendorNPMSpecifiers } from './npm_dependencies.js'
import { ensureLatestTypes } from './types.js'
import { nonNullable } from './utils/non_nullable.js'
import { BundleError } from './bundle_error.js'
import { getPathInHome } from './home_path.js'

export interface BundleOptions {
basePath?: string
Expand Down Expand Up @@ -172,15 +178,11 @@ export const bundle = async (
// The final file name of the bundles contains a SHA256 hash of the contents,
// which we can only compute now that the files have been generated. So let's
// rename the bundles to their permanent names.
const bundlePaths = await createFinalBundles(bundles, distDirectory, buildID)
const eszipPath = bundlePaths.find((path) => path.endsWith(eszipExtension))
await createFinalBundles(bundles, distDirectory, buildID)

const { internalFunctions: internalFunctionsWithConfig, userFunctions: userFunctionsWithConfig } =
await getFunctionConfigs({
basePath,
deno,
eszipPath,
featureFlags,
importMap,
internalFunctions,
log: logger,
Expand Down Expand Up @@ -224,81 +226,65 @@ export const bundle = async (
}

interface GetFunctionConfigsOptions {
basePath: string
deno: DenoBridge
eszipPath?: string
featureFlags?: FeatureFlags
importMap: ImportMap
internalFunctions: EdgeFunction[]
log: Logger
userFunctions: EdgeFunction[]
}

const getFunctionConfigs = async ({
basePath,
deno,
eszipPath,
featureFlags,
importMap,
log,
internalFunctions,
userFunctions,
}: GetFunctionConfigsOptions) => {
try {
const internalConfigPromises = internalFunctions.map(
async (func) => [func.name, await getFunctionConfig({ functionPath: func.path, importMap, deno, log })] as const,
)
const userConfigPromises = userFunctions.map(
async (func) => [func.name, await getFunctionConfig({ functionPath: func.path, importMap, deno, log })] as const,
)

// Creating a hash of function names to configuration objects.
const internalFunctionsWithConfig = Object.fromEntries(await Promise.all(internalConfigPromises))
const userFunctionsWithConfig = Object.fromEntries(await Promise.all(userConfigPromises))

return {
internalFunctions: internalFunctionsWithConfig,
userFunctions: userFunctionsWithConfig,
}
} catch (err) {
if (!(err instanceof Error && err.cause === 'IMPORT_ASSERT') || !eszipPath || !featureFlags?.edge_bundler_deno_v2) {
throw err
}
const functions = [...internalFunctions, ...userFunctions]
const results = await Promise.allSettled(
functions.map(async (func) => {
return [func.name, await getFunctionConfig({ functionPath: func.path, importMap, deno, log })] as const
}),
)
const legacyDeno = new DenoBridge({
cacheDirectory: getPathInHome('deno-cli-v1'),
useGlobal: false,
versionRange: LEGACY_DENO_VERSION_RANGE,
})

log.user(
'WARNING: Import assertions are deprecated and will be removed soon. Refer to https://ntl.fyi/import-assert for more information.',
)
for (let i = 0; i < results.length; i++) {
const result = results[i]
const func = functions[i]

// We offer support for some features of Deno 1.x that have been removed
// from 2.x, such as import assertions and the `window` global. When we
// see that we failed to extract a config due to those edge cases, re-run
// the script with Deno 1.x so we can extract the config.
if (
result.status === 'rejected' &&
result.reason instanceof Error &&
(result.reason.cause === 'IMPORT_ASSERT' || result.reason.cause === 'WINDOW_GLOBAL')
) {
try {
const fallbackConfig = await getFunctionConfig({ functionPath: func.path, importMap, deno: legacyDeno, log })

try {
// We failed to extract the configuration because there is an import assert
// in the function code, a deprecated feature that we used to support with
// Deno 1.x. To avoid a breaking change, we treat this error as a special
// case, using the generated ESZIP to extract the configuration. This works
// because import asserts are transpiled to import attributes.
const extractedESZIP = await extractESZIP(deno, eszipPath)
const configs = await Promise.all(
[...internalFunctions, ...userFunctions].map(async (func) => {
const relativePath = relative(basePath, func.path)
const functionPath = join(extractedESZIP.path, relativePath)
results[i] = { status: 'fulfilled', value: [func.name, fallbackConfig] }
} catch {
throw result.reason
}
}
}

return [func.name, await getFunctionConfig({ functionPath, importMap, deno, log })] as const
}),
)
const failure = results.find((result) => result.status === 'rejected')
if (failure) {
throw failure.reason
}

await extractedESZIP.cleanup()
const configs = results.map((config) => (config as PromiseFulfilledResult<[string, FunctionConfig]>).value)

return {
internalFunctions: Object.fromEntries(configs.slice(0, internalFunctions.length)),
userFunctions: Object.fromEntries(configs.slice(internalFunctions.length)),
}
} catch (err) {
throw new BundleError(
new Error(
'An error occurred while building an edge function that uses an import assertion. Refer to https://ntl.fyi/import-assert for more information.',
),
{ cause: err },
)
}
return {
internalFunctions: Object.fromEntries(configs.slice(0, internalFunctions.length)),
userFunctions: Object.fromEntries(configs.slice(internalFunctions.length)),
}
}

Expand Down
11 changes: 10 additions & 1 deletion packages/edge-bundler/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,21 @@ export const getFunctionConfig = async ({
const handleConfigError = (functionPath: string, exitCode: number, stderr: string, log: Logger) => {
let cause: string | Error | undefined

if (stderr.includes('Import assertions are deprecated')) {
if (
stderr.includes('Import assertions are deprecated') ||
stderr.includes(`SyntaxError: Unexpected identifier 'assert'`)
) {
log.system(`Edge function uses import assertions: ${functionPath}`)

cause = 'IMPORT_ASSERT'
}

if (stderr.includes('ReferenceError: window is not defined')) {
log.system(`Edge function uses the window global: ${functionPath}`)

cause = 'WINDOW_GLOBAL'
}

switch (exitCode) {
case ConfigExitCode.ImportError:
log.user(stderr)
Expand Down
15 changes: 0 additions & 15 deletions packages/edge-bundler/node/formats/eszip.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { join } from 'path'
import { pathToFileURL } from 'url'

import tmp from 'tmp-promise'

import { virtualRoot, virtualVendorRoot } from '../../shared/consts.js'
import type { WriteStage2Options } from '../../shared/stage2.js'
import { DenoBridge } from '../bridge.js'
Expand Down Expand Up @@ -88,16 +86,3 @@ const getESZIPPaths = () => {
importMap: join(denoPath, 'vendor', 'import_map.json'),
}
}

export const extract = async (deno: DenoBridge, functionPath: string) => {
const tmpDir = await tmp.dir({ unsafeCleanup: true })
const { extractor, importMap } = getESZIPPaths()
const flags = ['--allow-all', '--no-config', '--no-lock', `--import-map=${importMap}`, '--quiet']

await deno.run(['run', ...flags, extractor, functionPath, tmpDir.path], { pipeOutput: true })

return {
cleanup: tmpDir.cleanup,
path: join(tmpDir.path, 'source', 'root'),
}
}
4 changes: 2 additions & 2 deletions packages/edge-bundler/node/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import semver from 'semver'
import tmp from 'tmp-promise'
import { test, expect, vi } from 'vitest'

import { DenoBridge, DENO_VERSION_RANGE } from './bridge.js'
import { DenoBridge, LEGACY_DENO_VERSION_RANGE } from './bridge.js'
import { getPlatformTarget } from './platform.js'

const require = createRequire(import.meta.url)
const archiver = require('archiver')

test('Downloads the Deno CLI on demand and caches it for subsequent calls', async () => {
const latestVersion = semver.minVersion(DENO_VERSION_RANGE)?.version ?? ''
const latestVersion = semver.minVersion(LEGACY_DENO_VERSION_RANGE)?.version ?? ''
const mockBinaryOutput = `#!/usr/bin/env sh\n\necho "deno ${latestVersion}"`
const data = new PassThrough()
const archive = archiver('zip', { zlib: { level: 9 } })
Expand Down
1 change: 1 addition & 0 deletions packages/edge-bundler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"deno/**",
"!deno/**/*.test.ts",
"dist/**/*.js",
"!dist/**/*.test.js",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're currently including the test files in the published module. 😑

"dist/**/*.d.ts",
"shared/**"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import dict from './dict.json' assert { type: "json" }
export default async () => Response.json(dict)

export const config = {
path: "/with-import-assert"
path: "/with-import-assert-ts"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import dict from './dict.json' assert { type: "json" }


export default async () => Response.json(dict)

export const config = {
path: "/with-import-assert-js"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
window.foo = 1

export default async () => Response.json({})

export const config = {
path: "/with-window-global-ts"
}
Loading
Loading