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
+ }
47
72
48
73
// "readthedocsDataParse" is the "<script>" that calls:
49
74
//
@@ -52,22 +77,48 @@ const readthedocsFlyout = "div.rst-versions";
52
77
const readthedocsDataParse = "script[id=READTHEDOCS_DATA]:first-of-type" ;
53
78
const readthedocsData = "script[id=READTHEDOCS_DATA]" ;
54
79
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 ( ) ;
60
83
61
- async function handleRequest ( request ) {
62
- // perform the original request
63
- let originalResponse = await fetch ( request ) ;
84
+ const response = await fetch ( request ) ;
64
85
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" ) || "" ;
67
119
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" ;
71
122
const httpStatus = originalResponse . status ;
72
123
73
124
// Log some debugging data
@@ -76,12 +127,18 @@ async function handleRequest(request) {
76
127
console . log ( `X-RTD-Hosting-Integrations: ${ injectHostingIntegrations } ` ) ;
77
128
console . log ( `HTTP status: ${ httpStatus } ` ) ;
78
129
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
+
79
136
// 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" ) || "" ;
83
140
84
- // check to decide whether or not inject the new beta addons:
141
+ // Check to decide whether or not inject the new beta addons:
85
142
//
86
143
// - content type has to be "text/html"
87
144
// 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) {
94
151
// - header `X-RTD-Hosting-Integrations` is not present (added automatically when using `build.commands`)
95
152
//
96
153
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
131
175
. 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
+ } )
133
195
. transform ( originalResponse ) ;
134
196
}
135
197
}
136
198
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
+
137
214
// Modify `_static/searchtools.js` to re-enable Sphinx's default search
138
215
if (
139
216
( contentType . includes ( "text/javascript" ) ||
@@ -164,11 +241,11 @@ class removeElement {
164
241
class addPreloads {
165
242
element ( element ) {
166
243
console . log ( "addPreloads" ) ;
167
- element . append ( addonsJs , { html : true } ) ;
244
+ element . append ( AddonsConstants . scriptAddons , { html : true } ) ;
168
245
}
169
246
}
170
247
171
- class addMetaTags {
248
+ class addMetaTags {
172
249
constructor ( projectSlug , versionSlug , resolverFilename , httpStatus ) {
173
250
this . projectSlug = projectSlug ;
174
251
this . versionSlug = versionSlug ;
@@ -194,45 +271,27 @@ class addMetaTags{
194
271
}
195
272
}
196
273
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
+ **/
232
293
async function handleSearchToolsJSRequest ( originalResponse ) {
294
+ const { pattern, replacement } = AddonsConstants . replacements . searchtools ;
233
295
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 ) ;
238
297
}
0 commit comments