Skip to content

Commit

Permalink
Merge pull request #14796 from artsy/damassi/feat/early-hints-plugin
Browse files Browse the repository at this point in the history
feat(early-hints): Add support for emitting webpack .js early hint headers and passing via linkHeadersMiddleware
  • Loading branch information
damassi authored Nov 5, 2024
2 parents 818e23e + b9c54ea commit 4d6f44a
Show file tree
Hide file tree
Showing 10 changed files with 166 additions and 13 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions src/Server/__tests__/getWebpackEarlyHints.jest.ts
Original file line number Diff line number Diff line change
@@ -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([
`<https://cdn.example.com/chunk1.js>; rel=preload; as=script; crossorigin`,
`<https://cdn.example.com/chunk2.js>; rel=preload; as=script; crossorigin`,
])
expect(result.linkPreloadTags).toEqual([
`<link rel="preload" as="script" href="https://cdn.example.com/chunk1.js" crossorigin>`,
`<link rel="preload" as="script" href="https://cdn.example.com/chunk2.js" crossorigin>`,
])
})

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([
`</chunk1.js>; rel=preload; as=script; crossorigin`,
`</chunk2.js>; rel=preload; as=script; crossorigin`,
])
expect(result.linkPreloadTags).toEqual([
`<link rel="preload" as="script" href="/chunk1.js" crossorigin>`,
`<link rel="preload" as="script" href="/chunk2.js" crossorigin>`,
])
})
})
52 changes: 52 additions & 0 deletions src/Server/getWebpackEarlyHints.ts
Original file line number Diff line number Diff line change
@@ -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(
`<link rel="preload" as="script" href="${cdnUrl}${file}" crossorigin>`
)
return acc
},
{
linkHeaders: [],
linkPreloadTags: [],
}
)

return links
}
6 changes: 5 additions & 1 deletion src/Server/middleware/linkHeadersMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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`,
Expand All @@ -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()
}
4 changes: 4 additions & 0 deletions src/System/Router/renderServerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
},
Expand Down
1 change: 1 addition & 0 deletions src/dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/html.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
46 changes: 46 additions & 0 deletions webpack/plugins/EarlyHintsPlugin.js
Original file line number Diff line number Diff line change
@@ -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
)
}
)
})
}
}
8 changes: 2 additions & 6 deletions webpack/sharedPlugins.js
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -28,9 +28,5 @@ export const sharedPlugins = () => [
}`,
}),

// new PreloadWebpackPlugin({
// rel: "preload",
// as: "script",
// include: "initial",
// }),
new EarlyHintsPlugin(),
]
5 changes: 0 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7"
Expand Down

0 comments on commit 4d6f44a

Please sign in to comment.