-
Notifications
You must be signed in to change notification settings - Fork 61.5k
/
Copy pathrender-page.ts
205 lines (174 loc) · 7.49 KB
/
render-page.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
import http from 'http'
import { get } from 'lodash-es'
import type { Response } from 'express'
import type { Failbot } from '@github/failbot'
import type { ExtendedRequest } from '@/types'
import FailBot from '@/observability/lib/failbot.js'
import patterns from '@/frame/lib/patterns.js'
import getMiniTocItems from '@/frame/lib/get-mini-toc-items.js'
import { pathLanguagePrefixed } from '@/languages/lib/languages.js'
import statsd from '@/observability/lib/statsd.js'
import { allVersions } from '@/versions/lib/all-versions.js'
import { isConnectionDropped } from './halt-on-dropped-connection'
import { nextHandleRequest } from './next.js'
import { defaultCacheControl } from './cache-control.js'
import { minimumNotFoundHtml } from '../lib/constants.js'
const STATSD_KEY_RENDER = 'middleware.render_page'
const STATSD_KEY_404 = 'middleware.render_404'
async function buildRenderedPage(req: ExtendedRequest): Promise<string> {
const { context } = req
if (!context) throw new Error('request not contextualized')
const { page } = context
if (!page) throw new Error('page not set in context')
const path = req.pagePath || req.path
const pageRenderTimed = statsd.asyncTimer(page.render, STATSD_KEY_RENDER, [`path:${path}`])
return (await pageRenderTimed(context)) as string
}
async function buildMiniTocItems(req: ExtendedRequest): Promise<string | undefined> {
const { context } = req
if (!context) throw new Error('request not contextualized')
const { page } = context
// get mini TOC items on articles
if (!page || !page.showMiniToc) {
return
}
return getMiniTocItems(context.renderedPage, 0)
}
export default async function renderPage(req: ExtendedRequest, res: Response) {
const { context } = req
// This is a contextualizing the request so that when this `req` is
// ultimately passed into the `Error.getInitialProps` function,
// which NextJS executes at runtime on errors, so that we can
// from there send the error to Failbot.
req.FailBot = FailBot as Failbot
if (!context) throw new Error('request not contextualized')
const { page } = context
const path = req.pagePath || req.path
// render a 404 page
if (!page) {
if (process.env.NODE_ENV !== 'test' && context.redirectNotFound) {
console.error(
`\nTried to redirect to ${context.redirectNotFound}, but that page was not found.\n`,
)
}
if (!pathLanguagePrefixed(req.path)) {
defaultCacheControl(res)
return res.status(404).type('html').send(minimumNotFoundHtml)
}
// The rest is "unhandled" requests where we don't have the page
// but the URL looks like a real page.
statsd.increment(STATSD_KEY_404, 1, [
`url:${req.url}`,
`ip:${req.ip}`,
`path:${req.path}`,
`referer:${req.headers.referer || ''}`,
])
// This means, we allow the CDN to cache it, but to be purged at the
// next deploy. The length isn't very important as long as it gets
// a new chance after the next deploy + purge.
// This way, we only have to respond with this 404 once per deploy
// and the CDN can cache it.
defaultCacheControl(res)
// The reason we're *NOT* using `nextApp.render404` is because, in
// Next v13, is for two reasons:
//
// 1. You cannot control the `cache-control` header. It always
// gets set to `private, no-cache, no-store, max-age=0, must-revalidate`.
// which is causing problems with Fastly because then we can't
// let Fastly cache it till the next purge, even if we do set a
// `Surrogate-Control` header.
// 2. In local development, it will always hang and never respond.
// Eventually you get a timeout error (503) after 10 seconds.
//
// The solution is to render a custom page (which is the
// src/pages/404.tsx) but control the status code (and the Cache-Control).
//
// Create a new request for a real one.
const tempReq = new http.IncomingMessage(req as any) as ExtendedRequest
tempReq.method = 'GET'
// There is a `src/pages/_notfound.txt`. That's why this will render
// a working and valid React component.
// It's important to not use `src/pages/404.txt` (or `/404` as the path)
// here because then it will set the wrong Cache-Control header.
tempReq.url = '/_notfound'
tempReq.path = '/_notfound'
tempReq.cookies = {}
tempReq.headers = {}
// By default, since the lookup for a `src/pages/*.tsx` file will work,
// inside the `nextHandleRequest` function, by default it will
// think it all worked with a 200 OK.
res.status(404)
return nextHandleRequest(tempReq, res)
}
// Just finish fast without all the details like Content-Length
if (req.method === 'HEAD') {
return res.status(200).send('')
}
// Updating the Last-Modified header for substantive changes on a page for engineering
// Docs Engineering Issue #945
if (page.effectiveDate) {
// Note that if a page has an invalidate `effectiveDate` string value,
// it would be caught prior to this usage and ultimately lead to
// 500 error.
res.setHeader('Last-Modified', new Date(page.effectiveDate).toUTCString())
}
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
if (!req.context) throw new Error('request not contextualized')
req.context.renderedPage = await buildRenderedPage(req)
req.context.miniTocItems = await buildMiniTocItems(req)
// Stop processing if the connection was already dropped
if (isConnectionDropped(req, res)) return
// Create string for <title> tag
page.fullTitle = page.title
// add localized ` - GitHub Docs` suffix to <title> tag (except for the homepage)
if (!patterns.homepagePath.test(path)) {
if (
req.context.currentVersion === 'free-pro-team@latest' ||
!allVersions[req.context.currentVersion!]
) {
page.fullTitle += ' - ' + context.site!.data.ui.header.github_docs
} else {
const { versionTitle } = allVersions[req.context.currentVersion!]
page.fullTitle += ' - '
// Some plans don't have the word "GitHub" in them.
// E.g. "Enterprise Server 3.5"
// In those cases manually prefix the word "GitHub" before it.
if (!versionTitle.includes('GitHub')) {
page.fullTitle += 'GitHub '
}
page.fullTitle += versionTitle + ' Docs'
}
}
// Is the request for JSON debugging info?
const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production'
// `?json` query param for debugging request context
if (isRequestingJsonForDebugging) {
const json = req.query.json
if (Array.isArray(json)) {
// e.g. ?json=page.permalinks&json=currentPath
throw new Error("'json' query string can only be 1")
}
if (json) {
// deep reference: ?json=page.permalinks
return res.json(get(context, req.query.json as string))
} else {
// dump all the keys: ?json
return res.json({
message:
'The full context object is too big to display! Try one of the individual keys below, e.g. ?json=page. You can also access nested props like ?json=site.data.reusables',
keys: Object.keys(context),
})
}
}
if (context.markdownRequested) {
if (!page.autogenerated && page.documentType === 'article') {
return res.type('text/markdown').send(req.context.renderedPage)
} else {
const newUrl = req.originalUrl.replace(req.path, req.path.replace(/\.md$/, ''))
return res.redirect(newUrl)
}
}
defaultCacheControl(res)
return nextHandleRequest(req, res)
}