Skip to content

Commit c96a4a1

Browse files
committed
feat: ts aliasing + auto import and so register models
1 parent 6c8a1e5 commit c96a4a1

File tree

10 files changed

+291
-126
lines changed

10 files changed

+291
-126
lines changed

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

Lines changed: 238 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface SourceSpec {
3131
inline: string
3232
dependencies?: Record<string, string>
3333
yarnLock?: string
34+
tsConfig?: string
3435
}
3536

3637
interface InputSpec {
@@ -54,6 +55,95 @@ interface Manifest {
5455
}
5556
}
5657

58+
interface TypeScriptConfig {
59+
extends?: string
60+
compilerOptions?: {
61+
baseUrl?: string
62+
paths?: Record<string, string[]>
63+
[key: string]: any
64+
}
65+
[key: string]: any
66+
}
67+
68+
/**
69+
* Loads and resolves a TypeScript configuration file, handling extends
70+
* @param configPath Path to the tsconfig.json file
71+
* @param baseDir Base directory for resolving relative paths
72+
* @returns Resolved TypeScript configuration
73+
*/
74+
function loadTsConfig(configPath: string, _baseDir?: string): TypeScriptConfig | null {
75+
try {
76+
if (!fs.existsSync(configPath)) {
77+
return null
78+
}
79+
80+
const configContent = fs.readFileSync(configPath, { encoding: "utf8" })
81+
const config: TypeScriptConfig = JSON.parse(configContent)
82+
const configDir = path.dirname(configPath)
83+
84+
// If the config extends another config, load and merge it
85+
if (config.extends) {
86+
const extendsPath = path.resolve(configDir, config.extends)
87+
const baseConfig = loadTsConfig(extendsPath, configDir)
88+
89+
if (baseConfig) {
90+
// Merge configurations, with current config taking precedence
91+
const mergedConfig: TypeScriptConfig = {
92+
...baseConfig,
93+
...config,
94+
compilerOptions: {
95+
...baseConfig.compilerOptions,
96+
...config.compilerOptions,
97+
},
98+
}
99+
return mergedConfig
100+
}
101+
}
102+
103+
return config
104+
} catch (error) {
105+
moduleLogger.debug(`Failed to load TypeScript config from ${configPath}: ${error}`)
106+
return null
107+
}
108+
}
109+
110+
/**
111+
* Converts TypeScript path mappings to esbuild alias format
112+
* @param tsConfig TypeScript configuration
113+
* @param baseDir Base directory for resolving relative paths
114+
* @returns esbuild alias configuration
115+
*/
116+
function convertTsPathsToEsbuildAlias(
117+
tsConfig: TypeScriptConfig,
118+
baseDir: string
119+
): Record<string, string> | undefined {
120+
const { compilerOptions } = tsConfig
121+
if (!compilerOptions?.paths) {
122+
return undefined
123+
}
124+
125+
const { paths } = compilerOptions
126+
const alias: Record<string, string> = {}
127+
128+
for (const [pattern, mappings] of Object.entries(paths)) {
129+
if (mappings.length === 0) continue
130+
131+
// Take the first mapping (most common case)
132+
const mapping = mappings[0]
133+
134+
// Remove the /* suffix from pattern and mapping if present
135+
const cleanPattern = pattern.replace(/\/\*$/, "")
136+
const cleanMapping = mapping.replace(/\/\*$/, "")
137+
138+
// Resolve the mapping path relative to the base directory
139+
const resolvedMapping = path.resolve(baseDir, cleanMapping)
140+
141+
alias[cleanPattern] = resolvedMapping
142+
}
143+
144+
return Object.keys(alias).length > 0 ? alias : undefined
145+
}
146+
57147
/**
58148
* Bundles a TypeScript file using esbuild
59149
* @param filePath Path to the TypeScript file
@@ -73,15 +163,90 @@ async function bundleTypeScript(
73163
moduleLogger.debug(`Using temporary directory: ${tempDir}`)
74164

75165
try {
76-
// Create temp directory
166+
// Create temp directory for output only
77167
fs.mkdirSync(tempDir, { recursive: true })
78168

79169
// Get original file size for logging
80170
const originalSize = fs.statSync(filePath).size
81171

82-
// Default esbuild options optimized for readability
172+
// Check if models/index.ts exists and prepare to auto-import it
173+
const packageRoot = process.cwd()
174+
const modelsIndexPath = path.join(packageRoot, "models", "index.ts")
175+
const shouldAutoImportModels = fs.existsSync(modelsIndexPath)
176+
177+
// Create virtual entry content that imports models and re-exports the function
178+
const relativeFunctionPath = path.relative(packageRoot, filePath).replace(/\\/g, "/")
179+
let virtualEntryContent = ""
180+
181+
if (shouldAutoImportModels) {
182+
virtualEntryContent += `import './models';\n`
183+
moduleLogger.debug(`Auto-importing models from: ${modelsIndexPath}`)
184+
}
185+
186+
// Import and re-export the function as default
187+
virtualEntryContent += `export { default } from './${relativeFunctionPath}';\n`
188+
189+
moduleLogger.debug(`Virtual entry content:\n${virtualEntryContent}`)
190+
191+
// Load TypeScript configuration and extract aliases
192+
const functionDir = path.dirname(filePath)
193+
let tsConfig: TypeScriptConfig | null = null
194+
let esbuildAlias: Record<string, string> | undefined = undefined
195+
196+
// Try to load tsconfig.json from function directory first, then from current working directory
197+
const functionTsConfigPath = path.join(functionDir, "tsconfig.json")
198+
const cwdTsConfigPath = path.join(process.cwd(), "tsconfig.json")
199+
200+
let configPath: string | null = null
201+
let configBaseDir: string | null = null
202+
203+
if (fs.existsSync(functionTsConfigPath)) {
204+
tsConfig = loadTsConfig(functionTsConfigPath, functionDir)
205+
configPath = functionTsConfigPath
206+
configBaseDir = functionDir
207+
moduleLogger.debug(
208+
`Loaded TypeScript config from function directory: ${functionTsConfigPath}`
209+
)
210+
} else if (fs.existsSync(cwdTsConfigPath)) {
211+
tsConfig = loadTsConfig(cwdTsConfigPath, process.cwd())
212+
configPath = cwdTsConfigPath
213+
configBaseDir = process.cwd()
214+
moduleLogger.debug(
215+
`Loaded TypeScript config from current working directory: ${cwdTsConfigPath}`
216+
)
217+
}
218+
219+
// Convert TypeScript path aliases to esbuild aliases
220+
if (tsConfig && configPath && configBaseDir) {
221+
const configDir = path.dirname(configPath)
222+
const baseUrl = tsConfig.compilerOptions?.baseUrl || "."
223+
const resolvedBaseDir = path.resolve(configDir, baseUrl)
224+
225+
moduleLogger.debug(`TypeScript config found at: ${configPath}`)
226+
moduleLogger.debug(`Config directory: ${configDir}`)
227+
moduleLogger.debug(`Base URL: ${baseUrl}`)
228+
moduleLogger.debug(`Resolved base directory: ${resolvedBaseDir}`)
229+
moduleLogger.debug(`TypeScript paths: ${JSON.stringify(tsConfig.compilerOptions?.paths)}`)
230+
231+
esbuildAlias = convertTsPathsToEsbuildAlias(tsConfig, resolvedBaseDir)
232+
233+
if (esbuildAlias && Object.keys(esbuildAlias).length > 0) {
234+
moduleLogger.info(
235+
`Applied TypeScript path aliases to esbuild: ${JSON.stringify(esbuildAlias)}`
236+
)
237+
moduleLogger.info(`Base directory for aliases: ${resolvedBaseDir}`)
238+
} else {
239+
moduleLogger.debug(`No TypeScript path aliases found or converted`)
240+
}
241+
}
242+
243+
// Default esbuild options optimized for readability using stdin
83244
const defaultOptions: BuildOptions = {
84-
entryPoints: [filePath],
245+
stdin: {
246+
contents: virtualEntryContent,
247+
resolveDir: packageRoot,
248+
sourcefile: "virtual-entry.ts",
249+
},
85250
bundle: true,
86251
format: "esm",
87252
sourcemap: true,
@@ -96,16 +261,50 @@ async function bundleTypeScript(
96261
experimentalDecorators: true,
97262
},
98263
},
264+
// Add TypeScript path aliases if available
265+
...(esbuildAlias && { alias: esbuildAlias }),
99266
}
100267

101268
// If embedDeps is false, add plugin to keep dependencies external
102269
if (!embedDeps) {
103-
// Create a plugin to mark all non-relative imports as external
270+
// Create a plugin to mark all non-relative imports as external, except for aliases
104271
const externalizeNpmDepsPlugin: Plugin = {
105272
name: "externalize-npm-deps",
106273
setup(build) {
107-
// Filter for all import paths that don't start with ./ or ../
274+
// Filter for imports that don't start with ./ or ../ (non-relative imports)
108275
build.onResolve({ filter: /^[^./]/ }, args => {
276+
moduleLogger.debug(`Processing import: ${args.path} from ${args.importer || "entry"}`)
277+
278+
// Skip if this is an entry point (no importer means it's an entry point)
279+
if (!args.importer) {
280+
moduleLogger.debug(`Skipping entry point: ${args.path}`)
281+
return undefined
282+
}
283+
284+
// Skip built-in Node.js modules
285+
if (args.path.startsWith("node:")) {
286+
moduleLogger.debug(`Skipping Node.js built-in: ${args.path}`)
287+
return undefined
288+
}
289+
290+
// Check if this matches any of our TypeScript path aliases
291+
// Be more specific about alias matching to avoid false positives with scoped packages
292+
if (esbuildAlias) {
293+
for (const alias of Object.keys(esbuildAlias)) {
294+
// For aliases like "@", only match if it's followed by a slash (e.g., "@/models")
295+
// This prevents matching scoped packages like "@crossplane-js/sdk"
296+
if (alias === "@" && args.path.startsWith("@/")) {
297+
moduleLogger.debug(`Allowing alias resolution: ${args.path}`)
298+
return undefined // Let esbuild handle the alias resolution
299+
} else if (alias !== "@" && args.path.startsWith(alias)) {
300+
moduleLogger.debug(`Allowing alias resolution: ${args.path}`)
301+
return undefined // Let esbuild handle the alias resolution
302+
}
303+
}
304+
}
305+
306+
// For all other imports (npm packages, scoped packages, etc.), mark as external
307+
moduleLogger.debug(`Marking as external: ${args.path}`)
109308
return { path: args.path, external: true }
110309
})
111310
},
@@ -124,11 +323,11 @@ async function bundleTypeScript(
124323
// Bundle with esbuild
125324
await build(buildOptions)
126325

127-
// Read the bundled code
326+
// Read the bundled code (always single entry point now)
128327
const bundledCode = fs.readFileSync(outputFile, { encoding: "utf8" })
328+
const bundledSize = fs.statSync(outputFile).size
129329

130330
// Log bundle size information
131-
const bundledSize = fs.statSync(outputFile).size
132331
moduleLogger.debug(`Bundling complete: ${originalSize} bytes → ${bundledSize} bytes`)
133332

134333
return bundledCode
@@ -398,6 +597,38 @@ async function compoAction(
398597
}
399598
}
400599

600+
if (xfuncjsStep.input.spec.source.tsConfig === "__TSCONFIG__") {
601+
// Check for tsconfig.json in the function directory
602+
const functionTsConfigPath = path.join(functionDir, "tsconfig.json")
603+
const rootTsConfigPath = path.join(cwd(), "tsconfig.json")
604+
let tsConfig: string | null = null
605+
606+
if (fs.existsSync(functionTsConfigPath)) {
607+
try {
608+
// Use tsconfig.json from the function directory
609+
tsConfig = fs.readFileSync(functionTsConfigPath, {
610+
encoding: "utf8",
611+
})
612+
moduleLogger.debug(`Using tsconfig.json from function directory: ${functionName}`)
613+
} catch (error) {
614+
moduleLogger.error(`Error reading tsconfig.json in function directory: ${error}`)
615+
}
616+
} else if (fs.existsSync(rootTsConfigPath)) {
617+
try {
618+
// Use tsconfig.json from the current working directory
619+
tsConfig = fs.readFileSync(rootTsConfigPath, { encoding: "utf8" })
620+
moduleLogger.debug(`Using tsconfig.json from current working directory`)
621+
} catch (error) {
622+
moduleLogger.error(`Error reading tsconfig.json in current working directory: ${error}`)
623+
}
624+
}
625+
626+
// Add tsconfig.json to the manifest if found
627+
if (tsConfig) {
628+
xfuncjsStep.input.spec.source.tsConfig = tsConfig
629+
}
630+
}
631+
401632
// Generate final output using the already loaded XRD data
402633
let finalOutput: string
403634

packages/cli/src/commands/compo/templates/composition.default.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ spec:
1919
source:
2020
dependencies: __DEPENDENCIES__
2121
yarnLock: __YARN_LOCK__
22+
tsConfig: __TSCONFIG__
2223
inline: __FUNCTION_CODE__
2324
- step: automatically-detect-ready-composed-resources
2425
functionRef:
25-
name: function-auto-ready
26+
name: function-auto-ready

packages/server/src/index.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,18 @@ const main = async () => {
9494
isDefault: true,
9595
})
9696
.description("Start the HTTP server for executing code")
97-
.option("-c, --code-file-path <code-file-path>", "Path to the code file to execute")
9897
.option("-p, --port <number>", "Port to listen on", String(DEFAULT_PORT))
9998
.action(async options => {
99+
// Get code file path from environment variable
100+
const codeFilePath = process.env.XFUNCJS_CODE_FILE_PATH
101+
if (!codeFilePath) {
102+
moduleLogger.error("XFUNCJS_CODE_FILE_PATH environment variable is required")
103+
process.exit(1)
104+
}
105+
100106
// Validate code file path
101-
if (!(await fs.exists(options.codeFilePath))) {
102-
moduleLogger.error(`Code file not found: ${options.codeFilePath}`)
107+
if (!(await fs.exists(codeFilePath))) {
108+
moduleLogger.error(`Code file not found: ${codeFilePath}`)
103109
process.exit(1)
104110
}
105111

@@ -111,11 +117,11 @@ const main = async () => {
111117
process.exit(1)
112118
}
113119

114-
moduleLogger.info(`Code file path: ${options.codeFilePath}`)
120+
moduleLogger.info(`Code file path: ${codeFilePath}`)
115121

116122
// Start the server
117-
server = createServer(port, options.codeFilePath)
118-
moduleLogger.info(`Node.js process started for code file: ${options.codeFilePath}`)
123+
server = createServer(port, codeFilePath)
124+
moduleLogger.info(`Node.js process started for code file: ${codeFilePath}`)
119125
})
120126

121127
// Parse command line arguments with Commander

pkg/node/dependency.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func (dr *DependencyResolver) ResolveDependencies(inputDependencies map[string]s
3131

3232
if strings.HasPrefix(v, "link:") {
3333
// Resolve workspace package dependencies
34-
if resolved, _, resolveErr := ResolveWorkspacePackage(v, workspaceRoot, workspaceMap, logger); resolveErr != nil {
34+
if resolved, _, resolveErr := ResolveWorkspacePackage(k, workspaceRoot, workspaceMap, logger); resolveErr != nil {
3535
logger.WithField("dependency", k).
3636
WithField("value", v).
3737
WithField("error", resolveErr.Error()).

pkg/node/process.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ func (pm *ProcessManager) getOrCreateProcess(ctx context.Context, input *types.X
165165
}
166166

167167
// Prepare yarn environment
168-
yarnPath, err := pm.yarnInstaller.PrepareYarnEnvironment(uniqueDirPath, input.Spec.Source.YarnLock, procLogger)
168+
yarnPath, err := pm.yarnInstaller.PrepareYarnEnvironment(uniqueDirPath, input.Spec.Source.YarnLock, input.Spec.Source.TsConfig, procLogger)
169169
if err != nil {
170170
procLogger.WithField(logger.FieldError, err.Error()).
171171
Warn("Failed to prepare yarn environment, but continuing anyway")
@@ -189,10 +189,12 @@ func (pm *ProcessManager) getOrCreateProcess(ctx context.Context, input *types.X
189189
processCtx := context.Background()
190190

191191
// Use Node.js with TypeScript sources directly
192-
cmd := exec.CommandContext(processCtx, "node", "/app/packages/server/src/index.ts", "--code-file-path", tempFilePath)
192+
cmd := exec.CommandContext(processCtx, "node", "/app/packages/server/src/index.ts")
193193

194+
// Ensure our custom ESM alias loader is enabled via NODE_OPTIONS
194195
cmd.Env = append(os.Environ(),
195196
fmt.Sprintf("PORT=%d", port),
197+
fmt.Sprintf("XFUNCJS_CODE_FILE_PATH=%s", tempFilePath),
196198
"XFUNCJS_LOG_LEVEL=debug", // Ensure we capture all logs from Node.js
197199
"LOG_LEVEL=debug", // Fallback for Pino logger
198200
)

0 commit comments

Comments
 (0)