Skip to content

Commit a5e2746

Browse files
feat: generate edge function tarballs (#6568)
* feat: deploy edge functions as tarballs * chore: cleanup * refactor: abstract TS transpilation logic * chore: remove comment * chore: fix test * chore: update CI versions * chore: remove node prefix * fix: add type check * chore: support Node 14 * chore: update test * fix: fix test * fix: fix lint issue * refactor: use deno bundle * chore: add comment * chore: update Deno version * refactor: remove unused files * chore: remove lock file * refactor: revert lock flag * fix: fix test * chore: update Deno * refactor: revert lock flag * chore: add debug log * chore: add more debug * refactor: fix path generation * chore: clean up test util * refactor: revert version bump * chore: simplify test * chore: do not use lock file * fix: read feature flag * refactor: revert fixture change * fix: use stable hash * fix: fix file type check * fix: improve sorting
1 parent 9c06657 commit a5e2746

File tree

21 files changed

+489
-73
lines changed

21 files changed

+489
-73
lines changed

.github/workflows/workflow.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ jobs:
5050
os: [ubuntu-24.04, macos-14, windows-2025]
5151
node-version: ['22']
5252
# Must include the minimum deno version from the `DENO_VERSION_RANGE` constant in `node/bridge.ts`.
53-
deno-version: ['v1.39.0', 'v2.2.4']
53+
# We're adding v2.4.2 here because it's needed for the upcoming nimble release, so we can test
54+
# those workflows ahead of time before we can update the base version across the board.
55+
deno-version: ['v1.39.0', 'v2.2.4', 'v2.4.2']
5456
include:
5557
- os: ubuntu-24.04
5658
# Earliest supported version

package-lock.json

Lines changed: 1 addition & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/edge-bundler/deno/lib/common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const loadWithRetry = (specifier: string, delay = 1000, maxTry = 3) => {
4242
maxTry,
4343
});
4444
} catch (error) {
45-
if (isTooManyTries(error as Error)) {
45+
if (error instanceof Error && isTooManyTries(error)) {
4646
console.error(`Loading ${specifier} failed after ${maxTry} tries.`);
4747
}
4848
throw error;

packages/edge-bundler/node/bridge.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import pathKey from 'path-key'
77
import semver from 'semver'
88

99
import { download } from './downloader.js'
10+
import { FeatureFlags } from './feature_flags.js'
1011
import { getPathInHome } from './home_path.js'
1112
import { getLogger, Logger } from './logger.js'
1213
import { getBinaryExtension } from './platform.js'
@@ -16,27 +17,31 @@ const DENO_VERSION_FILE = 'version.txt'
1617
// When updating DENO_VERSION_RANGE, ensure that the deno version
1718
// on the netlify/buildbot build image satisfies this range!
1819
// https://github.com/netlify/buildbot/blob/f9c03c9dcb091d6570e9d0778381560d469e78ad/build-image/noble/Dockerfile#L410
19-
const DENO_VERSION_RANGE = '1.39.0 - 2.2.4'
20+
export const DENO_VERSION_RANGE = '1.39.0 - 2.2.4'
2021

21-
type OnBeforeDownloadHook = () => void | Promise<void>
22-
type OnAfterDownloadHook = (error?: Error) => void | Promise<void>
22+
const NEXT_DENO_VERSION_RANGE = '^2.4.2'
2323

24-
interface DenoOptions {
24+
export type OnBeforeDownloadHook = () => void | Promise<void>
25+
export type OnAfterDownloadHook = (error?: Error) => void | Promise<void>
26+
27+
export interface DenoOptions {
2528
cacheDirectory?: string
2629
debug?: boolean
2730
denoDir?: string
31+
featureFlags?: FeatureFlags
2832
logger?: Logger
2933
onAfterDownload?: OnAfterDownloadHook
3034
onBeforeDownload?: OnBeforeDownloadHook
3135
useGlobal?: boolean
3236
versionRange?: string
3337
}
3438

35-
interface ProcessRef {
39+
export interface ProcessRef {
3640
ps?: ExecaChildProcess<string>
3741
}
3842

3943
interface RunOptions {
44+
cwd?: string
4045
env?: NodeJS.ProcessEnv
4146
extendEnv?: boolean
4247
pipeOutput?: boolean
@@ -45,7 +50,7 @@ interface RunOptions {
4550
rejectOnExitCode?: boolean
4651
}
4752

48-
class DenoBridge {
53+
export class DenoBridge {
4954
cacheDirectory: string
5055
currentDownload?: ReturnType<DenoBridge['downloadBinary']>
5156
debug: boolean
@@ -64,7 +69,9 @@ class DenoBridge {
6469
this.onAfterDownload = options.onAfterDownload
6570
this.onBeforeDownload = options.onBeforeDownload
6671
this.useGlobal = options.useGlobal ?? true
67-
this.versionRange = options.versionRange ?? DENO_VERSION_RANGE
72+
this.versionRange =
73+
options.versionRange ??
74+
(options.featureFlags?.edge_bundler_generate_tarball ? NEXT_DENO_VERSION_RANGE : DENO_VERSION_RANGE)
6875
}
6976

7077
private async downloadBinary() {
@@ -245,11 +252,11 @@ class DenoBridge {
245252
// process, awaiting its execution.
246253
async run(
247254
args: string[],
248-
{ env: inputEnv, extendEnv = true, rejectOnExitCode = true, stderr, stdout }: RunOptions = {},
255+
{ cwd, env: inputEnv, extendEnv = true, rejectOnExitCode = true, stderr, stdout }: RunOptions = {},
249256
) {
250257
const { path: binaryPath } = await this.getBinaryPath()
251258
const env = this.getEnvironmentVariables(inputEnv)
252-
const options: Options = { env, extendEnv, reject: rejectOnExitCode }
259+
const options: Options = { cwd, env, extendEnv, reject: rejectOnExitCode }
253260

254261
return DenoBridge.runWithBinary(binaryPath, args, { options, stderr, stdout })
255262
}
@@ -271,6 +278,3 @@ class DenoBridge {
271278
}
272279
}
273280
}
274-
275-
export { DENO_VERSION_RANGE, DenoBridge }
276-
export type { DenoOptions, OnAfterDownloadHook, OnBeforeDownloadHook, ProcessRef }

packages/edge-bundler/node/bundle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum BundleFormat {
22
ESZIP2 = 'eszip2',
33
JS = 'js',
4+
TARBALL = 'tar',
45
}
56

67
export interface Bundle {

packages/edge-bundler/node/bundler.test.ts

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { Buffer } from 'buffer'
2+
import { execSync } from 'node:child_process'
23
import { access, readdir, readFile, rm, writeFile } from 'fs/promises'
34
import { join, resolve } from 'path'
45
import process from 'process'
56
import { pathToFileURL } from 'url'
67

8+
import { lt } from 'semver'
79
import tmp from 'tmp-promise'
8-
import { test, expect, vi } from 'vitest'
10+
import { test, expect, vi, describe } from 'vitest'
911

1012
import { importMapSpecifier } from '../shared/consts.js'
11-
import { runESZIP, useFixture } from '../test/util.js'
13+
import { runESZIP, runTarball, useFixture } from '../test/util.js'
1214

1315
import { BundleError } from './bundle_error.js'
1416
import { bundle, BundleOptions } from './bundler.js'
@@ -48,7 +50,6 @@ test('Produces an ESZIP bundle', async () => {
4850
expect(importMapURL).toBe(importMapSpecifier)
4951

5052
const bundlePath = join(distPath, bundles[0].asset)
51-
5253
const { func1, func2, func3 } = await runESZIP(bundlePath)
5354

5455
expect(func1).toBe('HELLO, JANE DOE!')
@@ -499,7 +500,7 @@ test('Loads npm modules from bare specifiers', async () => {
499500
const { func1 } = await runESZIP(bundlePath, vendorDirectory.path)
500501

501502
expect(func1).toBe(
502-
`<parent-1><child-1>JavaScript</child-1></parent-1>, <parent-2><child-2><grandchild-1>APIs<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-2>, <parent-3><child-2><grandchild-1>Markup<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-3>`,
503+
`<parent-1><child-1>JavaScript</child-1></parent-1>, <parent-2><child-2><grandchild-1>APIs<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-2>, <parent-3><child-2><grandchild-1>Markup<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-3>, TmV0bGlmeQ==`,
503504
)
504505

505506
await cleanup()
@@ -692,3 +693,98 @@ test('Loads edge functions from the Frameworks API', async () => {
692693

693694
await cleanup()
694695
})
696+
697+
const denoVersion = execSync('deno eval --no-lock "console.log(Deno.version.deno)"').toString()
698+
699+
describe.skipIf(lt(denoVersion, '2.4.2'))(
700+
'Produces a tarball bundle',
701+
() => {
702+
test('With only local imports', async () => {
703+
const systemLogger = vi.fn()
704+
const { basePath, cleanup, distPath } = await useFixture('imports_node_builtin', { copyDirectory: true })
705+
const declarations: Declaration[] = [
706+
{
707+
function: 'func1',
708+
path: '/func1',
709+
},
710+
]
711+
const vendorDirectory = await tmp.dir()
712+
713+
await bundle([join(basePath, 'netlify/edge-functions')], distPath, declarations, {
714+
basePath,
715+
configPath: join(basePath, '.netlify/edge-functions/config.json'),
716+
featureFlags: {
717+
edge_bundler_generate_tarball: true,
718+
},
719+
systemLogger,
720+
})
721+
722+
expect(
723+
systemLogger.mock.calls.find((call) => call[0] === 'Could not track dependencies in edge function:'),
724+
).toBeUndefined()
725+
726+
const expectedOutput = {
727+
func1: 'ok',
728+
}
729+
730+
const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
731+
const manifest = JSON.parse(manifestFile)
732+
733+
const tarballPath = join(distPath, manifest.bundles[0].asset)
734+
const tarballResult = await runTarball(tarballPath)
735+
expect(tarballResult).toStrictEqual(expectedOutput)
736+
737+
const eszipPath = join(distPath, manifest.bundles[1].asset)
738+
const eszipResult = await runESZIP(eszipPath)
739+
expect(eszipResult).toStrictEqual(expectedOutput)
740+
741+
await cleanup()
742+
await rm(vendorDirectory.path, { force: true, recursive: true })
743+
})
744+
745+
// TODO: https://github.com/denoland/deno/issues/30187
746+
test.todo('Using npm modules', async () => {
747+
const systemLogger = vi.fn()
748+
const { basePath, cleanup, distPath } = await useFixture('imports_npm_module', { copyDirectory: true })
749+
const sourceDirectory = join(basePath, 'functions')
750+
const declarations: Declaration[] = [
751+
{
752+
function: 'func1',
753+
path: '/func1',
754+
},
755+
]
756+
const vendorDirectory = await tmp.dir()
757+
758+
await bundle([sourceDirectory], distPath, declarations, {
759+
basePath,
760+
featureFlags: {
761+
edge_bundler_generate_tarball: true,
762+
},
763+
importMapPaths: [join(basePath, 'import_map.json')],
764+
vendorDirectory: vendorDirectory.path,
765+
systemLogger,
766+
})
767+
768+
expect(
769+
systemLogger.mock.calls.find((call) => call[0] === 'Could not track dependencies in edge function:'),
770+
).toBeUndefined()
771+
772+
const expectedOutput = `<parent-1><child-1>JavaScript</child-1></parent-1>, <parent-2><child-2><grandchild-1>APIs<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-2>, <parent-3><child-2><grandchild-1>Markup<cwd>${process.cwd()}</cwd></grandchild-1></child-2></parent-3>, TmV0bGlmeQ==`
773+
774+
const manifestFile = await readFile(resolve(distPath, 'manifest.json'), 'utf8')
775+
const manifest = JSON.parse(manifestFile)
776+
777+
const tarballPath = join(distPath, manifest.bundles[0].asset)
778+
const tarballResult = await runTarball(tarballPath)
779+
expect(tarballResult.func1).toBe(expectedOutput)
780+
781+
const eszipPath = join(distPath, manifest.bundles[1].asset)
782+
const eszipResult = await runESZIP(eszipPath, vendorDirectory.path)
783+
expect(eszipResult.func1).toBe(expectedOutput)
784+
785+
await cleanup()
786+
await rm(vendorDirectory.path, { force: true, recursive: true })
787+
})
788+
},
789+
10_000,
790+
)

packages/edge-bundler/node/bundler.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { EdgeFunction } from './edge_function.js'
1515
import { FeatureFlags, getFlags } from './feature_flags.js'
1616
import { findFunctions } from './finder.js'
1717
import { bundle as bundleESZIP } from './formats/eszip.js'
18+
import { bundle as bundleTarball } from './formats/tarball.js'
1819
import { ImportMap } from './import_map.js'
1920
import { getLogger, LogFunction, Logger } from './logger.js'
2021
import { writeManifest } from './manifest.js'
@@ -66,6 +67,7 @@ export const bundle = async (
6667
const options: DenoOptions = {
6768
debug,
6869
cacheDirectory,
70+
featureFlags,
6971
logger,
7072
onAfterDownload,
7173
onBeforeDownload,
@@ -114,27 +116,47 @@ export const bundle = async (
114116
vendorDirectory,
115117
})
116118

119+
const bundles: Bundle[] = []
120+
121+
if (featureFlags.edge_bundler_generate_tarball) {
122+
bundles.push(
123+
await bundleTarball({
124+
basePath,
125+
buildID,
126+
debug,
127+
deno,
128+
distDirectory,
129+
functions,
130+
featureFlags,
131+
importMap: importMap.clone(),
132+
vendorDirectory: vendor?.directory,
133+
}),
134+
)
135+
}
136+
117137
if (vendor) {
118138
importMap.add(vendor.importMap)
119139
}
120140

121-
const functionBundle = await bundleESZIP({
122-
basePath,
123-
buildID,
124-
debug,
125-
deno,
126-
distDirectory,
127-
externals,
128-
functions,
129-
featureFlags,
130-
importMap,
131-
vendorDirectory: vendor?.directory,
132-
})
141+
bundles.push(
142+
await bundleESZIP({
143+
basePath,
144+
buildID,
145+
debug,
146+
deno,
147+
distDirectory,
148+
externals,
149+
functions,
150+
featureFlags,
151+
importMap,
152+
vendorDirectory: vendor?.directory,
153+
}),
154+
)
133155

134156
// The final file name of the bundles contains a SHA256 hash of the contents,
135157
// which we can only compute now that the files have been generated. So let's
136158
// rename the bundles to their permanent names.
137-
await createFinalBundles([functionBundle], distDirectory, buildID)
159+
await createFinalBundles(bundles, distDirectory, buildID)
138160

139161
// Retrieving a configuration object for each function.
140162
// Run `getFunctionConfig` in parallel as it is a non-trivial operation and spins up deno
@@ -165,7 +187,7 @@ export const bundle = async (
165187
})
166188

167189
const manifest = await writeManifest({
168-
bundles: [functionBundle],
190+
bundles,
169191
declarations,
170192
distDirectory,
171193
featureFlags,

0 commit comments

Comments
 (0)