UNPKG

@stackbit/sdk

Version:
234 lines (220 loc) 9.67 kB
import * as esbuild from 'esbuild'; import * as path from 'path'; import * as fs from 'fs-extra'; import * as os from 'os'; import { ConfigLoadError } from './config-errors'; import { LoadStackbitConfigResult, LoadStackbitConfigResultWithReloadDestroy } from './config-loader-utils'; import { parseInlineProperty } from './config-loader-static'; export async function loadStackbitConfigFromJs({ configPath, watch, callback, logger, outDir }: { configPath: string; watch?: boolean; callback?: (result: LoadStackbitConfigResult) => void; logger?: any; outDir?: string; }): Promise<LoadStackbitConfigResultWithReloadDestroy> { let buildContext: esbuild.BuildContext | undefined; try { // resolve config relative to cwd if it is not absolute configPath = path.resolve(configPath); logger = logger?.createLogger({ label: 'config-loader-esbuilt' }); logger?.debug(`building stackbit config from ${configPath}`); const projectDir = path.dirname(configPath); const fileName = path.basename(configPath); outDir = outDir ? path.resolve(projectDir, outDir) : outDir; // clean previously cached files if (outDir && (await fs.pathExists(outDir))) { // delete only files starting with "stackbit.config." as there may be other cached files const files = await fs.readdir(outDir); for (const file of files) { if (file.startsWith('stackbit.config.')) { await fs.remove(path.join(outDir, file)); } } } let isFirstBuild = true; let isReloading = false; const tempDir = outDir || (await fs.mkdtemp(path.join(os.tmpdir(), 'stackbit-config-'))); const useEsm = await configHasEsmFlag(configPath); const configExtension = useEsm ? `mjs` : `cjs`; const outfilePath = path.join(tempDir, 'stackbit.config.' + configExtension); buildContext = await esbuild.context({ entryPoints: [configPath], entryNames: '[name].[hash]', bundle: true, platform: 'node', target: 'es2021', outfile: outfilePath, sourcemap: true, format: useEsm ? 'esm' : 'cjs', jsx: 'transform', // needed in case models are co-located with React components logLevel: 'silent', metafile: true, absWorkingDir: projectDir, packages: 'external', define: { __dirname: JSON.stringify(projectDir), __filename: JSON.stringify(configPath) }, plugins: watch ? [ { name: 'stackbit-esbuild-watch-plugin', setup(build) { build.onEnd(async (result) => { // The plugin's onEnd() function is called for first and successive builds, // including when calling buildContext.rebuild(). // But we don't want to invoke the callback for the first build or when // the rebuild() is called manually, because the result is returned from // the loadStackbitConfigFromJs() and rebuild() functions. if (isFirstBuild || isReloading) { return; } logger?.debug(`${fileName} was changed and rebuilt`); const configResult = await loadConfigFromBuildResult(result, fileName, projectDir, logger, useEsm); callback?.(configResult); }); } } ] : [] }); if (watch) { await buildContext.watch(); } const result = await buildContext.rebuild(); const configResult = await loadConfigFromBuildResult(result, fileName, projectDir, logger, useEsm); isFirstBuild = false; let destroyed = false; return { ...configResult, destroy: async () => { if (destroyed) { return; } destroyed = true; await buildContext!.dispose(); }, reload: async (result?: esbuild.BuildResult): Promise<LoadStackbitConfigResult> => { if (destroyed) { const message = `Error reloading Stackbit configuration, 'reload' called after 'destroy'`; logger?.debug(message); return { config: null, error: new ConfigLoadError(message) }; } logger?.debug('reload stackbit config'); isReloading = true; try { result = result ?? (await buildContext!.rebuild()); } catch (error: any) { logger?.error('error reloading stackbit config', { error }); return { config: null, error: new ConfigLoadError(`Error reloading stackbit config: ${error.message}`, { originalError: error }) }; } finally { isReloading = false; } return loadConfigFromBuildResult(result, fileName, projectDir, logger, useEsm); } }; } catch (error: any) { buildContext?.dispose(); return { config: null, error: new ConfigLoadError(`Error loading Stackbit configuration: ${error.message}`, { originalError: error }), destroy: async () => void 0 }; } } async function loadConfigFromBuildResult( result: esbuild.BuildResult, fileName: string, projectDir: string, logger?: any, useEsm?: boolean ): Promise<LoadStackbitConfigResult> { try { if (result.errors.length > 0) { const message = result.errors.reduce((message, error) => { const loc = error.location; if (loc) { message += `\n${loc.file}:${loc.line}:${loc.column}: ERROR: ${error.text}`; } else { message += `\n${error.text}`; } return message; }, `Error loading Stackbit configuration from ${fileName}. Build failed with ${result.errors.length} error${result.errors.length > 1 ? 's' : ''}:`); return { config: null, error: new ConfigLoadError(message) }; } // TODO: if the loaded code has error it will provide sourcemaps; // (await import('source-map-support')).install() const importFresh = async (modulePath: string) => { if (useEsm) { return import(path.join('file://', `${modulePath}?c=${Math.random()}`)); } else { const resolvedModulePath = require.resolve(modulePath); delete require.cache[resolvedModulePath]; return require(resolvedModulePath); } }; const outfilePath = Object.keys(result.metafile!.outputs).find((outputFilePath) => outputFilePath.match(/stackbit\.config\.[^.]+\.[cm]?js$/) !== null); const absOutputFilePath = path.join(projectDir, outfilePath!); logger?.debug(`loading compiled ${fileName} from ${outfilePath}`); const exports = await importFresh(absOutputFilePath); if ('default' in exports) { // esm compiled config return { config: exports.default, error: null }; } else if ('__esModule' in exports && exports.__esModule) { if (!('default' in exports)) { return { config: null, error: new ConfigLoadError(`Error loading Stackbit configuration, no default export found in ${fileName}`) }; } return { config: exports.default, error: null }; } return { config: exports, error: null }; } catch (error: any) { if (error.code === 'ERR_REQUIRE_ESM') { const message = `It appears that one of the external dependencies in ${fileName} is an ES Module. ` + `However, ${fileName} is compiled into a CommonJS module by default, preventing it from importing ES Modules. ` + `To compile stackbit.config.ts into ES Module, please add "\x1b[32museESM: true\x1b[0m" to the config. `; if (error.stack && typeof error.stack === 'string') { error.stack = message + error.stack; } if (error.stack && typeof error.message === 'string') { error.message = message + error.message; } } return { config: null, error: new ConfigLoadError(`Error loading Stackbit configuration from ${fileName}: ${error.message}`, { originalError: error }) }; } } async function configHasEsmFlag(filePath: string) { const jsConfigString = await fs.readFile(filePath, 'utf-8'); const useEsm = parseInlineProperty(jsConfigString, 'useESM'); return !!useEsm; }