UNPKG

vite-plugin-singlefile-compression

Version:

Compress all assets and embeds them into dist/index.html, making it convenient to share as a single HTML file.

459 lines (458 loc) 18.8 kB
import pc from "picocolors"; import { minify } from "html-minifier-terser"; import { JSDOM } from "jsdom"; import path from "path"; import fs from "fs"; import { pathToFileURL } from "url"; import base128 from "base128-ascii"; import zlib from "zlib"; import svgToTinyDataUri from "mini-svg-data-uri"; import { lookup } from "mrmime"; //#region package.json var name = "vite-plugin-singlefile-compression"; var version = "2.4.8"; //#endregion //#region src/compress.ts const compressors = { "deflate-raw"(script) { return zlib.deflateRawSync(script, { level: zlib.constants.Z_BEST_COMPRESSION }); }, deflate(script) { return zlib.deflateSync(script, { level: zlib.constants.Z_BEST_COMPRESSION }); }, gzip(script) { return zlib.gzipSync(script, { level: zlib.constants.Z_BEST_COMPRESSION }); }, brotli: zlib.brotliCompressSync, zstd: zlib.zstdCompressSync && function(script) { return zlib.zstdCompressSync(script, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 19 } }); } }; const compressFormatAlias = { deflateRaw: "deflate-raw", gz: "gzip", br: "brotli", brotliCompress: "brotli", zstandard: "zstd", zst: "zstd" }; function switchCompressor(format) { if (compressors.hasOwnProperty(format)) { const f = compressors[format]; if (f) return f; throw Error(`Could not get compressor: Please upgrade node.js or set your compressor function.`); } { const _format = format.replace(/-([a-zA-Z])/g, (m, a) => a.toUpperCase()); for (const funcName of [_format + "CompressSync", _format + "Sync"]) if (Object.prototype.hasOwnProperty.call(zlib, funcName)) { const f = zlib[funcName]; if (typeof f == "function") return f; } } try { const cs = new CompressionStream(format); return (script) => new Response(new Response(script).body.pipeThrough(cs)).bytes(); } catch {} throw Error(`Could not get compressor: Unknown compress format '${format}', please set your compressor function.`); } function compress(format, script, compressor) { if (typeof compressor != "function") compressor = switchCompressor(format); return compressor(script); } //#endregion //#region _dist/templateRaw.ts const files = { "assets": ["{let e=", ";for(let l in e)for(let r of document.querySelectorAll(`[src=\"data:${l}\"]`))r.src=e[l]}"], "base128": [ "{let e=Response,_=document,n=_.createElement(\"script\"),t=", ",r=", ",o=new Uint8Array(", "),p=0,d=0,T,a,h=e=>(a=t.charCodeAt(p++))>>7?a=0:a;for(n.type=\"module\";p<", ";o[d++]=a<<8-T|h()>>--T)T||h(T=7);new e(new e(o).body.pipeThrough(new DecompressionStream(", "))).text().then(e=>{n.innerHTML=e,_.head.appendChild(n)})}" ], "base64": [ "fetch(\"data:;base64,", "\").then(e=>new Response(e.body.pipeThrough(new DecompressionStream(", "))).text()).then(e=>{var t=document,n=t.createElement(\"script\");n.type=\"module\",n.innerHTML=e,t.head.appendChild(n)})" ], "importmeta": ["{let e=import.meta,t=e.resolve,r=e.url=new URL(", ",location).href;e.resolve=function(e){return/^\\.{0,2}\\//.test(e)?new URL(e,r).href:t.apply(this,arguments)}}"] }; //#endregion //#region src/to-base64.ts const toBase64 = typeof Uint8Array.prototype.toBase64 == "function" ? (bytes) => Uint8Array.prototype.toBase64.call(bytes) : (bytes) => Buffer.prototype.base64Slice.call(bytes, 0, bytes.length); //#endregion //#region src/getTemplate.ts /** base128 const inputLen string */ const base128ConstInputLenStr = files.base128[0].slice(-2, -1) + ".length"; /** base128 inputLen variable name */ const base128InputLenVarName = files.base128[1].slice(-2, -1); /** base128 const outLen string */ const base128ConstOutLenStr = base128InputLenVarName + "/8*7"; const template = { async base(script, format, useBase128, compressor) { const compressedBytes = await compress(format, script, compressor); const _format_ = JSON.stringify(format); if (useBase128) { const t = files.base128; const b128Result = base128.encode(compressedBytes); const b128Str = b128Result.toJSTemplateLiterals(); /** input length string */ const inputLenStr = b128Result.bytes.length.toString(); /** out length string */ const outLenStr = compressedBytes.length.toString(); if (inputLenStr.length + outLenStr.length > 17) return t[0].concat(b128Str, t[1], base128ConstInputLenStr, t[2], base128ConstOutLenStr, t[3], base128InputLenVarName, t[4], _format_, t[5]); return t[0].concat(b128Str, t[2], outLenStr, t[3], inputLenStr, t[4], _format_, t[5]); } const t = files.base64; const b64 = toBase64(compressedBytes); return t[0].concat(b64, t[1], _format_, t[2]); }, assets(assetsJSON) { const t = files.assets; return t[0].concat(JSON.stringify(assetsJSON), t[1]); }, importmeta(p) { const t = files.importmeta; return t[0].concat(JSON.stringify(p), t[1]); } }; //#endregion //#region src/encoding-api.ts const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); //#endregion //#region src/dataurl.ts function toDataURL(name, source) { if (/\.svg$/i.test(name)) { if (typeof source != "string") source = textDecoder.decode(source); return svgToTinyDataUri(source); } if (typeof source == "string") source = textEncoder.encode(source); return `data:${lookup(name)};base64,${toBase64(source)}`; } function toDataURL_andGetByteLength(name, source) { if (/\.svg$/i.test(name)) { if (typeof source == "string") return { dataURL: svgToTinyDataUri(source), byteLength: Buffer.byteLength(source) }; return { dataURL: svgToTinyDataUri(textDecoder.decode(source)), byteLength: source.byteLength }; } if (typeof source == "string") source = textEncoder.encode(source); return { dataURL: `data:${lookup(name)};base64,${toBase64(source)}`, byteLength: source.byteLength }; } //#endregion //#region src/kB.ts function kB(size) { const s = String(size).padStart(4, "0"); return `${s.slice(0, -3)}.${s.slice(-3)} kB`; } //#endregion //#region src/options.ts const defaultHtmlMinifierTerserOptions = { removeAttributeQuotes: true, removeComments: true, collapseWhitespace: true, removeOptionalTags: true, removeRedundantAttributes: true, minifyJS: false }; function getInnerOptions(opt) { opt ||= {}; const enableCompress = opt.enableCompress ?? true; return { rename: opt.rename == null ? void 0 : String(opt.rename), enableCompress, useBase128: opt.useBase128 ?? true, compressFormat: opt.compressFormat ? compressFormatAlias.hasOwnProperty(opt.compressFormat) ? compressFormatAlias[opt.compressFormat] : String(opt.compressFormat) : "deflate-raw", compressor: typeof opt.compressor == "function" ? opt.compressor : void 0, htmlMinifierTerser: opt.htmlMinifierTerser == null || opt.htmlMinifierTerser === true ? defaultHtmlMinifierTerserOptions : opt.htmlMinifierTerser, tryInlineHtmlAssets: opt.tryInlineHtmlAssets ?? true, removeInlinedAssetFiles: opt.removeInlinedAssetFiles ?? true, tryInlineHtmlPublicIcon: opt.tryInlineHtmlPublicIcon ?? true, removeInlinedPublicIconFiles: opt.removeInlinedPublicIconFiles ?? true, enableCompressInlinedIcon: enableCompress && (opt.enableCompressInlinedIcon ?? false), useImportMetaPolyfill: opt.useImportMetaPolyfill ?? false, quiet: opt.quiet ?? false }; } //#endregion //#region src/cutPrefix.ts function cutPrefix(str, prefix) { return str.startsWith(prefix) ? str.slice(prefix.length) : str; } //#endregion //#region src/source-to-string.ts function sourceToString(source) { return typeof source == "string" ? source : textDecoder.decode(source); } //#endregion //#region src/has-substring.ts function hasSubstring(str, sub) { const i = str.indexOf(sub); if (i === -1) return 0; if (i === str.lastIndexOf(sub)) return 1; return 2; } //#endregion //#region src/index.ts function singleFileCompression(opt) { let conf; const innerOptions = getInnerOptions(opt); return { name, enforce: "post", config(...args) { return setConfig.call(this, innerOptions, ...args); }, configResolved(c) { conf = c; }, generateBundle(outputOptions, bundle, isWrite) { return generateBundle.call(this, bundle, conf, innerOptions); } }; } function setConfig(opt, config, env) { config.base ??= "./"; const build = config.build ??= {}; build.cssCodeSplit ??= false; build.assetsInlineLimit ??= () => true; build.modulePreload ?? build.polyfillModulePreload ?? (build.modulePreload = { polyfill: false }); if (this.meta.rolldownVersion) { const rolldownOptions = build.rolldownOptions ?? build.rollupOptions ?? (build.rolldownOptions = {}); for (const output of [rolldownOptions.output ??= {}].flat(1)) output.codeSplitting ?? output.inlineDynamicImports ?? (output.codeSplitting = false); } else { const rollupOptions = build.rollupOptions ??= {}; for (const output of [rollupOptions.output ??= {}].flat(1)) output.inlineDynamicImports ??= true; } } async function generateBundle(bundle, config, options) { if (!options.quiet) console.log(pc.reset("\n\n") + pc.cyan(name + " " + version) + " " + (options.enableCompress ? pc.green(options.compressFormat + " " + (options.useBase128 ? "base128-ascii" : "base64")) : pc.red("disable-compress"))); if (options.rename && options.rename != "index.html" && Object.prototype.hasOwnProperty.call(bundle, "index.html") && !Object.prototype.hasOwnProperty.call(bundle, options.rename)) { bundle[options.rename] = bundle["index.html"]; bundle[options.rename].fileName = options.rename; delete bundle["index.html"]; } let fakeScript = ""; const regenerateFakeScript = () => { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$0123456789"; fakeScript = alphabet.charAt(Math.random() * 54); for (let i = 1; i < 8; i++) fakeScript += alphabet.charAt(Math.random() * 64); fakeScript += "()"; }; regenerateFakeScript(); const distURL = pathToFileURL(config.build.outDir).href + "/"; /** "assets/" */ const assetsDir = path.posix.join(config.build.assetsDir, "/"); /** "./assets/" */ const assetsDirWithBase = config.base + assetsDir; /** '[href^="./assets/"]' */ const assetsHrefSelector = `[href^="${assetsDirWithBase}"]`; /** '[src^="./assets/"]' */ const assetsSrcSelector = `[src^="${assetsDirWithBase}"]`; const globalDelete = /* @__PURE__ */ new Set(); const globalDoNotDelete = /* @__PURE__ */ new Set(); const globalRemoveDistFileNames = /* @__PURE__ */ new Set(); const globalAssetsDataURLCache = {}; const globalPublicFilesCache = {}; /** format: ["assets/index-XXXXXXXX.js"] */ const bundleAssetsNames = []; /** format: ["index.html"] */ const bundleHTMLNames = []; for (const name in bundle) if (name.startsWith(assetsDir)) bundleAssetsNames.push(name); else if (/\.html$/i.test(name)) bundleHTMLNames.push(name); for (const htmlFileName of bundleHTMLNames) { if (!options.quiet) console.log("\n " + pc.underline(pc.cyan(distURL) + pc.greenBright(bundle[htmlFileName].fileName))); const htmlChunk = bundle[htmlFileName], oldHTML = sourceToString(htmlChunk.source), dom = new JSDOM(oldHTML), document = dom.window.document, thisDel = /* @__PURE__ */ new Set(), newJSCode = [], scriptElement = document.querySelector(`script[type=module]${assetsSrcSelector}`), scriptName = scriptElement ? cutPrefix(scriptElement.getAttribute("src"), config.base) : "", compressHeadElements = []; let oldSize = options.quiet ? NaN : Buffer.byteLength(oldHTML); scriptElement?.remove(); let allCSS = ""; const linkStylesheet = document.querySelectorAll(`link[rel=stylesheet]${assetsHrefSelector}`); for (const element of linkStylesheet) { const name = cutPrefix(element.href, config.base); thisDel.add(name); const css = bundle[name]; const cssSource = sourceToString(css.source); if (cssSource) { if (!options.quiet) oldSize += Buffer.byteLength(cssSource); for (const name of bundleAssetsNames) if (cssSource.includes(name.slice(assetsDir.length))) globalDoNotDelete.add(name); allCSS += cssSource.replace(/(\s*\/\*([^*]|\*(?!\/))*\*\/)*\s*$/, ""); } if (options.enableCompress) element.remove(); } if (allCSS) { const e = document.createElement("style"); e.innerHTML = allCSS; if (options.enableCompress) compressHeadElements.push(e); else { linkStylesheet[0].before(e); for (const e of linkStylesheet) e.remove(); } } const assetsDataURL = {}; if (options.tryInlineHtmlAssets) for (const element of document.querySelectorAll(assetsSrcSelector)) { const name = cutPrefix(element.src, assetsDirWithBase); if (/\.js$/i.test(name)) continue; if (!options.enableCompress || !Object.prototype.hasOwnProperty.call(assetsDataURL, name)) { const bundleName = assetsDir + name; const a = bundle[bundleName]; if (!a) continue; thisDel.add(bundleName); let dataURL; if (Object.prototype.hasOwnProperty.call(globalAssetsDataURLCache, name)) { const cache = globalAssetsDataURLCache[name]; if (!options.quiet) oldSize += cache.byteLength; dataURL = cache.dataURL; } else if (options.quiet) { dataURL = toDataURL(name, a.source); globalAssetsDataURLCache[name] = { dataURL, byteLength: NaN }; } else { const result = toDataURL_andGetByteLength(name, a.source); oldSize += result.byteLength; dataURL = result.dataURL; globalAssetsDataURLCache[name] = result; } if (options.enableCompress) assetsDataURL[name] = dataURL; else element.src = dataURL; } if (options.enableCompress) element.src = `data:${name}`; } const createIconElement = (href) => { const e = document.createElement("link"); e.rel = "icon"; if (href != null) e.href = href; return e; }; const getPublicIcon = (faviconName) => { if (Object.prototype.hasOwnProperty.call(globalPublicFilesCache, faviconName)) return globalPublicFilesCache[faviconName]; let _path = path.join(config.build.outDir, faviconName); if (fs.existsSync(_path)) globalRemoveDistFileNames.add(faviconName); else { _path = path.join(config.publicDir, faviconName); if (!fs.existsSync(_path)) return null; } const b = fs.readFileSync(_path); return globalPublicFilesCache[faviconName] = { buffer: b, dataURL: toDataURL(faviconName, b), size: b.length }; }; const linkFaviconAll = document.querySelectorAll(`link[rel=icon][href]:not([href=""]),link[rel="shortcut icon"][href]:not([href=""])`); if (linkFaviconAll.length == 0) { if (options.tryInlineHtmlPublicIcon) { const fileCache = getPublicIcon("favicon.ico"); if (fileCache) { if (!options.quiet) oldSize += fileCache.size; const e = createIconElement(fileCache.dataURL); if (options.enableCompressInlinedIcon) compressHeadElements.push(e); else document.head.appendChild(e); } } } else for (const linkFavicon of linkFaviconAll) { let faviconName = linkFavicon.href; const faviconIsDataURL = /^data:/i.test(faviconName); if (!faviconIsDataURL) faviconName = cutPrefix(faviconName, config.base); const setFaviconDataURL = (dataURL) => { if (options.enableCompressInlinedIcon) if (linkFavicon) { linkFavicon.remove(); linkFavicon.href = dataURL; compressHeadElements.push(linkFavicon); } else compressHeadElements.push(createIconElement(dataURL)); else if (linkFavicon) linkFavicon.href = dataURL; else document.head.appendChild(createIconElement(dataURL)); }; if (faviconIsDataURL) { if (options.enableCompressInlinedIcon) { linkFavicon.remove(); compressHeadElements.push(linkFavicon); } } else if (bundleAssetsNames.includes(faviconName)) { const asset = bundle[faviconName]; if (asset) { setFaviconDataURL(toDataURL(faviconName, asset.source)); thisDel.add(faviconName); } } else if (options.tryInlineHtmlPublicIcon) { const fileCache = getPublicIcon(faviconName); if (fileCache) { if (!options.quiet) oldSize += fileCache.size; setFaviconDataURL(fileCache.dataURL); } } } if (compressHeadElements.length) { const html = compressHeadElements.map((v) => v.outerHTML).join(""); newJSCode.push(`document.head.insertAdjacentHTML("beforeend",${JSON.stringify(html)})`); } let newHTML = ""; const fakeScriptElement = document.body.appendChild(document.createElement("script")); for (;;) { fakeScriptElement.innerHTML = fakeScript; newHTML = dom.serialize(); const has = hasSubstring(newHTML, fakeScript); if (has === 0) throw Error(`Failed to inject fakeScript`); if (has === 1) { if (!options.htmlMinifierTerser) break; newHTML = await minify(newHTML, options.htmlMinifierTerser); const has = hasSubstring(newHTML, fakeScript); if (has === 0) throw Error(`Failed to inject fakeScript`); if (has === 1) break; } regenerateFakeScript(); } function inlineHtmlAssets() { if (options.tryInlineHtmlAssets) { const assetsJSON = JSON.stringify(assetsDataURL); if (assetsJSON !== "{}") newJSCode.push(template.assets(assetsJSON)); } } if (scriptElement) { thisDel.add(scriptName); let { code } = bundle[scriptName]; if (!options.quiet) oldSize += Buffer.byteLength(code); code = code.replace(/;?\s*$/, ""); for (const name of bundleAssetsNames) { const assetName = name.slice(assetsDir.length); if (code.includes(assetName)) globalDoNotDelete.add(name); } inlineHtmlAssets(); if (options.useImportMetaPolyfill) newJSCode.push(template.importmeta(scriptName)); if (/\b__VITE_PRELOAD__\b/.test(code)) if (code.startsWith("var ")) code = "var __VITE_PRELOAD__," + code.slice(4); else newJSCode.push("var __VITE_PRELOAD__"); newJSCode.push(code); } else inlineHtmlAssets(); let outputScript = newJSCode.join(";").replaceAll("<\/script", "<\\/script"); if (options.enableCompress) outputScript = await template.base(outputScript, options.compressFormat, options.useBase128, options.compressor); htmlChunk.source = newHTML = newHTML.replace(fakeScript, () => outputScript); if (!options.quiet) console.log(" " + pc.gray(kB(oldSize) + " -> ") + pc.cyanBright(kB(Buffer.byteLength(newHTML))) + "\n"); for (const name of thisDel) globalDelete.add(name); } if (options.removeInlinedAssetFiles) { for (const name of globalDelete) if (!globalDoNotDelete.has(name)) delete bundle[name]; } if (options.removeInlinedPublicIconFiles) { const { outDir } = config.build; const mustStartsWith = path.resolve(outDir) + path.sep; for (const name of globalRemoveDistFileNames) try { const _path = path.resolve(outDir, name); if (_path.startsWith(mustStartsWith)) fs.rmSync(_path, { force: true }); } catch (e) { if (!options.quiet) console.error(e); } } if (!options.quiet) console.log(pc.green("Finish.") + pc.reset("\n")); } //#endregion export { singleFileCompression as default, singleFileCompression, defaultHtmlMinifierTerserOptions };