|
| 1 | +import { tmpdir } from "os" |
1 | 2 | import path from "path" |
2 | 3 | import { fileURLToPath } from "url" |
3 | 4 |
|
4 | 5 | import { createLogger } from "@crossplane-js/libs" |
5 | 6 | import { Command } from "commander" |
| 7 | +import { build } from "esbuild" |
| 8 | +import type { BuildOptions, Plugin } from "esbuild" |
6 | 9 | import fs from "fs-extra" |
| 10 | +import { v4 as uuidv4 } from "uuid" |
7 | 11 | import YAML from "yaml" |
8 | 12 |
|
9 | 13 | // Create a logger for this module |
@@ -50,12 +54,117 @@ interface Manifest { |
50 | 54 | } |
51 | 55 | } |
52 | 56 |
|
| 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 | + |
53 | 141 | /** |
54 | 142 | * Main function for the compo command |
55 | 143 | * Processes function directories and generates composition manifests |
| 144 | + * @param options Command options |
56 | 145 | * @returns Promise<void> |
57 | 146 | */ |
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 | + } |
59 | 168 | const cwd = () => `${process.cwd()}` |
60 | 169 | try { |
61 | 170 | // Find the functions directory in the current working directory |
@@ -180,10 +289,23 @@ async function compoAction(): Promise<void> { |
180 | 289 | moduleLogger.warn(`Skipping ${functionName}: composition.fn.ts not found`) |
181 | 290 | continue |
182 | 291 | } |
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 | + } |
187 | 309 | } |
188 | 310 |
|
189 | 311 | if (xfuncjsStep.input.spec.dependencies === "__DEPENDENCIES__") { |
@@ -287,9 +409,12 @@ export default function (program: Command): void { |
287 | 409 | program |
288 | 410 | .command("compo") |
289 | 411 | .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 => { |
291 | 416 | try { |
292 | | - await compoAction() |
| 417 | + await compoAction(options) |
293 | 418 | } catch (err) { |
294 | 419 | moduleLogger.error(`Error running compo command: ${err}`) |
295 | 420 | process.exit(1) |
|
0 commit comments