From 70abecc372fb2bc82c4086fb7141204f68b5eb2d Mon Sep 17 00:00:00 2001 From: pieh Date: Mon, 1 Jun 2026 20:14:31 +0200 Subject: [PATCH 1/4] fix: use matching deno version and bundle DENO_DIR for tarball bundles --- packages/edge-bundler/node/bridge.ts | 22 +++-- packages/edge-bundler/node/bundler.test.ts | 60 ++++++++++-- packages/edge-bundler/node/bundler.ts | 26 ++++- packages/edge-bundler/node/formats/tarball.ts | 97 +++++++++++++++++++ 4 files changed, 190 insertions(+), 15 deletions(-) diff --git a/packages/edge-bundler/node/bridge.ts b/packages/edge-bundler/node/bridge.ts index 8c3f313e18..7ff02c7241 100644 --- a/packages/edge-bundler/node/bridge.ts +++ b/packages/edge-bundler/node/bridge.ts @@ -16,6 +16,8 @@ const DENO_VERSION_FILE = 'version.txt' export const LEGACY_DENO_VERSION_RANGE = '1.39.0 - 2.2.4' +export const TARBALL_DENO_VERSION_RANGE = '2.3.1' + // 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 @@ -42,6 +44,11 @@ export interface ProcessRef { interface RunOptions { cwd?: string + // Overrides the `DENO_DIR` for this invocation only, taking precedence over + // the bridge-level `denoDir`. Used to point a single command (e.g. `deno + // cache`) at a bundle-local cache without affecting other commands that + // should keep using the shared, cross-build cache. + denoDir?: string env?: NodeJS.ProcessEnv extendEnv?: boolean pipeOutput?: boolean @@ -246,11 +253,12 @@ To install Deno manually: https://ntl.fyi/install-deno`, return { global: false, path: downloadedPath } } - getEnvironmentVariables(inputEnv: NodeJS.ProcessEnv = {}) { + getEnvironmentVariables(inputEnv: NodeJS.ProcessEnv = {}, denoDirOverride?: string) { const env: NodeJS.ProcessEnv = { ...inputEnv } - if (this.denoDir !== undefined) { - env.DENO_DIR = this.denoDir + const denoDir = denoDirOverride ?? this.denoDir + if (denoDir !== undefined) { + env.DENO_DIR = denoDir } // Ensure PATH is always set as otherwise we are not able to find the global deno binary @@ -263,10 +271,10 @@ To install Deno manually: https://ntl.fyi/install-deno`, // process, awaiting its execution. async run( args: string[], - { cwd, env: inputEnv, extendEnv = true, rejectOnExitCode = true, stderr, stdout }: RunOptions = {}, + { cwd, denoDir, env: inputEnv, extendEnv = true, rejectOnExitCode = true, stderr, stdout }: RunOptions = {}, ) { const { path: binaryPath } = await this.getBinaryPath() - const env = this.getEnvironmentVariables(inputEnv) + const env = this.getEnvironmentVariables(inputEnv, denoDir) const options: Options = { cwd, env, extendEnv, reject: rejectOnExitCode } return DenoBridge.runWithBinary(binaryPath, args, { options, stderr, stdout }) @@ -277,10 +285,10 @@ To install Deno manually: https://ntl.fyi/install-deno`, async runInBackground( args: string[], ref?: ProcessRef, - { env: inputEnv, extendEnv = true, pipeOutput, stderr, stdout }: RunOptions = {}, + { denoDir, env: inputEnv, extendEnv = true, pipeOutput, stderr, stdout }: RunOptions = {}, ) { const { path: binaryPath } = await this.getBinaryPath() - const env = this.getEnvironmentVariables(inputEnv) + const env = this.getEnvironmentVariables(inputEnv, denoDir) const options: Options = { env, extendEnv } const ps = DenoBridge.runWithBinary(binaryPath, args, { options, pipeOutput, stderr, stdout }) diff --git a/packages/edge-bundler/node/bundler.test.ts b/packages/edge-bundler/node/bundler.test.ts index b4f7fd0e7e..bb93f11b51 100644 --- a/packages/edge-bundler/node/bundler.test.ts +++ b/packages/edge-bundler/node/bundler.test.ts @@ -816,6 +816,54 @@ describe.skipIf(lt(denoVersion, '2.4.2'))( await cleanup() }) + test('Includes a relocated Deno cache (DENO_DIR) in the tarball', async () => { + const { basePath, cleanup, distPath } = await useFixture('imports_node_builtin', { copyDirectory: true }) + const declarations: Declaration[] = [ + { + function: 'func1', + path: '/func1', + }, + ] + + await bundle([join(basePath, 'netlify/edge-functions')], distPath, declarations, { + basePath, + configPath: join(basePath, '.netlify/edge-functions/config.json'), + featureFlags: { + edge_bundler_generate_tarball: true, + }, + }) + + const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8') + const manifest = JSON.parse(manifestFile) + const tarballPath = join(distPath, manifest.bundles[0].asset) + + const entries: string[] = [] + await tar.list({ + file: tarballPath, + onReadEntry: (entry) => { + entries.push(entry.path) + }, + }) + + // The transpiled local function should be cached under the runtime mount + // path (`gen/file/platform/...`), not the arbitrary bundling path. + expect(entries).toContain('./.deno_dir/gen/file/platform/func1.ts.js') + + const genFileEntries = entries.filter((entry) => entry.startsWith('./.deno_dir/gen/file/')) + expect(genFileEntries.length).toBeGreaterThan(0) + + // No `gen/file` entry should leak a bundling-time absolute path; they must + // all live under the relocated `platform/` directory. + for (const entry of genFileEntries) { + expect(entry.startsWith('./.deno_dir/gen/file/platform/')).toBe(true) + } + + // The absolute-path-keyed dependency-analysis cache should be dropped. + expect(entries.some((entry) => entry.includes('dep_analysis_cache'))).toBe(false) + + await cleanup() + }) + test('Using npm and remote modules', async () => { const systemLogger = vi.fn() const { basePath, cleanup, distPath } = await useFixture('imports_npm_module', { copyDirectory: true }) @@ -1355,13 +1403,11 @@ describe.skipIf(lt(denoVersion, '2.4.2'))( expect(entries).toContain('./func1.ts') // The vendored deno_dom WASM payload must be present in the tarball. - // Deno <2.6 vendors `.wasm` imports under a `.d.mts` extension (with a - // content-hash suffix); 2.6+ keeps the original `.wasm` extension. - const denoDomVendorPrefix = './vendor/deno.land/x/deno_dom@v0.1.56/build/deno-wasm/' - const expectedWasmEntry = lt(denoVersion, '2.6.0') - ? `${denoDomVendorPrefix}#deno-wasm_bg.wasm_d2792.d.mts` - : `${denoDomVendorPrefix}deno-wasm_bg.wasm` - expect(entries).toContain(expectedWasmEntry) + // Tarball bundling always vendors with the runtime-matched Deno (see + // `TARBALL_DENO_VERSION_RANGE`), independent of the local Deno running + // these tests. That version is <2.6, which vendors `.wasm` imports under a + // hashed `.d.mts` name. + expect(entries).toContain('./vendor/deno.land/x/deno_dom@v0.1.56/build/deno-wasm/#deno-wasm_bg.wasm_d2792.d.mts') const eszipPath = join(distPath, manifest.bundles[1].asset) const eszipResult = await runESZIP(eszipPath) diff --git a/packages/edge-bundler/node/bundler.ts b/packages/edge-bundler/node/bundler.ts index 33321d970a..d70148202c 100644 --- a/packages/edge-bundler/node/bundler.ts +++ b/packages/edge-bundler/node/bundler.ts @@ -12,6 +12,7 @@ import { OnAfterDownloadHook, OnBeforeDownloadHook, LEGACY_DENO_VERSION_RANGE, + TARBALL_DENO_VERSION_RANGE, } from './bridge.js' import type { Bundle } from './bundle.js' import { FunctionConfig, getFunctionConfig } from './config.js' @@ -133,6 +134,11 @@ export const bundle = async ( let finalizeTarballBundle: Awaited> | undefined if (featureFlags.edge_bundler_generate_tarball || featureFlags.edge_bundler_dry_run_generate_tarball) { + // Tarball bundles are executed at runtime by a Deno version matching + // `TARBALL_DENO_VERSION_RANGE`, so we bundle them with that same version to + // keep the produced module graph and Deno cache compatible. + const denoTarball = createTarballDenoBridge(options) + const tarballInitialPromise = (async () => { const start = Date.now() @@ -141,7 +147,7 @@ export const bundle = async ( basePath, buildID, debug, - deno, + deno: denoTarball, distDirectory, functions, featureFlags, @@ -276,6 +282,24 @@ export const bundle = async ( return { functions, manifest } } +// Builds a `DenoBridge` pinned to the Deno version used to run tarball bundles +// at runtime (`TARBALL_DENO_VERSION_RANGE`). It reuses the shared options +// (including `denoDir`, so the module download cache is shared across builds) +// but downloads its binary into a dedicated directory so it doesn't clash with +// the default bridge's binary, which targets a different version range. +const createTarballDenoBridge = (options: DenoOptions) => { + const binaryCacheDirectory = + options.cacheDirectory === undefined + ? getPathInHome('deno-cli-tarball') + : join(options.cacheDirectory, 'deno-cli-tarball') + + return new DenoBridge({ + ...options, + cacheDirectory: binaryCacheDirectory, + versionRange: TARBALL_DENO_VERSION_RANGE, + }) +} + interface GetFunctionConfigsOptions { deno: DenoBridge importMap: ImportMap diff --git a/packages/edge-bundler/node/formats/tarball.ts b/packages/edge-bundler/node/formats/tarball.ts index e484691efc..d003def5d3 100644 --- a/packages/edge-bundler/node/formats/tarball.ts +++ b/packages/edge-bundler/node/formats/tarball.ts @@ -47,6 +47,93 @@ interface FinalizeTarballBundleOptions { const getUnixPath = (input: string) => input.split(path.sep).join('/') +// Name of the Deno cache directory (DENO_DIR) we create inside the bundle. The +// runtime points `DENO_DIR` at this directory once the bundle is mounted. +const DENO_DIR_NAME = '.deno_dir' + +// The directory (relative to the filesystem root) the bundle is mounted at when +// executed at runtime. Deno keys the transpiled output of local source files by +// their absolute path under `gen/file/`, so cache entries are relocated from the +// (arbitrary) bundling path to this path to remain resolvable at runtime. +const RUNTIME_MOUNT_DIR = 'platform' + +interface CreateDenoCacheOptions { + deno: DenoBridge + bundleDirPath: string + denoConfigPath: string + entrypoints: string[] +} + +/** + * Runs `deno cache` to populate a DENO_DIR inside the bundle, then rewrites it + * so it's usable from the runtime mount path. The cache lets the runtime skip + * transpiling (and re-downloading) modules on cold start. + */ +const createDenoCache = async ({ deno, bundleDirPath, denoConfigPath, entrypoints }: CreateDenoCacheOptions) => { + console.log('[dev-log] Creating Deno cache for tarball bundle...') + const denoDir = path.join(bundleDirPath, DENO_DIR_NAME) + + // `--vendor` makes Deno resolve dependencies from the vendor directory, just + // like the runtime does, so the cached output lines up with runtime resolution. + // `--config` loads the import map from the generated deno.json. `--allow-import` + // permits fetching from hosts outside Deno's default allow-list (mirroring how + // the runtime and the vendoring step are invoked). + await deno.run(['cache', '--allow-import', '--vendor', '--config', denoConfigPath, ...entrypoints], { + cwd: bundleDirPath, + denoDir, + }) + + await relocateGenFileCache(bundleDirPath, denoDir) + await dropAbsolutePathCaches(denoDir) +} + +/** + * Deno stores the transpiled output of local source files under + * `/gen/file/`, using the real (symlink-resolved) + * path. Because the bundle is created at an arbitrary path but mounted at + * `/` at runtime, we move that subtree so the entries match the + * runtime paths. Remote and vendored modules are cached under `gen/https/` + * (content-addressed) and need no relocation. + */ +const relocateGenFileCache = async (bundleDirPath: string, denoDir: string) => { + const genFileDir = path.join(denoDir, 'gen', 'file') + + // Deno resolves symlinks before computing the cache path (e.g. on macOS + // `/var` -> `/private/var`), so we resolve the bundle path the same way to + // locate the subtree it emitted. + const realBundleDirPath = await fs.realpath(bundleDirPath) + const segments = realBundleDirPath.split(path.sep).filter(Boolean) + const source = path.join(genFileDir, ...segments) + const destination = path.join(genFileDir, RUNTIME_MOUNT_DIR) + + if (!existsSync(source)) { + throw new Error(`Expected Deno to emit transpiled local files at ${source}, but the directory was not found`) + } + + await fs.rename(source, destination) + + // Remove the now-empty leading directories (e.g. `gen/file/private/...`) left + // behind by the move so they don't end up in the tarball. + if (segments[0] !== RUNTIME_MOUNT_DIR) { + await fs.rm(path.join(genFileDir, segments[0]), { recursive: true, force: true }) + } +} + +/** + * Removes Deno's dependency-analysis cache, a SQLite database that keys local + * modules by absolute path. Its entries wouldn't match the runtime paths, so it + * would just be dead weight in the tarball; Deno regenerates it on demand. + */ +const dropAbsolutePathCaches = async (denoDir: string) => { + const entries = await fs.readdir(denoDir) + + await Promise.all( + entries + .filter((entry) => entry.startsWith('dep_analysis_cache')) + .map((entry) => fs.rm(path.join(denoDir, entry), { force: true })), + ) +} + export const bundle = async ({ buildID, deno, @@ -167,6 +254,16 @@ export const bundle = async ({ } } + // Produce a Deno cache (DENO_DIR) inside the bundle so the runtime can skip + // downloading and transpiling modules on cold start, and include it in the + // tarball. + await createDenoCache({ + deno, + bundleDirPath: bundleDir.path, + denoConfigPath, + entrypoints: Object.values(initialManifest.functions), + }) + // First stage of bundling is now done. To finalize bundling we require functionConfig, routes and postCacheRoutes // so we could inject those into bundle manifest. Tarball bundling is done in 2-step process process to preserve ordering // and potential errors messages that could be thrown to make sure we don't impact behaviors. Otherwise we would throw different From bf375ab69d55307036700dd04637ce806992defa Mon Sep 17 00:00:00 2001 From: pieh Date: Fri, 5 Jun 2026 12:24:43 +0200 Subject: [PATCH 2/4] fix: use deno-tarball, this is not cli specific --- packages/edge-bundler/node/bundler.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/edge-bundler/node/bundler.ts b/packages/edge-bundler/node/bundler.ts index d70148202c..f9732d8e92 100644 --- a/packages/edge-bundler/node/bundler.ts +++ b/packages/edge-bundler/node/bundler.ts @@ -289,9 +289,7 @@ export const bundle = async ( // the default bridge's binary, which targets a different version range. const createTarballDenoBridge = (options: DenoOptions) => { const binaryCacheDirectory = - options.cacheDirectory === undefined - ? getPathInHome('deno-cli-tarball') - : join(options.cacheDirectory, 'deno-cli-tarball') + options.cacheDirectory === undefined ? getPathInHome('deno-tarball') : join(options.cacheDirectory, 'deno-tarball') return new DenoBridge({ ...options, From 18a6aef88db13c20f95294b41e39c153ea7be720 Mon Sep 17 00:00:00 2001 From: pieh Date: Fri, 5 Jun 2026 12:24:13 +0200 Subject: [PATCH 3/4] test: normalize output printing path to cached tarball specific deno version --- packages/build/tests/edge_functions/tests.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/build/tests/edge_functions/tests.js b/packages/build/tests/edge_functions/tests.js index d25da1fbc6..f3e9a64030 100644 --- a/packages/build/tests/edge_functions/tests.js +++ b/packages/build/tests/edge_functions/tests.js @@ -161,7 +161,9 @@ for (const variant of FLAG_VARIANTS) { if (platform !== 'win32') { test.serial(variant.id + ' - handles failure when bundling Edge Functions', async (t) => { const output = await new Fixture('./fixtures/functions_invalid').withFlags(variant.flags).runWithBuild() - t.snapshot(normalizeOutput(output)) + t.snapshot( + normalizeOutput(output.replace(/"?[a-zA-Z0-9\\/.-_]*deno-tarball[/\\]deno[a-zA-Z0-9\\/.-_]*"?/g, 'deno')), + ) }) } From 17826385bef70dec641a2c3378b633dd61dfc65a Mon Sep 17 00:00:00 2001 From: pieh Date: Fri, 5 Jun 2026 12:35:14 +0200 Subject: [PATCH 4/4] fix: keep only 'gen' dir --- packages/edge-bundler/node/formats/tarball.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/edge-bundler/node/formats/tarball.ts b/packages/edge-bundler/node/formats/tarball.ts index d003def5d3..0c7a388ab8 100644 --- a/packages/edge-bundler/node/formats/tarball.ts +++ b/packages/edge-bundler/node/formats/tarball.ts @@ -57,6 +57,13 @@ const DENO_DIR_NAME = '.deno_dir' // (arbitrary) bundling path to this path to remain resolvable at runtime. const RUNTIME_MOUNT_DIR = 'platform' +// Subdirectory of DENO_DIR holding Deno's emit cache: the transpiled JS output +// of non-JS sources (TypeScript, JSX). Deno calls this the `gen` cache (the +// `EmitCache`, surfaced as the emit/TypeScript cache in `deno info`). Shipping +// it lets the runtime skip transpiling those sources on cold start, and it's the +// only DENO_DIR artifact we keep — see `pruneDenoDirToGen`. +const DENO_EMIT_CACHE_DIR = 'gen' + interface CreateDenoCacheOptions { deno: DenoBridge bundleDirPath: string @@ -84,7 +91,7 @@ const createDenoCache = async ({ deno, bundleDirPath, denoConfigPath, entrypoint }) await relocateGenFileCache(bundleDirPath, denoDir) - await dropAbsolutePathCaches(denoDir) + await pruneDenoDirToGen(denoDir) } /** @@ -96,7 +103,7 @@ const createDenoCache = async ({ deno, bundleDirPath, denoConfigPath, entrypoint * (content-addressed) and need no relocation. */ const relocateGenFileCache = async (bundleDirPath: string, denoDir: string) => { - const genFileDir = path.join(denoDir, 'gen', 'file') + const genFileDir = path.join(denoDir, DENO_EMIT_CACHE_DIR, 'file') // Deno resolves symlinks before computing the cache path (e.g. on macOS // `/var` -> `/private/var`), so we resolve the bundle path the same way to @@ -120,17 +127,22 @@ const relocateGenFileCache = async (bundleDirPath: string, denoDir: string) => { } /** - * Removes Deno's dependency-analysis cache, a SQLite database that keys local - * modules by absolute path. Its entries wouldn't match the runtime paths, so it - * would just be dead weight in the tarball; Deno regenerates it on demand. + * Prunes the DENO_DIR down to just the emit cache (`DENO_EMIT_CACHE_DIR`) — the + * transpiled output we actually want to ship. We use an allowlist (keep the emit + * cache) rather than a blocklist (drop known-bad entries) so we stay robust to + * future Deno versions writing new, non-portable artifacts into the cache. + * Everything else Deno puts there is either regenerated on demand (e.g. the + * `dep_analysis_cache*` SQLite DBs, which key modules by absolute path and so + * wouldn't match runtime paths) or redundant with the vendored sources already + * in the bundle (`remote/` stays empty under `--vendor`). */ -const dropAbsolutePathCaches = async (denoDir: string) => { +const pruneDenoDirToGen = async (denoDir: string) => { const entries = await fs.readdir(denoDir) await Promise.all( entries - .filter((entry) => entry.startsWith('dep_analysis_cache')) - .map((entry) => fs.rm(path.join(denoDir, entry), { force: true })), + .filter((entry) => entry !== DENO_EMIT_CACHE_DIR) + .map((entry) => fs.rm(path.join(denoDir, entry), { recursive: true, force: true })), ) }