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..014862461a9 --- /dev/null +++ b/src/Server/__tests__/getWebpackEarlyHints.jest.ts @@ -0,0 +1,54 @@ +import fs from "fs" +import path from "path" +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("getWebpackEarlyHints", () => { + 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 = getWebpackEarlyHints() + + 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 = getWebpackEarlyHints() + + 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..60de6c0aa4a --- /dev/null +++ b/src/Server/getWebpackEarlyHints.ts @@ -0,0 +1,52 @@ +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 getWebpackEarlyHints = (): { + 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 + ) + + return { + linkHeaders: [], + linkPreloadTags: [], + } + } + + 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 bd39e005c3d..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 { getWebpackEarlyHints } from "Server/getWebpackEarlyHints" /** * Link headers allow 103: Early Hints to be sent to the client (by Cloudflare). @@ -13,6 +13,8 @@ export function linkHeadersMiddleware( res: ArtsyResponse, next: NextFunction ) { + const { linkHeaders } = getWebpackEarlyHints() + if (!res.headersSent) { res.header("Link", [ `<${CDN_URL}>; rel=preconnect; crossorigin`, @@ -22,7 +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`, + ...linkHeaders, ]) } + next() } diff --git a/src/System/Router/renderServerApp.tsx b/src/System/Router/renderServerApp.tsx index 6814c546e78..682e9d323c5 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 { 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. @@ -49,12 +50,15 @@ export const renderServerApp = ({ const scripts = extractScriptTags?.() + const { linkPreloadTags } = getWebpackEarlyHints() + const options = { cdnUrl: NODE_ENV === "production" ? CDN_URL : "", content: { body: html, data: sharify.script(), head: headTagsString, + linkPreloadTags, scripts, style: styleTags, }, 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) 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/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..045821f22af 100644 --- a/webpack/sharedPlugins.js +++ b/webpack/sharedPlugins.js @@ -1,7 +1,7 @@ // @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" @@ -28,9 +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"