Skip to content

Commit def3239

Browse files
committed
feat: add bundler to compo
1 parent 8f262de commit def3239

File tree

3 files changed

+417
-7
lines changed

3 files changed

+417
-7
lines changed

packages/cli/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@
1111
"dependencies": {
1212
"@crossplane-js/libs": "workspace:^",
1313
"commander": "^13.1.0",
14+
"esbuild": "^0.25.4",
1415
"fs-extra": "^11.3.0",
1516
"node-ts-modules": "^0.0.2",
17+
"uuid": "^11.1.0",
1618
"yaml": "^2.7.0"
1719
},
1820
"publishConfig": {
1921
"access": "public"
22+
},
23+
"devDependencies": {
24+
"@types/uuid": "^10.0.0"
2025
}
2126
}

packages/cli/src/commands/compo/index.ts

Lines changed: 132 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import { tmpdir } from "os"
12
import path from "path"
23
import { fileURLToPath } from "url"
34

45
import { createLogger } from "@crossplane-js/libs"
56
import { Command } from "commander"
7+
import { build } from "esbuild"
8+
import type { BuildOptions, Plugin } from "esbuild"
69
import fs from "fs-extra"
10+
import { v4 as uuidv4 } from "uuid"
711
import YAML from "yaml"
812

913
// Create a logger for this module
@@ -50,12 +54,117 @@ interface Manifest {
5054
}
5155
}
5256

