Skip to content

Commit 241d7a7

Browse files
committed
feat: add ESBuild-based edge bundler
Introduces EdgeESBuildBundler as an alternative to the webpack-based EdgeBundler. This leverages ESBuild (already a dependency via @fastly/js-compute) for faster bundling with platform: 'browser' for Service Worker compatibility. Key changes: - Add src/EdgeESBuildBundler.js extending BaseBundler directly - Support for --bundler esbuild CLI flag - Handle fastly:* external modules via esbuild plugin - Add test fixture and integration tests for Cloudflare wrangler and Fastly viceroy - Add fs-extra and esbuild as direct dependencies Tested locally with both Cloudflare wrangler and Fastly viceroy runtimes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Lars Trieloff <[email protected]>
1 parent c7a769a commit 241d7a7

File tree

8 files changed

+2477
-448
lines changed

8 files changed

+2477
-448
lines changed

package-lock.json

Lines changed: 1837 additions & 447 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@
4242
"dotenv": "17.2.3",
4343
"eslint": "9.4.0",
4444
"esmock": "2.7.3",
45-
"fs-extra": "11.3.2",
4645
"husky": "9.1.7",
4746
"lint-staged": "16.2.6",
4847
"mocha": "11.7.5",
4948
"mocha-multi-reporters": "1.5.1",
5049
"nock": "13.5.6",
5150
"semantic-release": "25.0.2",
51+
"wrangler": "^4.0.0",
5252
"yauzl": "3.2.0"
5353
},
5454
"lint-staged": {
@@ -64,7 +64,9 @@
6464
"@fastly/js-compute": "3.35.2",
6565
"chalk-template": "1.1.2",
6666
"constants-browserify": "1.0.0",
67+
"esbuild": "^0.25.0",
6768
"form-data": "4.0.4",
69+
"fs-extra": "11.3.0",
6870
"tar": "7.5.2"
6971
}
7072
}

