UNPKG

apostrophe

Version:
1,241 lines (1,181 loc) • 49.2 kB
const path = require('path'); const fs = require('fs-extra'); const express = require('express'); const Promise = require('bluebird'); const { stripIndent } = require('common-tags'); const { createId } = require('@paralleldrive/cuid2'); const chokidar = require('chokidar'); const _ = require('lodash'); const { glob } = require('./lib/path'); const globalIcons = require('./lib/globalIcons'); const { checkModulesWebpackConfig, getWebpackExtensions, fillExtraBundles, transformRebundledFor } = require('./lib/webpack/utils'); module.exports = { options: { alias: 'asset', // If true, create both modern JavaScript and ES5 (IE11) compatibility // builds of the ui/src browser code in each module, and serve them // to the appropriate browsers without overhead for modern browsers. // This does not attempt to compile the admin UI (ui/apos) for ES5. es5: false, // If this option is true and process.env.NODE_ENV is not `production`, // the browser will refresh when the Apostrophe application // restarts. A useful companion to `nodemon`. refreshOnRestart: false, // If false no UI assets sources will be watched in development. // This option has no effect in production (watch disabled). watch: true, // Milliseconds to wait between asset sources changes before // performing a build. watchDebounceMs: 1000, // Object containing instructions for remapping existing bundles. // See the modulre reference documentation for more information. rebundleModules: undefined, // In case of external front end like Astro, this option allows to // disable the build of the public UI assets. publicBundle: true, // Breakpoint preview in the admin UI. // NOTE: the whole breakpointPreviewMode option must be carried over // to the project for overrides to work properly. // Nested object options are not deep merged in Apostrophe. breakpointPreviewMode: { // Enable breakpoint preview mode enable: true, // Warn during build about unsupported media queries. debug: false, // If we can resize the preview container? resizable: false, // Screens with icons // For adding icons, please refer to the icons documentation // https://docs.apostrophecms.org/reference/module-api/module-overview.html#icons screens: { desktop: { label: 'apostrophe:breakpointPreviewDesktop', width: '1440px', height: '900px', icon: 'monitor-icon', shortcut: true }, tablet: { label: 'apostrophe:breakpointPreviewTablet', width: '1024px', height: '768px', icon: 'tablet-icon', shortcut: true }, mobile: { label: 'apostrophe:breakpointPreviewMobile', width: '414px', height: '896px', icon: 'cellphone-icon', shortcut: true } }, // Transform method used on media feature // Can be either: // - (mediaFeature) => { return mediaFeature.replaceAll('xx', 'yy'); } // - null transform: null }, // If true, the source maps will be generated in production. // This option is useful for debugging in production productionSourceMaps: false, // By default, productionSourceMaps: true will push the source maps live right // alongside the bundle files, allowing sources to be inspected in production. // If you would like to send your production sourcemaps somewhere else, // specify productionSourceMapsDir. After that, doing something with the // sourcemaps is entirely up to you productionSourceMapsDir: null, // The configuration to control the development server and HMR when // supported. The value can be: - boolean `false`: disable the dev server and // HMR. - boolean `true`: same as `public` (default). - string `public`: // serve only the source files from the `ui/src` folder. - string `apos`: // serve only the admin UI files from the `ui/apos` folder. hmr: 'public', // Force the HMR WS port when it operates on the same process as Apostrophe. // Most of the time you won't need to change this. hmrPort: null, // Let the external build module inject a pollyfill for the module preload, // adding the `modulepreload` support for the browsers that don't support // it. Can be disabled in e.g. external front-ends. modulePreloadPolyfill: true, // Completely disable the asset runtime auto-build system. // When an external build module is registered, only manifest data // will be loaded and no build will be executed. autoBuild: true }, async init(self) { // Cache the environment variables, because Node.js doesn't makes // system calls to get them every time. We don't expect them to change // during the runtime. self.isDebugModeCached = process.env.APOS_ASSET_DEBUG === '1'; self.isDevModeCached = process.env.NODE_ENV !== 'production'; self.isProductionModeCached = process.env.NODE_ENV === 'production'; // External build module configuration (when registered). // See method `configureBuildModule` for more information. self.externalBuildModuleConfig = {}; self.restartId = self.apos.util.generateId(); self.iconMap = { ...globalIcons }; // Used throughout the build process, cached forever here. self.modulesToBeInstantiated = await self.apos.modulesToBeInstantiated(); // The namespace filled by `configureBuilds()` self.builds = {}; self.configureBuilds(); // The namespace filled by `initUploadfs()` self.uploadfs = null; await self.initUploadfs(); self.enableBrowserData(); // The namespace filled by `setWebpackExtensions()` (`webpack` property // processing). self.extraBundles = {}; self.webpackExtensions = {}; self.webpackExtensionOptions = {}; self.verifiedBundles = {}; self.rebundleModules = []; await self.setWebpackExtensions(); // The namespace filled by `setBuildExtensions()` (`build` property). // The properties above set by `setWebpackExtensions()` will be also // overridden. self.moduleBuildExtensions = {}; await self.setBuildExtensions(); // Set only if the external build module is registered. Contains // the entrypoints configuration for the current build module. self.moduleBuildEntrypoints = []; // Set after a successful build. It contains only the processed entrypoints // with attached `bundles` property containing the bundle files. This can be // later called by the systems that are injecting the scripts and // stylesheets in the browser. We also need this as a separate property for // possible server side hot module replacement scenarios. self.currentBuildManifest = { sourceMapsRoot: null, devServerUrl: null, entrypoints: [] }; // Adapt the options to not contradict each other. if (self.options.hmr === true) { self.options.hmr = 'public'; } // do not allow the dev server value contradicting the `publicBundle` option if (!self.options.publicBundle && self.options.hmr === 'public') { self.options.hmr = false; } // Initial build options, will be updated during the build process. self.currentBuildOptions = { isTask: null, hmr: self.options.hmr, devServer: self.options.hmr }; self.buildWatcherEnable = process.env.APOS_ASSET_WATCH !== '0' && self.options.watch !== false; self.buildWatcherDebounceMs = parseInt(self.options.watchDebounceMs || 1000, 10); self.buildWatcher = null; // A signal that the build data has been initialized and all modules // have been registered. await self.emit('afterInit'); }, handlers (self) { return { 'apostrophe:modulesRegistered': { async runUiBuildTask() { const ran = await self.autorunUiBuildTask(); // When the build is not executed, make the minimum required // computations to ensure we can inject the assets in the browser. if (!ran) { await self.runWithoutBuild(); } if (ran) { await self.watchUiAndRebuild(); } }, injectAssetsPlaceholders() { self.apos.template.prepend('head', '@apostrophecms/asset:stylesheets'); self.apos.template.append('body', '@apostrophecms/asset:scripts'); } }, 'apostrophe:destroy': { async destroyUploadfs() { if (self.uploadfs && (self.uploadfs !== self.apos.uploadfs)) { await Promise.promisify(self.uploadfs.destroy)(); } }, async destroyBuildWatcher() { if (self.buildWatcher) { await self.buildWatcher.close(); self.buildWatcher = null; } } } }; }, components(self) { return { scripts(req, data) { const placeholder = `[scripts-placeholder:${createId()}]`; req.scriptsPlaceholder = placeholder; return { placeholder }; }, stylesheets(req, data) { const placeholder = `[stylesheets-placeholder:${createId()}]`; req.stylesheetsPlaceholder = placeholder; return { placeholder }; } }; }, tasks(self) { const webpackBuild = require('./lib/build/task')(self); return { build: { usage: 'Build Apostrophe frontend CSS and JS bundles', afterModuleInit: true, async task(argv = {}) { self.inBuildTask = true; if (self.hasBuildModule()) { return self.build(argv); } // Debugging but only if we don't have an external build module. // If we do, the debug output is handled by the respective setter. self.printDebug('setWebpackExtensions', { builds: self.builds, extraBundles: self.extraBundles, webpackExtensions: self.webpackExtensions, webpackExtensionOptions: self.webpackExtensionOptions, verifiedBundles: self.verifiedBundles, rebundleModules: self.rebundleModules }); return webpackBuild.task(argv); } }, 'clear-cache': { usage: 'Clear build cache', afterModuleReady: true, async task(argv) { if (self.hasBuildModule()) { await self.getBuildModule().clearCache(); } await fs.emptyDir(self.getCacheBasePath()); self.apos.util.log( self.apos.task.getReq().t('apostrophe:assetBuildCacheCleared') ); } }, reset: { usage: 'Clear all build artifacts, public folders (without release) and cache', afterModuleReady: true, async task(argv) { if (self.hasBuildModule()) { await self.getBuildModule().clearCache(); await self.getBuildModule().reset(); } await fs.emptyDir(self.getCacheBasePath()); await fs.emptyDir(self.getBundleRootDir()); self.apos.util.log( self.apos.task.getReq().t('apostrophe:assetBuildCacheCleared') ); } } }; }, middleware(self) { return { serveStaticAssets: { before: '@apostrophecms/express', middleware: express.static(self.apos.rootDir + '/public', self.options.static || {}) } }; }, methods(self) { return { // Public API for external build modules. ...require('./lib/build/external-module-api')(self), // Internals ...require('./lib/build/internals')(self), // A helper to detect re-bundled and moved to the main build bundles // by module name. Open the implementation for more details. transformRebundledFor, isDevMode() { return self.isDevModeCached; }, isDebugMode() { return self.isDebugModeCached; }, isProductionMode() { return self.isProductionModeCached; }, // External build modules can register themselves here. This should // happen on the special asset module event `afterInit` (see `handlers`). // This module will also detect if a system watcher should be disabled // depending on the asset module configuration and the external build // module capabilities. // // options: // - alias (required): the alias string to use in the webpack // configuration. This usually matches the bundler name, e.g. 'vite', // 'webpack', etc. - devServer (optional): whether the build module // supports a dev server. - hmr (optional): whether the build module // supports a hot module replacement. // // The external build module should initialize before the asset module: // module.exports = { // before: '@apostrophecms/asset', // ... // }; // // The external build module must implement various methods to be used by // the Apostrophe core // // ** `async build(options)`: the build method to be called by // Apostrophe. // // Accepts build `options`, see `getBuildOptions()` for more information. // Returns an object with properties: // * `entrypoints`(required, array of objects), containing all // entrypoints that are processed by the build module. If a build is // performed, `bundles` (`Set`) property will be added by the core asset // module to each processed entrypoint. It contains the bundle files that // are created by the post-build system. Beside the standard entrypoint // shape (see `getBuildEntrypoints()`), each entrypoint should also // contain a `manifest` object. // // `manifest` properties: // - `root` - the relative to `apos.asset.getBuildRootDir()` path to the // folder containing all files described in the manifest. - `files` - // object which properties are: - `js` - an array of paths to the JS // files. The only JS files that are getting bundled by scene. It's // usually the main entrypoint file. It's an array to allow additional // files for bundling. - `css` - an array of paths to the CSS files. // Available only when the build manifest is available. They are bundled // by scene. - `imports` - an array of paths to the statically imported // (shared) JS files. These are copied to the bundle folder and released. // They will be inserted into the HTML with `rel="modulepreload"` // attribute. - `dynamicImports` - an array of paths to the dynamically // imported JS files. These are copied to the bundle folder and released, // but not inserted into the HTML. - `assets` - an array of paths to the // assets (images, fonts, etc.) used by the entrypoint. These should be // copied to the bundle folder and released and are not inserted into the // HTML. - `src` - an object with keys corresponding to the input // extension and values array of relative URL path to the entry source // file that should be served by the dev server. Currently only `js` key // is supported. Can be null (e.g. for `ui/public`). Usually contains the // main entrypoint file. - `devServer` - boolean if the entrypoint should // be served by the dev server. * `sourceMapsRoot` (optional, string or // null) the absolute path to the location when source maps are stored. // They will be copied to the bundle folder with the folder same // structure. // // ** `async startDevServer(options)`: the method to start the dev // server. // // It's called only if the module supports dev server and HMR, `hmr` is // enabled and if it's appropriate to start the dev server (development // mode, not a task, etc.). Accepts the same options as the `build` // method. Returns the same object as the `build` method with additional // development related properties: * `devServerUrl` (optional, string or // null) the base server URL for the dev server when available. * // `hmrTypes` (optional, array of strings) the entrypoint types that are // currently served with HMR. * `ts` (optional, number) the timestamp ms // of the last `apos` build. This number will be written to the // `.manifest.json` file to allow the external build module to optimize // the build time based on the last build change. The module can retrieve // both `getSystemLastChangeMs()` and `loadSavedBuildManifest()` methods // to perform a cache check. // // ** `async watch(watcher, options)`: the method to attach the watcher // to the external build module. // // It's called only if the module supports hmr, `hmr` is enabled and // if it's appropriate to start the watcher (development mode, not a // task, watch enabled etc.). The watcher is a chokidar instance that // watches the original module source files. The method should attach the // necessary listeners to the watcher. Returns void. // // ** `async entrypoints(options)`: the method to retrieve the // entrypoints configuration. // // It's called only if no build is executed and the external build module // is registered. The method should return the enhanced entrypoints in the // same format as the `build` method returns. The module is allowed // // ** `async clearCache()`: clear the internal bundler cache. // // This method is called when the `@apostrophecms/asset:clear-cache` task // is executed. IMPORTANT: The directory that the module uses for bundler // caching (usually in `data/temp`) should include the current namespace // (`apos.asset.getNamespace()`) and build module alias to avoid conflicts // with other modules and multisite environments. // // ** `async reset()`: Clear all build artifacts (including manifest). // // Called when the `@apostrophecms/asset:reset` task is executed. // configureBuildModule(moduleSelf, options = {}) { const name = moduleSelf.__meta.name; if (self.hasBuildModule()) { throw new Error( `Module ${name} is attempting to register as a build module, ` + `but ${self.getBuildModuleConfig().name} is already registered.` ); } if (!options.alias) { throw new Error( `Module ${name} is attempting to register as a build module, but no alias is provided.` ); } self.externalBuildModuleConfig = { name, alias: options.alias, devServer: options.devServer, hmr: options.hmr }; self.setBuildExtensionsForExternalModule(); // We can now query if the build module is going to perform HRM // and thus if it's safe to explicitly disable the watcher. It's early // enough to do that here. if (!self.hasHMR()) { self.buildWatcherEnable = false; } }, // Available after external build module is registered. // The internal module property should never be used directly nor // modified after the initialization. getBuildModuleConfig() { return self.externalBuildModuleConfig; }, hasBuildModule() { return !!self.getBuildModuleConfig().name; }, getBuildModule() { return self.apos.modules[self.getBuildModuleConfig().name]; }, getBuildModuleAlias() { return self.getBuildModuleConfig().alias; }, hasDevServer() { return self.hasBuildModule() && self.isDevMode() && self.options.hmr !== false && self.buildWatcherEnable && !!self.getBuildModuleConfig().devServer; }, hasHMR() { return self.hasDevServer() && !!self.getBuildModuleConfig().hmr; }, // `argv` is the arguments passed to the original build task // - `check-apos-build` is a boolean flag (or undefined) that indicates // if build checks should be performed. `false` means that the build // should be executed as a task (no checks are performed). `true` means // that the build should be executed with a cached mechanisms for `apos`. // - `changes` is an array of changed files. This is reserved for the // legacy build system and should never appear in the external build // module. // // Returns an object: // - `isTask`: if `true`, the build is executed as a task. If false // optimization can be applied (e.g. build apostrophe admin UI only // once). - `hmr`: if `true`, the hot module replacement is enabled. - // `hmrPort`: the port for the HMR WS server. If not set, the default port // is used. - `devServer`: if `false`, the dev server is disabled. // Otherwise, it's a string (enum) `public` or `apos`. Note that if `hmr` // is disabled, the dev server will be always `false`. - // `modulePreloadPolyfill`: if `true`, a module preload polyfill is // injected. - `types`: optional array, if present it represents the only // entrypoint types (entrypoint.type) that should be built. - // `sourcemaps`: if `true`, the source maps are generated in production. - // `postcssViewportToContainerToggle`: the configuration for the // breakpoint preview plugin. // // Note that this getter depends on the current build task arguments. You // shouldn't use that directly. getBuildOptions(argv) { if (!argv) { // assuming not a task, legacy stuff argv = { 'check-apos-build': true }; } const options = { isTask: !argv['check-apos-build'], hmr: self.hasHMR(), hmrPort: self.options.hmrPort, modulePreloadPolyfill: self.options.modulePreloadPolyfill, sourcemaps: self.options.productionSourceMaps, postcssViewportToContainerToggle: { enable: self.options.breakpointPreviewMode?.enable === true, debug: self.options.breakpointPreviewMode?.debug === true, modifierAttr: 'data-breakpoint-preview-mode', transform: self.options.breakpointPreviewMode?.transform } }; options.devServer = !options.isTask && self.hasDevServer() ? self.options.hmr : false; // Skip prebundled UI and keep only the apos scenes. if (!self.options.publicBundle) { options.types = [ 'apos', 'index' ]; } return options; }, // Build the assets using the external build module. // The `argv` object is the `argv` object passed to the original build // task. See getBuildOptions() for more information. async build(argv) { const buildOptions = self.getBuildOptions(argv); self.currentBuildOptions = buildOptions; // Copy all `/public` folders from the modules to the bundle root. await self.copyModulesFolder({ target: path.join(self.getBundleRootDir(), 'modules'), folder: 'public', modules: self.getRegisteredModules() }); // Switch to dev server mode if the dev server is enabled. if (buildOptions.devServer) { await self.startDevServer(buildOptions); } else { self.currentBuildManifest = await self.getBuildModule() .build(buildOptions); } // Create and copy bundles (`-bundle.xxx` files) per scene into the // bundle root. const bundles = await self.computeBuildScenes(self.currentBuildManifest); // Retrieve `/modules` public assets from the bundle root for // deployment. const publicAssets = await glob('modules/**/*', { cwd: self.getBundleRootDir(), nodir: true, follow: false, absolute: false }); // Copy the static & dynamic imports and file assets to the bundle root. const deployableArtefacts = await self.copyBuildArtefacts( self.currentBuildManifest ); // Save the build manifest in the bundle root. await self.saveBuildManifest(self.currentBuildManifest); // Deploy everything to the release location. // All paths are relative to the bundle root. const deployFiles = [ ...new Set( [ ...publicAssets, ...bundles, ...deployableArtefacts ] ) ]; if (self.options.productionSourceMaps && !self.options.productionSourceMapsDir) { deployFiles.push(...bundles.map(bundle => `${bundle}.map`)); } await self.deploy(deployFiles); self.printDebug('build-end', { currentBuildManifest: self.currentBuildManifest, deployFiles }); }, async startDevServer(buildOptions) { self.currentBuildManifest = await self.getBuildModule() .startDevServer(buildOptions); }, // A before hook for the new `watch` and the legacy `watchUiAndRebuild` // methods. Extend to apply custom logic before the watch system is // started. It is called after a custom watcher is (optionally) registered // and before an internal watcher is instantiated (when appropriate). async beforeWatch() { // Do nothing by default. }, // The new watch system, works only when an external build module is // registered. This method is invoked only when appropriate. The watcher // will trigger changes with paths relative to the `apos.npmRootDir`. async watch() { await self.beforeWatch(); if (!self.buildWatcher) { const watchDirs = await self.computeWatchFolders(); self.buildWatcher = chokidar.watch(watchDirs, { cwd: self.apos.npmRootDir, ignoreInitial: true, ignored: self.ignoreWatchLocation }); } // Log the initial watch message let loggedOnce = false; const logOnce = (...msg) => { if (!loggedOnce) { self.apos.util.log(...msg); loggedOnce = true; } }; // Allow the module to add more paths, attach listeners, etc. self.buildWatcher .on('error', e => self.apos.util.error(`Watcher error: ${e}`)) .on('ready', () => logOnce( self.apos.task.getReq().t('apostrophe:assetBuildWatchStarted') )); await self.getBuildModule().watch(self.buildWatcher, self.getBuildOptions()); }, // https://github.com/paulmillr/chokidar?tab=readme-ov-file#path-filtering // Override to ignore files/folders from the watch. The method is called // twice: - once with only the `file` parameter - once with both `file` // and `fsStats` parameters The `file` is the relative to the // `apos.rootDir` path to the file or folder. The `fsStats` is the // `fs.Stats` object. Return `true` to ignore the file/folder. ignoreWatchLocation(file, fsStats) { return false; }, // Run the minimal required computations to ensure manifest data is // available. async runWithoutBuild() { if (!self.hasBuildModule()) { return; } // Hydrate the entrypoints with the saved manifest data and // set the current build manifest data. const buildOptions = self.getBuildOptions(); const entrypoints = await self.getBuildModule().entrypoints(buildOptions); // Command line tasks other than the asset build task do not display a // warning if there is no manifest from a previous build attempt as they // do not depend on the manifest to succeed const silent = self.apos.isTask() && !self.inBuildTask; const { manifest = [], devServerUrl, hmrTypes } = await self.loadSavedBuildManifest(silent); self.currentBuildManifest.devServerUrl = devServerUrl; self.currentBuildManifest.hmrTypes = hmrTypes ?? []; for (const entry of manifest) { const entrypoint = entrypoints.find((e) => e.name === entry.name); // It is possible that we run without build in development mode // when we are in a "shared" mode (e.g. use external vite instance). if (!entrypoint) { continue; } entrypoint.manifest = entry; if (!entrypoint.bundles) { entrypoint.bundles = new Set(entry.bundles ?? []); } self.currentBuildManifest.entrypoints.push({ ...entrypoint, manifest: entry }); } }, // Print a debug structured log only when asset debug mode is enabled // by environment variable (because it's too chatty!). printDebug(id, ...rest) { if (self.isDebugMode()) { self.logDebug(id, ...rest); } }, // The method is called only if no external build module is registered. // Compute and set the `webpack` data provided by the modules. async setWebpackExtensions(result) { const { extensions = {}, extensionOptions = {}, verifiedBundles = {}, rebundleModules = {} } = await getWebpackExtensions({ getMetadata: self.apos.synth.getMetadata, modulesToInstantiate: self.getRegisteredModules(), rebundleModulesConfig: self.options.rebundleModules }); // For testing purposes, we can pass a result object if (result) { Object.assign(result, { extensions, extensionOptions, verifiedBundles, rebundleModules }); } self.extraBundles = fillExtraBundles(verifiedBundles); self.webpackExtensions = extensions; self.webpackExtensionOptions = extensionOptions; self.verifiedBundles = verifiedBundles; self.rebundleModules = rebundleModules; }, // Optional functions passed to webpack's mergeWithCustomize, allowing // fine control over merging of the webpack configuration for the // src build. Extend or override to alter the default behavior. // See https://github.com/survivejs/webpack-merge#mergewithcustomize-customizearray-customizeobject-configuration--configuration srcCustomizeArray(a, b, key) { // Keep arrays unique when merging if ( [ 'resolveLoader.extensions', 'resolveLoader.modules', 'resolve.extensions', 'resolve.modules' ].includes(key) ) { return _.uniq([ ...a, ...b ]); } }, srcCustomizeObject(a, b, key) { // override to alter the default webpack merge behavior }, async initUploadfs() { if (self.options.uploadfs) { self.uploadfs = await self.apos.modules['@apostrophecms/uploadfs'].getInstance(self.options.uploadfs); } else { self.uploadfs = self.apos.uploadfs; } }, stylesheetsHelper(when) { return ''; }, scriptsHelper(when) { return ''; }, shouldRefreshOnRestart() { return self.options.refreshOnRestart && (process.env.NODE_ENV !== 'production'); }, // Returns a unique identifier for the current version of the // codebase (the current release). Checks for a release-id file // (ideal for webpack which can create such a file in a build step), // HEROKU_RELEASE_VERSION (for Heroku), PLATFORM_TREE_ID (for // platform.sh), APOS_RELEASE_ID (for custom cases), a directory component // containing at least YYYY-MM-DD (for stagecoach), and finally the git // hash, if the project root is a git checkout (useful when debugging // production builds locally, and some people do deploy this way). // // If none of these are found, throws an error demanding that // APOS_RELEASE_ID or release-id be set up. // // TODO: auto-detect more cases, such as Azure app service. In the // meantime you can set APOS_RELEASE_ID from whatever you have before // running Apostrophe. // // The identifier should be reasonably short and must be URL-friendly. It // must be available both when running the asset build task and when // running the site. getReleaseId() { const viaEnv = process.env.APOS_RELEASE_ID || process.env.HEROKU_RELEASE_VERSION || process.env.PLATFORM_TREE_ID; if (viaEnv) { return viaEnv; } try { return fs.readFileSync(`${self.apos.rootDir}/release-id`, 'utf8').trim(); } catch (e) { // OK, consider fallbacks instead } const realPath = fs.realpathSync(self.apos.rootDir); // Stagecoach and similar: find a release timestamp in the path and use // that const matches = realPath.match(/\/(\d\d\d\d-\d\d-\d\d[^/]+)/); if (matches) { return matches[1]; } try { const fromGit = require('child_process').execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); return fromGit; } catch (e) { throw new Error(stripIndent` When running in production you must set the APOS_RELEASE_ID environment variable to a short, unique string identifying this particular release of the application, or write it to the file release-id. Apostrophe will also autodetect HEROKU_RELEASE_VERSION, PLATFORM_TREE_ID or the current git commit if your deployment is a git checkout. `); } }, // Can be overridden to namespace several asset bundles // in a single codebase. // // Env var option is for unit testing only getNamespace() { return process.env.APOS_DEBUG_NAMESPACE || 'default'; }, getAssetBaseUrl() { const namespace = self.getNamespace(); if (self.isProductionMode()) { const releaseId = self.getReleaseId(); const releaseDir = `/apos-frontend/releases/${releaseId}/${namespace}`; if (process.env.APOS_UPLOADFS_ASSETS) { return `${self.uploadfs.getUrl()}${releaseDir}`; } else { return releaseDir; } } return `/apos-frontend/${namespace}`; }, getCacheBasePath() { return process.env.APOS_ASSET_CACHE || path.join(self.apos.rootDir, 'data/temp/webpack-cache'); }, // Override to set externally a build watcher (a `chokidar` instance). // This method will be invoked only if/when needed. // Example: // ```js // registerBuildWatcher() { // self.buildWatcher = chokidar.watch(pathsToWatch, { // cwd: self.apos.rootDir, // ignoreInitial: true // }); // } // ``` registerBuildWatcher() { self.buildWatcher = null; }, // Run build task automatically when appropriate. // If `changes` is provided (array of modified files/folders, relative // to the application root), this method will return the result of the // build task (array of builds that have been triggered by the changes). // If `changes` is not provided (falsy value), a boolean will be returned, // indicating if the build task has been invoked or not. // IMPORTANT: Be cautious when changing the return type behavior. // The build watcher initialization (event triggered) depends on a // Boolean value, and the rebuild handler (triggered by the build watcher // on detected change) depends on an Array value. Returns boolean if // `changes` is not provided, otherwise an array of builds that were // triggered by the changes. async autorunUiBuildTask(changes) { let result = changes ? [] : false; let _changes; if ( // Do not automatically build the UI if we're starting from a task !self.apos.isTask() && // Or if we're in production process.env.NODE_ENV !== 'production' && // Or if we've set an app option to skip the auto build self.apos.options.autoBuild !== false ) { // Only when a legacy build is in play. if (!self.hasBuildModule()) { checkModulesWebpackConfig(self.apos.modules, self.apos.task.getReq().t); } // If starting up normally, run the build task, checking if we // really need to update the apos build if (changes) { // Important: don't pass empty string, it will cause the task // to enter selective build mode and do nothing. Undefined is OK. _changes = changes.join(','); } const buildsTriggered = await self.apos.task.invoke('@apostrophecms/asset:build', { 'check-apos-build': true, changes: _changes }); result = _changes ? buildsTriggered : true; } return result; }, // The rebuild handler triggered (debounced) by the build watcher. // The `changes` argument is a reference to a central pool of changes. // It contains relative to the application root file paths. // The pool is being exhausted before triggering the build task. // Array manipulations are sync only, so no race condition is possible. // `rebuildCallback` is used for testing and debug purposes. It allows // access to the changes processed by the build task, // the new restartId and the build names that the changes have triggered. // This handler has no watcher dependencies and it's safe to be invoked // by any code base. async rebuild(changes, rebuildCallback) { rebuildCallback = typeof rebuildCallback === 'function' ? rebuildCallback : () => {}; const result = { changes: [], restartId: self.restartId, builds: [] }; const pulledChanges = []; let change = changes.pop(); while (change) { // Fix windows paths pulledChanges.push(change.replace(/\\/g, '/')); change = changes.pop(); } // No changes - should never happen. if (pulledChanges.length === 0) { return rebuildCallback(result); } try { const buildsTriggered = await self.autorunUiBuildTask(pulledChanges); if (buildsTriggered.length > 0) { self.restartId = self.apos.util.generateId(); } return rebuildCallback({ changes: pulledChanges, restartId: self.restartId, builds: buildsTriggered }); } catch (e) { // The build error is detailed enough, no message // on our end. self.apos.util.error(e); } rebuildCallback(result); }, // Start watching assets from `modules/` and // every symlinked package found in `node_modules/`. // `rebuildCallback` is invoked with queue length argument // on actual build attempt only. // It's there mainly for testing and debugging purposes. async watchUiAndRebuild(rebuildCallback) { if (!self.buildWatcherEnable) { return; } // Allow custom watcher registration self.registerBuildWatcher(); if (self.hasBuildModule()) { return self.watch(rebuildCallback); } // A before hook, after the watcher is registered. await self.beforeWatch(); if (!self.buildWatcher) { // Whach the entire `module-name/ui` folder now, but only // for the registered modules that have one. // Also add support for ignoring the watch location - same // as the external build module system. const watchDirs = await self.computeWatchFolders(); const rootDir = self.apos.rootDir; self.buildWatcher = chokidar.watch(watchDirs, { cwd: rootDir, ignoreInitial: true, ignored: self.ignoreWatchLocation }); } // chokidar may invoke ready event multiple times, // we want one "watch enabled" message. let loggedOnce = false; const logOnce = (...msg) => { if (!loggedOnce) { self.apos.util.log(...msg); loggedOnce = true; } }; const error = self.apos.util.error; const queue = []; let queueLength = 0; let queueRunning = false; // The pool of changes - it HAS to be exhausted by the rebuild handler // or we'll end up with a memory leak in development. const changesPool = []; const debounceRebuild = _.debounce(chain, self.buildWatcherDebounceMs, { leading: false, trailing: true }); const addChangeAndDebounceRebuild = (fileOrDir) => { changesPool.push(fileOrDir); return debounceRebuild(); }; self.buildWatcher .on('add', addChangeAndDebounceRebuild) .on('change', addChangeAndDebounceRebuild) .on('unlink', addChangeAndDebounceRebuild) .on('addDir', addChangeAndDebounceRebuild) .on('unlinkDir', addChangeAndDebounceRebuild) .on('error', e => error(`Watcher error: ${e}`)) .on('ready', () => logOnce( self.apos.task.getReq().t('apostrophe:assetBuildWatchStarted') )); // Simple, capped, self-exhausting queue implementation. function enqueue(fn) { if (queueLength === 2) { return; } queue.push(fn); queueLength++; }; async function dequeue() { if (!queueLength) { queueRunning = false; return; } queueRunning = true; await queue.pop()(changesPool, rebuildCallback); queueLength--; await dequeue(); } async function chain() { enqueue(self.rebuild); if (!queueRunning) { await dequeue(); } } }, // An implementation method that you should not need to call. // Sets a predetermined configuration for the frontend builds. // If you are trying to enable IE11 support for ui/src, use the // `es5: true` option (es5 builds are disabled by default). configureBuilds() { self.srcPrologue = stripIndent` (function() { window.apos = window.apos || {}; var data = document.body && document.body.getAttribute('data-apos'); Object.assign(window.apos, JSON.parse(data || '{}')); if (data) { document.body.removeAttribute('data-apos'); } if (window.apos.modules) { for (const module of Object.values(window.apos.modules)) { if (module.alias) { window.apos[module.alias] = module; } } } })(); `; self.builds = { src: { scenes: [ 'apos' ], webpack: true, outputs: [ 'css', 'js' ], label: 'apostrophe:modernBuild', // Load index.js and index.scss from each module index: true, // Load only in browsers that support ES6 modules condition: 'module', prologue: self.srcPrologue, // The new `type` option used in the entrypoint configuration type: 'index', // The new optional configuration option for the allowed input file // extensions inputs: [ 'js', 'scss' ] }, apos: { scenes: [ 'apos' ], outputs: [ 'js' ], webpack: true, label: 'apostrophe:apostropheAdminUi', // Only rebuilt on npm updates unless APOS_DEV is set in the // environment to indicate that the dev writes project level or npm // linked admin UI code of their own which might be newer than // package-lock.json apos: true, prologue: stripIndent` import 'Modules/@apostrophecms/ui/scss/global/import-all.scss'; import emitter from 'tiny-emitter/instance'; window.apos.bus = { $on: (...args) => emitter.on(...args), $once: (...args) => emitter.once(...args), $off: (...args) => emitter.off(...args), $emit: (...args) => emitter.emit(...args) };`, // Load only in browsers that support ES6 modules condition: 'module', type: 'apos' } // We could add an apos-ie11 bundle that just pushes a "sorry // charlie" prologue, if we chose }; if (self.options.publicBundle) { self.builds.public = { scenes: [ 'public', 'apos' ], outputs: [ 'css', 'js' ], label: 'apostrophe:rawCssAndJs', // Just concatenates webpack: false, type: 'bundled' }; self.builds.src.scenes.push('public'); } }, // Filter the given css performing any necessary transformations, // such as support for the /modules path regardless of where // static assets are actually deployed filterCss(css, { modulesPrefix }) { return self.filterCssUrls(css, url => { if (url.startsWith('/modules')) { return url.replace('/modules', modulesPrefix); } return url; }); }, // Run all URLs in CSS through a filter function filterCssUrls(css, filter) { css = css.replace(/url\(([^'"].*?)\)/g, function(s, url) { return 'url(' + filter(url) + ')'; }); css = css.replace(/url\("([^"]+?)"\)/g, function(s, url) { return 'url("' + filter(url) + '")'; }); css = css.replace(/url\('([^']+?)'\)/g, function(s, url) { return 'url(\'' + filter(url) + '\')'; }); return css; }, // Return the URL of the asset with the given path, taking into account // the release id, uploadfs, etc. url(path) { return `${self.getAssetBaseUrl()}${path}`; }, devServerUrl(path) { return `${self.getDevServerUrl()}${path}`; } }; }, helpers(self) { return { stylesheets: function (when) { return self.stylesheetsHelper(when); }, scripts: function (when) { return self.scriptsHelper(when); }, refreshOnRestart() { if (!self.shouldRefreshOnRestart()) { return ''; } const prefix = self.apos.prefix || ''; return self.apos.template.safe( `<script data-apos-refresh-on-restart="${prefix}${self.action}/restart-id" ` + `src="${prefix}${self.action}/refresh-on-restart"></script>` ); }, // Return the URL of the release asset with the given path, taking into // account the release id, uploadfs, etc. url(path) { return self.url(path); }, devServerUrl(path) { return self.devServerUrl(path); } }; }, apiRoutes(self) { if (!self.shouldRefreshOnRestart()) { return; } return { get: { refreshOnRestart(req) { req.res.setHeader('content-type', 'text/javascript'); return fs.readFileSync(path.join(__dirname, '/lib/refresh-on-restart.js'), 'utf8'); } }, // Use a POST route so IE11 doesn't cache it post: { async restartId(req) { // Long polling: keep the logs quiet by responding slowly, except the // first time. If we restart, the request will fail immediately, // and the client will know to try again with `fast`. The client also // uses `fast` the first time. if (req.query.fast) { return self.restartId; } // Long polling will be interrupted if restartId changes. let delay = 30000; const step = 300; const oldRestartId = self.restartId; while (delay > 0 && oldRestartId === self.restartId) { delay -= step; await Promise.delay(step); } return self.restartId; } } }; } };