Skip to content

Commit 524189c

Browse files
committed
Update injection script
1 parent ab4c393 commit 524189c

File tree

1 file changed

+198
-139
lines changed

1 file changed

+198
-139
lines changed

packages/addons-inject/index.js

+198-139
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,74 @@
1-
/*
2-
3-
Script to inject the new Addons implementation on pages served by El Proxito.
4-
5-
This script is ran on a Cloudflare Worker and modifies the HTML with two different purposes:
6-
7-
1. remove the old implementation of our flyout (``readthedocs-doc-embed.js`` and others)
8-
2. inject the new addons implementation (``readthedocs-addons.js``) script
9-
10-
Currently, we are doing 1) only when users opt-in into the new beta addons.
11-
In the future, when our addons become stable, we will always remove the old implementation,
12-
making all the projects to use the addons by default.
13-
14-
*/
15-
16-
// add "readthedocs-addons.js" inside the "<head>"
17-
const addonsJs =
18-
'<script async type="text/javascript" src="/_/static/javascript/readthedocs-addons.js"></script>';
19-
20-
// selectors we want to remove
21-
// https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/#selectors
22-
const analyticsJs =
23-
'script[src="/_/static/javascript/readthedocs-analytics.js"]';
24-
const docEmbedCss = 'link[href="/_/static/css/readthedocs-doc-embed.css"]';
25-
const docEmbedJs =
26-
'script[src="/_/static/javascript/readthedocs-doc-embed.js"]';
27-
const analyticsJsAssets =
28-
'script[src="https://assets.readthedocs.org/static/javascript/readthedocs-analytics.js"]';
29-
const docEmbedCssAssets =
30-
'link[href="https://assets.readthedocs.org/static/css/readthedocs-doc-embed.css"]';
31-
const docEmbedJsAssets =
32-
'script[src="https://assets.readthedocs.org/static/javascript/readthedocs-doc-embed.js"]';
33-
const docEmbedJsAssetsCore =
34-
'script[src="https://assets.readthedocs.org/static/core/js/readthedocs-doc-embed.js"]';
35-
const docEmbedJsAssetsProxied =
36-
'script[src="/_/static/core/js/readthedocs-doc-embed.js"]';
37-
const badgeOnlyCssAssets =
38-
'link[href="https://assets.readthedocs.org/static/css/badge_only.css"]';
39-
const badgeOnlyCssAssetsProxied = 'link[href="/_/static/css/badge_only.css"]';
40-
const readthedocsExternalVersionWarning =
41-
"[role=main] > div:first-child > div:first-child.admonition.warning";
42-
const readthedocsExternalVersionWarningFuroTheme =
43-
"[role=main] > div:first-child.admonition.warning";
44-
const readthedocsExternalVersionWarningBookTheme =
45-
"#main-content > div > div > article > div:first-child.admonition.warning";
46-
const readthedocsFlyout = "div.rst-versions";
1+
/* Module to inject the new Addons implementation on pages served by El Proxito.
2+
*
3+
* This module is run as a Cloudflare Worker and modifies files with two different purposes:
4+
*
5+
* 1. remove the old implementation of our flyout (``readthedocs-doc-embed.js`` and others)
6+
* 2. inject the new Addons implementation (``readthedocs-addons.js``) script
7+
*
8+
* Currently, we are performing both of these operations for all projects, except
9+
* those that have opted out of Addons.
10+
*/
11+
12+
/** Class to wrap all module constants, reused in tests
13+
*
14+
* This is a slightly silly hack because Workers can't export consts, though
15+
* exported classes seem to be fine. This should really be a separate import,
16+
* but this complicates the Worker deployment a little.
17+
**/
18+
export class AddonsConstants {
19+
// Add "readthedocs-addons.js" inside the "<head>"
20+
static scriptAddons =
21+
'<script async type="text/javascript" src="/_/static/javascript/readthedocs-addons.js"></script>';
22+
23+
// Selectors we want to remove
24+
// https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/#selectors
25+
static removalScripts = [
26+
'script[src="/_/static/javascript/readthedocs-analytics.js"]',
27+
'script[src="/_/static/javascript/readthedocs-doc-embed.js"]',
28+
'script[src="/_/static/core/js/readthedocs-doc-embed.js"]',
29+
'script[src="https://assets.readthedocs.org/static/javascript/readthedocs-analytics.js"]',
30+
'script[src="https://assets.readthedocs.org/static/javascript/readthedocs-doc-embed.js"]',
31+
'script[src="https://assets.readthedocs.org/static/core/js/readthedocs-doc-embed.js"]',
32+
];
33+
static removalLinks = [
34+
'link[href="/_/static/css/readthedocs-doc-embed.css"]',
35+
'link[href="https://assets.readthedocs.org/static/css/readthedocs-doc-embed.css"]',
36+
// Badge only and proxied stylesheets
37+
'link[href="https://assets.readthedocs.org/static/css/badge_only.css"]',
38+
'link[href="/_/static/css/badge_only.css"]',
39+
];
40+
static removalElements = [
41+
// Sphinx-rtd-theme version warning
42+
"[role=main] > div:first-child > div:first-child.admonition.warning",
43+
// Furo version warning
44+
"[role=main] > div:first-child.admonition.warning",
45+
// Book version warning
46+
"#main-content > div > div > article > div:first-child.admonition.warning",
47+
// Flyout
48+
"div.rst-versions",
49+
];
50+
51+
// Additional replacements that we perform
52+
static replacements = {
53+
searchtools: {
54+
pattern: `/* Search initialization removed for Read the Docs */`,
55+
replacement: `
56+
/* Search initialization manipulated by Read the Docs using Cloudflare Workers */
57+
/* See https://github.com/readthedocs/addons/issues/219 for more information */
58+
59+
function initializeSearch() {
60+
Search.init();
61+
}
62+
63+
if (document.readyState !== "loading") {
64+
initializeSearch();
65+
}
66+
else {
67+
document.addEventListener("DOMContentLoaded", initializeSearch);
68+
}`,
69+
},
70+
};
71+
}
4772

