From 44d23d86b475c6959e29136d3d1dbffb683df817 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Mon, 4 Nov 2024 22:05:43 -0800 Subject: [PATCH 1/5] feat(webpack): Add support for emitting webpack .js early hint headers --- .../middleware/linkHeadersMiddleware.ts | 36 ++++++++++++++- webpack/plugins/EarlyHintsPlugin.js | 46 +++++++++++++++++++ webpack/sharedPlugins.js | 3 ++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 webpack/plugins/EarlyHintsPlugin.js diff --git a/src/Server/middleware/linkHeadersMiddleware.ts b/src/Server/middleware/linkHeadersMiddleware.ts index bd39e005c3d..51c967bbd83 100644 --- a/src/Server/middleware/linkHeadersMiddleware.ts +++ b/src/Server/middleware/linkHeadersMiddleware.ts @@ -1,6 +1,7 @@ +import path from "path" +import fs from "fs" import type { NextFunction } from "express" import type { ArtsyRequest, ArtsyResponse } from "./artsyExpress" - import { CDN_URL, GEMINI_CLOUDFRONT_URL, WEBFONT_URL } from "Server/config" /** @@ -22,7 +23,40 @@ export function linkHeadersMiddleware( `<${WEBFONT_URL}/ll-unica77_regular.woff2>; rel=preload; as=font; crossorigin`, `<${WEBFONT_URL}/ll-unica77_medium.woff2>; rel=preload; as=font; crossorigin`, `<${WEBFONT_URL}/ll-unica77_italic.woff2>; rel=preload; as=font; crossorigin`, + ...getWebpackHintHeaders(), ]) } next() } + +const getWebpackHintHeaders = () => { + let chunkFiles + + try { + const earlyHintsPath = path.join( + process.cwd(), + "public/assets", + "early-hints.json" + ) + chunkFiles = JSON.parse(fs.readFileSync(earlyHintsPath, "utf-8")) + } catch (error) { + console.error( + "[linkHeadersMiddleware] Could not load webpack early-hints.json:", + error + ) + } + + const cdnUrl = (() => { + if (process.env.NODE_ENV === "development") { + return "" + } + + return CDN_URL + })() + + const hintHeaders = chunkFiles.map( + file => `<${cdnUrl}${file}>; rel=preload; as=script; crossorigin` + ) + + return hintHeaders +} diff --git a/webpack/plugins/EarlyHintsPlugin.js b/webpack/plugins/EarlyHintsPlugin.js new file mode 100644 index 00000000000..5937632d31f --- /dev/null +++ b/webpack/plugins/EarlyHintsPlugin.js @@ -0,0 +1,46 @@ +// @ts-check + +import webpack from "webpack" + +/** + * This plugin generates a JSON file with the list of entry chunk files to + * be used by the server to send early hints to the client. + */ + +export class EarlyHintsPlugin { + /** + * @param {webpack.Compiler} compiler + */ + apply(compiler) { + compiler.hooks.thisCompilation.tap("EarlyHintPlugin", compilation => { + compilation.hooks.processAssets.tap( + { + name: "EarlyHintPlugin", + stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, + }, + assets => { + const publicPath = compilation.outputOptions.publicPath || "" + + /** + * Collect entry chunk files (JavaScript files only) + * @type {string[]} + */ + const entryChunkFiles = Array.from(compilation.chunks) + .filter(chunk => chunk.canBeInitial()) // Select only entry chunks + .reduce((acc, chunk) => { + const jsFiles = Array.from(chunk.files) + .filter(file => file.endsWith(".js")) + .map(file => `${publicPath}${file}`) + return acc.concat(jsFiles) + }, /** @type {string[]} */ ([])) + + // Output `early-hints.json` to webpack output/publicPath directory + assets["early-hints.json"] = new webpack.sources.RawSource( + JSON.stringify(entryChunkFiles), + false + ) + } + ) + }) + } +} diff --git a/webpack/sharedPlugins.js b/webpack/sharedPlugins.js index e0dafa297e8..0937f8b6453 100644 --- a/webpack/sharedPlugins.js +++ b/webpack/sharedPlugins.js @@ -2,6 +2,7 @@ import { RetryChunkLoadPlugin } from "webpack-retry-chunk-load-plugin" // import PreloadWebpackPlugin from "@vue/preload-webpack-plugin" +import { EarlyHintsPlugin } from "./plugins/EarlyHintsPlugin" import NodePolyfillPlugin from "node-polyfill-webpack-plugin" import webpack from "webpack" @@ -33,4 +34,6 @@ export const sharedPlugins = () => [ // as: "script", // include: "initial", // }), + + new EarlyHintsPlugin(), ] From 8e8bab8c970fdb04cb76d847a928beaad78af07f Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Tue, 5 Nov 2024 08:59:20 -0800 Subject: [PATCH 2/5] feat(html): inject linkPreloadTags --- package.json | 1 - .../__tests__/getWebpackEarlyHints.jest.ts | 54 +++++++++++++++++++ src/Server/getWebpackEarlyHints.ts | 47 ++++++++++++++++ .../middleware/linkHeadersMiddleware.ts | 40 ++------------ src/System/Router/renderServerApp.tsx | 4 ++ src/html.ejs | 2 + webpack/sharedPlugins.js | 7 --- yarn.lock | 5 -- 8 files changed, 112 insertions(+), 48 deletions(-) create mode 100644 src/Server/__tests__/getWebpackEarlyHints.jest.ts create mode 100644 src/Server/getWebpackEarlyHints.ts diff --git a/package.json b/package.json index 1acf0eb76ff..5a67b4b7576 100644 --- a/package.json +++ b/package.json @@ -275,7 +275,6 @@ "@types/yup": "^0.29.13", "@typescript-eslint/eslint-plugin": "2.30.0", "@typescript-eslint/parser": "4.18.0", - "@vue/preload-webpack-plugin": "^2.0.0", "@wojtekmaj/enzyme-adapter-react-17": "0.6.6", "babel-jest": "^29.5.0", "babel-loader": "8.2.3", diff --git a/src/Server/__tests__/getWebpackEarlyHints.jest.ts b/src/Server/__tests__/getWebpackEarlyHints.jest.ts new file mode 100644 index 00000000000..3f4c99ad983 --- /dev/null +++ b/src/Server/__tests__/getWebpackEarlyHints.jest.ts @@ -0,0 +1,54 @@ +import fs from "fs" +import path from "path" +import { getWebpackEalyHints } from "Server/getWebpackEarlyHints" + +jest.mock("fs") +jest.mock("Server/config", () => ({ CDN_URL: "https://cdn.example.com" })) + +const HINTS_PATH = path.join(process.cwd(), "public/assets", "early-hints.json") + +describe("getWebpackEalyHints", () => { + const mockReadFileSync = fs.readFileSync as jest.Mock + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should return link headers and preload tags with CDN URL in production", () => { + process.env.NODE_ENV = "production" + const mockChunkFiles = ["/chunk1.js", "/chunk2.js"] + + mockReadFileSync.mockReturnValueOnce(JSON.stringify(mockChunkFiles)) + + const result = getWebpackEalyHints() + + expect(fs.readFileSync).toHaveBeenCalledWith(HINTS_PATH, "utf-8") + expect(result.linkHeaders).toEqual([ + `; rel=preload; as=script; crossorigin`, + `; rel=preload; as=script; crossorigin`, + ]) + expect(result.linkPreloadTags).toEqual([ + ``, + ``, + ]) + }) + + it("should return link headers and preload tags without CDN URL in development", () => { + process.env.NODE_ENV = "development" + const mockChunkFiles = ["/chunk1.js", "/chunk2.js"] + + mockReadFileSync.mockReturnValueOnce(JSON.stringify(mockChunkFiles)) + + const result = getWebpackEalyHints() + + expect(fs.readFileSync).toHaveBeenCalledWith(HINTS_PATH, "utf-8") + expect(result.linkHeaders).toEqual([ + `; rel=preload; as=script; crossorigin`, + `; rel=preload; as=script; crossorigin`, + ]) + expect(result.linkPreloadTags).toEqual([ + ``, + ``, + ]) + }) +}) diff --git a/src/Server/getWebpackEarlyHints.ts b/src/Server/getWebpackEarlyHints.ts new file mode 100644 index 00000000000..c498c989e86 --- /dev/null +++ b/src/Server/getWebpackEarlyHints.ts @@ -0,0 +1,47 @@ +import { CDN_URL } from "Server/config" +import path from "path" +import fs from "fs" + +const HINTS_PATH = path.join(process.cwd(), "public/assets", "early-hints.json") + +export const getWebpackEalyHints = (): { + linkHeaders: string[] + linkPreloadTags: string[] +} => { + let chunkFiles + + try { + chunkFiles = JSON.parse(fs.readFileSync(HINTS_PATH, "utf-8")) + } catch (error) { + console.error( + "[getWebpackEarlyHints] Could not load webpack early-hints.json:", + error + ) + } + + const cdnUrl = (() => { + if (process.env.NODE_ENV === "development") { + return "" + } + + return CDN_URL + })() + + const links = chunkFiles.reduce( + (acc, file) => { + acc.linkHeaders.push( + `<${cdnUrl}${file}>; rel=preload; as=script; crossorigin` + ) + acc.linkPreloadTags.push( + `` + ) + return acc + }, + { + linkHeaders: [], + linkPreloadTags: [], + } + ) + + return links +} diff --git a/src/Server/middleware/linkHeadersMiddleware.ts b/src/Server/middleware/linkHeadersMiddleware.ts index 51c967bbd83..5a961c904fb 100644 --- a/src/Server/middleware/linkHeadersMiddleware.ts +++ b/src/Server/middleware/linkHeadersMiddleware.ts @@ -1,8 +1,7 @@ -import path from "path" -import fs from "fs" import type { NextFunction } from "express" import type { ArtsyRequest, ArtsyResponse } from "./artsyExpress" import { CDN_URL, GEMINI_CLOUDFRONT_URL, WEBFONT_URL } from "Server/config" +import { getWebpackEalyHints } from "Server/getWebpackEarlyHints" /** * Link headers allow 103: Early Hints to be sent to the client (by Cloudflare). @@ -14,6 +13,8 @@ export function linkHeadersMiddleware( res: ArtsyResponse, next: NextFunction ) { + const { linkHeaders } = getWebpackEalyHints() + if (!res.headersSent) { res.header("Link", [ `<${CDN_URL}>; rel=preconnect; crossorigin`, @@ -23,40 +24,9 @@ export function linkHeadersMiddleware( `<${WEBFONT_URL}/ll-unica77_regular.woff2>; rel=preload; as=font; crossorigin`, `<${WEBFONT_URL}/ll-unica77_medium.woff2>; rel=preload; as=font; crossorigin`, `<${WEBFONT_URL}/ll-unica77_italic.woff2>; rel=preload; as=font; crossorigin`, - ...getWebpackHintHeaders(), + ...linkHeaders, ]) } - next() -} - -const getWebpackHintHeaders = () => { - let chunkFiles - - try { - const earlyHintsPath = path.join( - process.cwd(), - "public/assets", - "early-hints.json" - ) - chunkFiles = JSON.parse(fs.readFileSync(earlyHintsPath, "utf-8")) - } catch (error) { - console.error( - "[linkHeadersMiddleware] Could not load webpack early-hints.json:", - error - ) - } - - const cdnUrl = (() => { - if (process.env.NODE_ENV === "development") { - return "" - } - - return CDN_URL - })() - const hintHeaders = chunkFiles.map( - file => `<${cdnUrl}${file}>; rel=preload; as=script; crossorigin` - ) - - return hintHeaders + next() } diff --git a/src/System/Router/renderServerApp.tsx b/src/System/Router/renderServerApp.tsx index 6814c546e78..2c40563ae34 100644 --- a/src/System/Router/renderServerApp.tsx +++ b/src/System/Router/renderServerApp.tsx @@ -7,6 +7,7 @@ import { getENV } from "Utils/getENV" import { ServerAppResults } from "System/Router/serverRouter" import { Transform } from "stream" import { ENABLE_SSR_STREAMING } from "Server/config" +import { getWebpackEalyHints } from "Server/getWebpackEarlyHints" // TODO: Use the same variables as the asset middleware. Both config and sharify // have a default CDN_URL while this does not. @@ -49,12 +50,15 @@ export const renderServerApp = ({ const scripts = extractScriptTags?.() + const { linkPreloadTags } = getWebpackEalyHints() + const options = { cdnUrl: NODE_ENV === "production" ? CDN_URL : "", content: { body: html, data: sharify.script(), head: headTagsString, + linkPreloadTags, scripts, style: styleTags, }, diff --git a/src/html.ejs b/src/html.ejs index ac1dd77cc57..651ff8e5d6d 100644 --- a/src/html.ejs +++ b/src/html.ejs @@ -36,6 +36,8 @@ <%- htmlWebpackPlugin.tags.headTags.map((originalTag) => { const tag = { ...originalTag, attributes: { ...originalTag.attributes } }; tag.attributes['src'] = "<" + "%= cdnUrl %" + ">" + originalTag.attributes['src']; return tag; }) %> <%% } %> + <%%- content.linkPreloadTags.join("") %> + <%%- content.head %> <%%- content.style %> <%%- content.data %> diff --git a/webpack/sharedPlugins.js b/webpack/sharedPlugins.js index 0937f8b6453..045821f22af 100644 --- a/webpack/sharedPlugins.js +++ b/webpack/sharedPlugins.js @@ -1,7 +1,6 @@ // @ts-check import { RetryChunkLoadPlugin } from "webpack-retry-chunk-load-plugin" -// import PreloadWebpackPlugin from "@vue/preload-webpack-plugin" import { EarlyHintsPlugin } from "./plugins/EarlyHintsPlugin" import NodePolyfillPlugin from "node-polyfill-webpack-plugin" import webpack from "webpack" @@ -29,11 +28,5 @@ export const sharedPlugins = () => [ }`, }), - // new PreloadWebpackPlugin({ - // rel: "preload", - // as: "script", - // include: "initial", - // }), - new EarlyHintsPlugin(), ] diff --git a/yarn.lock b/yarn.lock index ccf3b5b9de1..34b5630bcd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5914,11 +5914,6 @@ "@typescript-eslint/types" "4.9.1" eslint-visitor-keys "^2.0.0" -"@vue/preload-webpack-plugin@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@vue/preload-webpack-plugin/-/preload-webpack-plugin-2.0.0.tgz#a43bfc087e91f7d0efb0086100148f4b16437b68" - integrity sha512-RoorRB50WehYbsiWu497q8egZBYlrvOo9KBUG41uth4O023Cbs+7POLm9uw2CAiViBAIhvpw1Y4w4i+MZxOfXw== - "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" From 457f929c78f3f092f0ac4f2181291f9c1fc5723d Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Tue, 5 Nov 2024 09:22:19 -0800 Subject: [PATCH 3/5] fix: smoke tests --- src/Server/getWebpackEarlyHints.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Server/getWebpackEarlyHints.ts b/src/Server/getWebpackEarlyHints.ts index c498c989e86..4474bac53bd 100644 --- a/src/Server/getWebpackEarlyHints.ts +++ b/src/Server/getWebpackEarlyHints.ts @@ -17,6 +17,11 @@ export const getWebpackEalyHints = (): { "[getWebpackEarlyHints] Could not load webpack early-hints.json:", error ) + + return { + linkHeaders: [], + linkPreloadTags: [], + } } const cdnUrl = (() => { From 56416718d162c0ebfbbe5e23fb19ef7dd78241ae Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Tue, 5 Nov 2024 10:30:23 -0800 Subject: [PATCH 4/5] fix(html): early hints --- src/dev.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dev.js b/src/dev.js index 63ba7de24a6..a4ef71e748e 100644 --- a/src/dev.js +++ b/src/dev.js @@ -50,6 +50,7 @@ const wdm = webpackDevMiddleware(compiler, { * @see https://github.com/artsy/reaction/blob/master/src/Artsy/Router/serverRouter.tsx */ return ( + /early-hints/.test(filePath) || /loadable-stats/.test(filePath) || /manifest/.test(filePath) || /\.ejs/.test(filePath) From b9c54ea19e5a042f1da8c7423fd0089e0fb708f6 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Tue, 5 Nov 2024 12:13:53 -0800 Subject: [PATCH 5/5] fix: typo --- src/Server/__tests__/getWebpackEarlyHints.jest.ts | 8 ++++---- src/Server/getWebpackEarlyHints.ts | 2 +- src/Server/middleware/linkHeadersMiddleware.ts | 4 ++-- src/System/Router/renderServerApp.tsx | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Server/__tests__/getWebpackEarlyHints.jest.ts b/src/Server/__tests__/getWebpackEarlyHints.jest.ts index 3f4c99ad983..014862461a9 100644 --- a/src/Server/__tests__/getWebpackEarlyHints.jest.ts +++ b/src/Server/__tests__/getWebpackEarlyHints.jest.ts @@ -1,13 +1,13 @@ import fs from "fs" import path from "path" -import { getWebpackEalyHints } from "Server/getWebpackEarlyHints" +import { getWebpackEarlyHints } from "Server/getWebpackEarlyHints" jest.mock("fs") jest.mock("Server/config", () => ({ CDN_URL: "https://cdn.example.com" })) const HINTS_PATH = path.join(process.cwd(), "public/assets", "early-hints.json") -describe("getWebpackEalyHints", () => { +describe("getWebpackEarlyHints", () => { const mockReadFileSync = fs.readFileSync as jest.Mock afterEach(() => { @@ -20,7 +20,7 @@ describe("getWebpackEalyHints", () => { mockReadFileSync.mockReturnValueOnce(JSON.stringify(mockChunkFiles)) - const result = getWebpackEalyHints() + const result = getWebpackEarlyHints() expect(fs.readFileSync).toHaveBeenCalledWith(HINTS_PATH, "utf-8") expect(result.linkHeaders).toEqual([ @@ -39,7 +39,7 @@ describe("getWebpackEalyHints", () => { mockReadFileSync.mockReturnValueOnce(JSON.stringify(mockChunkFiles)) - const result = getWebpackEalyHints() + const result = getWebpackEarlyHints() expect(fs.readFileSync).toHaveBeenCalledWith(HINTS_PATH, "utf-8") expect(result.linkHeaders).toEqual([ diff --git a/src/Server/getWebpackEarlyHints.ts b/src/Server/getWebpackEarlyHints.ts index 4474bac53bd..60de6c0aa4a 100644 --- a/src/Server/getWebpackEarlyHints.ts +++ b/src/Server/getWebpackEarlyHints.ts @@ -4,7 +4,7 @@ import fs from "fs" const HINTS_PATH = path.join(process.cwd(), "public/assets", "early-hints.json") -export const getWebpackEalyHints = (): { +export const getWebpackEarlyHints = (): { linkHeaders: string[] linkPreloadTags: string[] } => { diff --git a/src/Server/middleware/linkHeadersMiddleware.ts b/src/Server/middleware/linkHeadersMiddleware.ts index 5a961c904fb..bc6ad6bc448 100644 --- a/src/Server/middleware/linkHeadersMiddleware.ts +++ b/src/Server/middleware/linkHeadersMiddleware.ts @@ -1,7 +1,7 @@ import type { NextFunction } from "express" import type { ArtsyRequest, ArtsyResponse } from "./artsyExpress" import { CDN_URL, GEMINI_CLOUDFRONT_URL, WEBFONT_URL } from "Server/config" -import { getWebpackEalyHints } from "Server/getWebpackEarlyHints" +import { getWebpackEarlyHints } from "Server/getWebpackEarlyHints" /** * Link headers allow 103: Early Hints to be sent to the client (by Cloudflare). @@ -13,7 +13,7 @@ export function linkHeadersMiddleware( res: ArtsyResponse, next: NextFunction ) { - const { linkHeaders } = getWebpackEalyHints() + const { linkHeaders } = getWebpackEarlyHints() if (!res.headersSent) { res.header("Link", [ diff --git a/src/System/Router/renderServerApp.tsx b/src/System/Router/renderServerApp.tsx index 2c40563ae34..682e9d323c5 100644 --- a/src/System/Router/renderServerApp.tsx +++ b/src/System/Router/renderServerApp.tsx @@ -7,7 +7,7 @@ import { getENV } from "Utils/getENV" import { ServerAppResults } from "System/Router/serverRouter" import { Transform } from "stream" import { ENABLE_SSR_STREAMING } from "Server/config" -import { getWebpackEalyHints } from "Server/getWebpackEarlyHints" +import { getWebpackEarlyHints } from "Server/getWebpackEarlyHints" // TODO: Use the same variables as the asset middleware. Both config and sharify // have a default CDN_URL while this does not. @@ -50,7 +50,7 @@ export const renderServerApp = ({ const scripts = extractScriptTags?.() - const { linkPreloadTags } = getWebpackEalyHints() + const { linkPreloadTags } = getWebpackEarlyHints() const options = { cdnUrl: NODE_ENV === "production" ? CDN_URL : "",