@serwist/webpack-plugin
Version:
A plugin for your webpack build process, helping you generate a manifest of local files that should be precached.
247 lines (218 loc) • 9.72 kB
text/typescript
import path from "node:path";
import { escapeRegExp, replaceAndUpdateSourceMap } from "@serwist/build";
import { toUnix } from "@serwist/utils";
import prettyBytes from "pretty-bytes";
import type { Compilation, Compiler, WebpackError, default as Webpack } from "webpack";
import type { InjectManifestOptions, InjectManifestOptionsComplete } from "./lib/types.js";
import { validateInjectManifestOptions } from "./lib/validator.js";
import { getManifestEntriesFromCompilation } from "./lib/get-manifest-entries-from-compilation.js";
import { getSourcemapAssetName } from "./lib/get-sourcemap-asset-name.js";
import { relativeToOutputPath } from "./lib/relative-to-output-path.js";
import { performChildCompilation } from "./lib/perform-child-compilation.js";
// Used to keep track of swDest files written by *any* instance of this plugin.
// See https://github.com/GoogleChrome/workbox/issues/2181
const _generatedAssetNames = new Set<string>();
/**
* This class supports compiling a service worker file provided via `swSrc`,
* and injecting into that service worker a list of URLs and revision
* information for precaching based on the webpack asset pipeline.
*
* Use an instance of `InjectManifest` in the
* [`plugins` array](https://webpack.js.org/concepts/plugins/#usage) of a
* webpack config.
*
* In addition to injecting the manifest, this plugin will perform a compilation
* of the `swSrc` file, using the options from the main webpack configuration.
*
* ```
* // The following lists some common options; see the rest of the documentation
* // for the full set of options and defaults.
* new InjectManifest({
* exclude: [/.../, '...'],
* maximumFileSizeToCacheInBytes: ...,
* swSrc: '...',
* });
* ```
*/
export class InjectManifest {
protected config: InjectManifestOptionsComplete;
private alreadyCalled: boolean;
private webpack: typeof Webpack;
/**
* Creates an instance of InjectManifest.
*/
constructor(config: InjectManifestOptions) {
// We are essentially lying to TypeScript. When `handleMake`
// is called, `this.config` will be replaced by a validated config.
this.config = config as InjectManifestOptionsComplete;
this.alreadyCalled = false;
this.webpack = null!;
}
/**
* @param compiler default compiler object passed from webpack
*
* @private
*/
private propagateWebpackConfig(compiler: Compiler): void {
this.webpack = compiler.webpack;
const parsedSwSrc = path.parse(this.config.swSrc);
// Because this.config is listed last, properties that are already set
// there take precedence over derived properties from the compiler.
this.config = {
// Use swSrc with a hardcoded .js extension, in case swSrc is a .ts file.
swDest: `${parsedSwSrc.name}.js`,
...this.config,
};
}
/**
* `getManifestEntriesFromCompilation` with a few additional checks.
*
* @private
*/
private async getManifestEntries(compilation: Compilation, config: InjectManifestOptionsComplete) {
if (config.disablePrecacheManifest) {
return {
size: 0,
sortedEntries: undefined,
manifestString: "undefined",
};
}
// See https://github.com/GoogleChrome/workbox/issues/1790
if (this.alreadyCalled) {
const warningMessage = `${this.constructor.name} has been called multiple times, perhaps due to running webpack in --watch mode. The precache manifest generated after the first call may be inaccurate! Please see https://github.com/GoogleChrome/workbox/issues/1790 for more information.`;
if (!compilation.warnings.some((warning) => warning instanceof Error && warning.message === warningMessage)) {
compilation.warnings.push(new Error(warningMessage) as WebpackError);
}
} else {
this.alreadyCalled = true;
}
// Ensure that we don't precache any of the assets generated by *any*
// instance of this plugin.
config.exclude.push(({ asset }) => _generatedAssetNames.has(asset.name));
const { size, sortedEntries } = await getManifestEntriesFromCompilation(compilation, config);
let manifestString = JSON.stringify(sortedEntries);
if (
this.config.compileSrc &&
// See https://github.com/GoogleChrome/workbox/issues/2729
!(compilation.options?.devtool === "eval-cheap-source-map" && compilation.options.optimization?.minimize)
) {
// See https://github.com/GoogleChrome/workbox/issues/2263
manifestString = manifestString.replace(/"/g, `'`);
}
return { size, sortedEntries, manifestString };
}
/**
* @param compiler default compiler object passed from webpack
*
* @private
*/
apply(compiler: Compiler): void {
this.propagateWebpackConfig(compiler);
compiler.hooks.make.tapPromise(this.constructor.name, (compilation) =>
this.handleMake(compiler, compilation).catch((error: WebpackError) => {
compilation.errors.push(error);
}),
);
// webpack should not be null at this point.
const { PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER } = this.webpack.Compilation;
// Specifically hook into thisCompilation, as per
// https://github.com/webpack/webpack/issues/11425#issuecomment-690547848
compiler.hooks.thisCompilation.tap(this.constructor.name, (compilation) => {
compilation.hooks.processAssets.tapPromise(
{
name: this.constructor.name,
// TODO(jeffposnick): This may need to change eventually.
// See https://github.com/webpack/webpack/issues/11822#issuecomment-726184972
stage: PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER - 10,
},
() =>
this.addAssets(compilation).catch((error: WebpackError) => {
compilation.errors.push(error);
}),
);
});
}
/**
* @param compiler The webpack parent compiler.
* @param compilation The webpack compilation.
*
* @private
*/
private addSrcToAssets(compiler: Compiler, compilation: Compilation): void {
const source = compiler.inputFileSystem!.readFileSync!(this.config.swSrc);
compilation.emitAsset(this.config.swDest!, new this.webpack.sources.RawSource(source));
}
/**
* @param compiler The webpack parent compiler.
* @param compilation The webpack compilation.
*
* @private
*/
private async handleMake(compiler: Compiler, compilation: Compilation): Promise<void> {
this.config = await validateInjectManifestOptions(this.config);
this.config.swDest = relativeToOutputPath(compilation, this.config.swDest!);
_generatedAssetNames.add(this.config.swDest);
if (this.config.compileSrc) {
await performChildCompilation(
compiler,
compilation,
this.constructor.name,
this.config.swSrc,
this.config.swDest,
this.config.webpackCompilationPlugins,
);
} else {
this.addSrcToAssets(compiler, compilation);
// This used to be a fatal error, but just warn at runtime because we
// can't validate it easily.
if (Array.isArray(this.config.webpackCompilationPlugins) && this.config.webpackCompilationPlugins.length > 0) {
compilation.warnings.push(new Error("'compileSrc' is 'false', so the 'webpackCompilationPlugins' option will be ignored.") as WebpackError);
}
}
}
/**
* @param compilation The webpack compilation.
*
* @private
*/
private async addAssets(compilation: Compilation): Promise<void> {
const config = Object.assign({}, this.config);
const { size, sortedEntries, manifestString } = await this.getManifestEntries(compilation, config);
// See https://webpack.js.org/contribute/plugin-patterns/#monitoring-the-watch-graph
compilation.fileDependencies.add(path.resolve(config.swSrc));
const swAsset = compilation.getAsset(config.swDest!);
const swAssetString = swAsset!.source.source().toString();
const globalRegexp = new RegExp(escapeRegExp(config.injectionPoint), "g");
const injectionResults = swAssetString.match(globalRegexp);
if (!injectionResults) {
throw new Error(`Can't find ${config.injectionPoint} in your SW source.`);
}
if (injectionResults.length !== 1) {
throw new Error(
`Multiple instances of ${config.injectionPoint} were found in your SW source. Include it only once. For more info, see https://github.com/GoogleChrome/workbox/issues/2681`,
);
}
const sourcemapAssetName = getSourcemapAssetName(compilation, swAssetString, config.swDest!);
if (sourcemapAssetName) {
_generatedAssetNames.add(sourcemapAssetName);
const sourcemapAsset = compilation.getAsset(sourcemapAssetName);
const { source, map } = await replaceAndUpdateSourceMap({
jsFilename: toUnix(config.swDest!),
originalMap: JSON.parse(sourcemapAsset!.source.source().toString()),
originalSource: swAssetString,
replaceString: manifestString,
searchString: config.injectionPoint,
});
compilation.updateAsset(sourcemapAssetName, new this.webpack.sources.RawSource(map));
compilation.updateAsset(config.swDest!, new this.webpack.sources.RawSource(source));
} else {
// If there's no sourcemap associated with swDest, a simple string
// replacement will suffice.
compilation.updateAsset(config.swDest!, new this.webpack.sources.RawSource(swAssetString.replace(config.injectionPoint, manifestString)));
}
if (compilation.getLogger) {
const logger = compilation.getLogger(this.constructor.name);
logger.info(`The service worker at ${config.swDest ?? ""} will precache ${sortedEntries?.length ?? 0} URLs, totaling ${prettyBytes(size)}.`);
}
}
}