src/EdgeESBuildBundler.js

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { fileURLToPath } from 'url';
13+
import path from 'path';
14+
import fse from 'fs-extra';
15+
import * as esbuild from 'esbuild';
16+
import chalk from 'chalk-template';
17+
import { BaseBundler } from '@adobe/helix-deploy';
18+
19+
// eslint-disable-next-line no-underscore-dangle
20+
const __dirname = path.resolve(fileURLToPath(import.meta.url), '..');
21+
22+
/**
23+
* Creates the action bundle using ESBuild for edge compute platforms
24+
* (Cloudflare Workers, Fastly Compute@Edge)
25+
*/
26+
export default class EdgeESBuildBundler extends BaseBundler {
27+
constructor(cfg) {
28+
super(cfg);
29+
this.arch = 'edge';
30+
this.type = 'esbuild';
31+
}
32+
33+
/**
34+
* Creates the esbuild plugin for handling edge-specific module resolution
35+
*/
36+
createEdgePlugin() {
37+
const { cfg } = this;
38+
39+
return {
40+
name: 'helix-edge',
41+
setup(build) {
42+
// Handle fastly:* modules as external (they're provided by the runtime)
43+
build.onResolve({ filter: /^fastly:/ }, (args) => ({
44+
path: args.path,
45+
external: true,
46+
}));
47+
48+
// Alias ./main.js to the user's entry point
49+
build.onResolve({ filter: /^\.\/main\.js$/ }, () => ({
50+
path: cfg.file,
51+
}));
52+
53+
// Alias @adobe/fetch and @adobe/helix-fetch to the polyfill
54+
const fetchPolyfill = path.resolve(__dirname, 'template', 'polyfills', 'fetch.js');
55+
build.onResolve({ filter: /^@adobe\/(helix-)?fetch$/ }, () => ({
56+
path: fetchPolyfill,
57+
}));
58+
59+
// Handle user-defined externals (filter to strings only)
60+
const allExternals = [
61+
...(cfg.externals || []),
62+
...(cfg.edgeExternals || []),
63+
'./params.json',
64+
'aws-sdk',
65+
'@google-cloud/secret-manager',
66+
'@google-cloud/storage',
67+
].filter((ext) => typeof ext === 'string');
68+
69+
allExternals.forEach((external) => {
70+
const pattern = external.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
71+
build.onResolve({ filter: new RegExp(`^${pattern}$`) }, (args) => ({
72+
path: args.path,
73+
external: true,
74+
}));
75+
});
76+
},
77+
};
78+
}
79+
80+
async getESBuildConfig() {
81+
const { cfg } = this;
82+
83+
/** @type {esbuild.BuildOptions} */
84+
const opts = {
85+
// Entry point - the universal edge adapter
86+
entryPoints: [cfg.adapterFile || path.resolve(__dirname, 'template', 'edge-index.js')],
87+
88+
// Output configuration
89+
outfile: path.relative(cfg.cwd, cfg.edgeBundle),
90+
bundle: true,
91+
write: true,
92+
93+
// Platform settings for edge compute (Service Worker-like environment)
94+
platform: 'browser',
95+
target: 'es2022',
96+
format: 'esm',
97+
98+
// Working directory
99+
absWorkingDir: cfg.cwd,
100+
101+
// Don't minify by default for easier debugging
102+
minify: false,
103+
104+
// Tree shaking
105+
treeShaking: true,
106+
107+
// Generate metafile for dependency analysis
108+
metafile: true,
109+
110+
// Plugins for edge-specific handling
111+
plugins: [this.createEdgePlugin()],
112+
113+
// Conditions for package.json exports field
114+
conditions: ['worker', 'browser'],
115+
116+
// Define globals
117+
define: {
118+
'process.env.NODE_ENV': '"production"',
119+
},
120+
121+
// Banner for identification
122+
banner: {
123+
js: '/* Helix Edge Bundle - ESBuild */',
124+
},
125+
};
126+
127+
// Apply minification if requested
128+
if (cfg.minify) {
129+
opts.minify = cfg.minify;
130+
}
131+
132+
// Progress handler (esbuild doesn't have built-in progress, but we can log)
133+
if (cfg.progressHandler) {
134+
// esbuild is fast enough that progress isn't really needed
135+
// but we can notify at start/end
136+
cfg.progressHandler(0, 'Starting esbuild bundle...');
137+
}
138+
139+
return opts;
140+
}
141+
142+
async createBundle() {
143+
const { cfg } = this;
144+
if (!cfg.edgeBundle) {
145+
throw Error('edge bundle path is undefined');
146+
}
147+
if (!cfg.depFile) {
148+
throw Error('dependencies info path is undefined');
149+
}
150+
151+
const m = cfg.minify ? 'minified ' : '';
152+
if (!cfg.progressHandler) {
153+
cfg.log.info(`--: creating edge ${m}bundle using esbuild ...`);
154+
}
155+
156+
const config = await this.getESBuildConfig();
157+
158+
// Ensure output directory exists
159+
await fse.ensureDir(path.dirname(path.resolve(cfg.cwd, cfg.edgeBundle)));
160+
161+
const result = await esbuild.build(config);
162+
163+
// Process metafile for dependency info
164+
await this.resolveDependencyInfos(result.metafile);
165+
166+
// Write dependencies info file
167+
await fse.writeJson(cfg.depFile, cfg.dependencies, { spaces: 2 });
168+
169+
if (!cfg.progressHandler) {
170+
cfg.log.info(chalk`{green ok:} created edge bundle {yellow ${config.outfile}}`);
171+
}
172+
173+
return result;
174+
}
175+
176+
/**
177+
* Resolves dependency information from esbuild metafile
178+
*/
179+
async resolveDependencyInfos(metafile) {
180+
const { cfg } = this;
181+
182+
const resolved = {};
183+
const deps = {};
184+
185+
const depNames = Object.keys(metafile.inputs);
186+
187+
await Promise.all(depNames.map(async (depName) => {
188+
const absDepPath = path.resolve(cfg.cwd, depName);
189+
const segs = absDepPath.split('/');
190+
let idx = segs.lastIndexOf('node_modules');
191+
if (idx < 0) {
192+
return;
193+
}
194+
idx += 1;
195+
if (segs[idx].charAt(0) === '@') {
196+
idx += 1;
197+
}
198+
segs.splice(idx + 1);
199+
const dir = path.resolve('/', ...segs);
200+
201+
try {
202+
if (!resolved[dir]) {
203+
const pkgJson = await fse.readJson(path.resolve(dir, 'package.json'));
204+
const id = `${pkgJson.name}:${pkgJson.version}`;
205+
resolved[dir] = {
206+
id,
207+
name: pkgJson.name,
208+
version: pkgJson.version,
209+
};
210+
}
211+
const dep = resolved[dir];
212+
deps[dep.id] = dep;
213+
} catch {
214+
// ignore - not a package
215+
}
216+
}));
217+
218+
// Sort and store dependencies
219+
cfg.dependencies.main = Object.values(deps)
220+
.sort((d0, d1) => d0.name.localeCompare(d1.name));
221+
}
222+
223+
async updateArchive(archive, packageJson) {
224+
await super.updateArchive(archive, packageJson);
225+
archive.file(this.cfg.edgeBundle, { name: 'index.js' });
226+
227+
// Add wrangler.toml for Cloudflare compatibility
228+
archive.append([
229+
'account_id = "fakefakefake"',
230+
`name = "${this.cfg.packageName}/${this.cfg.name}"`,
231+
'type = "javascript"',
232+
'workers_dev = true',
233+
].join('\n'), { name: 'wrangler.toml' });
234+
}
235+
236+
// eslint-disable-next-line class-methods-use-this
237+
validateBundle() {
238+
// TODO: validate edge bundle
239+
// Could potentially use wrangler/viceroy for validation
240+
}
241+
}

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
import ComputeAtEdgeDeployer from './ComputeAtEdgeDeployer.js';
1414
import FastlyGateway from './FastlyGateway.js';
1515
import EdgeBundler from './EdgeBundler.js';
16+
import EdgeESBuildBundler from './EdgeESBuildBundler.js';
1617
import CloudflareDeployer from './CloudflareDeployer.js';
1718

1819
export const plugins = [
1920
ComputeAtEdgeDeployer,
2021
FastlyGateway,
2122
CloudflareDeployer,
2223
EdgeBundler,
24+
EdgeESBuildBundler,
2325
];

0 commit comments

Comments
 (0)