diff --git a/packages/edge-bundler/deno/extract.ts b/packages/edge-bundler/deno/extract.ts deleted file mode 100644 index 873a72464f..0000000000 --- a/packages/edge-bundler/deno/extract.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { loadESZIP } from 'https://deno.land/x/eszip@v0.55.2/eszip.ts' - -const [functionPath, destPath] = Deno.args - -const eszip = await loadESZIP(functionPath) - -await eszip.extract(destPath) diff --git a/packages/edge-bundler/node/bridge.test.ts b/packages/edge-bundler/node/bridge.test.ts index 8e51893be1..4315231a14 100644 --- a/packages/edge-bundler/node/bridge.test.ts +++ b/packages/edge-bundler/node/bridge.test.ts @@ -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 } }) @@ -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 } }) diff --git a/packages/edge-bundler/node/bridge.ts b/packages/edge-bundler/node/bridge.ts index d75447abd0..3dcea43ee5 100644 --- a/packages/edge-bundler/node/bridge.ts +++ b/packages/edge-bundler/node/bridge.ts @@ -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 export type OnAfterDownloadHook = (error?: Error) => void | Promise @@ -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() { diff --git a/packages/edge-bundler/node/bundler.test.ts b/packages/edge-bundler/node/bundler.test.ts index 0807337a6a..d502e23361 100644 --- a/packages/edge-bundler/node/bundler.test.ts +++ b/packages/edge-bundler/node/bundler.test.ts @@ -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() @@ -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() diff --git a/packages/edge-bundler/node/bundler.ts b/packages/edge-bundler/node/bundler.ts index beb509932c..49abb0175c 100644 --- a/packages/edge-bundler/node/bundler.ts +++ b/packages/edge-bundler/node/bundler.ts @@ -1,12 +1,18 @@ 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' @@ -14,7 +20,7 @@ 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' @@ -22,7 +28,7 @@ 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 @@ -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, @@ -224,10 +226,7 @@ export const bundle = async ( } interface GetFunctionConfigsOptions { - basePath: string deno: DenoBridge - eszipPath?: string - featureFlags?: FeatureFlags importMap: ImportMap internalFunctions: EdgeFunction[] log: Logger @@ -235,70 +234,57 @@ interface GetFunctionConfigsOptions { } 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)), } } diff --git a/packages/edge-bundler/node/config.ts b/packages/edge-bundler/node/config.ts index b73fe3fc75..7283f993af 100644 --- a/packages/edge-bundler/node/config.ts +++ b/packages/edge-bundler/node/config.ts @@ -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) diff --git a/packages/edge-bundler/node/formats/eszip.ts b/packages/edge-bundler/node/formats/eszip.ts index 66351e8c6b..7457d815af 100644 --- a/packages/edge-bundler/node/formats/eszip.ts +++ b/packages/edge-bundler/node/formats/eszip.ts @@ -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' @@ -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'), - } -} diff --git a/packages/edge-bundler/node/main.test.ts b/packages/edge-bundler/node/main.test.ts index b530bc6b1f..78cff9e762 100644 --- a/packages/edge-bundler/node/main.test.ts +++ b/packages/edge-bundler/node/main.test.ts @@ -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 } }) diff --git a/packages/edge-bundler/package.json b/packages/edge-bundler/package.json index cb04591cac..14d0521467 100644 --- a/packages/edge-bundler/package.json +++ b/packages/edge-bundler/package.json @@ -9,6 +9,7 @@ "deno/**", "!deno/**/*.test.ts", "dist/**/*.js", + "!dist/**/*.test.js", "dist/**/*.d.ts", "shared/**" ], diff --git a/packages/edge-bundler/test/fixtures/with_import_assert/functions/dict.json b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/dict.json similarity index 100% rename from packages/edge-bundler/test/fixtures/with_import_assert/functions/dict.json rename to packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/dict.json diff --git a/packages/edge-bundler/test/fixtures/with_import_assert/functions/func1.ts b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func1.ts similarity index 79% rename from packages/edge-bundler/test/fixtures/with_import_assert/functions/func1.ts rename to packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func1.ts index 552dbf5e8c..4ba28092b4 100644 --- a/packages/edge-bundler/test/fixtures/with_import_assert/functions/func1.ts +++ b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func1.ts @@ -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" } diff --git a/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func2.js b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func2.js new file mode 100644 index 0000000000..b85b5548c5 --- /dev/null +++ b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func2.js @@ -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" +} diff --git a/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func3.ts b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func3.ts new file mode 100644 index 0000000000..79c03dfa70 --- /dev/null +++ b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func3.ts @@ -0,0 +1,7 @@ +window.foo = 1 + +export default async () => Response.json({}) + +export const config = { + path: "/with-window-global-ts" +} diff --git a/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func4.js b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func4.js new file mode 100644 index 0000000000..df378fc460 --- /dev/null +++ b/packages/edge-bundler/test/fixtures/with_deno_1x_features/functions/func4.js @@ -0,0 +1,7 @@ +window.foo = 1 + +export default async () => Response.json({}) + +export const config = { + path: "/with-window-global-js" +}