Skip to content

Commit e20c284

Browse files
committed
feat(prerender): hash assets and add version querystring
1 parent 4f8934d commit e20c284

16 files changed

+353
-85
lines changed

src/compiler/optimize/minify-css.ts

+35-6
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,44 @@
1-
import { hasError } from '@utils';
1+
import { CssNode, CssNodeType } from '../style/css-parser/css-parse-declarations'
2+
import { hasError, isFunction, isString } from '@utils';
23
import { parseCss } from '../style/css-parser/parse-css';
34
import { serializeCss } from '../style/css-parser/serialize-css';
45

5-
export const minifyCss = (cssString: string) => {
6-
const parseResults = parseCss(cssString);
6+
export const minifyCss = async (input: { css: string, resolveUrl?: (url: string) => Promise<string> | string; }) => {
7+
const parseResults = parseCss(input.css);
78

89
if (hasError(parseResults.diagnostics)) {
9-
return cssString;
10+
return input.css;
11+
}
12+
if (isFunction(input.resolveUrl) && parseResults.stylesheet && Array.isArray(parseResults.stylesheet.rules)) {
13+
await resolveStylesheetUrl(parseResults.stylesheet.rules, input.resolveUrl, new Map());
1014
}
1115

12-
const output = serializeCss(parseResults.stylesheet, {});
16+
return serializeCss(parseResults.stylesheet, {});
17+
};
1318

14-
return output;
19+
const resolveStylesheetUrl = async (nodes: CssNode[], resolveUrl: (url: string) => Promise<string> | string, resolved: Map<string, string>) => {
20+
for (const node of nodes) {
21+
if (node.type === CssNodeType.Declaration && isString(node.value) && node.value.includes('url(')) {
22+
const urlSplt = node.value.split(',').map(n => n.trim());
23+
for (let i = 0; i < urlSplt.length; i++) {
24+
const r = /url\((.*?)\)/.exec(urlSplt[i]);
25+
if (r) {
26+
const orgUrl = r[1].replace(/(\'|\")/g, '');
27+
const newUrl = await resolveUrl(orgUrl);
28+
urlSplt[i] = urlSplt[i].replace(orgUrl, newUrl);
29+
}
30+
}
31+
node.value = urlSplt.join(',');
32+
}
33+
if (Array.isArray(node.declarations)) {
34+
await resolveStylesheetUrl(node.declarations, resolveUrl, resolved);
35+
}
36+
if (Array.isArray(node.rules)) {
37+
await resolveStylesheetUrl(node.rules, resolveUrl, resolved);
38+
}
39+
if (Array.isArray(node.keyframes)) {
40+
await resolveStylesheetUrl(node.keyframes, resolveUrl, resolved);
41+
}
42+
}
1543
};
44+

src/compiler/optimize/optimize-css.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export const optimizeCss = async (inputOpts: OptimizeCssInput) => {
1515
}
1616
}
1717
if (inputOpts.minify !== false) {
18-
result.output = minifyCss(result.output);
18+
result.output = await minifyCss({
19+
css: result.output,
20+
resolveUrl: inputOpts.resolveUrl
21+
});
1922
}
2023
return result;
2124
};

src/compiler/prerender/prerender-hydrate-options.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const getHydrateOptions = (prerenderConfig: d.PrerenderConfig, url: URL,
88
url: prerenderUrl,
99
addModulePreloads: true,
1010
approximateLineWidth: 100,
11+
hashAssets: 'querystring',
1112
inlineExternalStyleSheets: true,
1213
minifyScriptElements: true,
1314
minifyStyleElements: true,

src/compiler/prerender/prerender-main.ts

+1
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ const runPrerenderOutputTarget = async (
192192
srcIndexHtmlPath,
193193
outputTarget,
194194
hydrateOpts,
195+
manager
195196
);
196197
if (diagnostics.length > 0 || !templateData || !isString(templateData.html)) {
197198
return;

src/compiler/prerender/prerender-optimize.ts

+130-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type * as d from '../../declarations';
2-
import { flatOne, unique } from '@utils';
2+
import { catchError, flatOne, isString, unique } from '@utils';
33
import { getScopeId } from '../style/scope-css';
44
import { injectModulePreloads } from '../html/inject-module-preloads';
55
import { optimizeCss } from '../optimize/optimize-css';
66
import { optimizeJs } from '../optimize/optimize-js';
77
import { join } from 'path';
8+
import { minifyCss } from '../optimize/minify-css';
89

9-
export const inlineExternalStyleSheets = async (config: d.Config, appDir: string, doc: Document) => {
10+
export const inlineExternalStyleSheets = async (sys: d.CompilerSystem, appDir: string, doc: Document) => {
1011
const documentLinks = Array.from(doc.querySelectorAll('link[rel=stylesheet]')) as HTMLLinkElement[];
1112
if (documentLinks.length === 0) {
1213
return;
@@ -22,7 +23,7 @@ export const inlineExternalStyleSheets = async (config: d.Config, appDir: string
2223
const fsPath = join(appDir, href);
2324

2425
try {
25-
let styles = await config.sys.readFile(fsPath);
26+
let styles = await sys.readFile(fsPath);
2627

2728
const optimizeResults = await optimizeCss({
2829
input: styles,
@@ -94,25 +95,27 @@ export const minifyScriptElements = async (doc: Document, addMinifiedAttr: boole
9495
);
9596
};
9697

97-
export const minifyStyleElements = async (doc: Document, addMinifiedAttr: boolean) => {
98+
export const minifyStyleElements = async (sys: d.CompilerSystem, appDir: string, doc: Document, currentUrl: URL, addMinifiedAttr: boolean) => {
9899
const styleElms = Array.from(doc.querySelectorAll('style')).filter(styleElm => {
99100
if (styleElm.hasAttribute(dataMinifiedAttr)) {
100101
return false;
101102
}
102103
return true;
103104
});
104105

105-
if (styleElms.length === 0) {
106-
return;
107-
}
108-
109106
await Promise.all(
110107
styleElms.map(async styleElm => {
111108
const content = styleElm.innerHTML.trim();
112109
if (content.length > 0) {
113110
const optimizeResults = await optimizeCss({
114111
input: content,
115112
minify: true,
113+
async resolveUrl(urlProp) {
114+
const assetUrl = new URL(urlProp, currentUrl);
115+
const hash = await getAssetFileHash(sys, appDir, assetUrl);
116+
assetUrl.searchParams.append('v', hash);
117+
return assetUrl.pathname + assetUrl.search;
118+
}
116119
});
117120
if (optimizeResults.diagnostics.length === 0) {
118121
styleElm.innerHTML = optimizeResults.output;
@@ -191,4 +194,123 @@ export const hasStencilScript = (doc: Document) => {
191194
return !!doc.querySelector('script[data-stencil]');
192195
};
193196

197+
export const hashAssets = async (sys: d.CompilerSystem, diagnostics: d.Diagnostic[], hydrateOpts: d.PrerenderHydrateOptions, appDir: string, doc: Document, currentUrl: URL) => {
198+
// do one at a time to prevent too many opened files and memory usage issues
199+
// hash id is cached in each worker, so shouldn't have to do this for every page
200+
201+
// update the stylesheet content first so the hash url()s are apart of the file's hash too
202+
const links = Array.from(doc.querySelectorAll('link[rel=stylesheet][href]')) as HTMLLinkElement[];
203+
204+
for (const link of links) {
205+
const href = link.getAttribute('href');
206+
if (isString(href) && href.length > 0) {
207+
const stylesheetUrl = new URL(href, currentUrl);
208+
if (currentUrl.host === stylesheetUrl.host) {
209+
try {
210+
const filePath = join(appDir, stylesheetUrl.pathname);
211+
let css = await sys.readFile(filePath);
212+
if (isString(css)) {
213+
css = await minifyCss({
214+
css,
215+
async resolveUrl(urlProp) {
216+
const assetUrl = new URL(urlProp, stylesheetUrl);
217+
const hash = await getAssetFileHash(sys, appDir, assetUrl);
218+
assetUrl.searchParams.append('v', hash);
219+
return assetUrl.pathname + assetUrl.search;
220+
}
221+
});
222+
await sys.writeFile(filePath, css);
223+
}
224+
} catch (e) {
225+
catchError(diagnostics, e);
226+
}
227+
}
228+
}
229+
}
230+
231+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="stylesheet"]', ['href']);
232+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="prefetch"]', ['href']);
233+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="preload"]', ['href']);
234+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="modulepreload"]', ['href']);
235+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="icon"]', ['href']);
236+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="apple-touch-icon"]', ['href']);
237+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'link[rel="manifest"]', ['href']);
238+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'script', ['src']);
239+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'img', ['src', 'srcset']);
240+
await hashAsset(sys, hydrateOpts, appDir, doc, currentUrl, 'picture > source', ['srcset']);
241+
}
242+
243+
const hashAsset = async (sys: d.CompilerSystem, hydrateOpts: d.PrerenderHydrateOptions, appDir: string, doc: Document, currentUrl: URL, selector: string, srcAttrs: string[]) => {
244+
const elms = Array.from(doc.querySelectorAll(selector));
245+
246+
// do one at a time to prevent too many opened files and memory usage issues
247+
for (const elm of elms) {
248+
for (const attrName of srcAttrs) {
249+
const srcValues = getAttrUrls(attrName, elm.getAttribute(attrName));
250+
for (const srcValue of srcValues) {
251+
const assetUrl = new URL(srcValue.src, currentUrl);
252+
if (assetUrl.hostname === currentUrl.hostname) {
253+
if (hydrateOpts.hashAssets === 'querystring' && !assetUrl.searchParams.has('v')) {
254+
const hash = await getAssetFileHash(sys, appDir, assetUrl);
255+
if (isString(hash)) {
256+
assetUrl.searchParams.append('v', hash);
257+
const attrValue = setAttrUrls(assetUrl, srcValue.descriptor);
258+
elm.setAttribute(attrName, attrValue);
259+
}
260+
}
261+
}
262+
}
263+
}
264+
}
265+
}
266+
267+
export const getAttrUrls = (attrName: string, attrValue: string) => {
268+
const srcValues: { src: string, descriptor?: string }[] = [];
269+
if (isString(attrValue)) {
270+
if (attrName.toLowerCase() === 'srcset') {
271+
attrValue.split(',').map(a => a.trim()).filter(a => a.length > 0).forEach(src => {
272+
const spaceSplt = src.split(' ');
273+
if (spaceSplt[0].length > 0) {
274+
srcValues.push({ src: spaceSplt[0], descriptor: spaceSplt[1] });
275+
}
276+
});
277+
} else {
278+
srcValues.push({ src: attrValue });
279+
}
280+
}
281+
return srcValues;
282+
}
283+
284+
export const setAttrUrls = (url: URL, descriptor: string) => {
285+
let src = url.pathname + url.search;
286+
if (isString(descriptor)) {
287+
src += ' ' + descriptor;
288+
}
289+
return src;
290+
};
291+
292+
const hashedAssets = new Map<string, Promise<string | null>>();
293+
294+
const getAssetFileHash = async (sys: d.CompilerSystem, appDir: string, assetUrl: URL) => {
295+
let p = hashedAssets.get(assetUrl.pathname);
296+
if (!p) {
297+
p = new Promise<string | null>(async resolve => {
298+
const assetFilePath = join(appDir, assetUrl.pathname);
299+
try {
300+
const data = await sys.readFile(assetFilePath, 'binary');
301+
if (data != null) {
302+
const hash = await sys.generateContentHash(data, 10);
303+
resolve(hash);
304+
return;
305+
}
306+
} catch (e) {
307+
console.error(e);
308+
}
309+
resolve(null);
310+
});
311+
hashedAssets.set(assetUrl.pathname, p);
312+
}
313+
return p;
314+
}
315+
194316
const dataMinifiedAttr = 'data-m';

src/compiler/prerender/prerender-queue.ts

+1
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const prerenderUrl = async (results: d.PrerenderResults, manager: d.PrerenderMan
113113
}
114114

115115
const prerenderRequest: d.PrerenderUrlRequest = {
116+
appDir: manager.outputTarget.appDir,
116117
baseUrl: manager.outputTarget.baseUrl,
117118
buildId: results.buildId,
118119
componentGraphPath: manager.componentGraphPath,

src/compiler/prerender/prerender-template-html.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const generateTemplateHtml = async (
1717
srcIndexHtmlPath: string,
1818
outputTarget: d.OutputTargetWww,
1919
hydrateOpts: d.PrerenderHydrateOptions,
20+
manager: d.PrerenderManager
2021
) => {
2122
try {
2223
if (!isString(srcIndexHtmlPath)) {
@@ -56,7 +57,7 @@ export const generateTemplateHtml = async (
5657

5758
if (hydrateOpts.inlineExternalStyleSheets && !isDebug) {
5859
try {
59-
await inlineExternalStyleSheets(config, outputTarget.appDir, doc);
60+
await inlineExternalStyleSheets(config.sys, outputTarget.appDir, doc);
6061
} catch (e) {
6162
catchError(diagnostics, e);
6263
}
@@ -72,7 +73,8 @@ export const generateTemplateHtml = async (
7273

7374
if (hydrateOpts.minifyStyleElements && !isDebug) {
7475
try {
75-
await minifyStyleElements(doc, true);
76+
const baseUrl = new URL(outputTarget.baseUrl, manager.devServerHostUrl);
77+
await minifyStyleElements(config.sys, outputTarget.appDir, doc, baseUrl, true);
7678
} catch (e) {
7779
catchError(diagnostics, e);
7880
}

src/compiler/prerender/prerender-worker.ts

+23-13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type * as d from '../../declarations';
22
import {
33
addModulePreloads,
44
excludeStaticComponents,
5+
hashAssets,
56
minifyScriptElements,
67
minifyStyleElements,
78
removeModulePreloads,
@@ -31,6 +32,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d
3132

3233
try {
3334
const url = new URL(prerenderRequest.url, prerenderRequest.devServerHostUrl);
35+
const baseUrl = new URL(prerenderRequest.baseUrl);
3436
const componentGraph = getComponentGraph(sys, prerenderRequest.componentGraphPath);
3537

3638
// webpack work-around/hack
@@ -44,6 +46,7 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d
4446
// create a new window by cloning the cached parsed window
4547
const win = hydrateApp.createWindowFromHtml(prerenderCtx.templateHtml, prerenderRequest.templateId);
4648
const doc = win.document;
49+
win.location.href = url.href;
4750

4851
// patch this new window
4952
if (isFunction(sys.applyPrerenderGlobalPatch)) {
@@ -85,6 +88,17 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d
8588
const hydrateResults = (await hydrateApp.hydrateDocument(doc, hydrateOpts)) as d.HydrateResults;
8689
results.diagnostics.push(...hydrateResults.diagnostics);
8790

91+
if (typeof prerenderConfig.filePath === 'function') {
92+
try {
93+
const userWriteToFilePath = prerenderConfig.filePath(url, results.filePath);
94+
if (typeof userWriteToFilePath === 'string') {
95+
results.filePath = userWriteToFilePath;
96+
}
97+
} catch (e) {
98+
catchError(results.diagnostics, e);
99+
}
100+
}
101+
88102
if (hydrateOpts.staticDocument) {
89103
removeStencilScripts(doc);
90104
removeModulePreloads(doc);
@@ -103,32 +117,28 @@ export const prerenderWorker = async (sys: d.CompilerSystem, prerenderRequest: d
103117
}
104118
}
105119

106-
const minifyPromises: Promise<any>[] = [];
120+
const docPromises: Promise<any>[] = [];
107121
if (hydrateOpts.minifyStyleElements && !prerenderRequest.isDebug) {
108-
minifyPromises.push(minifyStyleElements(doc, false));
122+
docPromises.push(minifyStyleElements(sys, prerenderRequest.appDir, doc, url, false));
109123
}
110124

111125
if (hydrateOpts.minifyScriptElements && !prerenderRequest.isDebug) {
112-
minifyPromises.push(minifyScriptElements(doc, false));
113-
}
114-
115-
if (minifyPromises.length > 0) {
116-
await Promise.all(minifyPromises);
126+
docPromises.push(minifyScriptElements(doc, false));
117127
}
118128

119-
if (typeof prerenderConfig.filePath === 'function') {
129+
if (hydrateOpts.hashAssets && !prerenderRequest.isDebug) {
120130
try {
121-
const userWriteToFilePath = prerenderConfig.filePath(url, results.filePath);
122-
if (typeof userWriteToFilePath === 'string') {
123-
results.filePath = userWriteToFilePath;
124-
}
131+
docPromises.push(hashAssets(sys, results.diagnostics, hydrateOpts, prerenderRequest.appDir, doc, url));
125132
} catch (e) {
126133
catchError(results.diagnostics, e);
127134
}
128135
}
136+
137+
if (docPromises.length > 0) {
138+
await Promise.all(docPromises);
139+
}
129140

130141
if (prerenderConfig.crawlUrls !== false) {
131-
const baseUrl = new URL(prerenderRequest.baseUrl);
132142
results.anchorUrls = crawlAnchorsForNextUrls(
133143
prerenderConfig,
134144
results.diagnostics,

0 commit comments

Comments
 (0)