UNPKG

@jsenv/core

Version:

Tool to develop, test and build js projects

1,464 lines (1,399 loc) 51.1 kB
/* * Build is split in 3 steps: * 1. craft * 2. shape * 3. refine * * craft: prepare all the materials * - resolve, fetch and transform all source files into "rawKitchen.graph" * shape: this step can drastically change url content and their relationships * - bundling * - optimizations (minification) * refine: perform minor changes on the url contents * - cleaning html * - url versioning * - ressource hints * - injecting urls into service workers */ import { Abort, raceProcessTeardownEvents } from "@jsenv/abort"; import { parseHtml, stringifyHtmlAst } from "@jsenv/ast"; import { assertAndNormalizeDirectoryUrl, clearDirectorySync, compareFileUrls, createLookupPackageDirectory, ensureEmptyDirectory, lookupPackageDirectory, readPackageAtOrNull, updateJsonFileSync, writeFileSync, } from "@jsenv/filesystem"; import { ANSI, createDynamicLog, createLogger, createTaskLog, humanizeDuration, humanizeMemory, UNICODE, } from "@jsenv/humanize"; import { applyNodeEsmResolution } from "@jsenv/node-esm-resolution"; import { startMonitoringCpuUsage, startMonitoringMemoryUsage, } from "@jsenv/os-metrics"; import { jsenvPluginBundling } from "@jsenv/plugin-bundling"; import { jsenvPluginMinification } from "@jsenv/plugin-minification"; import { jsenvPluginJsModuleFallback } from "@jsenv/plugin-transpilation"; import { browserDefaultRuntimeCompat, inferRuntimeCompatFromClosestPackage, nodeDefaultRuntimeCompat, } from "@jsenv/runtime-compat"; import { urlIsOrIsInsideOf, urlToBasename, urlToExtension, urlToFilename, urlToRelativeUrl, } from "@jsenv/urls"; import { memoryUsage as processMemoryUsage } from "node:process"; import { watchSourceFiles } from "../helpers/watch_source_files.js"; import { jsenvCoreDirectoryUrl } from "../jsenv_core_directory_url.js"; import { createKitchen } from "../kitchen/kitchen.js"; import { GRAPH_VISITOR } from "../kitchen/url_graph/url_graph_visitor.js"; import { jsenvPluginDirectoryReferenceEffect } from "../plugins/directory_reference_effect/jsenv_plugin_directory_reference_effect.js"; import { jsenvPluginInlining } from "../plugins/inlining/jsenv_plugin_inlining.js"; import { createPluginController, createPluginStore, } from "../plugins/plugin_controller.js"; import { getCorePlugins } from "../plugins/plugins.js"; import { jsenvPluginReferenceAnalysis } from "../plugins/reference_analysis/jsenv_plugin_reference_analysis.js"; import { renderBuildDoneLog } from "./build_content_report.js"; import { defaultRuntimeCompat, logsDefault } from "./build_params.js"; import { createBuildSpecifierManager } from "./build_specifier_manager.js"; import { createBuildUrlsGenerator } from "./build_urls_generator.js"; import { jsenvPluginLineBreakNormalization } from "./jsenv_plugin_line_break_normalization.js"; import { jsenvPluginMappings } from "./jsenv_plugin_mappings.js"; /** * Generate an optimized version of source files into a directory. * * @param {Object} params * @param {string|url} params.sourceDirectoryUrl * Directory containing source files * @param {string|url} params.buildDirectoryUrl * Directory where optimized files will be written * @param {object} params.entryPoints * Object where keys are paths to source files and values are their future name in the build directory. * Keys are relative to sourceDirectoryUrl * @param {object} params.runtimeCompat * Code generated will be compatible with these runtimes * @param {string} [params.assetsDirectory] * Directory where asset files will be written. By default sibling to the entry build file. * @param {string|url} [params.base=""] * Urls in build file contents will be prefixed with this string * @param {boolean|object} [params.bundling=true] * Reduce number of files written in the build directory * @param {boolean|object} [params.minification=true] * Minify the content of files written into the build directory * @param {boolean} [params.versioning=true] * Use versioning on files written in the build directory * @param {('search_param'|'filename')} [params.versioningMethod="search_param"] * Controls how url are versioned in the build directory * @param {('none'|'inline'|'file'|'programmatic')} [params.sourcemaps="none"] * Generate sourcemaps in the build directory * @param {('error'|'copy'|'preserve')|function} [params.directoryReferenceEffect="error"] * What to do when a reference leads to a directory on the filesystem * @return {Promise<Object>} buildReturnValue * @return {Promise<Object>} buildReturnValue.buildInlineContents * Contains content that is inline into build files * @return {Promise<Object>} buildReturnValue.buildManifest * Map build file paths without versioning to versioned file paths */ export const build = async ({ sourceDirectoryUrl, buildDirectoryUrl, entryPoints = {}, logs, outDirectoryUrl, buildDirectoryCleanPatterns = { "**/*": true }, returnBuildInlineContents, returnBuildManifest, returnBuildFileVersions, signal = new AbortController().signal, handleSIGINT = true, writeOnFileSystem = true, watch = false, sourceFilesConfig = {}, cooldownBetweenFileEvents, ...rest }) => { const entryPointArray = []; // param validation { const unexpectedParamNames = Object.keys(rest); if (unexpectedParamNames.length > 0) { throw new TypeError( `${unexpectedParamNames.join(",")}: there is no such param`, ); } // source and build directory { sourceDirectoryUrl = assertAndNormalizeDirectoryUrl( sourceDirectoryUrl, "sourceDirectoryUrl", ); buildDirectoryUrl = assertAndNormalizeDirectoryUrl( buildDirectoryUrl, "buildDirectoryUrl", ); } // entry points { if (typeof entryPoints !== "object" || entryPoints === null) { throw new TypeError( `The value "${entryPoints}" for "entryPoints" is invalid: it must be an object.`, ); } const keys = Object.keys(entryPoints); const isSingleEntryPoint = keys.length === 1; for (const key of keys) { // key (sourceRelativeUrl) let sourceUrl; let runtimeType; { if (isBareSpecifier(key)) { const packageConditions = [ "development", "dev:*", "node", "import", ]; try { const { url, type } = applyNodeEsmResolution({ conditions: packageConditions, parentUrl: sourceDirectoryUrl, specifier: key, }); if (type === "field:browser") { runtimeType = "browser"; } sourceUrl = url; } catch (e) { throw new Error( `The key "${key}" in "entryPoints" is invalid: it cannot be resolved.`, { cause: e }, ); } } else { if (!key.startsWith("./")) { throw new TypeError( `The key "${key}" in "entryPoints" is invalid: it must start with "./".`, ); } try { sourceUrl = new URL(key, sourceDirectoryUrl).href; } catch { throw new TypeError( `The key "${key}" in "entryPoints" is invalid: it must be a relative url.`, ); } } if (!urlIsOrIsInsideOf(sourceUrl, sourceDirectoryUrl)) { throw new Error( `The key "${key}" in "entryPoints" is invalid: it must be inside the source directory at ${sourceDirectoryUrl}.`, ); } if (!runtimeType) { const ext = urlToExtension(sourceUrl); if (ext === ".html" || ext === ".css") { runtimeType = "browser"; } } } // value (entryPointParams) const value = entryPoints[key]; { if (value === null || typeof value !== "object") { throw new TypeError( `The value "${value}" in "entryPoints" is invalid: it must be an object.`, ); } const forEntryPointOrEmpty = isSingleEntryPoint ? "" : ` for entry point "${key}"`; const unexpectedEntryPointParamNames = Object.keys(value).filter( (key) => !Object.hasOwn(entryPointDefaultParams, key), ); if (unexpectedEntryPointParamNames.length) { throw new TypeError( `The value${forEntryPointOrEmpty} contains unknown keys: ${unexpectedEntryPointParamNames.join(",")}.`, ); } const { versioningMethod } = value; if (versioningMethod !== undefined) { if (!["filename", "search_param"].includes(versioningMethod)) { throw new TypeError( `The versioningMethod "${versioningMethod}"${forEntryPointOrEmpty} is invalid: it must be "filename" or "search_param".`, ); } } const { buildRelativeUrl } = value; if (buildRelativeUrl !== undefined) { let buildUrl; try { buildUrl = new URL(buildRelativeUrl, buildDirectoryUrl); } catch { throw new TypeError( `The buildRelativeUrl "${buildRelativeUrl}"${forEntryPointOrEmpty} is invalid: it must be a relative url.`, ); } if (!urlIsOrIsInsideOf(buildUrl, buildDirectoryUrl)) { throw new Error( `The buildRelativeUrl "${buildRelativeUrl}"${forEntryPointOrEmpty} is invalid: it must be inside the build directory at ${buildDirectoryUrl}.`, ); } } const { runtimeCompat } = value; if (runtimeCompat !== undefined) { if (runtimeCompat === null || typeof runtimeCompat !== "object") { throw new TypeError( `The runtimeCompat "${runtimeCompat}"${forEntryPointOrEmpty} is invalid: it must be an object.`, ); } } } entryPointArray.push({ key, sourceUrl, sourceRelativeUrl: `./${urlToRelativeUrl(sourceUrl, sourceDirectoryUrl)}`, params: { ...value }, runtimeType, }); } } // logs if (logs === undefined) { logs = logsDefault; } else { if (typeof logs !== "object") { throw new TypeError( `The value "${logs}" is invalid for param logs: it must be an object.`, ); } const unexpectedLogsKeys = Object.keys(logs).filter( (key) => !Object.hasOwn(logsDefault, key), ); if (unexpectedLogsKeys.length > 0) { throw new TypeError( `The param logs have unknown params: ${unexpectedLogsKeys.join(",")}.`, ); } } if (outDirectoryUrl !== undefined) { outDirectoryUrl = assertAndNormalizeDirectoryUrl( outDirectoryUrl, "outDirectoryUrl", ); } } const operation = Abort.startOperation(); operation.addAbortSignal(signal); if (handleSIGINT) { operation.addAbortSource((abort) => { return raceProcessTeardownEvents( { SIGINT: true, }, abort, ); }); } const cpuMonitoring = startMonitoringCpuUsage(); operation.addEndCallback(cpuMonitoring.stop); const [processCpuUsageMonitoring] = cpuMonitoring; const memoryMonitoring = startMonitoringMemoryUsage(); const [processMemoryUsageMonitoring] = memoryMonitoring; const interval = setInterval(() => { processCpuUsageMonitoring.measure(); processMemoryUsageMonitoring.measure(); }, 500).unref(); operation.addEndCallback(() => { clearInterval(interval); }); const logLevel = logs.level; const logger = createLogger({ logLevel }); const animatedLogEnabled = logs.animated && // canEraseProcessStdout process.stdout.isTTY && // if there is an error during execution npm will mess up the output // (happens when npm runs several command in a workspace) // so we enable hot replace only when !process.exitCode (no error so far) process.exitCode !== 1; let startBuildLogs = () => {}; const renderEntyPointBuildDoneLog = ( entryBuildInfo, { sourceUrlToLog, buildUrlToLog }, ) => { let content = ""; const applyColorOnFileRelativeUrl = (fileRelativeUrl, color) => { const fileUrl = new URL(fileRelativeUrl, rootPackageDirectoryUrl); const packageDirectoryUrl = lookupPackageDirectory(fileUrl); if ( !packageDirectoryUrl || packageDirectoryUrl === rootPackageDirectoryUrl ) { return ANSI.color(fileRelativeUrl, color); } const parentDirectoryUrl = new URL("../", packageDirectoryUrl).href; const beforePackageDirectoryName = urlToRelativeUrl( parentDirectoryUrl, rootPackageDirectoryUrl, ); const packageDirectoryName = urlToFilename(packageDirectoryUrl); const afterPackageDirectoryUrl = urlToRelativeUrl( fileUrl, packageDirectoryUrl, ); const beforePackageNameStylized = ANSI.color( beforePackageDirectoryName, color, ); const packageNameStylized = ANSI.color( ANSI.effect(packageDirectoryName, ANSI.UNDERLINE), color, ); const afterPackageNameStylized = ANSI.color( `/${afterPackageDirectoryUrl}`, color, ); return `${beforePackageNameStylized}${packageNameStylized}${afterPackageNameStylized}`; }; content += `${UNICODE.OK} ${applyColorOnFileRelativeUrl(sourceUrlToLog, ANSI.GREY)} ${ANSI.color("->", ANSI.GREY)} ${applyColorOnFileRelativeUrl(buildUrlToLog, "")}`; // content += " "; // content += ANSI.color("(", ANSI.GREY); // content += ANSI.color( // humanizeDuration(entryBuildInfo.duration, { short: true }), // ANSI.GREY, // ); // content += ANSI.color(")", ANSI.GREY); content += "\n"; return content; }; const renderBuildEndLog = ({ duration, buildFileContents }) => { // tell how many files are generated in build directory // tell the repartition? // this is not really useful for single build right? processCpuUsageMonitoring.end(); processMemoryUsageMonitoring.end(); return renderBuildDoneLog({ entryPointArray, duration, buildFileContents, processCpuUsage: processCpuUsageMonitoring.info, processMemoryUsage: processMemoryUsageMonitoring.info, }); }; if (animatedLogEnabled) { startBuildLogs = () => { const startMs = Date.now(); let dynamicLog = createDynamicLog(); const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frameIndex = 0; let oneWrite = false; const memoryHeapUsedAtStart = processMemoryUsage().heapUsed; const renderDynamicLog = () => { frameIndex = frameIndex === frames.length - 1 ? 0 : frameIndex + 1; let dynamicLogContent = ""; dynamicLogContent += `${frames[frameIndex]} `; dynamicLogContent += `building ${entryPointArray.length} entry points`; const msEllapsed = Date.now() - startMs; const infos = []; const duration = humanizeDuration(msEllapsed, { short: true, decimals: 0, rounded: false, }); infos.push(ANSI.color(duration, ANSI.GREY)); let memoryUsageColor = ANSI.GREY; const memoryHeapUsed = processMemoryUsage().heapUsed; if (memoryHeapUsed > 2.5 * memoryHeapUsedAtStart) { memoryUsageColor = ANSI.YELLOW; } else if (memoryHeapUsed > 1.5 * memoryHeapUsedAtStart) { memoryUsageColor = null; } const memoryHeapUsedFormatted = humanizeMemory(memoryHeapUsed, { short: true, decimals: 0, }); infos.push(ANSI.color(memoryHeapUsedFormatted, memoryUsageColor)); const infoFormatted = infos.join(ANSI.color(`/`, ANSI.GREY)); dynamicLogContent += ` ${ANSI.color( "[", ANSI.GREY, )}${infoFormatted}${ANSI.color("]", ANSI.GREY)}`; if (oneWrite) { dynamicLogContent = `\n${dynamicLogContent}`; } dynamicLogContent = `${dynamicLogContent}\n`; return dynamicLogContent; }; dynamicLog.update(renderDynamicLog()); const interval = setInterval(() => { dynamicLog.update(renderDynamicLog()); }, 150).unref(); signal.addEventListener("abort", () => { clearInterval(interval); }); return { onEntryPointBuildStart: ( entryBuildInfo, { sourceUrlToLog, buildUrlToLog }, ) => { return () => { oneWrite = true; dynamicLog.clearDuringFunctionCall((write) => { const log = renderEntyPointBuildDoneLog(entryBuildInfo, { sourceUrlToLog, buildUrlToLog, }); write(log); }, renderDynamicLog()); }; }, onBuildEnd: ({ buildFileContents, duration }) => { clearInterval(interval); dynamicLog.update(""); dynamicLog.destroy(); dynamicLog = null; logger.info(""); logger.info(renderBuildEndLog({ duration, buildFileContents })); }, }; }; } else { startBuildLogs = () => { if (entryPointArray.length === 1) { const [singleEntryPoint] = entryPointArray; logger.info(`building ${singleEntryPoint.key}`); } else { logger.info(`building ${entryPointArray.length} entry points`); } logger.info(""); return { onEntryPointBuildStart: ( entryBuildInfo, { sourceUrlToLog, buildUrlToLog }, ) => { return () => { logger.info( renderEntyPointBuildDoneLog(entryBuildInfo, { sourceUrlToLog, buildUrlToLog, }), ); }; }, onBuildEnd: ({ buildFileContents, duration }) => { logger.info(renderBuildEndLog({ duration, buildFileContents })); }, }; }; } // we want to start building the entry point that are deeper // - they are more likely to be small // - they are more likely to be referenced by highter files that will depend on them entryPointArray.sort((a, b) => { return compareFileUrls(a.sourceUrl, b.sourceUrl); }); const lookupPackageDirectoryUrl = createLookupPackageDirectory(); const packageDirectoryCache = new Map(); const readPackageDirectory = (url) => { const fromCache = packageDirectoryCache.get(url); if (fromCache !== undefined) { return fromCache; } return readPackageAtOrNull(url); }; const packageDirectory = { url: lookupPackageDirectory(sourceDirectoryUrl), find: lookupPackageDirectoryUrl, read: readPackageDirectory, }; if (outDirectoryUrl === undefined) { if ( process.env.CAPTURING_SIDE_EFFECTS || (!import.meta.build && urlIsOrIsInsideOf(sourceDirectoryUrl, jsenvCoreDirectoryUrl)) ) { outDirectoryUrl = new URL("../.jsenv_b/", sourceDirectoryUrl).href; } else if (packageDirectory.url) { outDirectoryUrl = `${packageDirectory.url}.jsenv/`; } } let rootPackageDirectoryUrl; if (packageDirectory.url) { const parentPackageDirectoryUrl = packageDirectory.find( new URL("../", packageDirectory.url), ); rootPackageDirectoryUrl = parentPackageDirectoryUrl || packageDirectory.url; } else { rootPackageDirectoryUrl = packageDirectory.url; } const runBuild = async ({ signal }) => { const startDate = Date.now(); const { onBuildEnd, onEntryPointBuildStart } = startBuildLogs(); const buildUrlsGenerator = createBuildUrlsGenerator({ sourceDirectoryUrl, buildDirectoryUrl, }); let someEntryPointUseNode = false; for (const entryPoint of entryPointArray) { let { runtimeCompat } = entryPoint.params; if (runtimeCompat === undefined) { const runtimeCompatFromPackage = inferRuntimeCompatFromClosestPackage( entryPoint.sourceUrl, { runtimeType: entryPoint.runtimeType, }, ); if (runtimeCompatFromPackage) { entryPoint.params.runtimeCompat = runtimeCompat = runtimeCompatFromPackage; } else { entryPoint.params.runtimeCompat = runtimeCompat = entryPoint.runtimeType === "browser" ? browserDefaultRuntimeCompat : nodeDefaultRuntimeCompat; } } if (!someEntryPointUseNode && "node" in runtimeCompat) { someEntryPointUseNode = true; } } const entryBuildInfoMap = new Map(); let entryPointIndex = 0; const entryOutDirSet = new Set(); for (const entryPoint of entryPointArray) { let entryOutDirCandidate = `entry_${urlToBasename(entryPoint.sourceRelativeUrl)}/`; let entryInteger = 1; while (entryOutDirSet.has(entryOutDirCandidate)) { entryInteger++; entryOutDirCandidate = `entry_${urlToBasename(entryPoint.sourceRelativeUrl)}_${entryInteger}/`; } const entryOutDirname = entryOutDirCandidate; entryOutDirSet.add(entryOutDirname); const entryOutDirectoryUrl = new URL(entryOutDirname, outDirectoryUrl); const { entryReference, buildEntryPoint } = await prepareEntryPointBuild( { signal, sourceDirectoryUrl, buildDirectoryUrl, outDirectoryUrl: entryOutDirectoryUrl, sourceRelativeUrl: entryPoint.sourceRelativeUrl, packageDirectory, buildUrlsGenerator, someEntryPointUseNode, }, entryPoint.params, ); const entryPointBuildRelativeUrl = entryPoint.params.buildRelativeUrl; const entryBuildInfo = { index: entryPointIndex, entryReference, entryUrlInfo: entryReference.urlInfo, buildRelativeUrl: entryPointBuildRelativeUrl, buildFileContents: undefined, buildFileVersions: undefined, buildInlineContents: undefined, buildManifest: undefined, duration: null, buildEntryPoint: () => { const sourceUrl = new URL( entryPoint.sourceRelativeUrl, sourceDirectoryUrl, ); const buildUrl = new URL( entryPointBuildRelativeUrl, buildDirectoryUrl, ); const sourceUrlToLog = rootPackageDirectoryUrl ? urlToRelativeUrl(sourceUrl, rootPackageDirectoryUrl) : entryPoint.key; const buildUrlToLog = rootPackageDirectoryUrl ? urlToRelativeUrl(buildUrl, rootPackageDirectoryUrl) : entryPointBuildRelativeUrl; const entryPointBuildStartMs = Date.now(); const onEntryPointBuildEnd = onEntryPointBuildStart(entryBuildInfo, { sourceUrlToLog, buildUrlToLog, }); const promise = (async () => { const result = await buildEntryPoint({ getOtherEntryBuildInfo: (url) => { if (url === entryReference.url) { return null; } const otherEntryBuildInfo = entryBuildInfoMap.get(url); if (!otherEntryBuildInfo) { return null; } return otherEntryBuildInfo; }, }); entryBuildInfo.buildFileContents = result.buildFileContents; entryBuildInfo.buildFileVersions = result.buildFileVersions; entryBuildInfo.buildInlineContents = result.buildInlineContents; entryBuildInfo.buildManifest = result.buildManifest; entryBuildInfo.buildSideEffectFiles = result.buildSideEffectFiles; entryBuildInfo.duration = Date.now() - entryPointBuildStartMs; onEntryPointBuildEnd(); })(); entryBuildInfo.promise = promise; return promise; }, }; entryBuildInfoMap.set(entryReference.url, entryBuildInfo); entryPointIndex++; } const promises = []; for (const [, entryBuildInfo] of entryBuildInfoMap) { const promise = entryBuildInfo.buildEntryPoint(); promises.push(promise); } await Promise.all(promises); const buildFileContents = {}; const buildFileVersions = {}; const buildInlineContents = {}; const buildManifest = {}; const buildSideEffectUrlSet = new Set(); for (const [, entryBuildInfo] of entryBuildInfoMap) { Object.assign(buildFileContents, entryBuildInfo.buildFileContents); Object.assign(buildFileVersions, entryBuildInfo.buildFileVersions); Object.assign(buildInlineContents, entryBuildInfo.buildInlineContents); Object.assign(buildManifest, entryBuildInfo.buildManifest); for (const buildSideEffectUrl of entryBuildInfo.buildSideEffectFiles) { buildSideEffectUrlSet.add(buildSideEffectUrl); } } if (writeOnFileSystem) { clearDirectorySync(buildDirectoryUrl, buildDirectoryCleanPatterns); const buildRelativeUrls = Object.keys(buildFileContents); for (const buildRelativeUrl of buildRelativeUrls) { const buildUrl = new URL(buildRelativeUrl, buildDirectoryUrl); writeFileSync(buildUrl, buildFileContents[buildRelativeUrl]); } if (buildSideEffectUrlSet.size) { const normalizeSideEffectFileUrl = (url) => { const urlRelativeToPackage = urlToRelativeUrl( url, packageDirectory.url, ); return urlRelativeToPackage[0] === "." ? urlRelativeToPackage : `./${urlRelativeToPackage}`; }; const updatePackageSideEffects = (sideEffectUrlSet) => { const packageJsonFileUrl = new URL( "./package.json", packageDirectory.url, ).href; const sideEffectRelativeUrlArray = []; for (const sideEffectUrl of sideEffectUrlSet) { sideEffectRelativeUrlArray.push( normalizeSideEffectFileUrl(sideEffectUrl), ); } updateJsonFileSync(packageJsonFileUrl, { sideEffects: sideEffectRelativeUrlArray, }); }; const sideEffects = readPackageDirectory( packageDirectory.url, )?.sideEffects; if (sideEffects === false) { updatePackageSideEffects(buildSideEffectUrlSet); } else if (Array.isArray(sideEffects)) { const sideEffectUrlSet = new Set(); const packageSideEffectUrlSet = new Set(); for (const sideEffectFileRelativeUrl of sideEffects) { const sideEffectFileUrl = new URL( sideEffectFileRelativeUrl, packageDirectory.url, ).href; packageSideEffectUrlSet.add(sideEffectFileUrl); } let hasSomeOutdatedSideEffectUrl = false; for (const packageSideEffectUrl of packageSideEffectUrlSet) { if ( urlIsOrIsInsideOf(packageSideEffectUrl, buildDirectoryUrl) && !buildSideEffectUrlSet.has(packageSideEffectUrl) ) { hasSomeOutdatedSideEffectUrl = true; } else { sideEffectUrlSet.add(packageSideEffectUrl); } } let hasSomeNewSideEffectsUrl = false; for (const buildSideEffectUrl of buildSideEffectUrlSet) { if (packageSideEffectUrlSet.has(buildSideEffectUrl)) { continue; } hasSomeNewSideEffectsUrl = true; sideEffectUrlSet.add(buildSideEffectUrl); } if (hasSomeOutdatedSideEffectUrl || hasSomeNewSideEffectsUrl) { updatePackageSideEffects(sideEffectUrlSet); } } } } onBuildEnd({ buildFileContents, buildInlineContents, buildManifest, duration: Date.now() - startDate, }); return { ...(returnBuildInlineContents ? { buildInlineContents } : {}), ...(returnBuildManifest ? { buildManifest } : {}), ...(returnBuildFileVersions ? { buildFileVersions } : {}), }; }; if (!watch) { try { const result = await runBuild({ signal: operation.signal, }); return result; } finally { await operation.end(); } } let resolveFirstBuild; let rejectFirstBuild; const firstBuildPromise = new Promise((resolve, reject) => { resolveFirstBuild = resolve; rejectFirstBuild = reject; }); let buildAbortController; let watchFilesTask; const startBuild = async () => { const buildTask = createTaskLog("build"); buildAbortController = new AbortController(); try { const result = await runBuild({ signal: buildAbortController.signal, logLevel: "warn", }); buildTask.done(); resolveFirstBuild(result); watchFilesTask = createTaskLog("watch files"); } catch (e) { if (Abort.isAbortError(e)) { buildTask.fail(`build aborted`); } else if (e.code === "PARSE_ERROR") { buildTask.fail(); console.error(e.stack); watchFilesTask = createTaskLog("watch files"); } else { buildTask.fail(); rejectFirstBuild(e); throw e; } } }; startBuild(); let startTimeout; const stopWatchingSourceFiles = watchSourceFiles( sourceDirectoryUrl, ({ url, event }) => { if (watchFilesTask) { watchFilesTask.happen( `${url.slice(sourceDirectoryUrl.length)} ${event}`, ); watchFilesTask = null; } buildAbortController.abort(); // setTimeout is to ensure the abortController.abort() above // is properly taken into account so that logs about abort comes first // then logs about re-running the build happens clearTimeout(startTimeout); startTimeout = setTimeout(startBuild, 20); }, { sourceFilesConfig, keepProcessAlive: true, cooldownBetweenFileEvents, }, ); operation.addAbortCallback(() => { stopWatchingSourceFiles(); }); await firstBuildPromise; return stopWatchingSourceFiles; }; const entryPointDefaultParams = { buildRelativeUrl: undefined, runtimeCompat: defaultRuntimeCompat, plugins: [], mappings: undefined, assetsDirectory: undefined, base: undefined, ignore: undefined, bundling: true, minification: true, versioning: true, referenceAnalysis: {}, nodeEsmResolution: undefined, packageConditions: undefined, magicExtensions: undefined, magicDirectoryIndex: undefined, directoryReferenceEffect: undefined, scenarioPlaceholders: undefined, injections: undefined, transpilation: {}, preserveComments: undefined, versioningMethod: "search_param", // "filename", "search_param" versioningViaImportmap: true, versionLength: 8, lineBreakNormalization: process.platform === "win32", http: false, sourcemaps: "none", sourcemapsSourcesContent: undefined, assetManifest: false, assetManifestFileRelativeUrl: "asset-manifest.json", packageSideEffects: true, packageDependencies: "auto", // "auto", "ignore", "include" }; const prepareEntryPointBuild = async ( { signal, sourceDirectoryUrl, buildDirectoryUrl, outDirectoryUrl, sourceRelativeUrl, packageDirectory, buildUrlsGenerator, someEntryPointUseNode, }, entryPointParams, ) => { let { buildRelativeUrl, runtimeCompat, plugins, mappings, assetsDirectory, base, ignore, bundling, minification, versioning, referenceAnalysis, nodeEsmResolution, packageConditions, magicExtensions, magicDirectoryIndex, directoryReferenceEffect, scenarioPlaceholders, injections, transpilation, preserveComments, versioningMethod, versioningViaImportmap, versionLength, lineBreakNormalization, http, sourcemaps, sourcemapsSourcesContent, assetManifest, assetManifestFileRelativeUrl, packageSideEffects, packageDependencies, } = { ...entryPointDefaultParams, ...entryPointParams, }; // param defaults and normalization { if (entryPointParams.buildRelativeUrl === undefined) { buildRelativeUrl = entryPointParams.buildRelativeUrl = sourceRelativeUrl; } const buildUrl = new URL(buildRelativeUrl, buildDirectoryUrl); entryPointParams.buildRelativeUrl = buildRelativeUrl = urlToRelativeUrl( buildUrl, buildDirectoryUrl, ); if (entryPointParams.assetsDirectory === undefined) { const entryBuildUrl = new URL(buildRelativeUrl, buildDirectoryUrl).href; const entryBuildRelativeUrl = urlToRelativeUrl( entryBuildUrl, buildDirectoryUrl, ); if (entryBuildRelativeUrl.includes("/")) { const assetDirectoryUrl = new URL("./", entryBuildUrl); assetsDirectory = urlToRelativeUrl( assetDirectoryUrl, buildDirectoryUrl, ); } else { assetsDirectory = ""; } } if ( assetsDirectory && assetsDirectory[assetsDirectory.length - 1] !== "/" ) { assetsDirectory = `${assetsDirectory}/`; } if (entryPointParams.base === undefined) { base = someEntryPointUseNode ? "./" : "/"; } if (entryPointParams.bundling === undefined) { bundling = true; } if (bundling === true) { bundling = {}; } if (entryPointParams.minification === undefined) { minification = !someEntryPointUseNode; } if (minification === true) { minification = {}; } if (entryPointParams.versioning === undefined) { versioning = !someEntryPointUseNode; } if (entryPointParams.versioningMethod === undefined) { versioningMethod = entryPointDefaultParams.versioningMethod; } if (entryPointParams.assetManifest === undefined) { assetManifest = versioningMethod === "filename"; } if (entryPointParams.preserveComments === undefined) { preserveComments = someEntryPointUseNode; } } const buildOperation = Abort.startOperation(); buildOperation.addAbortSignal(signal); const explicitJsModuleConversion = sourceRelativeUrl.includes("?js_module_fallback") || sourceRelativeUrl.includes("?as_js_classic"); const contextSharedDuringBuild = { buildStep: "craft", buildDirectoryUrl, assetsDirectory, versioning, versioningViaImportmap, }; const rawKitchen = createKitchen({ signal, logLevel: "warn", rootDirectoryUrl: sourceDirectoryUrl, ignore, // during first pass (craft) we keep "ignore:" when a reference is ignored // so that the second pass (shape) properly ignore those urls ignoreProtocol: "keep", build: true, runtimeCompat, initialContext: contextSharedDuringBuild, sourcemaps, sourcemapsSourcesContent, outDirectoryUrl: outDirectoryUrl ? new URL("craft/", outDirectoryUrl) : undefined, packageDirectory, packageDependencies, }); let _getOtherEntryBuildInfo; const rawPluginStore = await createPluginStore([ ...(mappings ? [jsenvPluginMappings(mappings)] : []), { name: "jsenv:other_entry_point_build_during_craft", fetchUrlContent: async (urlInfo) => { if (!_getOtherEntryBuildInfo) { return null; } const otherEntryBuildInfo = _getOtherEntryBuildInfo(urlInfo.url); if (!otherEntryBuildInfo) { return null; } urlInfo.otherEntryBuildInfo = otherEntryBuildInfo; return { type: "entry_build", // this ensure the rest of jsenv do not try to scan or modify the content content: "", // we don't know yet the content it will be known later filenameHint: otherEntryBuildInfo.entryUrlInfo.filenameHint, }; }, }, ...plugins, ...(bundling ? [jsenvPluginBundling(bundling)] : []), jsenvPluginMinification(minification, { preserveComments }), ...getCorePlugins({ packageDirectory, rootDirectoryUrl: sourceDirectoryUrl, runtimeCompat, referenceAnalysis, nodeEsmResolution, packageConditions, magicExtensions, magicDirectoryIndex, directoryReferenceEffect, injections, transpilation: { babelHelpersAsImport: !explicitJsModuleConversion, ...transpilation, jsModuleFallback: false, }, inlining: false, http, scenarioPlaceholders, packageSideEffects, }), ]); const rawPluginController = await createPluginController( rawPluginStore, rawKitchen, ); rawKitchen.setPluginController(rawPluginController); const rawRootUrlInfo = rawKitchen.graph.rootUrlInfo; let entryReference; await rawRootUrlInfo.dependencies.startCollecting(() => { entryReference = rawRootUrlInfo.dependencies.found({ trace: { message: `"${sourceRelativeUrl}" from "entryPoints"` }, isEntryPoint: true, type: "entry_point", specifier: sourceRelativeUrl, filenameHint: buildRelativeUrl, }); }); return { entryReference, buildEntryPoint: async ({ getOtherEntryBuildInfo }) => { craft: { _getOtherEntryBuildInfo = getOtherEntryBuildInfo; if (outDirectoryUrl) { await ensureEmptyDirectory(new URL(`craft/`, outDirectoryUrl)); } await rawRootUrlInfo.cookDependencies({ operation: buildOperation }); } const finalKitchen = createKitchen({ name: "shape", logLevel: "warn", rootDirectoryUrl: sourceDirectoryUrl, // here most plugins are not there // - no external plugin // - no plugin putting reference.mustIgnore on https urls // At this stage it's only about redirecting urls to the build directory // consequently only a subset or urls are supported includedProtocols: ["file:", "data:", "virtual:", "ignore:"], ignore, ignoreProtocol: "remove", build: true, runtimeCompat, initialContext: contextSharedDuringBuild, sourcemaps, sourcemapsComment: "relative", sourcemapsSourcesContent, outDirectoryUrl: outDirectoryUrl ? new URL("shape/", outDirectoryUrl) : undefined, packageDirectory, packageDependencies, }); const buildSpecifierManager = createBuildSpecifierManager({ rawKitchen, finalKitchen, logger: createLogger({ logLevel: "warn" }), sourceDirectoryUrl, buildDirectoryUrl, base, assetsDirectory, buildUrlsGenerator, versioning, versioningMethod, versionLength, canUseImportmap: versioningViaImportmap && rawKitchen.graph.getUrlInfo(entryReference.url).type === "html" && rawKitchen.context.isSupportedOnCurrentClients("importmap"), }); const finalPluginStore = await createPluginStore([ jsenvPluginReferenceAnalysis({ ...referenceAnalysis, fetchInlineUrls: false, // inlineContent: false, }), jsenvPluginDirectoryReferenceEffect(directoryReferenceEffect, { rootDirectoryUrl: sourceDirectoryUrl, }), ...(lineBreakNormalization ? [jsenvPluginLineBreakNormalization()] : []), jsenvPluginJsModuleFallback({ remapImportSpecifier: (specifier, parentUrl) => { return buildSpecifierManager.remapPlaceholder(specifier, parentUrl); }, }), jsenvPluginInlining(), { name: "jsenv:optimize", appliesDuring: "build", transformUrlContent: async (urlInfo) => { await rawKitchen.pluginController.callAsyncHooks( "optimizeBuildUrlContent", urlInfo, (optimizeReturnValue) => { urlInfo.mutateContent(optimizeReturnValue); }, ); }, }, buildSpecifierManager.jsenvPluginMoveToBuildDirectory, ]); const finalPluginController = await createPluginController( finalPluginStore, finalKitchen, { initialPuginsMeta: rawKitchen.pluginController.pluginsMeta, }, ); finalKitchen.setPluginController(finalPluginController); bundle: { const bundlerMap = new Map(); for (const plugin of rawKitchen.pluginController.activePlugins) { const bundle = plugin.bundle; if (!bundle) { continue; } if (typeof bundle !== "object") { throw new Error( `bundle must be an object, found "${bundle}" on plugin named "${plugin.name}"`, ); } for (const type of Object.keys(bundle)) { const bundleFunction = bundle[type]; if (!bundleFunction) { continue; } if (bundlerMap.has(type)) { // first plugin to define a bundle hook wins continue; } bundlerMap.set(type, { plugin, bundleFunction: bundle[type], urlInfoMap: new Map(), }); } } if (bundlerMap.size === 0) { break bundle; } const addToBundlerIfAny = (rawUrlInfo) => { const bundler = bundlerMap.get(rawUrlInfo.type); if (bundler) { bundler.urlInfoMap.set(rawUrlInfo.url, rawUrlInfo); } }; // ignore unused urls thanks to "forEachUrlInfoStronglyReferenced" // it avoid bundling things that are not actually used // happens for: // - js import assertions // - conversion to js classic using ?as_js_classic or ?js_module_fallback GRAPH_VISITOR.forEachUrlInfoStronglyReferenced( rawKitchen.graph.rootUrlInfo, (rawUrlInfo) => { if (rawUrlInfo.isEntryPoint) { addToBundlerIfAny(rawUrlInfo); } if (rawUrlInfo.type === "html") { for (const referenceToOther of rawUrlInfo.referenceToOthersSet) { if ( referenceToOther.isResourceHint && referenceToOther.expectedType === "js_module" ) { const referencedUrlInfo = referenceToOther.urlInfo; if ( referencedUrlInfo && // something else than the resource hint is using this url referencedUrlInfo.referenceFromOthersSet.size > 0 ) { addToBundlerIfAny(referencedUrlInfo); continue; } } if (referenceToOther.isWeak) { continue; } const referencedUrlInfo = referenceToOther.urlInfo; if (referencedUrlInfo.isInline) { if (referencedUrlInfo.type !== "js_module") { continue; } addToBundlerIfAny(referencedUrlInfo); continue; } addToBundlerIfAny(referencedUrlInfo); } return; } // File referenced with // - new URL("./file.js", import.meta.url) // - import.meta.resolve("./file.js") // are entry points that should be bundled // For instance we will bundle service worker/workers detected like this if (rawUrlInfo.type === "js_module") { for (const referenceToOther of rawUrlInfo.referenceToOthersSet) { if ( referenceToOther.type === "js_url" || referenceToOther.subtype === "import_meta_resolve" ) { const referencedUrlInfo = referenceToOther.urlInfo; let isAlreadyBundled = false; for (const referenceFromOther of referencedUrlInfo.referenceFromOthersSet) { if (referenceFromOther.url === referencedUrlInfo.url) { if ( referenceFromOther.subtype === "import_dynamic" || referenceFromOther.type === "script" ) { isAlreadyBundled = true; break; } } } if (!isAlreadyBundled) { addToBundlerIfAny(referencedUrlInfo); } continue; } if (referenceToOther.type === "js_inline_content") { // we should bundle it too right? } } } }, ); for (const [, bundler] of bundlerMap) { const urlInfosToBundle = Array.from(bundler.urlInfoMap.values()); if (urlInfosToBundle.length === 0) { continue; } await buildSpecifierManager.applyBundling({ bundler, urlInfosToBundle, }); } } shape: { finalKitchen.context.buildStep = "shape"; if (outDirectoryUrl) { await ensureEmptyDirectory(new URL(`shape/`, outDirectoryUrl)); } const finalRootUrlInfo = finalKitchen.graph.rootUrlInfo; await finalRootUrlInfo.dependencies.startCollecting(() => { finalRootUrlInfo.dependencies.found({ trace: { message: `entryPoint` }, isEntryPoint: true, type: "entry_point", specifier: entryReference.url, }); }); await finalRootUrlInfo.cookDependencies({ operation: buildOperation, }); } const buildSideEffectFiles = []; refine: { finalKitchen.context.buildStep = "refine"; const htmlRefineSet = new Set(); const registerHtmlRefine = (htmlRefine) => { htmlRefineSet.add(htmlRefine); }; replace_placeholders: { await buildSpecifierManager.replacePlaceholders(); } /* * Update <link rel="preload"> and friends after build (once we know everything) * - Used to remove resource hint targeting an url that is no longer used: * - because of bundlings * - because of import assertions transpilation (file is inlined into JS) */ resync_resource_hints: { buildSpecifierManager.prepareResyncResourceHints({ registerHtmlRefine, }); } mutate_html: { GRAPH_VISITOR.forEach(finalKitchen.graph, (urlInfo) => { if (!urlInfo.url.startsWith("file:")) { return; } if (urlInfo.type !== "html") { return; } const htmlAst = parseHtml({ html: urlInfo.content, url: urlInfo.url, storeOriginalPositions: false, }); for (const htmlRefine of htmlRefineSet) { const htmlMutationCallbackSet = new Set(); const registerHtmlMutation = (callback) => { htmlMutationCallbackSet.add(callback); }; htmlRefine(htmlAst, { registerHtmlMutation }); for (const htmlMutationCallback of htmlMutationCallbackSet) { htmlMutationCallback(); } } // cleanup jsenv attributes from html as a last step urlInfo.content = stringifyHtmlAst(htmlAst, { cleanupJsenvAttributes: true, cleanupPositionAttributes: true, }); }); } inject_urls_in_service_workers: { const inject = buildSpecifierManager.prepareServiceWorkerUrlInjection(); if (inject) { await inject(); buildOperation.throwIfAborted(); } } refine_hook: { const refineBuildUrlContentCallbackSet = new Set(); const refineBuildCallbackSet = new Set(); for (const plugin of rawKitchen.pluginController.activePlugins) { const refineBuildUrlContent = plugin.refineBuildUrlContent; if (refineBuildUrlContent) { refineBuildUrlContentCallbackSet.add(refineBuildUrlContent); } const refineBuild = plugin.refineBuild; if (refineBuild) { refineBuildCallbackSet.add(refineBuild); } } if (refineBuildUrlContentCallbackSet.size) { GRAPH_VISITOR.forEachUrlInfoStronglyReferenced( finalKitchen.graph.rootUrlInfo, (buildUrlInfo) => { if (!buildUrlInfo.url.startsWith("file:")) { return; } for (const refineBuildUrlContentCallback of refineBuildUrlContentCallbackSet) { refineBuildUrlContentCallback(buildUrlInfo, { buildUrl: buildSpecifierManager.getBuildUrl(buildUrlInfo), registerBuildSideEffectFile: (buildFileUrl) => { buildSideEffectFiles.push(buildFileUrl); }, }); } }, ); } if (refineBuildCallb