Skip to content

Commit 8fea595

Browse files
authored
feat(ssr): remove external files dependencies (#10885)
Before the ssr dist had dependencies on external files (e.g. index.html). By in-lining those dependencies we can generate a portable standalone main.js.
1 parent 90c0e4f commit 8fea595

19 files changed

+188
-214
lines changed

Diff for: .env.testing

+2
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@ BUILD_ALWAYS_ALLOW_ROBOTS=true
3434
REACT_APP_ENABLE_PLUS=true
3535

3636
REACT_APP_FXA_SIGNIN_URL=/users/fxa/login/authenticate/
37+
38+
BASE_URL="https://developer.mozilla.org"

Diff for: .gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ yarn-error.log*
4747
/server/*.js.map
4848
/ssr/dist/
4949
/ssr/*.js
50+
!/ssr/mozilla.dnthelper.min.js
5051
!/ssr/webpack.config.js
5152
/ssr/*.js.map
5253
/tool/*.js

Diff for: client/config/webpack.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ function config(webpackEnv) {
9595
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
9696
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
9797
// Get environment variables to inject into our app.
98-
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
98+
const env = getClientEnvironment(paths.publicUrlOrPath.replace(/\/$/, ""));
9999

100100
const shouldUseReactRefresh = env.raw.FAST_REFRESH;
101101

Diff for: client/public/index.html

+4-4
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99
of the file that has a hash in it.
1010
-->
1111

12-
<link rel="icon" href="%PUBLIC_URL%/favicon-48x48.png" />
12+
<link rel="icon" href="/favicon-48x48.png" />
1313

14-
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
14+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
1515

1616
<meta name="theme-color" content="#ffffff" />
1717

18-
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18+
<link rel="manifest" href="/manifest.json" />
1919

2020
<link
2121
rel="search"
@@ -58,7 +58,7 @@
5858
property="og:description"
5959
content="The MDN Web Docs site provides information about Open Web technologies including HTML, CSS, and APIs for both Web sites and progressive web apps."
6060
/>
61-
<meta property="og:image" content="%PUBLIC_URL%/mdn-social-share.png" />
61+
<meta property="og:image" content="/mdn-social-share.png" />
6262
<meta property="og:image:type" content="image/png" />
6363
<meta property="og:image:height" content="1080" />
6464
<meta property="og:image:width" content="1920" />

Diff for: client/scripts/build.js

+12
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import printBuildError from "react-dev-utils/printBuildError.js";
1515

1616
import configFactory from "../config/webpack.config.js";
1717
import paths from "../config/paths.js";
18+
import { hashSomeStaticFilesForClientBuild } from "./postprocess-client-build.js";
1819

1920
// Makes the script crash on unhandled rejections instead of silently
2021
// ignoring them. In the future, promise rejections that are not handled will
@@ -120,6 +121,17 @@ checkBrowsers(paths.appPath, isInteractive)
120121
}
121122
}
122123
)
124+
.then(async () => {
125+
const { results } = await hashSomeStaticFilesForClientBuild(paths.appBuild);
126+
console.log(
127+
chalk.green(
128+
`Hashed ${results.length} files in ${path.join(
129+
paths.appBuild,
130+
"index.html"
131+
)}`
132+
)
133+
);
134+
})
123135
.catch((err) => {
124136
if (err && err.message) {
125137
console.log(err.message);

Diff for: tool/optimize-client-build.ts renamed to client/scripts/postprocess-client-build.js

+18-13
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import path from "node:path";
99
import cheerio from "cheerio";
1010
import md5File from "md5-file";
1111

12-
export async function runOptimizeClientBuild(buildRoot) {
12+
export async function hashSomeStaticFilesForClientBuild(buildRoot) {
1313
const indexHtmlFilePath = path.join(buildRoot, "index.html");
1414
const indexHtml = fs.readFileSync(indexHtmlFilePath, "utf-8");
1515

@@ -26,17 +26,11 @@ export async function runOptimizeClientBuild(buildRoot) {
2626
if (element.attribs.property !== "og:image") {
2727
return;
2828
}
29+
// This is a can of worms. Using from environment for now.
30+
// We need to use an absolute URL for "og:image".
31+
hrefPrefix = process.env.BASE_URL || "";
2932
href = element.attribs.content;
3033
attributeKey = "content";
31-
// This is an unfortunate hack. The value for the
32-
// <meta property=og:image content=...> needs to be an absolute URL.
33-
// We tested with a relative URL and it seems it doesn't work in Twitter.
34-
// So we hardcode the URL to be our production domain so the URL is
35-
// always absolute.
36-
// Yes, this makes it a bit weird to use a build of this on a dev,
37-
// stage, preview, or a local build. Especially if the hashed URL doesn't
38-
// always work. But it's a fair price to pay.
39-
hrefPrefix = "https://developer.mozilla.org";
4034
} else {
4135
href = element.attribs.href;
4236
if (!href) {
@@ -75,7 +69,7 @@ export async function runOptimizeClientBuild(buildRoot) {
7569
const splitName = filePath.split(extName);
7670
const hashedFilePath = `${splitName[0]}.${hash}${extName}`;
7771
fs.copyFileSync(filePath, hashedFilePath);
78-
const hashedHref = filePathToHref(buildRoot, hashedFilePath);
72+
const hashedHref = filePathToHref(buildRoot, hashedFilePath, href);
7973
results.push({
8074
filePath,
8175
href,
@@ -101,8 +95,19 @@ export async function runOptimizeClientBuild(buildRoot) {
10195
}
10296

10397
// Turn 'C:\Path\to\client\build\favicon.ico' to '/favicon.ico'
104-
function filePathToHref(root, filePath) {
105-
return `${filePath.replace(root, "").replace(path.sep, "/")}`;
98+
// or 'https://foo.bar/favicon.ico' if href is an absolute URL.
99+
function filePathToHref(root, filePath, href) {
100+
let dummyOrExistingUrl = new URL(href, "http://localhost.example");
101+
dummyOrExistingUrl.pathname = "";
102+
let url = new URL(
103+
`${filePath.replace(root, "").replace(path.sep, "/")}`,
104+
dummyOrExistingUrl
105+
);
106+
if (url.hostname === "localhost.example") {
107+
return url.pathname;
108+
} else {
109+
return url.href;
110+
}
106111
}
107112

108113
// Turn '/favicon.ico' to 'C:\Path\to\client\build\favicon.ico'

Diff for: client/scripts/start.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ process.on("unhandledRejection", (err) => {
3232

3333
const appPackageJson = JSON.parse(fs.readFileSync(paths.appPackageJson));
3434

35-
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
35+
const env = getClientEnvironment(paths.publicUrlOrPath.replace(/\/$/, ""));
3636
const useYarn = fs.existsSync(paths.yarnLockFile);
3737
const isInteractive = process.stdout.isTTY;
3838

Diff for: client/src/homepage/contributor-spotlight/index.tsx

+1-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { Icon } from "../../ui/atoms/icon";
55
import Mandala from "../../ui/molecules/mandala";
66

77
import "./index.scss";
8-
const contributorGraphic = `${
9-
process.env.PUBLIC_URL || ""
10-
}/assets/mdn_contributor.png`;
8+
const contributorGraphic = "/assets/mdn_contributor.png";
119

1210
export function ContributorSpotlight(props: HydrationData<any>) {
1311
const fallbackData = props.hyData ? props : undefined;

Diff for: client/src/ui/atoms/avatar/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const Avatar = ({ userData }: { userData: UserData }) => {
66
// If we have user data and the user is logged in, show their
77
// profile pic, defaulting to the dino head if the avatar
88
// URL doesn't work.
9-
const avatarImage = `${process.env.PUBLIC_URL || ""}/assets/avatar.png`;
9+
const avatarImage = "/assets/avatar.png";
1010

1111
return (
1212
<div

Diff for: package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
"build:curriculum": "cross-env NODE_ENV=production NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node build/build-curriculum.ts",
2323
"build:dist": "tsc -p tsconfig.dist.json",
2424
"build:glean": "cd client && cross-env VIRTUAL_ENV=venv glean translate src/telemetry/metrics.yaml src/telemetry/pings.yaml -f typescript -o src/telemetry/generated",
25-
"build:prepare": "yarn build:client && yarn build:ssr && yarn tool optimize-client-build && yarn tool google-analytics-code && yarn tool popularities && yarn tool spas && yarn tool gather-git-history && yarn tool build-robots-txt",
26-
"build:ssr": "cd ssr && webpack --mode=production",
25+
"build:prepare": "yarn build:client && yarn build:ssr && yarn tool popularities && yarn tool spas && yarn tool gather-git-history && yarn tool build-robots-txt",
26+
"build:ssr": "cross-env NODE_ENV=production NODE_OPTIONS='--no-warnings=ExperimentalWarning --loader ts-node/esm' node ssr/prepare.ts && cd ssr && webpack --mode=production",
2727
"build:sw": "cd client/pwa && yarn && yarn build:prod",
2828
"build:sw-dev": "cd client/pwa && yarn && yarn build",
2929
"check:tsc": "find . -name 'tsconfig.json' ! -wholename '**/node_modules/**' -print0 | xargs -n1 -P 2 -0 sh -c 'cd `dirname $0` && echo \"🔄 $(pwd)\" && npx tsc --noEmit && echo \"☑️ $(pwd)\" || exit 255'",

Diff for: ssr/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include.ts

Diff for: ssr/ga.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import fs from "node:fs";
2+
import {
3+
BUILD_OUT_ROOT,
4+
GOOGLE_ANALYTICS_MEASUREMENT_ID,
5+
} from "../libs/env/index.js";
6+
import path from "node:path";
7+
8+
export async function generateGA() {
9+
const outFile = path.join(BUILD_OUT_ROOT, "static", "js", "gtag.js");
10+
const measurementIds =
11+
GOOGLE_ANALYTICS_MEASUREMENT_ID.split(",").filter(Boolean);
12+
if (measurementIds.length) {
13+
const dntHelperCode = fs
14+
.readFileSync(
15+
new URL("mozilla.dnthelper.min.js", import.meta.url),
16+
"utf-8"
17+
)
18+
.trim();
19+
20+
const firstMeasurementId = measurementIds[0];
21+
const gaScriptURL = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(firstMeasurementId)}`;
22+
23+
const code = `
24+
// Mozilla DNT Helper
25+
${dntHelperCode}
26+
// Load GA unless DNT is enabled.
27+
if (Mozilla && !Mozilla.dntEnabled()) {
28+
window.dataLayer = window.dataLayer || [];
29+
function gtag(){dataLayer.push(arguments);}
30+
gtag('js', new Date());
31+
${measurementIds
32+
.map((id) => `gtag('config', '${id}', { 'anonymize_ip': true });`)
33+
.join("\n ")}
34+
35+
var gaScript = document.createElement('script');
36+
gaScript.async = true;
37+
gaScript.src = '${gaScriptURL}';
38+
document.head.appendChild(gaScript);
39+
}`.trim();
40+
fs.writeFileSync(outFile, `${code}\n`, "utf-8");
41+
console.log(
42+
`Generated ${outFile} for SSR rendering using ${GOOGLE_ANALYTICS_MEASUREMENT_ID}.`
43+
);
44+
} else {
45+
console.log("No Google Analytics code file generated");
46+
}
47+
}

Diff for: ssr/include.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const WEBFONT_TAGS: string;
2+
export const GTAG_PATH: null | string;
3+
export const BASE_URL: string;
4+
export const ALWAYS_ALLOW_ROBOTS: boolean;

Diff for: ssr/index.ts

-10
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
1-
import path from "node:path";
2-
import { fileURLToPath } from "node:url";
3-
4-
import * as dotenv from "dotenv";
51
import React from "react";
62
import { StaticRouter } from "react-router-dom/server";
73

84
import { App } from "../client/src/app";
95
import render from "./render";
106

11-
const dirname = fileURLToPath(new URL(".", import.meta.url));
12-
13-
dotenv.config({
14-
path: path.join(dirname, "..", process.env.ENV_FILE || ".env"),
15-
});
16-
177
export function renderHTML(url, context) {
188
return render(
199
React.createElement(
File renamed without changes.

Diff for: ssr/prepare.ts

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import {
5+
ALWAYS_ALLOW_ROBOTS,
6+
BUILD_OUT_ROOT,
7+
BASE_URL,
8+
} from "../libs/env/index.js";
9+
import { generateGA } from "./ga.js";
10+
11+
const dirname = path.dirname(fileURLToPath(new URL(".", import.meta.url)));
12+
const clientBuildRoot = path.resolve(dirname, "client/build");
13+
14+
function extractWebFontURLs() {
15+
const urls: string[] = [];
16+
const manifest = JSON.parse(
17+
fs.readFileSync(path.join(clientBuildRoot, "asset-manifest.json"), "utf-8")
18+
);
19+
for (const entrypoint of manifest.entrypoints) {
20+
if (!entrypoint.endsWith(".css")) continue;
21+
const css = fs.readFileSync(
22+
path.join(clientBuildRoot, entrypoint),
23+
"utf-8"
24+
);
25+
const generator = extractCSSURLs(css, (url) => url.endsWith(".woff2"));
26+
urls.push(...generator);
27+
}
28+
return [...new Set(urls)];
29+
}
30+
31+
function* extractCSSURLs(css, filterFunction) {
32+
for (const match of css.matchAll(/url\((.*?)\)/g)) {
33+
const url = match[1];
34+
if (filterFunction(url)) {
35+
yield url;
36+
}
37+
}
38+
}
39+
40+
function webfontTags(webfontURLs): string {
41+
return webfontURLs
42+
.map(
43+
(url) =>
44+
`<link rel="preload" as="font" type="font/woff2" href="${url}" crossorigin>`
45+
)
46+
.join("");
47+
}
48+
49+
function gtagScriptPath(relPath = "/static/js/gtag.js") {
50+
const filePath = relPath.split("/").slice(1).join(path.sep);
51+
if (fs.existsSync(path.join(BUILD_OUT_ROOT, filePath))) {
52+
return relPath;
53+
}
54+
return null;
55+
}
56+
57+
function prepare() {
58+
const webfontURLs = extractWebFontURLs();
59+
const tags = webfontTags(webfontURLs);
60+
const gtagPath = gtagScriptPath();
61+
62+
fs.writeFileSync(
63+
path.join(dirname, "ssr", "include.ts"),
64+
`
65+
export const WEBFONT_TAGS = ${JSON.stringify(tags)};
66+
export const GTAG_PATH = ${JSON.stringify(gtagPath)};
67+
export const BASE_URL = ${JSON.stringify(BASE_URL)};
68+
export const ALWAYS_ALLOW_ROBOTS = ${JSON.stringify(ALWAYS_ALLOW_ROBOTS)};
69+
`
70+
);
71+
}
72+
73+
generateGA().then(() => prepare());

0 commit comments

Comments
 (0)