Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit afac414

Browse files
committed
Add page lookup for .image.js and screenshot calling
1 parent 9e81f09 commit afac414

File tree

5 files changed

+127
-121
lines changed

5 files changed

+127
-121
lines changed

packages/next/build/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ export default async function build(
389389
defaultLocale: string
390390
localeDetection?: false
391391
}
392+
ogImageNonce: string
392393
} = nextBuildSpan.traceChild('generate-routes-manifest').traceFn(() => ({
393394
version: 3,
394395
pages404: true,
@@ -408,6 +409,8 @@ export default async function build(
408409
}),
409410
dataRoutes: [],
410411
i18n: config.i18n || undefined,
412+
// TODO: update to actual randomness
413+
ogImageNonce: Math.random() + '',
411414
}))
412415

413416
if (rewrites.beforeFiles.length === 0 && rewrites.fallback.length === 0) {

packages/next/next-server/server/next-server.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ export default class Server {
170170
private incrementalCache: IncrementalCache
171171
protected router: Router
172172
protected dynamicRoutes?: DynamicRoutes
173-
protected customRoutes: CustomRoutes
173+
protected customRoutes: CustomRoutes & { ogImageNonce: string }
174+
private ogImageNonce: string
174175

175176
public constructor({
176177
dir = '.',
@@ -255,6 +256,8 @@ export default class Server {
255256
this.router = new Router(this.generateRoutes())
256257
this.setAssetPrefix(assetPrefix)
257258

259+
this.ogImageNonce = this.customRoutes.ogImageNonce
260+
258261
this.incrementalCache = new IncrementalCache({
259262
dev,
260263
distDir: this.distDir,
@@ -593,7 +596,7 @@ export default class Server {
593596
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
594597
}
595598

596-
protected getCustomRoutes(): CustomRoutes {
599+
protected getCustomRoutes(): CustomRoutes & { ogImageNonce: string } {
597600
const customRoutes = require(join(this.distDir, ROUTES_MANIFEST))
598601
let rewrites: CustomRoutes['rewrites']
599602

@@ -609,6 +612,7 @@ export default class Server {
609612
} else {
610613
rewrites = customRoutes.rewrites
611614
}
615+
612616
return Object.assign(customRoutes, { rewrites })
613617
}
614618

@@ -787,13 +791,6 @@ export default class Server {
787791
server.distDir
788792
),
789793
},
790-
{
791-
match: route('/_next/og-image'),
792-
type: 'route',
793-
name: '_next/og-image catchall',
794-
fn: (req, res, _params, parsedUrl) =>
795-
ogImageGenerator(server, req, res, parsedUrl, this.renderOpts.dev),
796-
},
797794
{
798795
match: route('/_next/:path*'),
799796
type: 'route',
@@ -1359,12 +1356,42 @@ export default class Server {
13591356
query: ParsedUrlQuery = {},
13601357
params: Params | null = null
13611358
): Promise<FindComponentsResult | null> {
1359+
// {page}.image.{jpg,png,webp} -> {page}.image -> puppeteer-core
1360+
// dev only: {page}.image.js -> server HTML
1361+
// {page}.image.{jpg,png,webp} -> query.__nextOgImage
1362+
1363+
// we don't allow rendering the HTML without the nonce in
1364+
// production
1365+
console.log('looking for pathname', { pathname })
1366+
1367+
if (
1368+
!this.renderOpts.dev &&
1369+
pathname.endsWith('.image') &&
1370+
query.__nextImageNonce !== this.ogImageNonce
1371+
) {
1372+
return null
1373+
}
1374+
13621375
let paths = [
13631376
// try serving a static AMP version first
13641377
query.amp ? normalizePagePath(pathname) + '.amp' : null,
1378+
1379+
// try looking up the statically generated image first or normal pathname
13651380
pathname,
13661381
].filter(Boolean)
13671382

1383+
// TODO: replace with official image extension list
1384+
const isOgImage = pathname.match(/\.image\.(jpe?g|png)/)
1385+
1386+
if (isOgImage) {
1387+
console.log('set ogImage mode', { pathname })
1388+
query.__nextOgImage = 'true'
1389+
// if no statically generated version is available we
1390+
// check if the pathname is valid and if so we render the
1391+
// image on-demand
1392+
paths.push(pathname.replace(/\.(jpe?g|png)$/, ''))
1393+
}
1394+
13681395
if (query.__nextLocale) {
13691396
paths = [
13701397
...paths.map(
@@ -1464,6 +1491,15 @@ export default class Server {
14641491
if (is404Page && !isDataReq) {
14651492
res.statusCode = 404
14661493
}
1494+
delete query.__nextImageNonce
1495+
1496+
if (query.__nextOgImage) {
1497+
delete query.__nextOgImage
1498+
console.log('rendering og image!!')
1499+
1500+
await ogImageGenerator(req, res, pathname, query, this.ogImageNonce)
1501+
return null
1502+
}
14671503

14681504
// ensure correct status is set when visiting a status page
14691505
// directly e.g. /500
@@ -2026,6 +2062,9 @@ export default class Server {
20262062
) {
20272063
let html: string | null
20282064
try {
2065+
// TODO: move this clearing somewhere better?
2066+
delete query.__nextOgImage
2067+
20292068
let result: null | FindComponentsResult = null
20302069

20312070
const is404 = res.statusCode === 404
Lines changed: 47 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,24 @@
11
import { IncomingMessage, ServerResponse } from 'http'
2-
import Stream from 'stream'
3-
import nodeUrl, { UrlWithParsedQuery } from 'url'
4-
import Server from './next-server'
2+
import path from 'path'
3+
import { UrlWithParsedQuery } from 'url'
54
import puppeteer from 'puppeteer-core'
65

76
let browser: puppeteer.Browser | undefined
87

9-
/* eslint-disable-next-line */
8+
// eslint-disable-next-line
109
enum ImageType {
1110
png = 'png',
1211
jpeg = 'jpeg',
1312
}
1413

1514
export async function ogImageGenerator(
16-
server: Server,
1715
req: IncomingMessage,
1816
res: ServerResponse,
19-
parsedUrl: UrlWithParsedQuery,
20-
isDev = false
17+
pathname: string,
18+
query: UrlWithParsedQuery['query'],
19+
nonce: string
2120
) {
22-
const { url, w, h, t } = parsedUrl.query
23-
24-
if (!url) {
25-
res.statusCode = 400
26-
res.end('"url" parameter is required')
27-
return { finished: true }
28-
} else if (Array.isArray(url)) {
29-
res.statusCode = 400
30-
res.end('"url" parameter cannot be an array')
31-
return { finished: true }
32-
}
21+
const { w, h } = query
3322

3423
if (!w) {
3524
res.statusCode = 400
@@ -51,16 +40,6 @@ export async function ogImageGenerator(
5140
return { finished: true }
5241
}
5342

54-
if (!t) {
55-
res.statusCode = 400
56-
res.end('"t" parameter (type) is required')
57-
return { finished: true }
58-
} else if (Array.isArray(t)) {
59-
res.statusCode = 400
60-
res.end('"t" parameter (type) cannot be an array')
61-
return { finished: true }
62-
}
63-
6443
const width = parseInt(w, 10)
6544
if (!width || isNaN(width)) {
6645
res.statusCode = 400
@@ -75,90 +54,49 @@ export async function ogImageGenerator(
7554
return { finished: true }
7655
}
7756

78-
let type = t as ImageType
79-
if (!Object.values(ImageType).includes(type)) {
57+
const type: ImageType = path.extname(pathname).substr(1) as ImageType
58+
59+
if (!Object.keys(ImageType).includes(type)) {
8060
res.statusCode = 400
8161
res.end(
8262
`"t" parameter (type) must be one of: ${Object.keys(ImageType).join(
83-
'\n'
63+
', '
8464
)}`
8565
)
8666
return { finished: true }
8767
}
8868

89-
let upstreamStatus: number
90-
let upstreamCache: string | null
91-
92-
try {
93-
const mockRes: any = new Stream.Writable()
94-
95-
const isStreamFinished = new Promise(function (resolve, reject) {
96-
mockRes.on('finish', () => resolve(true))
97-
mockRes.on('end', () => resolve(true))
98-
mockRes.on('error', () => reject())
99-
})
100-
101-
mockRes.write = (_chunk: Buffer | string) => {
102-
// no-op
103-
}
104-
mockRes._write = (chunk: Buffer | string) => {
105-
mockRes.write(chunk)
106-
}
107-
108-
const mockHeaders: Record<string, string | string[]> = {}
109-
110-
mockRes.writeHead = (_status: any, _headers: any) =>
111-
Object.assign(mockHeaders, _headers)
112-
mockRes.getHeader = (name: string) => mockHeaders[name.toLowerCase()]
113-
mockRes.getHeaders = () => mockHeaders
114-
mockRes.getHeaderNames = () => Object.keys(mockHeaders)
115-
mockRes.setHeader = (name: string, value: string | string[]) =>
116-
(mockHeaders[name.toLowerCase()] = value)
117-
mockRes._implicitHeader = () => {}
118-
mockRes.finished = false
119-
mockRes.statusCode = 200
120-
121-
const mockReq: any = new Stream.Readable()
122-
123-
mockReq._read = () => {
124-
mockReq.emit('end')
125-
mockReq.emit('close')
126-
return Buffer.from('')
127-
}
128-
129-
mockReq.headers = req.headers
130-
mockReq.method = req.method
131-
mockReq.url = url
132-
133-
await server.getRequestHandler()(mockReq, mockRes, nodeUrl.parse(url, true))
134-
await isStreamFinished
135-
upstreamStatus = mockRes.statusCode
136-
upstreamCache = mockRes.getHeader('Cache-Control')
137-
} catch (err) {
138-
res.statusCode = 500
139-
res.end('"url" parameter is valid but upstream response is invalid')
140-
return { finished: true }
141-
}
69+
const { localAddress, localPort } = req.connection
70+
const _server = (req.connection as any)._server
71+
const isHTTPS = _server.secureProtocol
72+
const imageUrl = `http${
73+
isHTTPS ? 's' : ''
74+
}://${localAddress}:${localPort}${pathname.replace(
75+
/\.(jpe?g|png)/,
76+
''
77+
)}?_nextImageNonce=${nonce}`
78+
79+
const absoluteUrl = new URL(imageUrl)
80+
const { buffer, upstreamStatus, upstreamCache } = await getScreenshot(
81+
absoluteUrl,
82+
width,
83+
height,
84+
type
85+
)
14286

143-
const proto = isDev ? 'http' : 'https'
144-
const host = req.headers.host
145-
console.log({ url, prefix: `${proto}://${host}` })
146-
const absoluteUrl = new URL(url, `${proto}://${host}`)
147-
const buffer = await getScreenshot(isDev, absoluteUrl, width, height, type)
14887
res.statusCode = upstreamStatus
14988
res.setHeader('Content-Type', `image/${type}`)
89+
15090
// TODO: should we also set ETag header?
91+
// re-use send-payload util?
15192
if (upstreamCache) {
15293
res.setHeader('Cache-Control', upstreamCache)
15394
}
15495
res.end(buffer)
15596
return { finished: true }
15697
}
15798

158-
function getOptions(isDev: boolean) {
159-
if (!isDev) {
160-
throw new Error('Production is not implemented yet')
161-
}
99+
function getOptions() {
162100
return {
163101
args: [],
164102
headless: true,
@@ -173,23 +111,34 @@ function getOptions(isDev: boolean) {
173111
}
174112

175113
async function getScreenshot(
176-
isDev: boolean,
177114
url: URL,
178115
width: number,
179116
height: number,
180117
type: ImageType
181-
) {
118+
): Promise<{
119+
buffer: Buffer
120+
upstreamStatus: number
121+
upstreamCache: string
122+
}> {
182123
if (!browser) {
183-
const options = getOptions(isDev)
124+
const options = getOptions()
184125
browser = await puppeteer.launch(options)
185126
}
186127
const page = await browser.newPage()
187128
await page.setViewport({ width, height })
188-
await page.goto(url.href)
129+
const response = await page.goto(url.href)
130+
const upstreamStatus = response.status()
131+
const upstreamCache = response.headers()['cache-control']
189132
const file = await page.screenshot({ type, encoding: 'binary' })
133+
190134
if (!file || typeof file === 'string') {
191135
throw new Error('Expected buffer but found ' + typeof file)
192136
}
193137
await page.close()
194-
return file
138+
139+
return {
140+
buffer: file,
141+
upstreamStatus,
142+
upstreamCache,
143+
}
195144
}

0 commit comments

Comments
 (0)