Skip to content

feat(next-server): implement brotli compression #19151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-reference/next.config.js/compression.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: Next.js provides gzip compression to compress rendered content and

# Compression

Next.js provides [**gzip**](https://tools.ietf.org/html/rfc6713#section-3) compression to compress rendered content and static files. Compression only works with the [`server` target](/docs/api-reference/next.config.js/build-target.md#server-target). In general you will want to enable compression on a HTTP proxy like [nginx](https://www.nginx.com/), to offload load from the `Node.js` process.
Next.js provides [**brotli**](https://tools.ietf.org/html/rfc7932) and [**gzip**](https://tools.ietf.org/html/rfc6713#section-3) compression to compress rendered content and static files. Compression only works with the [`server` target](/docs/api-reference/next.config.js/build-target.md#server-target). In general you will want to enable compression on a HTTP proxy like [nginx](https://www.nginx.com/), to offload load from the `Node.js` process.

To disable **compression**, open `next.config.js` and disable the `compress` config:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"shell-quote": "1.7.2",
"styled-components": "5.1.0",
"styled-jsx-plugin-postcss": "3.0.2",
"supertest": "6.0.1",
"tailwindcss": "1.1.3",
"taskr": "1.1.0",
"tree-kill": "1.2.2",
Expand Down
23 changes: 0 additions & 23 deletions packages/next/compiled/compression/LICENSE

This file was deleted.

1 change: 0 additions & 1 deletion packages/next/compiled/compression/index.js

This file was deleted.

1 change: 0 additions & 1 deletion packages/next/compiled/compression/package.json

This file was deleted.

265 changes: 265 additions & 0 deletions packages/next/next-server/server/compression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import zlib, { Zlib, ZlibOptions, BrotliOptions } from 'zlib'
import { IncomingMessage, ServerResponse as HttpServerResponse } from 'http'
import { Transform } from 'stream'
import compressible from 'compressible'
import onHeaders from 'on-headers'
import vary from 'vary'
import Accept from '@hapi/accept'

export type ServerResponse = HttpServerResponse & {
flush?: () => void
_header?: { [key: string]: any }
_implicitHeader?: () => void
}

export type RequestListener = (
req: IncomingMessage,
res: ServerResponse
) => void

type Listener = (...args: any[]) => void
type EventType = 'close' | 'drain' | 'error' | 'finish' | 'pipe' | 'unpipe'

export interface CompressionFilter {
(req?: IncomingMessage, res?: ServerResponse): boolean
}

export type Options = ZlibOptions &
BrotliOptions & {
threshold?: number
filter?: CompressionFilter
}

const preferredEncodings = ['gzip', 'deflate', 'identity']

if ('createBrotliCompress' in zlib) {
preferredEncodings.unshift('br')
}

const cacheControlNoTransformRegExp = /(?:^|,)\s*?no-transform\s*?(?:,|$)/

const Compression = (opts: Options = {}): RequestListener => {
const filter = opts.filter ?? shouldCompress
if (!opts.params) {
opts.params = {}
}
if (opts.params[zlib.constants.BROTLI_PARAM_QUALITY] === undefined) {
opts.params[zlib.constants.BROTLI_PARAM_QUALITY] = 4
}

const threshold: number = opts.threshold ?? 1024

return function compression(
req: IncomingMessage,
res: ServerResponse & {
flush?: () => void
_header?: { [key: string]: any }
_implicitHeader?: () => void
}
): void {
let ended: boolean = false
let stream: (Transform & Zlib) | null = null
let listeners: [EventType, Listener][] = []
let length: number

const _end = res.end
const _on = res.on
const _write = res.write

res.flush = function flush() {
if (stream) {
stream.flush()
}
}

res.write = function write(chunk: any, encoding: BufferEncoding): boolean {
if (ended) {
return false
}

if (!res._header) {
res._implicitHeader!()
}

return stream
? stream.write(toBuffer(chunk, encoding))
: _write.call(res, chunk, encoding)
} as typeof _write

res.end = function end(chunk: any, encoding: BufferEncoding): void {
if (ended) {
return
}

if (!res._header) {
if (!res.getHeader('Content-Length')) {
length = chunkLength(chunk, encoding)
}
res._implicitHeader!()
}

if (!stream) {
return _end.call(res, chunk, encoding)
}

ended = true

return chunk ? stream.end(toBuffer(chunk, encoding)) : stream.end()
} as typeof _end

res.on = function on(type: EventType, listener: (...args: any[]) => void) {
if (!listeners || type !== 'drain') {
return _on.call(res, type, listener)
}

if (stream) {
return (stream.on(type, listener) as unknown) as ServerResponse
}

// buffer listeners for future stream
listeners.push([type, listener])

return res
}

function nocompress() {
addListeners(res, _on, listeners)
listeners = []
}

onHeaders(res, () => {
// determine if request is filtered
if (!filter(req, res)) {
nocompress()
return
}

// determine if the entity should be transformed
if (!shouldTransform(req, res)) {
nocompress()
return
}

// vary
vary(res, 'Accept-Encoding')

// content-length below threshold
const contentLength = Number(res.getHeader('Content-Length'))
if (
(!Number.isNaN(contentLength) && contentLength < threshold) ||
length < threshold
) {
nocompress()
return
}

const encoding = res.getHeader('Content-Encoding') ?? 'identity'

// already encoded
if (encoding !== 'identity') {
nocompress()
return
}

// head
if (req.method === 'HEAD') {
nocompress()
return
}

// compression method
const acceptEncoding = req.headers['accept-encoding']
const method = Accept.encoding(
acceptEncoding as string,
preferredEncodings
)

// negotiation failed
if (method === 'identity') {
nocompress()
return
}

switch (method) {
case 'br':
stream = zlib.createBrotliCompress(opts)
break
case 'gzip':
stream = zlib.createGzip(opts)
break
case 'deflate':
stream = zlib.createDeflate(opts)
break
default:
// Do nothing
}

// add buffered listeners to stream
addListeners(stream!, stream!.on, listeners)

// header fields
res.setHeader('Content-Encoding', method)
res.removeHeader('Content-Length')

stream!.on('data', (chunk) => {
if (_write.call(res, chunk, 'utf8') === false) {
stream!.pause()
}
})

stream!.on('end', () => {
_end.apply(res)
})

_on.call(res, 'drain', () => {
stream!.resume()
})
})
}

function addListeners(
stream: Transform | ServerResponse,
on: (e: EventType, cb: Listener) => void,
listeners: [EventType, Listener][]
) {
for (let i = 0; i < listeners.length; i++) {
on.apply(stream, listeners[i])
}
}
}

function toBuffer(chunk: any, encoding: BufferEncoding) {
return !Buffer.isBuffer(chunk) ? Buffer.from(chunk, encoding) : chunk
}

function shouldCompress(_req: IncomingMessage, res: ServerResponse) {
const type = res.getHeader('Content-Type')

if (type === undefined || !compressible(type as string)) {
return false
}

return true
}

function shouldTransform(_req: IncomingMessage, res: ServerResponse) {
const cacheControl = res.getHeader('Cache-Control')

// Don't compress for Cache-Control: no-transform
// https://tools.ietf.org/html/rfc7234#section-5.2.2.4
return (
!cacheControl || !cacheControlNoTransformRegExp.test(String(cacheControl))
)
}

function chunkLength(chunk: any, encoding: BufferEncoding): number {
if (!chunk) {
return 0
}

return !Buffer.isBuffer(chunk)
? Buffer.byteLength(chunk, encoding)
: chunk.length
}

export default Compression
18 changes: 9 additions & 9 deletions packages/next/next-server/server/next-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import compression from 'next/dist/compiled/compression'
import fs from 'fs'
import chalk from 'chalk'
import { IncomingMessage, ServerResponse } from 'http'
Expand Down Expand Up @@ -85,14 +84,15 @@ import * as Log from '../../build/output/log'
import { imageOptimizer } from './image-optimizer'
import { detectDomainLocale } from '../lib/i18n/detect-domain-locale'
import cookie from 'next/dist/compiled/cookie'
import Compression from './compression'
import escapeStringRegexp from 'next/dist/compiled/escape-string-regexp'

const getCustomRouteMatcher = pathMatch(true)

type NextConfig = any

const getCustomRouteMatcher = pathMatch(true)

type Middleware = (
req: IncomingMessage,
res: ServerResponse,
next: (err?: Error) => void
) => void

type FindComponentsResult = {
components: LoadComponentsReturnType
Expand Down Expand Up @@ -152,7 +152,7 @@ export default class Server {
locales?: string[]
defaultLocale?: string
}
private compression?: Middleware
private compression?: ReturnType<typeof Compression>
private onErrorMiddleware?: ({ err }: { err: Error }) => Promise<void>
private incrementalCache: IncrementalCache
router: Router
Expand Down Expand Up @@ -212,7 +212,7 @@ export default class Server {
}

if (compress && this.nextConfig.target === 'server') {
this.compression = compression() as Middleware
this.compression = Compression()
}

// Initialize next/config with the environment configuration
Expand Down Expand Up @@ -1043,7 +1043,7 @@ export default class Server {

private handleCompression(req: IncomingMessage, res: ServerResponse): void {
if (this.compression) {
this.compression(req, res, () => {})
this.compression(req, res)
}
}

Expand Down
Loading