@serwist/webpack-plugin
Version:
A plugin for your webpack build process, helping you generate a manifest of local files that should be precached.
345 lines (344 loc) • 14.2 kB
JavaScript
import { n as performChildCompilation, t as relativeToOutputPath } from "./chunks/relative-to-output-path-Gvhd54pA.js";
import path from "node:path";
import { escapeRegExp, getSourceMapURL, replaceAndUpdateSourceMap, transformManifest } from "@serwist/build";
import { toUnix } from "@serwist/utils";
import prettyBytes from "pretty-bytes";
import crypto from "node:crypto";
import { SerwistConfigError, validationErrorMap } from "@serwist/build/schema";
//#region src/lib/get-asset-hash.ts
/**
* @param asset
* @returns The MD5 hash of the asset's source.
*
* @private
*/
const getAssetHash = (asset) => {
if (asset.info?.immutable) return null;
return crypto.createHash("md5").update(asset.source.source()).digest("hex");
};
//#endregion
//#region src/lib/resolve-webpack-url.ts
/**
* Resolves a url in the way that webpack would (with string concatenation)
*
* Use publicPath + filePath instead of url.resolve(publicPath, filePath) see:
* https://webpack.js.org/configuration/output/#output-publicpath
*
* @param publicPath The publicPath value from webpack's compilation.
* @param paths File paths to join
* @returns Joined file path
* @private
*/
const resolveWebpackURL = (publicPath, ...paths) => {
if (publicPath === "auto") return paths.join("");
return [publicPath, ...paths].join("");
};
//#endregion
//#region src/lib/get-manifest-entries-from-compilation.ts
/**
* For a given asset, checks whether at least one of the conditions matches.
*
* @param asset The webpack asset in question. This will be passed
* to any functions that are listed as conditions.
* @param compilation The webpack compilation. This will be passed
* to any functions that are listed as conditions.
* @param conditions
* @returns Whether or not at least one condition matches.
* @private
*/
const checkConditions = (asset, compilation, conditions = []) => {
const matchPart = compilation.compiler.webpack.ModuleFilenameHelpers.matchPart;
for (const condition of conditions) if (typeof condition === "function") {
if (condition({
asset,
compilation
})) return true;
} else if (matchPart(asset.name, condition)) return true;
return false;
};
/**
* Returns the names of all the assets in all the chunks in a chunk group,
* if provided a chunk group name.
* Otherwise, if provided a chunk name, return all the assets in that chunk.
* Otherwise, if there isn't a chunk group or chunk with that name, return null.
*
* @param compilation
* @param chunkOrGroup
* @returns
* @private
*/
const getNamesOfAssetsInChunkOrGroup = (compilation, chunkOrGroup) => {
const chunkGroup = compilation.namedChunkGroups?.get(chunkOrGroup);
if (chunkGroup) {
const assetNames = [];
for (const chunk of chunkGroup.chunks) assetNames.push(...getNamesOfAssetsInChunk(chunk));
return assetNames;
}
const chunk = compilation.namedChunks?.get(chunkOrGroup);
if (chunk) return getNamesOfAssetsInChunk(chunk);
return null;
};
/**
* Returns the names of all the assets in a chunk.
*
* @param chunk
* @returns
* @private
*/
const getNamesOfAssetsInChunk = (chunk) => {
const assetNames = [];
assetNames.push(...chunk.files);
if (chunk.auxiliaryFiles) assetNames.push(...chunk.auxiliaryFiles);
return assetNames;
};
/**
* Filters the set of assets out, based on the configuration options provided:
* - chunks and excludeChunks, for chunkName-based criteria.
* - include and exclude, for more general criteria.
*
* @param compilation The webpack compilation.
* @param config The validated configuration, obtained from the plugin.
* @returns The assets that should be included in the manifest,
* based on the criteria provided.
* @private
*/
const filterAssets = (compilation, config) => {
const filteredAssets = /* @__PURE__ */ new Set();
const assets = compilation.getAssets();
const allowedAssetNames = /* @__PURE__ */ new Set();
if (Array.isArray(config.chunks)) for (const name of config.chunks) {
const assetsInChunkOrGroup = getNamesOfAssetsInChunkOrGroup(compilation, name);
if (assetsInChunkOrGroup) for (const assetName of assetsInChunkOrGroup) allowedAssetNames.add(assetName);
else compilation.warnings.push(/* @__PURE__ */ new Error(`The chunk '${name}' was provided in your Serwist chunks config, but was not found in the compilation.`));
}
const deniedAssetNames = /* @__PURE__ */ new Set();
if (Array.isArray(config.excludeChunks)) for (const name of config.excludeChunks) {
const assetsInChunkOrGroup = getNamesOfAssetsInChunkOrGroup(compilation, name);
if (assetsInChunkOrGroup) for (const assetName of assetsInChunkOrGroup) deniedAssetNames.add(assetName);
}
for (const asset of assets) {
if (deniedAssetNames.has(asset.name)) continue;
if (Array.isArray(config.chunks) && !allowedAssetNames.has(asset.name)) continue;
if (checkConditions(asset, compilation, config.exclude)) continue;
if (!(!Array.isArray(config.include) || checkConditions(asset, compilation, config.include))) continue;
filteredAssets.add(asset);
}
return filteredAssets;
};
const getManifestEntriesFromCompilation = async (compilation, config) => {
const filteredAssets = filterAssets(compilation, config);
const { publicPath } = compilation.options.output;
const { manifestEntries, size, warnings } = await transformManifest({
fileDetails: Array.from(filteredAssets).map((asset) => {
return {
file: resolveWebpackURL(publicPath, asset.name),
hash: getAssetHash(asset),
size: asset.source.size() || 0
};
}),
additionalPrecacheEntries: config.additionalPrecacheEntries,
dontCacheBustURLsMatching: config.dontCacheBustURLsMatching,
manifestTransforms: config.manifestTransforms,
maximumFileSizeToCacheInBytes: config.maximumFileSizeToCacheInBytes,
modifyURLPrefix: config.modifyURLPrefix,
transformParam: compilation,
disablePrecacheManifest: config.disablePrecacheManifest
});
for (const warning of warnings) compilation.warnings.push(new Error(warning));
return {
size,
sortedEntries: manifestEntries?.sort((a, b) => a.url === b.url ? 0 : a.url > b.url ? 1 : -1)
};
};
//#endregion
//#region src/lib/get-sourcemap-asset-name.ts
/**
* If our bundled swDest file contains a sourcemap, we would invalidate that
* mapping if we just replaced injectionPoint with the stringified manifest.
* Instead, we need to update the swDest contents as well as the sourcemap
* at the same time.
*
* See https://github.com/GoogleChrome/workbox/issues/2235
*
* @param compilation The current webpack compilation.
* @param swContents The contents of the swSrc file, which may or
* may not include a valid sourcemap comment.
* @param swDest The configured swDest value.
* @returns If the swContents contains a valid sourcemap
* comment pointing to an asset present in the compilation, this will return the
* name of that asset. Otherwise, it will return undefined.
* @private
*/
const getSourcemapAssetName = (compilation, swContents, swDest) => {
const url = getSourceMapURL(swContents);
if (url) {
const swAssetDirname = path.dirname(swDest);
const sourcemapURLAssetName = path.normalize(path.join(swAssetDirname, url));
if (compilation.getAsset(sourcemapURLAssetName)) return sourcemapURLAssetName;
}
};
//#endregion
//#region src/lib/validator.ts
const validateInjectManifestOptions = async (input) => {
const result = await (await import("./chunks/schema-CpgCa1bB.js").then((n) => n.r)).injectManifestOptions.spa(input, { error: validationErrorMap });
if (!result.success) throw new SerwistConfigError({
moduleName: "@serwist/webpack-plugin",
message: JSON.stringify(result.error.format(), null, 2)
});
return result.data;
};
//#endregion
//#region src/inject-manifest.ts
const _generatedAssetNames = /* @__PURE__ */ new Set();
/**
* 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: '...',
* });
* ```
*/
var InjectManifest = class {
config;
alreadyCalled;
webpack;
/**
* Creates an instance of InjectManifest.
*/
constructor(config) {
this.config = config;
this.alreadyCalled = false;
this.webpack = null;
}
/**
* @param compiler default compiler object passed from webpack
*
* @private
*/
propagateWebpackConfig(compiler) {
this.webpack = compiler.webpack;
const parsedSwSrc = path.parse(this.config.swSrc);
this.config = {
swDest: `${parsedSwSrc.name}.js`,
...this.config
};
}
/**
* `getManifestEntriesFromCompilation` with a few additional checks.
*
* @private
*/
async getManifestEntries(compilation, config) {
if (config.disablePrecacheManifest) return {
size: 0,
sortedEntries: void 0,
manifestString: "undefined"
};
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));
} else this.alreadyCalled = true;
config.exclude.push(({ asset }) => _generatedAssetNames.has(asset.name));
const { size, sortedEntries } = await getManifestEntriesFromCompilation(compilation, config);
let manifestString = JSON.stringify(sortedEntries);
if (this.config.compileSrc && !(compilation.options?.devtool === "eval-cheap-source-map" && compilation.options.optimization?.minimize)) manifestString = manifestString.replace(/"/g, `'`);
return {
size,
sortedEntries,
manifestString
};
}
/**
* @param compiler default compiler object passed from webpack
*
* @private
*/
apply(compiler) {
this.propagateWebpackConfig(compiler);
compiler.hooks.make.tapPromise(this.constructor.name, (compilation) => this.handleMake(compiler, compilation).catch((error) => {
compilation.errors.push(error);
}));
const { PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER } = this.webpack.Compilation;
compiler.hooks.thisCompilation.tap(this.constructor.name, (compilation) => {
compilation.hooks.processAssets.tapPromise({
name: this.constructor.name,
stage: PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER - 10
}, () => this.addAssets(compilation).catch((error) => {
compilation.errors.push(error);
}));
});
}
/**
* @param compiler The webpack parent compiler.
* @param compilation The webpack compilation.
*
* @private
*/
addSrcToAssets(compiler, compilation) {
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
*/
async handleMake(compiler, compilation) {
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);
if (Array.isArray(this.config.webpackCompilationPlugins) && this.config.webpackCompilationPlugins.length > 0) compilation.warnings.push(/* @__PURE__ */ new Error("'compileSrc' is 'false', so the 'webpackCompilationPlugins' option will be ignored."));
}
}
/**
* @param compilation The webpack compilation.
*
* @private
*/
async addAssets(compilation) {
const config = Object.assign({}, this.config);
const { size, sortedEntries, manifestString } = await this.getManifestEntries(compilation, config);
compilation.fileDependencies.add(path.resolve(config.swSrc));
const swAssetString = compilation.getAsset(config.swDest).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 compilation.updateAsset(config.swDest, new this.webpack.sources.RawSource(swAssetString.replace(config.injectionPoint, manifestString)));
if (compilation.getLogger) compilation.getLogger(this.constructor.name).info(`The service worker at ${config.swDest ?? ""} will precache ${sortedEntries?.length ?? 0} URLs, totaling ${prettyBytes(size)}.`);
}
};
//#endregion
export { InjectManifest, validateInjectManifestOptions };
//# sourceMappingURL=index.mjs.map