4873
// "readthedocsDataParse" is the "<script>" that calls:
4974
//
@@ -52,22 +77,48 @@ const readthedocsFlyout = "div.rst-versions";
5277
const readthedocsDataParse = "script[id=READTHEDOCS_DATA]:first-of-type";
5378
const readthedocsData = "script[id=READTHEDOCS_DATA]";
5479

55-
// do this on a fetch
56-
addEventListener("fetch", (event) => {
57-
const request = event.request;
58-
event.respondWith(handleRequest(request));
59-
});
80+
async function onFetch(request, env, context) {
81+
// Avoid blank pages on exceptions
82+
context.passThroughOnException();
6083

61-
async function handleRequest(request) {
62-
// perform the original request
63-
let originalResponse = await fetch(request);
84+
const response = await fetch(request);
6485

65-
// get the content type of the response to manipulate the content only if it's HTML
66-
const contentType = originalResponse.headers.get("content-type") || "";
86+
if (response.body === null) {
87+
// TODO This was encountered when Cloudflare already has a request cached
88+
// and could respond with a 304 response. It's not clear if this is
89+
// necessary or wanted yet.
90+
console.debug("Response body was already null, passing through.");
91+
return response;
92+
}
93+
94+
const responseFallback = response.clone();
95+
96+
try {
97+
const transformed = await transformResponse(response);
98+
// Wait on the transformed text for force errors to evaluate. Timeout errors
99+
// and errors thrown during transform aren't actually raised until the body
100+
// is read
101+
return new Response(await transformed.text(), transformed);
102+
} catch (error) {
103+
console.error("Discarding error:", error);
104+
}
105+
106+
console.debug("Returning original response to avoid blank page");
107+
return responseFallback;
108+
}
109+
110+
export default {
111+
fetch: onFetch,
112+
};
113+
114+
async function transformResponse(originalResponse) {
115+
const { headers } = originalResponse;
116+
117+
// Get the content type of the response to manipulate the content only if it's HTML
118+
const contentType = headers.get("content-type") || "";
67119
const injectHostingIntegrations =
68-
originalResponse.headers.get("x-rtd-hosting-integrations") || "false";
69-
const forceAddons =
70-
originalResponse.headers.get("x-rtd-force-addons") || "false";
120+
headers.get("x-rtd-hosting-integrations") || "false";
121+
const forceAddons = headers.get("x-rtd-force-addons") || "false";
71122
const httpStatus = originalResponse.status;
72123

73124
// Log some debugging data
@@ -76,12 +127,18 @@ async function handleRequest(request) {
76127
console.log(`X-RTD-Hosting-Integrations: ${injectHostingIntegrations}`);
77128
console.log(`HTTP status: ${httpStatus}`);
78129

130+
// Debug mode test operations
131+
const throwError = headers.get("X-RTD-Throw-Error");
132+
if (throwError) {
133+
console.log(`Throw error: ${throwError}`);
134+
}
135+
79136
// get project/version slug from headers inject by El Proxito
80-
const projectSlug = originalResponse.headers.get("x-rtd-project") || "";
81-
const versionSlug = originalResponse.headers.get("x-rtd-version") || "";
82-
const resolverFilename = originalResponse.headers.get("x-rtd-resolver-filename") || "";
137+
const projectSlug = headers.get("x-rtd-project") || "";
138+
const versionSlug = headers.get("x-rtd-version") || "";
139+
const resolverFilename = headers.get("x-rtd-resolver-filename") || "";
83140

84-
// check to decide whether or not inject the new beta addons:
141+
// Check to decide whether or not inject the new beta addons:
85142
//
86143
// - content type has to be "text/html"
87144
// when all these conditions are met, we remove all the old JS/CSS files and inject the new beta flyout JS
@@ -94,46 +151,66 @@ async function handleRequest(request) {
94151
// - header `X-RTD-Hosting-Integrations` is not present (added automatically when using `build.commands`)
95152
//
96153
if (forceAddons === "true" && injectHostingIntegrations === "false") {
97-
return (
98-
new HTMLRewriter()
99-
.on(analyticsJs, new removeElement())
100-
.on(docEmbedCss, new removeElement())
101-
.on(docEmbedJs, new removeElement())
102-
.on(analyticsJsAssets, new removeElement())
103-
.on(docEmbedCssAssets, new removeElement())
104-
.on(docEmbedJsAssets, new removeElement())
105-
.on(docEmbedJsAssetsCore, new removeElement())
106-
.on(docEmbedJsAssetsProxied, new removeElement())
107-
.on(badgeOnlyCssAssets, new removeElement())
108-
.on(badgeOnlyCssAssetsProxied, new removeElement())
109-
.on(readthedocsExternalVersionWarning, new removeElement())
110-
.on(readthedocsExternalVersionWarningFuroTheme, new removeElement())
111-
.on(readthedocsExternalVersionWarningBookTheme, new removeElement())
112-
.on(readthedocsFlyout, new removeElement())
113-
// NOTE: I wasn't able to reliably remove the "<script>" that parses
114-
// the "READTHEDOCS_DATA" defined previously, so we are keeping it for now.
115-
//
116-
// .on(readthedocsDataParse, new removeElement())
117-
// .on(readthedocsData, new removeElement())
118-
.on("head", new addPreloads())
119-
.on("head", new addMetaTags(projectSlug, versionSlug, resolverFilename, httpStatus))
120-
.transform(originalResponse)
121-
);
122-
}
123-
124-
// Inject the new addons if the following conditions are met:
125-
//
126-
// - header `X-RTD-Hosting-Integrations` is present (added automatically when using `build.commands`)
127-
// - header `X-RTD-Force-Addons` is not present (user opted-in into new beta addons)
128-
//
129-
if (forceAddons === "false" && injectHostingIntegrations === "true") {
130-
return new HTMLRewriter()
154+
let rewriter = new HTMLRewriter();
155+
156+
// Remove by selectors
157+
for (const script of AddonsConstants.removalScripts) {
158+
rewriter.on(script, new removeElement());
159+
}
160+
for (const link of AddonsConstants.removalLinks) {
161+
rewriter.on(link, new removeElement());
162+
}
163+
for (const element of AddonsConstants.removalElements) {
164+
rewriter.on(element, new removeElement());
165+
}
166+
167+
// TODO match the pattern above and make this work
168+
// NOTE: I wasn't able to reliably remove the "<script>" that parses
169+
// the "READTHEDOCS_DATA" defined previously, so we are keeping it for now.
170+
//
171+
// rewriter.on(readthedocsDataParse, new removeElement())
172+
// rewriter.on(readthedocsData, new removeElement())
173+
174+
return rewriter
131175
.on("head", new addPreloads())
132-
.on("head", new addMetaTags(projectSlug, versionSlug, resolverFilename, httpStatus))
176+
.on(
177+
"head",
178+
new addMetaTags(
179+
projectSlug,
180+
versionSlug,
181+
resolverFilename,
182+
httpStatus,
183+
),
184+
)
185+
.on("*", {
186+
// This mimics a number of exceptions that can occur in the inner
187+
// request to origin, but mostly a hard to replicate timeout due to
188+
// a large page size.
189+
element(element) {
190+
if (throwError) {
191+
throw new Error("Manually triggered error in transform");
192+
}
193+
},
194+
})
133195
.transform(originalResponse);
134196
}
135197
}
136198

199+
// Inject the new addons if the following conditions are met:
200+
//
201+
// - header `X-RTD-Hosting-Integrations` is present (added automatically when using `build.commands`)
202+
// - header `X-RTD-Force-Addons` is not present (user opted-in into new beta addons)
203+
//
204+
if (forceAddons === "false" && injectHostingIntegrations === "true") {
205+
return new HTMLRewriter()
206+
.on("head", new addPreloads())
207+
.on(
208+
"head",
209+
new addMetaTags(projectSlug, versionSlug, resolverFilename, httpStatus),
210+
)
211+
.transform(originalResponse);
212+
}
213+
137214
// Modify `_static/searchtools.js` to re-enable Sphinx's default search
138215
if (
139216
(contentType.includes("text/javascript") ||
@@ -164,11 +241,11 @@ class removeElement {
164241
class addPreloads {
165242
element(element) {
166243
console.log("addPreloads");
167-
element.append(addonsJs, { html: true });
244+
element.append(AddonsConstants.scriptAddons, { html: true });
168245
}
169246
}
170247

171-
class addMetaTags{
248+
class addMetaTags {
172249
constructor(projectSlug, versionSlug, resolverFilename, httpStatus) {
173250
this.projectSlug = projectSlug;
174251
this.versionSlug = versionSlug;
@@ -194,45 +271,27 @@ class addMetaTags{
194271
}
195272
}
196273

197-
/*
198-
199-
Script to fix the old removal of the Sphinx search init.
200-
201-
Enabling addons breaks the default Sphinx search in old versions that are not possible to rebuilt.
202-
This is because we solved the problem in the `readthedocs-sphinx-ext` extension,
203-
but since those versions can't be rebuilt, the fix does not apply there.
204-
205-
To solve the problem in these old versions, we are using a CF worker to apply that fix on-the-fly
206-
at serving time on those old versions.
207-
208-
The fix basically replaces a Read the Docs comment in file `_static/searchtools.js`,
209-
introduced by `readthedocs-sphinx-ext` to _disable the initialization of Sphinx search_,
210-
with the real JavaScript to initialize the search, as Sphinx does by default.
211-
(in other words, it _reverts_ the manipulation done by `readthedocs-sphinx-ext`)
212-
213-
*/
214-
215-
const textToReplace = `/* Search initialization removed for Read the Docs */`;
216-
const textReplacement = `
217-
/* Search initialization manipulated by Read the Docs using Cloudflare Workers */
218-
/* See https://github.com/readthedocs/addons/issues/219 for more information */
219-
220-
function initializeSearch() {
221-
Search.init();
222-
}
223-
224-
if (document.readyState !== "loading") {
225-
initializeSearch();
226-
}
227-
else {
228-
document.addEventListener("DOMContentLoaded", initializeSearch);
229-
}
230-
`;
231-
274+
/** Fix the old removal of the Sphinx search init.
275+
*
276+
* Enabling addons breaks the default Sphinx search in old versions that can't
277+
* be rebuilt. We previously patches the Sphinx search in the
278+
* `readthedocs-sphinx-ext` extension, but since old versions can't be rebuilt,
279+
* the fix does not apply there.
280+
*
281+
* To solve the problem in old versions, we are using a Cloudflare worker to
282+
* apply this fix at serving time for those old versions.
283+
*
284+
* The fix replaces a Read the Docs comment in file `_static/searchtools.js`
285+
* that disabled the initialization of Sphinx search_. This change was
286+
* originally introduced by `readthedocs-sphinx-ext` and commented out the
287+
* initialization of the default Sphinx search. This reverts manipulation and
288+
* restores the original Sphinx search initialization.
289+
*
290+
* @param originalResponse {Response} - Response from origin
291+
* @returns {Response}
292+
**/
232293
async function handleSearchToolsJSRequest(originalResponse) {
294+
const { pattern, replacement } = AddonsConstants.replacements.searchtools;
233295
const content = await originalResponse.text();
234-
const modifiedResponse = new Response(
235-
content.replace(textToReplace, textReplacement),
236-
);
237-
return modifiedResponse;
296+
return new Response(content.replace(pattern, replacement), originalResponse);
238297
}

0 commit comments

Comments
 (0)