Skip to content

feat: add support for headers config #200

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

Merged
merged 10 commits into from
May 28, 2025
Merged
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"packages/dev": "2.3.1",
"packages/dev-utils": "2.2.0",
"packages/functions": "3.1.10",
"packages/headers": {},
"packages/otel": "1.1.0",
"packages/redirects": "1.1.4",
"packages/runtime": "2.2.2",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ npm run build --workspaces=true
| 🛠️ [@netlify/dev](packages/dev) | Emulation of the Netlify environment for local development | [![npm version](https://img.shields.io/npm/v/@netlify/dev.svg)](https://www.npmjs.com/package/@netlify/dev) |
| 🔧 [@netlify/dev-utils](packages/dev-utils) | TypeScript utilities for the local emulation of the Netlify environment | [![npm version](https://img.shields.io/npm/v/@netlify/dev-utils.svg)](https://www.npmjs.com/package/@netlify/dev-utils) |
| ⚡ [@netlify/functions](packages/functions) | TypeScript utilities for interacting with Netlify Functions | [![npm version](https://img.shields.io/npm/v/@netlify/functions.svg)](https://www.npmjs.com/package/@netlify/functions) |
| 📋 [@netlify/headers](packages/headers) | TypeScript implementation of Netlify's headers engine | [![npm version](https://img.shields.io/npm/v/@netlify/headers.svg)](https://www.npmjs.com/package/@netlify/headers) |
| 🔍 [@netlify/otel](packages/otel) | TypeScript utilities to interact with Netlify's OpenTelemetry | [![npm version](https://img.shields.io/npm/v/@netlify/otel.svg)](https://www.npmjs.com/package/@netlify/otel) |
| 🔄 [@netlify/redirects](packages/redirects) | TypeScript implementation of Netlify's rewrites and redirects engine | [![npm version](https://img.shields.io/npm/v/@netlify/redirects.svg)](https://www.npmjs.com/package/@netlify/redirects) |
| 🏛️ [@netlify/runtime](packages/runtime) | Netlify compute runtime | [![npm version](https://img.shields.io/npm/v/@netlify/runtime.svg)](https://www.npmjs.com/package/@netlify/runtime) |
Expand Down
41 changes: 40 additions & 1 deletion package-lock.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 @@ -8,6 +8,7 @@
"packages/blobs",
"packages/cache",
"packages/functions",
"packages/headers",
"packages/redirects",
"packages/runtime",
"packages/static",
Expand Down
1 change: 1 addition & 0 deletions packages/dev-utils/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export { watchDebounced } from './lib/watch-debounced.js'
export { EventInspector } from './test/event_inspector.js'
export { MockFetch } from './test/fetch.js'
export { Fixture } from './test/fixture.js'
export { createMockLogger } from './test/logger.js'
19 changes: 19 additions & 0 deletions packages/dev-utils/src/test/fixture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { exec } from 'node:child_process'
import { promises as fs } from 'node:fs'
import { EOL } from 'node:os'
import { dirname, join } from 'node:path'
import { promisify } from 'node:util'

Expand Down Expand Up @@ -88,6 +89,24 @@ export class Fixture {
return this
}

withHeadersFile({
headers = [],
pathPrefix = '',
}: {
headers?: { headers: string[]; path: string }[]
pathPrefix?: string
}) {
const dest = join(pathPrefix, '_headers')
const contents = headers
.map(
({ headers: headersValues, path: headerPath }) =>
`${headerPath}${EOL}${headersValues.map((header) => ` ${header}`).join(EOL)}`,
)
.join(EOL)

return this.withFile(dest, contents)
}

withStateFile(state: object) {
this.files['.netlify/state.json'] = JSON.stringify(state)

Expand Down
7 changes: 7 additions & 0 deletions packages/dev-utils/src/test/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Logger } from '../lib/logger.js'

export const createMockLogger = (): Logger => ({
log: () => {},
warn: () => {},
error: () => {},
})
1 change: 1 addition & 0 deletions packages/dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@netlify/config": "^23.0.7",
"@netlify/dev-utils": "2.2.0",
"@netlify/functions": "3.1.10",
"@netlify/headers": "0.0.0",
"@netlify/redirects": "1.1.4",
"@netlify/runtime": "2.2.2",
"@netlify/static": "1.1.4"
Expand Down
152 changes: 152 additions & 0 deletions packages/dev/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,158 @@ describe('Handling requests', () => {
await fixture.destroy()
})

test('Headers rules matching a static file are applied', async () => {
const fixture = new Fixture()
.withFile(
'netlify.toml',
`[build]
publish = "public"
[[headers]]
for = "/hello.txt"
[headers.values]
"Vary" = "User-Agent"
`,
)
.withHeadersFile({
pathPrefix: 'public',
headers: [{ path: '/hello.txt', headers: ['Cache-Control: max-age=42'] }],
})
.withFile('public/hello.txt', 'Hello from hello.txt')
.withFile('public/another-path.txt', 'Hello from another-path.txt')
const directory = await fixture.create()
const req = new Request('https://site.netlify/hello.txt')
const dev = new NetlifyDev({
projectRoot: directory,
})
await dev.start()

const matchRes = await dev.handle(req)

expect(await matchRes?.text()).toBe('Hello from hello.txt')
expect(Object.fromEntries(matchRes?.headers?.entries() ?? [])).toMatchObject({
'cache-control': 'max-age=42',
vary: 'User-Agent',
})

const noMatchRes = await dev.handle(new Request('https://site.netlify/another-path.txt'))
expect(await noMatchRes?.text()).toBe('Hello from another-path.txt')
expect(Object.fromEntries(noMatchRes?.headers?.entries() ?? [])).not.toMatchObject({
'cache-control': 'max-age=42',
vary: 'User-Agent',
})

await fixture.destroy()
})

test('Headers rules matching target of a rewrite to a static file are applied', async () => {
const fixture = new Fixture()
.withFile(
'netlify.toml',
`[build]
publish = "public"
[[headers]]
for = "/from"
[headers.values]
"X-Custom" = "value for from rule"
"X-Custom-From" = "another value for from rule"
[[headers]]
for = "/to.txt"
[headers.values]
"X-Custom" = "value for to rule"
`,
)
.withFile('public/_redirects', `/from /to.txt 200`)
.withFile('public/to.txt', `to.txt content`)
const directory = await fixture.create()
const dev = new NetlifyDev({
projectRoot: directory,
})
await dev.start()

const directRes = await dev.handle(new Request('https://site.netlify/to.txt'))
expect(await directRes?.text()).toBe('to.txt content')
expect(directRes?.headers.get('X-Custom')).toBe('value for to rule')
expect(directRes?.headers.get('X-Custom-From')).toBeNull()

const rewriteRes = await dev.handle(new Request('https://site.netlify/from'))
expect(await rewriteRes?.text()).toBe('to.txt content')
expect(rewriteRes?.headers.get('X-Custom')).toBe('value for to rule')
expect(rewriteRes?.headers.get('X-Custom-From')).toBeNull()

await fixture.destroy()
})

test('Headers rules matching a static file that shadows a function are applied', async () => {
const fixture = new Fixture()
.withFile(
'netlify.toml',
`[build]
publish = "public"
[[headers]]
for = "/shadowed-path.html"
[headers.values]
"X-Custom-Header" = "custom-value"
`,
)
.withFile('public/shadowed-path.html', 'Hello from the static file')
.withFile(
'netlify/functions/shadowed-path.mjs',
`export default async () => new Response("Hello from the function");
export const config = { path: "/shadowed-path.html", preferStatic: true };
`,
)
const directory = await fixture.create()
const req = new Request('https://site.netlify/shadowed-path.html')
const dev = new NetlifyDev({
projectRoot: directory,
})
await dev.start()

const res = await dev.handle(req)
expect(await res?.text()).toBe('Hello from the static file')
expect(Object.fromEntries(res?.headers?.entries() ?? [])).toMatchObject({
'x-custom-header': 'custom-value',
})

await fixture.destroy()
})

test('Headers rules matching an unshadowed function on a custom path are not applied', async () => {
const fixture = new Fixture()
.withFile(
'netlify.toml',
`[build]
publish = "public"
[[headers]]
for = "/hello.html"
[headers.values]
"X-Custom-Header" = "custom-value"
`,
)
.withFile('public/hello.html', 'Hello from the static file')
.withFile(
'netlify/functions/hello.mjs',
`export default async () => new Response("Hello from the function");
export const config = { path: "/hello.html" };
`,
)
const directory = await fixture.create()
const req = new Request('https://site.netlify/hello.html')
const dev = new NetlifyDev({
projectRoot: directory,
})
await dev.start()

const res = await dev.handle(req)
expect(await res?.text()).toBe('Hello from the function')
expect(res?.headers.get('x-custom-header')).toBeNull()

await fixture.destroy()
})

// TODO(FRB-1834): Implement this test when edge functions are supported
test.todo('Headers rules matching a path are not applied to edge function responses')

test('Invoking a function, updating its contents and invoking it again', async () => {
let fixture = new Fixture()
.withFile(
Expand Down
Loading
Loading