inject-manifest.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. /*
  2. Copyright 2018 Google LLC
  3. Use of this source code is governed by an MIT-style
  4. license that can be found in the LICENSE file or at
  5. https://opensource.org/licenses/MIT.
  6. */
  7. import {escapeRegExp} from 'workbox-build/build/lib/escape-regexp';
  8. import {replaceAndUpdateSourceMap} from 'workbox-build/build/lib/replace-and-update-source-map';
  9. import {validateWebpackInjectManifestOptions} from 'workbox-build/build/lib/validate-options';
  10. import prettyBytes from 'pretty-bytes';
  11. import stringify from 'fast-json-stable-stringify';
  12. import upath from 'upath';
  13. import webpack from 'webpack';
  14. import {getManifestEntriesFromCompilation} from './lib/get-manifest-entries-from-compilation';
  15. import {getSourcemapAssetName} from './lib/get-sourcemap-asset-name';
  16. import {relativeToOutputPath} from './lib/relative-to-output-path';
  17. import {WebpackInjectManifestOptions} from 'workbox-build';
  18. // Used to keep track of swDest files written by *any* instance of this plugin.
  19. // See https://github.com/GoogleChrome/workbox/issues/2181
  20. const _generatedAssetNames = new Set<string>();
  21. // SingleEntryPlugin in v4 was renamed to EntryPlugin in v5.
  22. const SingleEntryPlugin = webpack.EntryPlugin || webpack.SingleEntryPlugin;
  23. // webpack v4/v5 compatibility:
  24. // https://github.com/webpack/webpack/issues/11425#issuecomment-686607633
  25. const {RawSource} = webpack.sources || require('webpack-sources');
  26. /**
  27. * This class supports compiling a service worker file provided via `swSrc`,
  28. * and injecting into that service worker a list of URLs and revision
  29. * information for precaching based on the webpack asset pipeline.
  30. *
  31. * Use an instance of `InjectManifest` in the
  32. * [`plugins` array](https://webpack.js.org/concepts/plugins/#usage) of a
  33. * webpack config.
  34. *
  35. * In addition to injecting the manifest, this plugin will perform a compilation
  36. * of the `swSrc` file, using the options from the main webpack configuration.
  37. *
  38. * ```
  39. * // The following lists some common options; see the rest of the documentation
  40. * // for the full set of options and defaults.
  41. * new InjectManifest({
  42. * exclude: [/.../, '...'],
  43. * maximumFileSizeToCacheInBytes: ...,
  44. * swSrc: '...',
  45. * });
  46. * ```
  47. *
  48. * @memberof module:workbox-webpack-plugin
  49. */
  50. class InjectManifest {
  51. protected config: WebpackInjectManifestOptions;
  52. private alreadyCalled: boolean;
  53. /**
  54. * Creates an instance of InjectManifest.
  55. */
  56. constructor(config: WebpackInjectManifestOptions) {
  57. this.config = config;
  58. this.alreadyCalled = false;
  59. }
  60. /**
  61. * @param {Object} [compiler] default compiler object passed from webpack
  62. *
  63. * @private
  64. */
  65. propagateWebpackConfig(compiler: webpack.Compiler): void {
  66. // Because this.config is listed last, properties that are already set
  67. // there take precedence over derived properties from the compiler.
  68. this.config = Object.assign(
  69. {
  70. mode: compiler.options.mode,
  71. // Use swSrc with a hardcoded .js extension, in case swSrc is a .ts file.
  72. swDest: upath.parse(this.config.swSrc).name + '.js',
  73. },
  74. this.config,
  75. );
  76. }
  77. /**
  78. * @param {Object} [compiler] default compiler object passed from webpack
  79. *
  80. * @private
  81. */
  82. apply(compiler: webpack.Compiler): void {
  83. this.propagateWebpackConfig(compiler);
  84. compiler.hooks.make.tapPromise(this.constructor.name, (compilation) =>
  85. this.handleMake(compilation, compiler).catch(
  86. (error: webpack.WebpackError) => {
  87. compilation.errors.push(error);
  88. },
  89. ),
  90. );
  91. // webpack v4/v5 compatibility:
  92. // https://github.com/webpack/webpack/issues/11425#issuecomment-690387207
  93. if (webpack.version?.startsWith('4.')) {
  94. compiler.hooks.emit.tapPromise(this.constructor.name, (compilation) =>
  95. this.addAssets(compilation).catch((error: webpack.WebpackError) => {
  96. compilation.errors.push(error);
  97. }),
  98. );
  99. } else {
  100. const {PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER} = webpack.Compilation;
  101. // Specifically hook into thisCompilation, as per
  102. // https://github.com/webpack/webpack/issues/11425#issuecomment-690547848
  103. compiler.hooks.thisCompilation.tap(
  104. this.constructor.name,
  105. (compilation) => {
  106. compilation.hooks.processAssets.tapPromise(
  107. {
  108. name: this.constructor.name,
  109. // TODO(jeffposnick): This may need to change eventually.
  110. // See https://github.com/webpack/webpack/issues/11822#issuecomment-726184972
  111. stage: PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER - 10,
  112. },
  113. () =>
  114. this.addAssets(compilation).catch(
  115. (error: webpack.WebpackError) => {
  116. compilation.errors.push(error);
  117. },
  118. ),
  119. );
  120. },
  121. );
  122. }
  123. }
  124. /**
  125. * @param {Object} compilation The webpack compilation.
  126. * @param {Object} parentCompiler The webpack parent compiler.
  127. *
  128. * @private
  129. */
  130. async performChildCompilation(
  131. compilation: webpack.Compilation,
  132. parentCompiler: webpack.Compiler,
  133. ): Promise<void> {
  134. const outputOptions = {
  135. path: parentCompiler.options.output.path,
  136. filename: this.config.swDest,
  137. };
  138. const childCompiler = compilation.createChildCompiler(
  139. this.constructor.name,
  140. outputOptions,
  141. [],
  142. );
  143. childCompiler.context = parentCompiler.context;
  144. childCompiler.inputFileSystem = parentCompiler.inputFileSystem;
  145. childCompiler.outputFileSystem = parentCompiler.outputFileSystem;
  146. if (Array.isArray(this.config.webpackCompilationPlugins)) {
  147. for (const plugin of this.config.webpackCompilationPlugins) {
  148. // plugin has a generic type, eslint complains for an unsafe
  149. // assign and unsafe use
  150. // eslint-disable-next-line
  151. plugin.apply(childCompiler);
  152. }
  153. }
  154. new SingleEntryPlugin(
  155. parentCompiler.context,
  156. this.config.swSrc,
  157. this.constructor.name,
  158. ).apply(childCompiler);
  159. await new Promise<void>((resolve, reject) => {
  160. childCompiler.runAsChild((error, _entries, childCompilation) => {
  161. if (error) {
  162. reject(error);
  163. } else {
  164. compilation.warnings = compilation.warnings.concat(
  165. childCompilation?.warnings ?? [],
  166. );
  167. compilation.errors = compilation.errors.concat(
  168. childCompilation?.errors ?? [],
  169. );
  170. resolve();
  171. }
  172. });
  173. });
  174. }
  175. /**
  176. * @param {Object} compilation The webpack compilation.
  177. * @param {Object} parentCompiler The webpack parent compiler.
  178. *
  179. * @private
  180. */
  181. addSrcToAssets(
  182. compilation: webpack.Compilation,
  183. parentCompiler: webpack.Compiler,
  184. ): void {
  185. // eslint-disable-next-line
  186. const source = (parentCompiler.inputFileSystem as any).readFileSync(
  187. this.config.swSrc,
  188. );
  189. compilation.emitAsset(this.config.swDest!, new RawSource(source));
  190. }
  191. /**
  192. * @param {Object} compilation The webpack compilation.
  193. * @param {Object} parentCompiler The webpack parent compiler.
  194. *
  195. * @private
  196. */
  197. async handleMake(
  198. compilation: webpack.Compilation,
  199. parentCompiler: webpack.Compiler,
  200. ): Promise<void> {
  201. try {
  202. this.config = validateWebpackInjectManifestOptions(this.config);
  203. } catch (error) {
  204. if (error instanceof Error) {
  205. throw new Error(
  206. `Please check your ${this.constructor.name} plugin ` +
  207. `configuration:\n${error.message}`,
  208. );
  209. }
  210. }
  211. this.config.swDest = relativeToOutputPath(compilation, this.config.swDest!);
  212. _generatedAssetNames.add(this.config.swDest);
  213. if (this.config.compileSrc) {
  214. await this.performChildCompilation(compilation, parentCompiler);
  215. } else {
  216. this.addSrcToAssets(compilation, parentCompiler);
  217. // This used to be a fatal error, but just warn at runtime because we
  218. // can't validate it easily.
  219. if (
  220. Array.isArray(this.config.webpackCompilationPlugins) &&
  221. this.config.webpackCompilationPlugins.length > 0
  222. ) {
  223. compilation.warnings.push(
  224. new Error(
  225. 'compileSrc is false, so the ' +
  226. 'webpackCompilationPlugins option will be ignored.',
  227. ) as webpack.WebpackError,
  228. );
  229. }
  230. }
  231. }
  232. /**
  233. * @param {Object} compilation The webpack compilation.
  234. *
  235. * @private
  236. */
  237. async addAssets(compilation: webpack.Compilation): Promise<void> {
  238. // See https://github.com/GoogleChrome/workbox/issues/1790
  239. if (this.alreadyCalled) {
  240. const warningMessage =
  241. `${this.constructor.name} has been called ` +
  242. `multiple times, perhaps due to running webpack in --watch mode. The ` +
  243. `precache manifest generated after the first call may be inaccurate! ` +
  244. `Please see https://github.com/GoogleChrome/workbox/issues/1790 for ` +
  245. `more information.`;
  246. if (
  247. !compilation.warnings.some(
  248. (warning) =>
  249. warning instanceof Error && warning.message === warningMessage,
  250. )
  251. ) {
  252. compilation.warnings.push(
  253. new Error(warningMessage) as webpack.WebpackError,
  254. );
  255. }
  256. } else {
  257. this.alreadyCalled = true;
  258. }
  259. const config = Object.assign({}, this.config);
  260. // Ensure that we don't precache any of the assets generated by *any*
  261. // instance of this plugin.
  262. // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
  263. config.exclude!.push(({asset}) => _generatedAssetNames.has(asset.name));
  264. // See https://webpack.js.org/contribute/plugin-patterns/#monitoring-the-watch-graph
  265. const absoluteSwSrc = upath.resolve(this.config.swSrc);
  266. compilation.fileDependencies.add(absoluteSwSrc);
  267. const swAsset = compilation.getAsset(config.swDest!);
  268. const swAssetString = swAsset!.source.source().toString();
  269. const globalRegexp = new RegExp(escapeRegExp(config.injectionPoint!), 'g');
  270. const injectionResults = swAssetString.match(globalRegexp);
  271. if (!injectionResults) {
  272. throw new Error(
  273. `Can't find ${config.injectionPoint ?? ''} in your SW source.`,
  274. );
  275. }
  276. if (injectionResults.length !== 1) {
  277. throw new Error(
  278. `Multiple instances of ${config.injectionPoint ?? ''} were ` +
  279. `found in your SW source. Include it only once. For more info, see ` +
  280. `https://github.com/GoogleChrome/workbox/issues/2681`,
  281. );
  282. }
  283. const {size, sortedEntries} = await getManifestEntriesFromCompilation(
  284. compilation,
  285. config,
  286. );
  287. let manifestString = stringify(sortedEntries);
  288. if (
  289. this.config.compileSrc &&
  290. // See https://github.com/GoogleChrome/workbox/issues/2729
  291. !(
  292. compilation.options?.devtool === 'eval-cheap-source-map' &&
  293. compilation.options.optimization?.minimize
  294. )
  295. ) {
  296. // See https://github.com/GoogleChrome/workbox/issues/2263
  297. manifestString = manifestString.replace(/"/g, `'`);
  298. }
  299. const sourcemapAssetName = getSourcemapAssetName(
  300. compilation,
  301. swAssetString,
  302. config.swDest!,
  303. );
  304. if (sourcemapAssetName) {
  305. _generatedAssetNames.add(sourcemapAssetName);
  306. const sourcemapAsset = compilation.getAsset(sourcemapAssetName);
  307. const {source, map} = await replaceAndUpdateSourceMap({
  308. jsFilename: config.swDest!,
  309. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  310. originalMap: JSON.parse(sourcemapAsset!.source.source().toString()),
  311. originalSource: swAssetString,
  312. replaceString: manifestString,
  313. searchString: config.injectionPoint!,
  314. });
  315. compilation.updateAsset(sourcemapAssetName, new RawSource(map));
  316. compilation.updateAsset(config.swDest!, new RawSource(source));
  317. } else {
  318. // If there's no sourcemap associated with swDest, a simple string
  319. // replacement will suffice.
  320. compilation.updateAsset(
  321. config.swDest!,
  322. new RawSource(
  323. swAssetString.replace(config.injectionPoint!, manifestString),
  324. ),
  325. );
  326. }
  327. if (compilation.getLogger) {
  328. const logger = compilation.getLogger(this.constructor.name);
  329. logger.info(`The service worker at ${config.swDest ?? ''} will precache
  330. ${sortedEntries.length} URLs, totaling ${prettyBytes(size)}.`);
  331. }
  332. }
  333. }
  334. export {InjectManifest};