UNPKG

@serwist/build

Version:

A module that integrates into your build process, helping you generate a manifest of local files that should be precached.

594 lines (593 loc) 23.6 kB
import { n as validationErrorMap, t as SerwistConfigError } from "./chunks/error-Cl4d1Wf-.js"; import { t as DEFAULT_GLOB_PATTERNS } from "./chunks/constants-BLOVm9H2.js"; import assert from "node:assert"; import { oneLine } from "common-tags"; import crypto from "node:crypto"; import path from "node:path"; import { globSync } from "glob"; import fs, { readFileSync } from "node:fs"; import prettyBytes from "pretty-bytes"; import { z } from "zod"; import fsp from "node:fs/promises"; import { toUnix } from "@serwist/utils"; import { SourceMapConsumer, SourceMapGenerator } from "source-map"; //#region src/lib/errors.ts const errors = { "unable-to-get-rootdir": "Unable to get the root directory of your web app.", "no-extension": oneLine`Unable to detect a usable extension for a file in your web app directory.`, "invalid-file-manifest-name": oneLine`The File Manifest Name must have at least one character.`, "unable-to-get-file-manifest-name": "Unable to get a file manifest name.", "invalid-sw-dest": `The 'swDest' value must be a valid path.`, "unable-to-get-sw-name": "Unable to get a service worker file name.", "unable-to-get-save-config": oneLine`An error occurred when asking to save details in a config file.`, "unable-to-get-file-hash": oneLine`An error occurred when attempting to create a file hash.`, "unable-to-get-file-size": oneLine`An error occurred when attempting to get a file size.`, "unable-to-glob-files": "An error occurred when globbing for files.", "unable-to-make-manifest-directory": oneLine`Unable to make output directory for file manifest.`, "read-manifest-template-failure": "Unable to read template for file manifest", "populating-manifest-tmpl-failed": oneLine`An error occurred when populating the file manifest template.`, "manifest-file-write-failure": "Unable to write the file manifest.", "unable-to-make-sw-directory": oneLine`Unable to make the directories to output the service worker path.`, "sw-write-failure": "Unable to write the service worker file.", "sw-write-failure-directory": oneLine`Unable to write the service worker file; 'swDest' should be a full path to the file, not a path to a directory.`, "unable-to-copy-serwist-libraries": oneLine`One or more of the Serwist libraries could not be copied over to the destination directory: `, "invalid-glob-directory": oneLine`The supplied globDirectory must be a path as a string.`, "invalid-dont-cache-bust": oneLine`The supplied 'dontCacheBustURLsMatching' parameter must be a RegExp.`, "invalid-exclude-files": "The excluded files should be an array of strings.", "invalid-get-manifest-entries-input": oneLine`The input to 'getFileManifestEntries()' must be an object.`, "invalid-manifest-path": oneLine`The supplied manifest path is not a string with at least one character.`, "invalid-manifest-entries": oneLine`The manifest entries must be an array of strings or JavaScript objects containing a url parameter.`, "invalid-manifest-format": oneLine`The value of the 'format' option passed to generateFileManifest() must be either 'iife' (the default) or 'es'.`, "invalid-static-file-globs": oneLine`The 'globPatterns' value must be an array of strings.`, "invalid-templated-urls": oneLine`The 'templatedURLs' value should be an object that maps URLs to either a string, or to an array of glob patterns.`, "templated-url-matches-glob": oneLine`One of the 'templatedURLs' URLs is already being tracked via 'globPatterns': `, "invalid-glob-ignores": oneLine`The 'globIgnores' parameter must be an array of glob pattern strings.`, "manifest-entry-bad-url": oneLine`The generated manifest contains an entry without a URL string. This is likely an error with @serwist/build.`, "modify-url-prefix-bad-prefixes": oneLine`The 'modifyURLPrefix' parameter must be an object with string key value pairs.`, "invalid-inject-manifest-arg": oneLine`The input to 'injectManifest()' must be an object.`, "injection-point-not-found": oneLine`Unable to find a place to inject the manifest. Please ensure that your service worker file contains the following: `, "multiple-injection-points": oneLine`Please ensure that your 'swSrc' file contains only one match for the following: `, "bad-template-urls-asset": oneLine`There was an issue using one of the provided 'templatedURLs'.`, "invalid-generate-file-manifest-arg": oneLine`The input to generateFileManifest() must be an Object.`, "invalid-sw-src": `The 'swSrc' file can't be read.`, "same-src-and-dest": oneLine`Unable to find a place to inject the manifest. This is likely because swSrc and swDest are configured to the same file. Please ensure that your swSrc file contains the following:`, "no-module-name": oneLine`You must provide a moduleName parameter when calling getModuleURL().`, "bad-manifest-transforms-return-value": oneLine`The return value from a manifestTransform should be an object with 'manifest' and optionally 'warnings' properties.`, "string-entry-warning": oneLine`Some items were passed to additionalPrecacheEntries without revisioning info. This is generally NOT safe. Learn more at https://bit.ly/wb-precache.`, "cant-find-sourcemap": oneLine`The swSrc file refers to a sourcemap that can't be opened:`, "manifest-transforms": oneLine`When using manifestTransforms, you must provide an array of functions.` }; //#endregion //#region src/lib/get-composite-details.ts const getCompositeDetails = (compositeURL, dependencyDetails) => { let totalSize = 0; let compositeHash = ""; for (const fileDetails of dependencyDetails) { totalSize += fileDetails.size; compositeHash += fileDetails.hash === null ? "" : fileDetails.hash; } const md5 = crypto.createHash("md5"); md5.update(compositeHash); return { file: compositeURL, hash: md5.digest("hex"), size: totalSize }; }; //#endregion //#region src/lib/get-string-hash.ts function getStringHash(input) { const md5 = crypto.createHash("md5"); md5.update(input); return md5.digest("hex"); } //#endregion //#region src/lib/get-file-hash.ts const getFileHash = (file) => { try { return getStringHash(readFileSync(file)); } catch (err) { throw new Error(`${errors["unable-to-get-file-hash"]} '${err instanceof Error && err.message ? err.message : ""}'`); } }; //#endregion //#region src/lib/get-file-size.ts const getFileSize = (file) => { try { const stat = fs.statSync(file); if (!stat.isFile()) return null; return stat.size; } catch (err) { throw new Error(`${errors["unable-to-get-file-size"]} '${err instanceof Error && err.message ? err.message : ""}'`); } }; //#endregion //#region src/lib/get-file-details.ts const getFileDetails = ({ globDirectory, globFollow, globIgnores, globPattern }) => { let globbedFiles; let warning = ""; try { globbedFiles = globSync(globPattern, { cwd: globDirectory, follow: globFollow, ignore: globIgnores }); } catch (err) { throw new Error(`${errors["unable-to-glob-files"]} '${err instanceof Error && err.message ? err.message : ""}'`); } const globbedFileDetails = []; for (const file of globbedFiles) { const fullPath = path.join(globDirectory, file); const fileSize = getFileSize(fullPath); if (fileSize !== null) { const fileHash = getFileHash(fullPath); globbedFileDetails.push({ file: path.relative(globDirectory, fullPath), hash: fileHash, size: fileSize }); } } return { globbedFileDetails, warning }; }; //#endregion //#region src/lib/get-string-details.ts const getStringDetails = (url, str) => ({ file: url, hash: getStringHash(str), size: str.length }); //#endregion //#region src/lib/additional-precache-entries-transform.ts const additionalPrecacheEntriesTransform = (additionalPrecacheEntries) => { return (manifest) => { const warnings = []; const stringEntries = /* @__PURE__ */ new Set(); for (const additionalEntry of additionalPrecacheEntries) if (typeof additionalEntry === "string") { stringEntries.add(additionalEntry); manifest.push({ revision: null, size: 0, url: additionalEntry }); } else { if (additionalEntry && !additionalEntry.integrity && additionalEntry.revision === void 0) stringEntries.add(additionalEntry.url); manifest.push(Object.assign({ size: 0 }, additionalEntry)); } if (stringEntries.size > 0) { let urls = "\n"; for (const stringEntry of stringEntries) urls += ` - ${stringEntry}\n`; warnings.push(errors["string-entry-warning"] + urls); } return { manifest, warnings }; }; }; //#endregion //#region src/lib/maximum-size-transform.ts function maximumSizeTransform(maximumFileSizeToCacheInBytes) { return (originalManifest) => { const warnings = []; return { manifest: originalManifest.filter((entry) => { if (entry.size <= maximumFileSizeToCacheInBytes) return true; warnings.push(`${entry.url} is ${prettyBytes(entry.size)}, and won't be precached. Configure maximumFileSizeToCacheInBytes to change this limit.`); return false; }), warnings }; }; } //#endregion //#region src/lib/escape-regexp.ts const escapeRegExp = (str) => { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; //#endregion //#region src/lib/modify-url-prefix-transform.ts function modifyURLPrefixTransform(modifyURLPrefix) { if (!modifyURLPrefix || typeof modifyURLPrefix !== "object" || Array.isArray(modifyURLPrefix)) throw new Error(errors["modify-url-prefix-bad-prefixes"]); if (Object.keys(modifyURLPrefix).length === 0) return (manifest) => { return { manifest }; }; for (const key of Object.keys(modifyURLPrefix)) if (typeof modifyURLPrefix[key] !== "string") throw new Error(errors["modify-url-prefix-bad-prefixes"]); const prefixMatchesStrings = Object.keys(modifyURLPrefix).map(escapeRegExp).join("|"); const modifyRegex = new RegExp(`^(${prefixMatchesStrings})`); return (originalManifest) => { return { manifest: originalManifest.map((entry) => { if (typeof entry.url !== "string") throw new Error(errors["manifest-entry-bad-url"]); entry.url = entry.url.replace(modifyRegex, (match) => { return modifyURLPrefix[match]; }); return entry; }) }; }; } //#endregion //#region src/lib/no-revision-for-urls-matching-transform.ts function noRevisionForURLsMatchingTransform(regexp) { if (!(regexp instanceof RegExp)) throw new Error(errors["invalid-dont-cache-bust"]); return (originalManifest) => { return { manifest: originalManifest.map((entry) => { if (typeof entry.url !== "string") throw new Error(errors["manifest-entry-bad-url"]); if (entry.url.match(regexp)) entry.revision = null; return entry; }) }; }; } //#endregion //#region src/lib/transform-manifest.ts async function transformManifest({ additionalPrecacheEntries, dontCacheBustURLsMatching, fileDetails, manifestTransforms, maximumFileSizeToCacheInBytes, modifyURLPrefix, transformParam, disablePrecacheManifest }) { if (disablePrecacheManifest) return { count: 0, size: 0, manifestEntries: void 0, warnings: [] }; const allWarnings = []; const normalizedManifest = fileDetails.map((fileDetails) => ({ url: fileDetails.file.replace(/\\/g, "/"), revision: fileDetails.hash, size: fileDetails.size })); const transformsToApply = []; if (maximumFileSizeToCacheInBytes) transformsToApply.push(maximumSizeTransform(maximumFileSizeToCacheInBytes)); if (modifyURLPrefix) transformsToApply.push(modifyURLPrefixTransform(modifyURLPrefix)); if (dontCacheBustURLsMatching) transformsToApply.push(noRevisionForURLsMatchingTransform(dontCacheBustURLsMatching)); if (manifestTransforms) transformsToApply.push(...manifestTransforms); if (additionalPrecacheEntries) transformsToApply.push(additionalPrecacheEntriesTransform(additionalPrecacheEntries)); let transformedManifest = normalizedManifest; for (const transform of transformsToApply) { const result = await transform(transformedManifest, transformParam); if (!("manifest" in result)) throw new Error(errors["bad-manifest-transforms-return-value"]); transformedManifest = result.manifest; allWarnings.push(...result.warnings || []); } const count = transformedManifest.length; let size = 0; for (const manifestEntry of transformedManifest) { size += manifestEntry.size || 0; delete manifestEntry.size; } return { count, size, manifestEntries: transformedManifest, warnings: allWarnings }; } //#endregion //#region src/lib/get-file-manifest-entries.ts const getFileManifestEntries = async ({ additionalPrecacheEntries, dontCacheBustURLsMatching, globDirectory, globFollow, globIgnores, globPatterns = [], globStrict, manifestTransforms, maximumFileSizeToCacheInBytes, modifyURLPrefix, templatedURLs, disablePrecacheManifest }) => { if (disablePrecacheManifest) return { count: 0, size: 0, manifestEntries: void 0, warnings: [] }; const warnings = []; const allFileDetails = /* @__PURE__ */ new Map(); try { for (const globPattern of globPatterns) { const { globbedFileDetails, warning } = getFileDetails({ globDirectory, globFollow, globIgnores, globPattern, globStrict }); if (warning) warnings.push(warning); for (const details of globbedFileDetails) if (details && !allFileDetails.has(details.file)) allFileDetails.set(details.file, details); } } catch (error) { if (error instanceof Error && error.message) warnings.push(error.message); } if (templatedURLs) for (const url of Object.keys(templatedURLs)) { assert(!allFileDetails.has(url), errors["templated-url-matches-glob"]); const dependencies = templatedURLs[url]; if (Array.isArray(dependencies)) { const details = dependencies.reduce((previous, globPattern) => { try { const { globbedFileDetails, warning } = getFileDetails({ globDirectory, globFollow, globIgnores, globPattern, globStrict }); if (warning) warnings.push(warning); return previous.concat(globbedFileDetails); } catch (error) { const debugObj = {}; debugObj[url] = dependencies; throw new Error(`${errors["bad-template-urls-asset"]} '${globPattern}' from '${JSON.stringify(debugObj)}':\n${error instanceof Error ? error.toString() : ""}`); } }, []); if (details.length === 0) throw new Error(`${errors["bad-template-urls-asset"]} The glob pattern '${dependencies.toString()}' did not match anything.`); allFileDetails.set(url, getCompositeDetails(url, details)); } else if (typeof dependencies === "string") allFileDetails.set(url, getStringDetails(url, dependencies)); } const transformedManifest = await transformManifest({ additionalPrecacheEntries, dontCacheBustURLsMatching, manifestTransforms, maximumFileSizeToCacheInBytes, modifyURLPrefix, fileDetails: Array.from(allFileDetails.values()), disablePrecacheManifest }); transformedManifest.warnings.push(...warnings); return transformedManifest; }; //#endregion //#region src/lib/validate-options.ts const validateGetManifestOptions = async (input) => { const result = await (await import("./chunks/get-manifest-De0D0LAJ.js").then((n) => n.n)).getManifestOptions.spa(input, { error: validationErrorMap }); if (!result.success) throw new SerwistConfigError({ moduleName: "@serwist/build", message: z.prettifyError(result.error) }); return result.data; }; const validateInjectManifestOptions = async (input) => { const result = await (await import("./chunks/inject-manifest-DNqDY-04.js").then((n) => n.r)).injectManifestOptions.spa(input, { error: validationErrorMap }); if (!result.success) throw new SerwistConfigError({ moduleName: "@serwist/build", message: z.prettifyError(result.error) }); return result.data; }; //#endregion //#region src/get-manifest.ts /** * This method returns a list of URLs to precache, referred to as a "precache * manifest", along with details about the number of entries and their size, * based on the options you provide. * * ``` * // The following lists some common options; see the rest of the documentation * // for the full set of options and defaults. * const {count, manifestEntries, size, warnings} = await getManifest({ * dontCacheBustURLsMatching: [new RegExp('...')], * globDirectory: '...', * globPatterns: ['...', '...'], * maximumFileSizeToCacheInBytes: ..., * }); * ``` */ const getManifest = async (config) => { return await getFileManifestEntries(await validateGetManifestOptions(config)); }; //#endregion //#region src/lib/get-source-map-url.ts const innerRegex = /[#@] sourceMappingURL=([^\s'"]*)/; const regex = RegExp(`(?:/\\*(?:\\s*\r?\n(?://)?)?(?:${innerRegex.source})\\s*\\*/|//(?:${innerRegex.source}))\\s*`); function getSourceMapURL(srcContents) { const match = srcContents.match(regex); return match ? match[1] || match[2] || "" : null; } //#endregion //#region src/lib/rebase-path.ts function rebasePath({ baseDirectory, file }) { const absolutePath = path.resolve(file); const relativePath = path.relative(baseDirectory, absolutePath); return toUnix(path.normalize(relativePath)); } //#endregion //#region src/lib/replace-and-update-source-map.ts /** * Adapted from https://github.com/nsams/sourcemap-aware-replace, with modern * JavaScript updates, along with additional properties copied from originalMap. * * @param options * @returns An object containing both * originalSource with the replacement applied, and the modified originalMap. * @private */ async function replaceAndUpdateSourceMap({ jsFilename, originalMap, originalSource, replaceString, searchString }) { const generator = new SourceMapGenerator({ file: jsFilename }); const consumer = await new SourceMapConsumer(originalMap); let pos; let src = originalSource; const replacements = []; let lineNum = 0; let filePos = 0; const lines = src.split("\n"); for (let line of lines) { lineNum++; let searchPos = 0; while ((pos = line.indexOf(searchString, searchPos)) !== -1) { src = src.substring(0, filePos + pos) + replaceString + src.substring(filePos + pos + searchString.length); line = line.substring(0, pos) + replaceString + line.substring(pos + searchString.length); replacements.push({ line: lineNum, column: pos }); searchPos = pos + replaceString.length; } filePos += line.length + 1; } replacements.reverse(); consumer.eachMapping((mapping) => { for (const replacement of replacements) if (replacement.line === mapping.generatedLine && mapping.generatedColumn > replacement.column) { const offset = searchString.length - replaceString.length; mapping.generatedColumn -= offset; } if (mapping.source) { const newMapping = { generated: { line: mapping.generatedLine, column: mapping.generatedColumn }, original: { line: mapping.originalLine, column: mapping.originalColumn }, source: mapping.source }; return generator.addMapping(newMapping); } return mapping; }); consumer.destroy(); const updatedSourceMap = Object.assign(JSON.parse(generator.toString()), { names: originalMap.names, sourceRoot: originalMap.sourceRoot, sources: originalMap.sources, sourcesContent: originalMap.sourcesContent }); return { map: JSON.stringify(updatedSourceMap), source: src }; } //#endregion //#region src/lib/translate-url-to-sourcemap-paths.ts function translateURLToSourcemapPaths(url, swSrc, swDest) { let destPath; let srcPath; let warning; if (url && !url.startsWith("data:")) { const possibleSrcPath = path.resolve(path.dirname(swSrc), url); if (fs.existsSync(possibleSrcPath)) { srcPath = toUnix(possibleSrcPath); destPath = toUnix(path.resolve(path.dirname(swDest), url)); } else warning = `${errors["cant-find-sourcemap"]} ${possibleSrcPath}`; } return { destPath, srcPath, warning }; } //#endregion //#region src/inject-manifest.ts /** * This method creates a list of URLs to precache, referred to as a "precache * manifest", based on the options you provide. * * The manifest is injected into the `swSrc` file, and the placeholder string * `injectionPoint` determines where in the file the manifest should go. * * The final service worker file, with the manifest injected, is written to * disk at `swDest`. * * This method will not compile or bundle your `swSrc` file; it just handles * injecting the manifest. * * ``` * // The following lists some common options; see the rest of the documentation * // for the full set of options and defaults. * const {count, size, warnings} = await injectManifest({ * dontCacheBustURLsMatching: [new RegExp('...')], * globDirectory: '...', * globPatterns: ['...', '...'], * maximumFileSizeToCacheInBytes: ..., * swDest: '...', * swSrc: '...', * }); * ``` */ const injectManifest = async (config) => { const options = await validateInjectManifestOptions(config); for (const file of [options.swSrc, options.swDest]) options.globIgnores.push(rebasePath({ file, baseDirectory: options.globDirectory })); const globalRegexp = new RegExp(escapeRegExp(options.injectionPoint), "g"); const { count, size, manifestEntries, warnings } = await getFileManifestEntries(options); let swFileContents; try { swFileContents = await fsp.readFile(options.swSrc, "utf8"); } catch (error) { throw new Error(`${errors["invalid-sw-src"]} ${error instanceof Error && error.message ? error.message : ""}`); } const injectionResults = swFileContents.match(globalRegexp); const injectionPoint = options.injectionPoint ? options.injectionPoint : ""; if (!injectionResults) { if (path.resolve(options.swSrc) === path.resolve(options.swDest)) throw new Error(`${errors["same-src-and-dest"]} ${injectionPoint}`); throw new Error(`${errors["injection-point-not-found"]} ${injectionPoint}`); } assert(injectionResults.length === 1, `${errors["multiple-injection-points"]} ${injectionPoint}`); const manifestString = manifestEntries === void 0 ? "undefined" : JSON.stringify(manifestEntries); const filesToWrite = {}; const { destPath, srcPath, warning } = translateURLToSourcemapPaths(getSourceMapURL(swFileContents), options.swSrc, options.swDest); if (warning) warnings.push(warning); if (srcPath && destPath) { const { map, source } = await replaceAndUpdateSourceMap({ originalMap: JSON.parse(await fsp.readFile(srcPath, "utf-8")), jsFilename: toUnix(path.basename(options.swDest)), originalSource: swFileContents, replaceString: manifestString, searchString: options.injectionPoint }); filesToWrite[options.swDest] = source; filesToWrite[destPath] = map; } else filesToWrite[options.swDest] = swFileContents.replace(globalRegexp, manifestString); for (const [file, contents] of Object.entries(filesToWrite)) { try { await fsp.mkdir(path.dirname(file), { recursive: true }); } catch (error) { throw new Error(`${errors["unable-to-make-sw-directory"]} '${error instanceof Error && error.message ? error.message : ""}'`); } await fsp.writeFile(file, contents); } return { count, size, warnings, filePaths: Object.keys(filesToWrite).map((f) => toUnix(path.resolve(f))) }; }; //#endregion //#region src/index.ts /** * Use `JSON.stringify` instead. * * @deprecated */ const stringify = JSON.stringify; //#endregion export { DEFAULT_GLOB_PATTERNS, errors, escapeRegExp, getFileManifestEntries, getManifest, getSourceMapURL, injectManifest, rebasePath, replaceAndUpdateSourceMap, stringify, transformManifest, translateURLToSourcemapPaths, validateGetManifestOptions, validateInjectManifestOptions }; //# sourceMappingURL=index.mjs.map