Skip to content

Commit

Permalink
feat: use netlify-headers-parser (#3189)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky authored Aug 16, 2021
1 parent fa064f1 commit 8d77d49
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 112 deletions.
13 changes: 7 additions & 6 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
"minimist": "^1.2.5",
"multiparty": "^4.2.1",
"netlify": "^8.0.0",
"netlify-headers-parser": "^3.0.1",
"netlify-redirect-parser": "^11.0.1",
"netlify-redirector": "^0.2.1",
"node-fetch": "^2.6.0",
Expand Down
80 changes: 24 additions & 56 deletions src/utils/headers.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
const fs = require('fs')

const { get } = require('dot-prop')
const escapeRegExp = require('lodash/escapeRegExp')
const trimEnd = require('lodash/trimEnd')
const { parseAllHeaders } = require('netlify-headers-parser')

const TOKEN_COMMENT = '#'
const TOKEN_PATH = '/'
const { NETLIFYDEVWARN } = require('./logo')

// Our production logic uses regex too
const getRulePattern = (rule) => {
const ruleParts = rule.split('/').filter(Boolean)
const getRulePattern = (forPath) => {
const ruleParts = forPath.split('/').filter(Boolean)
if (ruleParts.length === 0) {
return `^/$`
}
Expand Down Expand Up @@ -59,67 +56,38 @@ const matchesPath = (rule, path) => {
* @returns {Object<string,string[]>}
*/
const headersForPath = function (rules, path) {
const matchingHeaders = Object.entries(rules)
.filter(([rule]) => matchesPath(rule, path))
.map(([, headers]) => headers)

const matchingHeaders = rules.filter(({ for: forPath }) => matchesPath(forPath, path)).map(getHeaderValues)
const pathObject = Object.assign({}, ...matchingHeaders)
return pathObject
}

const HEADER_SEPARATOR = ':'

const parseHeadersFile = function (filePath) {
if (!fs.existsSync(filePath)) {
return {}
}
if (fs.statSync(filePath).isDirectory()) {
console.warn('expected _headers file but found a directory at:', filePath)
return {}
}

const lines = fs
.readFileSync(filePath, { encoding: 'utf8' })
.split('\n')
.map((line, index) => ({ line: line.trim(), index }))
.filter(({ line }) => Boolean(line) && !line.startsWith(TOKEN_COMMENT))
const getHeaderValues = function ({ values }) {
return values
}

let path
let rules = {}
// eslint-disable-next-line fp/no-loops
for (const { line, index } of lines) {
if (line.startsWith(TOKEN_PATH)) {
path = line
continue
}
const parseHeaders = async function ({ headersFiles }) {
const { headers, errors } = await parseAllHeaders({ headersFiles })
handleHeadersErrors(errors)
return headers
}

if (!path) {
throw new Error('path should come before headers')
}
const handleHeadersErrors = function (errors) {
if (errors.length === 0) {
return
}

if (line.includes(HEADER_SEPARATOR)) {
const [key = '', ...value] = line.split(HEADER_SEPARATOR)
const [trimmedKey, trimmedValue] = [key.trim(), value.join(HEADER_SEPARATOR).trim()]
if (trimmedKey.length === 0 || trimmedValue.length === 0) {
throw new Error(`invalid header at line: ${index}\n${line}\n`)
}
const errorMessage = errors.map(getErrorMessage).join('\n\n')
throw new Error(`${NETLIFYDEVWARN} Warnings while parsing headers:
const currentHeaders = get(rules, `${path}.${trimmedKey}`) || []
rules = {
...rules,
[path]: {
...rules[path],
[trimmedKey]: [...currentHeaders, trimmedValue],
},
}
}
}
${errorMessage}`)
}

return rules
const getErrorMessage = function ({ message }) {
return message
}

module.exports = {
matchesPath,
headersForPath,
parseHeadersFile,
parseHeaders,
}
111 changes: 66 additions & 45 deletions src/utils/headers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const test = require('ava')

const { createSiteBuilder } = require('../../tests/utils/site-builder')

const { parseHeadersFile, headersForPath, matchesPath } = require('./headers')
const { parseHeaders, headersForPath, matchesPath } = require('./headers')

const headers = [
{ path: '/', headers: ['X-Frame-Options: SAMEORIGIN'] },
Expand Down Expand Up @@ -70,77 +70,98 @@ test.after(async (t) => {
/**
* Pass if we can load the test headers without throwing an error.
*/
test('_headers: syntax validates as expected', (t) => {
parseHeadersFile(path.resolve(t.context.builder.directory, '_headers'))
test('_headers: syntax validates as expected', async (t) => {
await parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_headers')] })
})

test('_headers: throws on invalid syntax', (t) => {
t.throws(() => parseHeadersFile(path.resolve(t.context.builder.directory, '_invalid_headers')), {
message: /invalid header at line: 5/,
test('_headers: throws on invalid syntax', async (t) => {
await t.throwsAsync(parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_invalid_headers')] }), {
message: /Missing header value/,
})
})

test('_headers: validate rules', (t) => {
const rules = parseHeadersFile(path.resolve(t.context.builder.directory, '_headers'))
t.deepEqual(rules, {
'/': {
'X-Frame-Options': ['SAMEORIGIN'],
test('_headers: validate rules', async (t) => {
const rules = await parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_headers')] })
t.deepEqual(rules, [
{
for: '/',
values: {
'X-Frame-Options': 'SAMEORIGIN',
},
},
'/*': {
'X-Frame-Thing': ['SAMEORIGIN'],
{
for: '/*',
values: {
'X-Frame-Thing': 'SAMEORIGIN',
},
},
'/static-path/*': {
'X-Frame-Options': ['DENY'],
'X-XSS-Protection': ['1; mode=block'],
'cache-control': ['max-age=0', 'no-cache', 'no-store', 'must-revalidate'],
{
for: '/static-path/*',
values: {
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'cache-control': 'max-age=0,no-cache,no-store,must-revalidate',
},
},
'/:placeholder/index.html': {
'X-Frame-Options': ['SAMEORIGIN'],
{
for: '/:placeholder/index.html',
values: {
'X-Frame-Options': 'SAMEORIGIN',
},
},
'/*/_next/static/chunks/*': {
'cache-control': ['public', 'max-age=31536000', 'immutable'],
{
for: '/*/_next/static/chunks/*',
values: {
'cache-control': 'public,max-age=31536000,immutable',
},
},
'/directory/*/test.html': {
'X-Frame-Options': ['test'],
{
for: '/directory/*/test.html',
values: {
'X-Frame-Options': 'test',
},
},
'/with-colon': {
'Custom-header': ['http://www.example.com'],
{
for: '/with-colon',
values: {
'Custom-header': 'http://www.example.com',
},
},
})
])
})

test('_headers: headersForPath testing', (t) => {
const rules = parseHeadersFile(path.resolve(t.context.builder.directory, '_headers'))
test('_headers: headersForPath testing', async (t) => {
const rules = await parseHeaders({ headersFiles: [path.resolve(t.context.builder.directory, '_headers')] })
t.deepEqual(headersForPath(rules, '/'), {
'X-Frame-Options': ['SAMEORIGIN'],
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': 'SAMEORIGIN',
'X-Frame-Thing': 'SAMEORIGIN',
})
t.deepEqual(headersForPath(rules, '/placeholder'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Thing': 'SAMEORIGIN',
})
t.deepEqual(headersForPath(rules, '/static-path/placeholder'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': ['DENY'],
'X-XSS-Protection': ['1; mode=block'],
'cache-control': ['max-age=0', 'no-cache', 'no-store', 'must-revalidate'],
'X-Frame-Thing': 'SAMEORIGIN',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'cache-control': 'max-age=0,no-cache,no-store,must-revalidate',
})
t.deepEqual(headersForPath(rules, '/static-path'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': ['DENY'],
'X-XSS-Protection': ['1; mode=block'],
'cache-control': ['max-age=0', 'no-cache', 'no-store', 'must-revalidate'],
'X-Frame-Thing': 'SAMEORIGIN',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'cache-control': 'max-age=0,no-cache,no-store,must-revalidate',
})
t.deepEqual(headersForPath(rules, '/placeholder/index.html'), {
'X-Frame-Options': ['SAMEORIGIN'],
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': 'SAMEORIGIN',
'X-Frame-Thing': 'SAMEORIGIN',
})
t.deepEqual(headersForPath(rules, '/placeholder/_next/static/chunks/placeholder'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'cache-control': ['public', 'max-age=31536000', 'immutable'],
'X-Frame-Thing': 'SAMEORIGIN',
'cache-control': 'public,max-age=31536000,immutable',
})
t.deepEqual(headersForPath(rules, '/directory/placeholder/test.html'), {
'X-Frame-Thing': ['SAMEORIGIN'],
'X-Frame-Options': ['test'],
'X-Frame-Thing': 'SAMEORIGIN',
'X-Frame-Options': 'test',
})
})

Expand Down
10 changes: 5 additions & 5 deletions src/utils/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const toReadableStream = require('to-readable-stream')
const { readFileAsync, fileExistsAsync, isFileAsync } = require('../lib/fs')

const { createStreamPromise } = require('./create-stream-promise')
const { parseHeadersFile, headersForPath } = require('./headers')
const { parseHeaders, headersForPath } = require('./headers')
const { NETLIFYDEVLOG, NETLIFYDEVWARN } = require('./logo')
const { createRewriter } = require('./rules-proxy')
const { onChanges } = require('./rules-proxy')
Expand Down Expand Up @@ -263,7 +263,7 @@ const serveRedirect = async function ({ req, res, proxy, match, options }) {

const MILLISEC_TO_SEC = 1e3

const initializeProxy = function (port, distDir, projectDir) {
const initializeProxy = async function (port, distDir, projectDir) {
const proxy = httpProxy.createProxyServer({
selfHandleResponse: true,
target: {
Expand All @@ -274,13 +274,13 @@ const initializeProxy = function (port, distDir, projectDir) {

const headersFiles = [...new Set([path.resolve(projectDir, '_headers'), path.resolve(distDir, '_headers')])]

let headerRules = headersFiles.reduce((prev, curr) => Object.assign(prev, parseHeadersFile(curr)), {})
let headerRules = await parseHeaders({ headersFiles })
onChanges(headersFiles, async () => {
console.log(
`${NETLIFYDEVLOG} Reloading headers files`,
(await pFilter(headersFiles, fileExistsAsync)).map((headerFile) => path.relative(projectDir, headerFile)),
)
headerRules = headersFiles.reduce((prev, curr) => Object.assign(prev, parseHeadersFile(curr)), {})
headerRules = await parseHeaders({ headersFiles })
})

proxy.before('web', 'stream', (req) => {
Expand Down Expand Up @@ -392,7 +392,7 @@ const onRequest = async ({ proxy, rewriter, settings, addonsUrls, functionsServe
const startProxy = async function (settings, addonsUrls, configPath, projectDir) {
const functionsServer = settings.functionsPort ? `http://localhost:${settings.functionsPort}` : null

const proxy = initializeProxy(settings.frameworkPort, settings.dist, projectDir)
const proxy = await initializeProxy(settings.frameworkPort, settings.dist, projectDir)

const rewriter = await createRewriter({
distDir: settings.dist,
Expand Down

1 comment on commit 8d77d49

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

Package size: 330 MB

Please sign in to comment.