@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
384 lines (325 loc) • 12.7 kB
text/typescript
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { createNodeLogger, createNodeSys } from "@stencil/core/sys/node";
import {
CompilerWatcher,
createCompiler,
loadConfig,
} from "@stencil/core/compiler";
import { PluginName, ResolvedEmbeddableConfig } from "./defineConfig";
import {
findFiles,
getComponentLibraryConfig,
} from "@embeddable.com/sdk-utils";
import * as sorcery from "sorcery";
import { Stats } from "node:fs";
import type { Logger } from "@stencil/core/internal";
const STYLE_IMPORTS_TOKEN = "{{STYLES_IMPORT}}";
const RENDER_IMPORT_TOKEN = "{{RENDER_IMPORT}}";
const PLUGIN_FLAGS_TOKEN = "{{PLUGIN_FLAGS}}";
// stencil doesn't support dynamic component tag name, so we need to replace it manually
const COMPONENT_TAG_TOKEN = "replace-this-with-component-name";
// In dev mode, skip source map chain merging for files above this size.
// Stencil outputs two kinds of files: large framework/runtime bundles (several MB each)
// and small per-component lazy chunks (typically < 50 KB, one per component).
// Loading the large bundles all at once via sorcery exhausts the V8 heap and crashes
// the dev server. The small per-component chunks — from the client's own code and from
// imported libraries alike — stay well under this threshold and still get processed,
// so browser devtools can resolve errors in any component back to the original .tsx source.
const DEV_SOURCEMAP_SIZE_THRESHOLD = 500 * 1024; // 500 KB
let triggeredBuildCount = 0;
/**
* Stencil watcher doesnt react on file metadata changes,
* so we have to change the file content to trigger a rebuild by appending a space character.
* This constant defines how many times the space character can be appended before the file is truncated back to its original size.
*/
export const TRIGGER_BUILD_ITERATION_LIMIT = 5;
let originalFileStats: Stats | null = null;
export function resetForTesting() {
triggeredBuildCount = 0;
originalFileStats = null;
}
/**
* Triggers a rebuild of a Stencil web component by modifying the `component.tsx` file.
*
* This function works by appending a space character to the file, which causes Stencil's watcher
* to detect a change and rebuild the component. After every TRIGGER_BUILD_ITERATION_LIMIT rebuilds, the file is truncated back
* to its original size to prevent indefinite growth and reset the internal rebuild counter.
*
* Append and truncate are used instead of rewriting the file to ensure minimal I/O overhead and preserve file metadata.
*/
export async function triggerWebComponentRebuild(
ctx: ResolvedEmbeddableConfig,
): Promise<void> {
const filePath = path.resolve(ctx.client.componentDir, "component.tsx");
if (triggeredBuildCount === 0) {
// store original file stats on the first build
originalFileStats = await fs.stat(filePath);
}
if (triggeredBuildCount === TRIGGER_BUILD_ITERATION_LIMIT && originalFileStats) {
await fs.truncate(filePath, originalFileStats.size);
triggeredBuildCount = 0; // reset the counter after resetting the file
} else {
await fs.appendFile(filePath, " ");
triggeredBuildCount++;
}
}
export default async (
ctx: ResolvedEmbeddableConfig,
pluginName: PluginName,
): Promise<void | CompilerWatcher> => {
await injectCSS(ctx, pluginName);
await injectBundleRender(ctx, pluginName);
const watcher = await runStencil(ctx);
if (watcher) {
watcher.on("buildFinish", () => {
// Stencil always changes the working directory to the root of the web component.
// We need to change it back to the client root directory so that relative paths
// resolve correctly during source map generation.
process.chdir(ctx.client.rootDir);
generateSourceMap(ctx, pluginName);
});
} else {
await generateSourceMap(ctx, pluginName);
}
return watcher;
};
/**
* Generates only the d.ts type declaration files using Stencil, without performing a full build.
* Used in dev mode to pre-generate types before the watcher starts, avoiding a double-build
* triggered by the watcher reacting to freshly generated d.ts files.
*
* Key differences from the default generate function:
* - Writes an empty style.css stub (no real CSS injection needed for type generation)
* - Injects a no-op render stub instead of the real render import
* - Always creates a fresh sys (never reuses ctx.dev?.sys) to avoid watcher interference
*/
export async function generateDTS(
ctx: ResolvedEmbeddableConfig,
): Promise<void> {
await injectEmptyCSS(ctx);
await injectBundleRenderStub(ctx);
await runStencil(ctx, { dtsOnly: true });
}
export async function injectCSS(
ctx: ResolvedEmbeddableConfig,
pluginName: PluginName,
) {
const CUSTOMER_BUILD = path.resolve(
ctx.client.buildDir,
ctx[pluginName].outputOptions.buildName,
);
const allFiles = await fs.readdir(CUSTOMER_BUILD);
const importFilePath = path
.relative(
ctx.client.componentDir,
path.resolve(ctx.client.buildDir, ctx[pluginName].outputOptions.buildName),
)
.replaceAll("\\", "/");
const imports = allFiles
.filter((fileName) => fileName.endsWith(".css"))
.map((fileName) => `@import '${importFilePath}/${fileName}';`);
const componentLibraries = ctx.client.componentLibraries;
for (const componentLibrary of componentLibraries) {
const { libraryName } = getComponentLibraryConfig(componentLibrary);
const allLibFiles = await fs.readdir(
path.resolve(ctx.client.rootDir, "node_modules", libraryName, "dist"),
);
allLibFiles
.filter((fileName) => fileName.endsWith(".css"))
.forEach((fileName) =>
imports.push(`@import '~${libraryName}/dist/${fileName}';`),
);
}
const cssFilesImportsStr = imports.join("\n");
const content = await fs.readFile(
path.resolve(ctx.core.templatesDir, "style.css.template"),
"utf8",
);
await fs.writeFile(
path.resolve(ctx.client.componentDir, "style.css"),
content.replace(STYLE_IMPORTS_TOKEN, cssFilesImportsStr),
);
}
export async function injectBundleRender(
ctx: ResolvedEmbeddableConfig,
pluginName: PluginName,
) {
const importFilePath = path
.relative(
ctx.client.componentDir,
path.resolve(ctx.client.buildDir, ctx[pluginName].outputOptions.buildName),
)
.replaceAll("\\", "/");
const importStr = `import render from '${importFilePath}/${ctx[pluginName].outputOptions.fileName}';`;
const pluginFlags = ctx[pluginName].pluginFlags ?? {};
const pluginFlagsStr = `const pluginFlags: Partial<PluginFlags> = ${JSON.stringify(pluginFlags)}`;
let content = await fs.readFile(
path.resolve(ctx.core.templatesDir, "component.tsx.template"),
"utf8",
);
if (!!ctx.dev?.watch) {
content = content.replace(COMPONENT_TAG_TOKEN, "embeddable-component");
}
await fs.writeFile(
path.resolve(ctx.client.componentDir, "component.tsx"),
content.replace(RENDER_IMPORT_TOKEN, importStr).replace(PLUGIN_FLAGS_TOKEN, pluginFlagsStr),
);
}
async function injectEmptyCSS(ctx: ResolvedEmbeddableConfig) {
await fs.writeFile(path.resolve(ctx.client.componentDir, "style.css"), "");
}
async function injectBundleRenderStub(
ctx: ResolvedEmbeddableConfig,
) {
const stubStr = `const render = (..._args: any[]) => {};`;
let content = await fs.readFile(
path.resolve(ctx.core.templatesDir, "component.tsx.template"),
"utf8",
);
content = content.replace(COMPONENT_TAG_TOKEN, "embeddable-component");
await fs.writeFile(
path.resolve(ctx.client.componentDir, "component.tsx"),
content.replace(RENDER_IMPORT_TOKEN, stubStr).replace(PLUGIN_FLAGS_TOKEN, "const pluginFlags: Partial<PluginFlags> = {}"),
);
}
async function addComponentTagName(filePath: string, bundleHash: string) {
// find entry file with a name *.entry.js
const entryFiles = await findFiles(path.dirname(filePath), /.*\.entry\.js/);
if (!entryFiles.length) {
return;
}
const entryFileName = entryFiles[0];
const [entryFileContent, fileContent] = await Promise.all([
fs.readFile(entryFileName[1], "utf8"),
fs.readFile(filePath, "utf8"),
]);
const newFileContent = fileContent.replace(
COMPONENT_TAG_TOKEN,
`embeddable-component-${bundleHash}`,
);
const newEntryFileContent = entryFileContent.replace(
COMPONENT_TAG_TOKEN.replaceAll("-", "_"),
`embeddable_component_${bundleHash}`,
);
await Promise.all([
fs.writeFile(filePath, newFileContent),
fs.writeFile(entryFileName[1], newEntryFileContent),
]);
}
async function runStencil(
ctx: ResolvedEmbeddableConfig,
options?: { dtsOnly?: boolean },
): Promise<void | CompilerWatcher> {
const logger = (options?.dtsOnly ? createNodeLogger() : ctx.dev?.logger || createNodeLogger()) as Logger;
const sys = options?.dtsOnly ? createNodeSys({ process }) : (ctx.dev?.sys || createNodeSys({ process }));
const devMode = !!ctx.dev?.watch && !options?.dtsOnly;
if (options?.dtsOnly) {
logger.setLevel("error")
logger.createTimeSpan = () => ({
duration: () => 0,
finish: () => 0,
});
}
const isWindows = process.platform === "win32";
const validated = await loadConfig({
initTsConfig: true,
logger,
sys,
config: {
devMode,
maxConcurrentWorkers: isWindows ? 0 : 8, // workers break on windows
// we will trigger a rebuild by updating the component.tsx file (see triggerBuild function)
watchIgnoredRegex: [/\.css$/, /\.d\.ts$/, /\.js$/],
rootDir: ctx.client.webComponentRoot,
configPath: path.resolve(
ctx.client.webComponentRoot,
"stencil.config.ts",
),
tsconfig: path.resolve(ctx.client.webComponentRoot, "tsconfig.json"),
namespace: "embeddable-wrapper",
srcDir: ctx.client.componentDir,
sourceMap: !options?.dtsOnly, // always generate source maps in both dev and prod
minifyJs: !devMode && !options?.dtsOnly,
minifyCss: !devMode && !options?.dtsOnly,
outputTargets: [
{
type: "dist",
buildDir: path.resolve(ctx.client.buildDir, "dist"),
},
],
},
});
const compiler = await createCompiler(validated.config);
if (devMode) {
sys.onProcessInterrupt(() => {
compiler.destroy();
});
return await compiler.createWatcher();
}
const buildResults = await compiler.build();
if (buildResults.hasError) {
console.error("Stencil build error:", buildResults.diagnostics);
throw new Error("Stencil build error");
} else {
await handleStencilBuildOutput(ctx);
}
await compiler.destroy();
process.chdir(ctx.client.rootDir);
}
async function handleStencilBuildOutput(ctx: ResolvedEmbeddableConfig) {
const entryFilePath = path.resolve(
ctx.client.stencilBuild,
"embeddable-wrapper.esm.js",
);
let fileName = "embeddable-wrapper.esm.js";
if (!ctx.dev?.watch && ctx.client.bundleHash) {
fileName = `embeddable-wrapper.esm-${ctx.client.bundleHash}.js`;
await addComponentTagName(entryFilePath, ctx.client.bundleHash);
}
await fs.rename(
entryFilePath,
path.resolve(ctx.client.stencilBuild, fileName),
);
}
async function generateSourceMap(
ctx: ResolvedEmbeddableConfig,
pluginName: PluginName,
) {
const componentBuildDir = path.resolve(
ctx.client.buildDir,
ctx[pluginName].outputOptions.buildName,
);
const stencilBuild = path.resolve(ctx.client.stencilBuild);
const tmpComponentDir = path.resolve(
stencilBuild,
ctx[pluginName].outputOptions.buildName,
);
await fs.cp(componentBuildDir, tmpComponentDir, { recursive: true });
const stencilFiles = await fs.readdir(stencilBuild);
const jsFiles = stencilFiles.filter((file) =>
file.toLowerCase().endsWith(".js"),
);
const isDevMode = !!ctx.dev?.watch;
// Sequential processing to avoid loading multiple source map chains into memory at once.
for (const jsFile of jsFiles) {
try {
const filePath = path.resolve(stencilBuild, jsFile);
// In dev mode, skip large files (framework/runtime bundles).
// Per-component chunks from both client and library code are small enough
// to process, keeping source maps working in browser devtools.
if (isDevMode) {
const { size } = await fs.stat(filePath);
if (size > DEV_SOURCEMAP_SIZE_THRESHOLD) {
continue;
}
}
const chain = await sorcery.load(filePath);
// overwrite the existing file
await chain.write();
} catch (e) {
// do nothing if a map file can not be generated
}
}
await fs.rm(tmpComponentDir, { recursive: true });
}