vite-preload
Version:
Speed up your Vite application by preloading server rendered lazy modules and stylesheets as early as possible
369 lines (365 loc) • 14.3 kB
JavaScript
;
var fs = require('node:fs');
var path = require('node:path');
var utils = require('./utils.cjs');
class ChunkCollector {
constructor(manifest, entry, preloadFonts = true, preloadAssets = false, nonce = '', asyncScript = false) {
Object.defineProperty(this, "manifest", {
enumerable: true,
configurable: true,
writable: true,
value: manifest
});
Object.defineProperty(this, "entry", {
enumerable: true,
configurable: true,
writable: true,
value: entry
});
Object.defineProperty(this, "preloadFonts", {
enumerable: true,
configurable: true,
writable: true,
value: preloadFonts
});
Object.defineProperty(this, "preloadAssets", {
enumerable: true,
configurable: true,
writable: true,
value: preloadAssets
});
Object.defineProperty(this, "nonce", {
enumerable: true,
configurable: true,
writable: true,
value: nonce
});
Object.defineProperty(this, "asyncScript", {
enumerable: true,
configurable: true,
writable: true,
value: asyncScript
});
Object.defineProperty(this, "modulesIds", {
enumerable: true,
configurable: true,
writable: true,
value: new Set()
});
Object.defineProperty(this, "preloads", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
this.__context_collectModuleId =
this.__context_collectModuleId.bind(this);
this.getChunks = this.getChunks.bind(this);
this.getSortedModules = this.getSortedModules.bind(this);
this.getTags = this.getTags.bind(this);
this.getLinkHeader = this.getLinkHeader.bind(this);
this.getLinkHeaders = this.getLinkHeaders.bind(this);
// Load the entry modules
collectModules('vite/legacy-polyfills', this);
collectModules(entry, this);
}
/**
* Function is called by `ChunkCollectorContext`
*/
__context_collectModuleId(moduleId) {
this.modulesIds.add(moduleId);
collectModules(moduleId, this);
}
/**
* @deprecated - use getChunks instead
*/
getSortedModules() {
const modules = Array.from(this.preloads.values());
return utils.sortPreloads(modules);
}
getChunks() {
const modules = Array.from(this.preloads.values());
return utils.sortPreloads(modules);
}
/**
* Returns all HTML tags for preload hints and stylesheets.
*
* See https://vitejs.dev/guide/backend-integration for using your own template
*/
getTags({ includeEntry, } = {}) {
const modules = this.getChunks();
return modules
.filter((m) => includeEntry || !m.isEntry)
.map(utils.createHtmlTag)
.filter((x) => x != null)
.join('\n');
}
/**
* Returns a `Link` header with all chunks to preload,
* including entry chunks.
*
* @example res.setHeader('link', collector.getLinkHeader());
*/
getLinkHeader() {
const modules = this.getChunks();
return utils.createLinkHeader(modules);
}
/**
* Returns an array of `Link` header values
*
* @example res.append('link', collector.getLinkHeaders());
*/
getLinkHeaders() {
return this.getChunks()
.map(utils.createSingleLinkHeader)
.filter((x) => x != null);
}
}
let manifestFromFile;
/**
* Create a chunk collector.
* This function will throw if not configured correctly
*/
function createChunkCollector(options) {
let manifest = {};
const entry = options.entry || 'index.html';
const enabled = process.env.NODE_ENV === 'production';
if (enabled) {
if (typeof options.manifest === 'string') {
if (manifestFromFile) {
manifest = manifestFromFile;
}
else {
const data = fs.readFileSync(options.manifest, 'utf8');
const json = JSON.parse(data);
manifestFromFile = manifest = json;
}
}
else {
manifest = options.manifest;
}
if (!options.manifest) {
throw new Error('options.manifest must be provided in production either as a path or object');
}
if (!manifest[entry]) {
throw new Error(`Vite manifest.json does not contain key "${entry}"`);
}
if (!manifest[entry].isEntry) {
throw new Error(`Module "${entry}" is not an entry module`);
}
}
const collector = new ChunkCollector(manifest, entry, options.preloadFonts, options.preloadAssets, options.nonce, options.asyncScript);
return collector;
}
/*
url: '/src/pages/Browse/index.ts',
id: '/<absolute>/src/pages/Browse/index.ts',
file: '/<absolute>/src/pages/Browse/index.ts',
*/
/**
* https://vitejs.dev/guide/backend-integration
* https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports
* https://github.com/vitejs/vite-plugin-vue/blob/main/playground/ssr-vue/src/entry-server.js
*/
/**
* This function figures out what modules are used based on the modules rendered by React.
*
* It follows https://vitejs.dev/guide/backend-integration
*/
function collectModules(moduleId, { entry, manifest, preloadAssets, preloadFonts, preloads, nonce, asyncScript, }) {
// The reported module ID is not in it's own chunk
// Possible cause for the missing module in the manifest is build.rollupOptions.output.experimentalMinChunkSize
if (!manifest[moduleId] || preloads.has(moduleId)) {
return preloads;
}
const built = getBuiltChunks(manifest, moduleId, entry, preloadFonts, preloadAssets, nonce, asyncScript);
for (const c of built) {
// Skip the whole chunk if its script file is already collected — mirrors the original
// `if (preloads.has(chunk.file)) continue;`, which dropped that chunk's css + assets too.
if (preloads.has(c.file)) {
continue;
}
preloads.set(c.file, c.script);
for (const [cssFile, cssPreload] of c.css) {
if (preloads.has(cssFile))
continue;
preloads.set(cssFile, cssPreload);
}
// Assets were set unconditionally in the original (last-write-wins, no has() guard).
for (const [assetFile, assetPreload] of c.assets) {
preloads.set(assetFile, assetPreload);
}
}
return preloads;
}
/**
* Per-manifest cache of pre-built per-module descriptors, keyed by
* `${entry}\0${flags}\0${moduleId}`.
*
* Only used when `nonce` is empty. The `Preload` descriptor objects (and their `comment`
* template strings) are otherwise a pure function of
* `(manifest, moduleId, entry, preloadFonts, preloadAssets, asyncScript)` — all stable at
* runtime — so the closure-memo (`collectChunks`) left descriptor construction as the largest
* remaining per-render allocation in this module. Building each module's descriptors once per
* process removes it. A non-empty per-request CSP `nonce` bypasses the cache and builds fresh,
* so cached objects never carry the wrong nonce and the cache cannot grow per request.
* WeakMap-keyed on the manifest object so a replaced manifest (rebuild / dev HMR) drops the
* stale cache.
*
* The cached `Preload` objects are SHARED across renders — callers must treat them as
* immutable (vite-preload only reads them in getChunks/getTags/getLinkHeader/sortPreloads).
*/
const descriptorCache = new WeakMap();
function buildChunksForModule(manifest, moduleId, entry, preloadFonts, preloadAssets, nonce, asyncScript) {
const chunks = collectChunks(manifest, moduleId);
const built = [];
for (const chunk of chunks.values()) {
const isPolyfill = chunk.src === 'vite/legacy-polyfills';
const isPrimaryModule = chunk.src === entry;
const script = {
// Only the entrypoint module is used as <script module>, everything else is <link rel=modulepreload>
rel: isPrimaryModule || isPolyfill ? 'module' : 'modulepreload',
href: chunk.file,
comment: `chunk: ${chunk.name}, isEntry: ${chunk.isEntry}`,
isEntry: chunk.isEntry,
nonce,
// The polyfill chunk should not be async and it should run before the entry chunk
asyncScript: asyncScript && !isPolyfill,
};
const css = [];
for (const cssFile of chunk.css || []) {
css.push([
cssFile,
{
rel: 'stylesheet',
href: cssFile,
comment: `chunk: ${chunk.name}, isEntry: ${chunk.isEntry}`,
isEntry: chunk.isEntry,
nonce,
},
]);
}
const assets = [];
if (preloadFonts || preloadAssets) {
// Assets such as svg, png imports
for (const asset of chunk.assets || []) {
const ext = path.extname(asset).substring(1);
let as;
let mimeType;
let skip = false;
switch (ext) {
case 'png':
case 'jpg':
case 'webp':
case 'svg':
as = 'image';
mimeType =
ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
if (!preloadAssets)
skip = true;
break;
case 'woff2':
case 'woff':
case 'ttf':
as = 'font';
mimeType = `font/${ext}`;
if (!preloadFonts)
skip = true;
break;
default:
// Unknown asset type: a <link rel="preload"> without a valid `as` is
// ignored by browsers (and Chrome logs a console warning), so emitting
// one is just wasted bytes. Skip it.
skip = true;
break;
}
if (skip)
continue;
assets.push([
asset,
{
rel: 'preload',
href: asset,
as,
type: mimeType,
comment: `Asset from chunk ${chunk.name}: ${chunk.file}`,
},
]);
}
}
built.push({ file: chunk.file, script, css, assets });
}
return built;
}
/**
* Returns the pre-built per-module chunk descriptors, memoized per process when `nonce` is
* empty (see {@link descriptorCache}); built fresh otherwise.
*/
function getBuiltChunks(manifest, moduleId, entry, preloadFonts, preloadAssets, nonce, asyncScript) {
// A per-request nonce makes the descriptors request-specific: build fresh, never cache.
if (nonce) {
return buildChunksForModule(manifest, moduleId, entry, preloadFonts, preloadAssets, nonce, asyncScript);
}
let perManifest = descriptorCache.get(manifest);
if (!perManifest) {
perManifest = new Map();
descriptorCache.set(manifest, perManifest);
}
const key = `${entry}\0${preloadFonts ? 1 : 0}${preloadAssets ? 1 : 0}${asyncScript ? 1 : 0}\0${moduleId}`;
let built = perManifest.get(key);
if (!built) {
built = buildChunksForModule(manifest, moduleId, entry, preloadFonts, preloadAssets, nonce, asyncScript);
perManifest.set(key, built);
}
return built;
}
/**
* Per-manifest cache of each module's fully-resolved chunk closure.
*
* `collectChunksRecursively` is a pure function of `(manifest, moduleId)` — the manifest is
* immutable at runtime — yet `collectModules` re-runs it for every rendered module on every
* request. For SSR apps that render many modules with deep, heavily-shared closures this is the
* single largest source of work and allocation in this module: it re-walks the import graph and
* re-clones every `{ ...chunk }` on each render. Memoizing makes each module's walk happen once
* per process. The cache is keyed on the manifest object (WeakMap) so a replaced manifest
* (production rebuild / dev HMR) transparently gets a fresh cache and the old one is GC'd.
*
* The returned map is shared — callers (only `collectModules`) read it and must not mutate it.
*/
const closureCache = new WeakMap();
function collectChunks(manifest, moduleId) {
let perManifest = closureCache.get(manifest);
if (!perManifest) {
perManifest = new Map();
closureCache.set(manifest, perManifest);
}
let chunks = perManifest.get(moduleId);
if (!chunks) {
chunks = new Map();
collectChunksRecursively(manifest, moduleId, chunks);
perManifest.set(moduleId, chunks);
}
return chunks;
}
function collectChunksRecursively(manifest, moduleId, chunks, isEntry) {
const chunk = manifest[moduleId];
if (!chunk) {
throw new Error(`Missing chunk '${moduleId}'`);
}
if (chunks.has(moduleId)) {
return;
}
chunks.set(moduleId, {
...chunk,
// Any static import in the entry chunk is considered an entry chunk
// and inlined by Vite in the generated HTML template but it's not
// marked with isEntry: true in the manifest
isEntry: isEntry || chunk.isEntry,
});
for (const importName of chunk.imports || []) {
collectChunksRecursively(manifest, importName, chunks, isEntry || chunk.isEntry);
}
}
exports.ChunkCollector = ChunkCollector;
exports.createChunkCollector = createChunkCollector;
//# sourceMappingURL=collector.cjs.map