57+
/**
58+
* Bundles a TypeScript file using esbuild
59+
* @param filePath Path to the TypeScript file
60+
* @param embedDeps Whether to embed dependencies or keep them external
61+
* @param customConfig Custom esbuild configuration
62+
* @returns Promise<string> The bundled code
63+
*/
64+
async function bundleTypeScript(
65+
filePath: string,
66+
embedDeps: boolean = false,
67+
customConfig: Partial<BuildOptions> = {}
68+
): Promise<string> {
69+
const tempDir = path.join(tmpdir(), `xfuncjs-bundle-${uuidv4()}`)
70+
const outputFile = path.join(tempDir, "bundle.js")
71+
72+
moduleLogger.debug(`Bundling TypeScript file: ${filePath}`)
73+
moduleLogger.debug(`Using temporary directory: ${tempDir}`)
74+
75+
try {
76+
// Create temp directory
77+
fs.mkdirSync(tempDir, { recursive: true })
78+
79+
// Get original file size for logging
80+
const originalSize = fs.statSync(filePath).size
81+
82+
// Default esbuild options optimized for readability
83+
const defaultOptions: BuildOptions = {
84+
entryPoints: [filePath],
85+
bundle: true,
86+
format: "esm",
87+
sourcemap: true,
88+
target: "esnext",
89+
outfile: outputFile,
90+
minify: false,
91+
keepNames: true,
92+
legalComments: "inline",
93+
}
94+
95+
// If embedDeps is false, add plugin to keep dependencies external
96+
if (!embedDeps) {
97+
// Create a plugin to mark all non-relative imports as external
98+
const externalizeNpmDepsPlugin: Plugin = {
99+
name: "externalize-npm-deps",
100+
setup(build) {
101+
// Filter for all import paths that don't start with ./ or ../
102+
build.onResolve({ filter: /^[^./]/ }, args => {
103+
return { path: args.path, external: true }
104+
})
105+
},
106+
}
107+
108+
defaultOptions.plugins = [externalizeNpmDepsPlugin]
109+
moduleLogger.debug(`Keeping all node_modules packages as external dependencies`)
110+
} else {
111+
moduleLogger.debug(`Embedding all dependencies in the bundle`)
112+
}
113+
114+
// Merge with custom config
115+
const buildOptions: BuildOptions = { ...defaultOptions, ...customConfig }
116+
moduleLogger.debug(`esbuild options: ${JSON.stringify(buildOptions)}`)
117+
118+
// Bundle with esbuild
119+
await build(buildOptions)
120+
121+
// Read the bundled code
122+
const bundledCode = fs.readFileSync(outputFile, { encoding: "utf8" })
123+
124+
// Log bundle size information
125+
const bundledSize = fs.statSync(outputFile).size
126+
moduleLogger.debug(`Bundling complete: ${originalSize} bytes → ${bundledSize} bytes`)
127+
128+
return bundledCode
129+
} catch (error) {
130+
moduleLogger.error(`Bundling failed: ${error}`)
131+
throw new Error(`Failed to bundle TypeScript file ${filePath}: ${error}`)
132+
} finally {
133+
// Clean up temp directory
134+
if (fs.existsSync(tempDir)) {
135+
fs.rmSync(tempDir, { recursive: true, force: true })
136+
moduleLogger.debug(`Cleaned up temporary directory: ${tempDir}`)
137+
}
138+
}
139+
}
140+
53141
/**
54142
* Main function for the compo command
55143
* Processes function directories and generates composition manifests
144+
* @param options Command options
56145
* @returns Promise<void>
57146
*/
58-
async function compoAction(): Promise<void> {
147+
async function compoAction(
148+
options: { bundle?: boolean; bundleConfig?: string; embedDeps?: boolean } = {}
149+
): Promise<void> {
150+
// Default to bundling enabled
151+
const shouldBundle = options.bundle !== false
152+
// Default to external dependencies (not embedded)
153+
const shouldEmbedDeps = options.embedDeps === true
154+
155+
moduleLogger.debug(`Bundle: ${shouldBundle}, Embed dependencies: ${shouldEmbedDeps}`)
156+
157+
// Parse custom bundle config if provided
158+
let bundleConfig: Partial<BuildOptions> = {}
159+
if (options.bundleConfig) {
160+
try {
161+
bundleConfig = JSON.parse(options.bundleConfig) as Partial<BuildOptions>
162+
moduleLogger.debug(`Using custom bundle configuration: ${JSON.stringify(bundleConfig)}`)
163+
} catch (error) {
164+
moduleLogger.error(`Invalid bundle configuration JSON: ${error}`)
165+
process.exit(1)
166+
}
167+
}
59168
const cwd = () => `${process.cwd()}`
60169
try {
61170
// Find the functions directory in the current working directory
@@ -180,10 +289,23 @@ async function compoAction(): Promise<void> {
180289
moduleLogger.warn(`Skipping ${functionName}: composition.fn.ts not found`)
181290
continue
182291
}
183-
// Read the function code
184-
const fnCode = fs.readFileSync(fnFilePath, { encoding: "utf8" })
185-
// Set the inline code
186-
xfuncjsStep.input.spec.source.inline = fnCode
292+
293+
if (shouldBundle) {
294+
moduleLogger.info(`Bundling TypeScript for ${functionName}`)
295+
try {
296+
const bundledCode = await bundleTypeScript(fnFilePath, shouldEmbedDeps, bundleConfig)
297+
xfuncjsStep.input.spec.source.inline = bundledCode
298+
moduleLogger.info(`Successfully bundled TypeScript for ${functionName}`)
299+
} catch (error) {
300+
moduleLogger.error(`Error bundling TypeScript for ${functionName}`)
301+
throw error // Propagate the error up
302+
}
303+
} else {
304+
// Original behavior when bundling is disabled
305+
moduleLogger.info(`Bundling disabled, using raw TypeScript for ${functionName}`)
306+
const fnCode = fs.readFileSync(fnFilePath, { encoding: "utf8" })
307+
xfuncjsStep.input.spec.source.inline = fnCode
308+
}
187309
}
188310

189311
if (xfuncjsStep.input.spec.dependencies === "__DEPENDENCIES__") {
@@ -287,9 +409,12 @@ export default function (program: Command): void {
287409
program
288410
.command("compo")
289411
.description("Generate composition manifests from function directories")
290-
.action(async () => {
412+
.option("--no-bundle", "Disable TypeScript bundling")
413+
.option("--bundle-config <json>", "Custom esbuild configuration (JSON string)")
414+
.option("--embed-deps", "Embed dependencies in the bundle (default: false)")
415+
.action(async options => {
291416
try {
292-
await compoAction()
417+
await compoAction(options)
293418
} catch (err) {
294419
moduleLogger.error(`Error running compo command: ${err}`)
295420
process.exit(1)

0 commit comments

Comments
 (0)