707afe1d4d
Adds `RenderResult`, replacing the `string` that `renderToHTML` used to return, with an `Observable`-like API that callers can use to subscribe and get a callback when chunks are available to flush, etc. This is the last architectural change needed for streaming. There are, however, other things currently standing in the way of streaming. For example, it is common to mutate `res` in `getServerSideProps` to do routing work, or write headers, before fetching page data. This pattern effectively nullifies any advantages of streaming. I may do a follow-up PR that adds an experimental alternative for applications not using React 18, but the main purpose for this support is for Suspense and Server Components. For that reason, there's no actual streaming here yet: instead we just flush a single chunk. A follow-up PR will add support for streaming suspense boundaries in React 18.
150 lines
3.4 KiB
TypeScript
150 lines
3.4 KiB
TypeScript
import { IncomingMessage, ServerResponse } from 'http'
|
|
import { isResSent } from '../shared/lib/utils'
|
|
import generateETag from 'etag'
|
|
import fresh from 'next/dist/compiled/fresh'
|
|
import { RenderResult } from './utils'
|
|
|
|
export type PayloadOptions =
|
|
| { private: true }
|
|
| { private: boolean; stateful: true }
|
|
| { private: boolean; stateful: false; revalidate: number | false }
|
|
|
|
export function setRevalidateHeaders(
|
|
res: ServerResponse,
|
|
options: PayloadOptions
|
|
) {
|
|
if (options.private || options.stateful) {
|
|
if (options.private || !res.hasHeader('Cache-Control')) {
|
|
res.setHeader(
|
|
'Cache-Control',
|
|
`private, no-cache, no-store, max-age=0, must-revalidate`
|
|
)
|
|
}
|
|
} else if (typeof options.revalidate === 'number') {
|
|
if (options.revalidate < 1) {
|
|
throw new Error(
|
|
`invariant: invalid Cache-Control duration provided: ${options.revalidate} < 1`
|
|
)
|
|
}
|
|
|
|
res.setHeader(
|
|
'Cache-Control',
|
|
`s-maxage=${options.revalidate}, stale-while-revalidate`
|
|
)
|
|
} else if (options.revalidate === false) {
|
|
res.setHeader('Cache-Control', `s-maxage=31536000, stale-while-revalidate`)
|
|
}
|
|
}
|
|
|
|
export function sendPayload(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
payload: any,
|
|
type: 'html' | 'json',
|
|
{
|
|
generateEtags,
|
|
poweredByHeader,
|
|
}: { generateEtags: boolean; poweredByHeader: boolean },
|
|
options?: PayloadOptions
|
|
): void {
|
|
sendRenderResult({
|
|
req,
|
|
res,
|
|
resultOrPayload: payload,
|
|
type,
|
|
generateEtags,
|
|
poweredByHeader,
|
|
options,
|
|
})
|
|
}
|
|
|
|
export function sendRenderResult({
|
|
req,
|
|
res,
|
|
resultOrPayload,
|
|
type,
|
|
generateEtags,
|
|
poweredByHeader,
|
|
options,
|
|
}: {
|
|
req: IncomingMessage
|
|
res: ServerResponse
|
|
resultOrPayload: RenderResult | string
|
|
type: 'html' | 'json'
|
|
generateEtags: boolean
|
|
poweredByHeader: boolean
|
|
options?: PayloadOptions
|
|
}): void {
|
|
if (isResSent(res)) {
|
|
return
|
|
}
|
|
|
|
if (poweredByHeader && type === 'html') {
|
|
res.setHeader('X-Powered-By', 'Next.js')
|
|
}
|
|
|
|
const isPayload = typeof resultOrPayload === 'string'
|
|
|
|
if (isPayload) {
|
|
const etag = generateEtags
|
|
? generateETag(resultOrPayload as string)
|
|
: undefined
|
|
if (sendEtagResponse(req, res, etag)) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if (!res.getHeader('Content-Type')) {
|
|
res.setHeader(
|
|
'Content-Type',
|
|
type === 'json' ? 'application/json' : 'text/html; charset=utf-8'
|
|
)
|
|
}
|
|
|
|
if (isPayload) {
|
|
res.setHeader(
|
|
'Content-Length',
|
|
Buffer.byteLength(resultOrPayload as string)
|
|
)
|
|
}
|
|
|
|
if (options != null) {
|
|
setRevalidateHeaders(res, options)
|
|
}
|
|
|
|
if (req.method === 'HEAD') {
|
|
res.end(null)
|
|
} else if (isPayload) {
|
|
res.end(resultOrPayload as string)
|
|
} else {
|
|
;(resultOrPayload as RenderResult)({
|
|
next: (chunk) => res.write(chunk),
|
|
error: (_) => res.end(),
|
|
complete: () => res.end(),
|
|
})
|
|
}
|
|
}
|
|
|
|
export function sendEtagResponse(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
etag: string | undefined
|
|
): boolean {
|
|
if (etag) {
|
|
/**
|
|
* The server generating a 304 response MUST generate any of the
|
|
* following header fields that would have been sent in a 200 (OK)
|
|
* response to the same request: Cache-Control, Content-Location, Date,
|
|
* ETag, Expires, and Vary. https://tools.ietf.org/html/rfc7232#section-4.1
|
|
*/
|
|
res.setHeader('ETag', etag)
|
|
}
|
|
|
|
if (fresh(req.headers, { etag })) {
|
|
res.statusCode = 304
|
|
res.end()
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|