@stackbit/sdk
Version:
234 lines (220 loc) • 9.67 kB
text/typescript
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;
}