UNPKG

@embeddable.com/sdk-core

Version:

Core Embeddable SDK module responsible for web-components bundling and publishing.

384 lines (325 loc) 12.7 kB
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 }); }