import { sync as globSync } from 'glob'; import * as path from 'path'; import * as cheerio from 'cheerio'; // import * as HtmlWebpackPlugin from 'html-webpack-plugin'; import * as OfflinePlugin from 'offline-plugin'; import { config } from '@erect/core/config'; import { getSourcePath } from '@erect/core/paths'; import { appHref, assetHref, bundleAssetBase, bundleAssetHref } from '@erect/core/shared/uri'; import { createLocation } from '@erect/core/shared/Location'; import { getServeStaticList, getServeStatic } from '@erect/server/serveStatic'; import { webpackConfigFactoryHook } from '../../hooks/webpackConfigFactory'; import { offlineWebpackPluginOptionsHook } from '../../hooks/offlineWebpackPluginOptions'; const hook = webpackConfigFactoryHook.addFilter('offlineServiceWorker', (webpackConfig, { buildOptions: { optimize, target } }) => { // Offline Page generation. // // We use the HtmlWebpackPlugin to produce an "offline" html Layout that // can be used by our service worker (see the OfflinePlugin below) in // order support offline rendering of our application. // We will only create the service worker required Layout if enabled in // config and if we are building the production version of client. if (!optimize || target !== 'client') { return webpackConfig; } if (!config.offlineServiceWorker.enabled) { return webpackConfig; } // We use the offline-plugin to generate the service worker. It also // provides a runtime installation script which gets executed within // the client. // @see https://github.com/NekR/offline-plugin // // This plugin generates a service worker script which as configured below // will precache all our generated client bundle assets as well as our // static "public" folder assets. // // It has also been configured to make use of a HtmlWebpackPlugin // generated "offline" Layout so that users can still used the application // offline. // // Any time our static files or generated bundle files change the user's // caches will be updated. // // We will only include the service worker if enabled in config. const { navigateFallbackURL } = config.offlineServiceWorker; const resolveCache = (acc: string[], url: string) => { if (url === ':htmlPage:') { acc.push(...getHtmlPageFiles()); } else if (url === ':assets:') { acc.push(...getAllAssetFiles()); } else { const special = url.match(/^\:(.+)\:$/); if (special != null) { const asset = special[1].match(/^assets:(.+)$/); if (asset != null) { acc.push(...getAssetFiles(asset[1])); } else if (special[1] === 'rest' || special[1] === 'externals') { acc.push(url); } else { throw new Error(`Unkown special cache option ":${special[1]}:"`); } } else { let fixed = url; if (fixed.substr(0, 2) === '//') { fixed = fixed.replace(/^\/\//, `http${config.ssl.enabled ? 's' : ''}://`); } else if (fixed.substr(0, 2) === './' || fixed.substr(0, 3) === '../') { fixed = appHref(fixed); } acc.push(fixed); } } return acc; }; const getHtmlPageFiles = () => { const htmlPage = config.htmlPage({ nonce: '', location: createLocation(appHref(navigateFallbackURL)), }).toString(); const $ = cheerio.load(htmlPage, { decodeEntities: false }); const selectors = [ 'appHref[appHref]', 'script[src]', ]; const externals: string[] = []; for (const element of $(selectors.join(',')).toArray()) { let external: string; switch (element.tagName) { case 'link': external = $(element).attr('href'); break; case 'script': external = $(element).attr('src'); break; default: throw new Error(`Could not handle "${element.tagName}"`); } if (external.trim() === '') { continue; } let excluded = false; for (const excludeRegExp of config.offlineServiceWorker.htmlPageExternals.exclude) { if (external.match(excludeRegExp)) { excluded = true; break; } } if (excluded) { continue; } resolveCache(externals, external); } return externals; }; const getAssetFiles = (label: string) => { const { localPath, caches, } = getServeStatic(label); const files: string[] = []; for (const pattern of caches) { const publicAssetPathGlob = path.resolve( getSourcePath(), localPath, pattern, ); files.push( // First get all the matching public folder files. ...globSync(publicAssetPathGlob, { nodir: true }) // Then map them to relative paths against the public folder. // We need to do this as we need the "web" paths for each one. .map(publicFile => path.relative( path.resolve( getSourcePath(), localPath, ), publicFile ) ) // Add the leading "/" indicating the file is being hosted // off the root of the application. .map(relativePath => assetHref( label, `./${relativePath}`, ), ), ); } return files; }; const getAllAssetFiles = () => { const files: string[] = []; for (const { label } of getServeStaticList()) { files.push(...getAssetFiles(label)); } return files; }; if (webpackConfig.plugins == null) { webpackConfig.plugins = []; } webpackConfig.plugins.push( (new OfflinePlugin( offlineWebpackPluginOptionsHook.filter({ // Setting this value lets the plugin know where our generated client // assets will be served from. // el.g. /client/ publicPath: bundleAssetBase(), // When using the publicPath we need to disable the "relativePaths" // feature of this plugin. relativePaths: false, // Our offline support will be done via a service worker. // Read more on them here: // http://bit.ly/2f8q7Td ServiceWorker: { // When the user is offline then this html Layout will be used at // the appBase that loads all our cached client scripts. This Layout // is generated by the HtmlWebpackPlugin above, which takes care // of injecting all of our client scripts into the body. // Please see the HtmlWebpackPlugin configuration above for more // information on this Layout. navigateFallbackURL: appHref(navigateFallbackURL), // The name of the service worker script that will get generated. output: `${config.offlineServiceWorker.fileName}.js`, // Enable events so that we can register updates. events: true, // By default the service worker will be output and served from the // publicPath setting above in the root config of the OfflinePlugin. // This means that it would be served from /client/sw.js // We do not want this! Service workers have to be served from the // root of our application in order for them to work correctly. // Therefore we override the publicPath here. The sw.js will still // live in at the /build/client/sw.js output location therefore in // our server configuration we need to make sure that any requests // to /sw.js will serve the /build/client/sw.js file. publicPath: bundleAssetHref(`./${config.offlineServiceWorker.fileName}.js`), }, // According to the Mozilla docs, AppCache is considered deprecated. // @see https://mzl.la/1pOZ5wF // It does however have much wider support compared to the newer // Service Worker specification, so you could consider enabling it // if you needed. AppCache: false, // No need to cache .htaccess. See http://mxs.is/googmp, // this is applied before any match in `caches` section // excludes: [], caches: { main: [ ...(config.polyfillIO.enabled ? [`${config.polyfillIO.url}?features=${config.polyfillIO.features.join(',')}`] : [] ), appHref(navigateFallbackURL), bundleAssetHref('config.js'), ...config.offlineServiceWorker.caches.main.reduce(resolveCache, []), ], // All chunks marked as `additional`, loaded after Main section // and do not prevent SW to install. Change to `optional` if // do not want them to be preloaded at all (cached only when first loaded) optional: [ ...config.offlineServiceWorker.caches.optional.reduce(resolveCache, []), ], additional: [ ...config.offlineServiceWorker.caches.additional.reduce(resolveCache, []), ], }, excludes: [ bundleAssetHref(`${config.bundles.client.vendorDll.fileName}.lock`), ...config.offlineServiceWorker.excludes.reduce(resolveCache, []), ], // Which external files should be included with the service worker? // Add the polyfill io script as an external if it is enabled. externals: [ ...config.offlineServiceWorker.externals, ], }, {}), )), ); return webpackConfig; }); if (process.env.BUILD_FLAG_IS_DEV === 'true' || !config.offlineServiceWorker.enabled) { hook.disable(); }