Skip to content

Commit 8d6189e

Browse files
committed
feat: add support for headers config
1 parent 22a6dfc commit 8d6189e

20 files changed

+786
-4
lines changed

.release-please-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"packages/dev": "2.3.0",
55
"packages/dev-utils": "2.2.0",
66
"packages/functions": "3.1.9",
7+
"packages/headers": {},
78
"packages/otel": "1.1.0",
89
"packages/redirects": "1.1.4",
910
"packages/runtime": "2.2.2",

package-lock.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"packages/blobs",
99
"packages/cache",
1010
"packages/functions",
11+
"packages/headers",
1112
"packages/redirects",
1213
"packages/runtime",
1314
"packages/static",

packages/dev-utils/src/test/fixture.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { exec } from 'node:child_process'
22
import { promises as fs } from 'node:fs'
3-
import { dirname, join } from 'node:path'
3+
import { EOL } from 'node:os'
4+
import path, { dirname, join } from 'node:path'
45
import { promisify } from 'node:util'
56

67
import tmp from 'tmp-promise'
@@ -88,6 +89,24 @@ export class Fixture {
8889
return this
8990
}
9091

92+
withHeadersFile({
93+
headers = [],
94+
pathPrefix = '',
95+
}: {
96+
headers?: { headers: string[]; path: string }[]
97+
pathPrefix?: string
98+
}) {
99+
const dest = path.join(pathPrefix, '_headers')
100+
const contents = headers
101+
.map(
102+
({ headers: headersValues, path: headerPath }) =>
103+
`${headerPath}${EOL}${headersValues.map((header) => ` ${header}`).join(EOL)}`,
104+
)
105+
.join(EOL)
106+
107+
return this.withFile(dest, contents)
108+
}
109+
91110
withStateFile(state: object) {
92111
this.files['.netlify/state.json'] = JSON.stringify(state)
93112

packages/dev/src/main.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isFile } from './lib/fs.js'
88
import { NetlifyDev } from './main.js'
99

1010
import { withMockApi } from '../test/mock-api.js'
11+
import { config } from 'node:process'
1112

1213
describe('Handling requests', () => {
1314
describe('No linked site', () => {
@@ -88,6 +89,158 @@ describe('Handling requests', () => {
8889
await fixture.destroy()
8990
})
9091

92+
test('Headers rules matching a static file are applied', async () => {
93+
const fixture = new Fixture()
94+
.withFile(
95+
'netlify.toml',
96+
`[build]
97+
publish = "public"
98+
[[headers]]
99+
for = "/hello.txt"
100+
[headers.values]
101+
"Vary" = "User-Agent"
102+
`,
103+
)
104+
.withHeadersFile({
105+
pathPrefix: 'public',
106+
headers: [{ path: '/hello.txt', headers: ['Cache-Control: max-age=42'] }],
107+
})
108+
.withFile('public/hello.txt', 'Hello from hello.txt')
109+
.withFile('public/another-path.txt', 'Hello from another-path.txt')
110+
const directory = await fixture.create()
111+
const req = new Request('https://site.netlify/hello.txt')
112+
const dev = new NetlifyDev({
113+
projectRoot: directory,
114+
})
115+
await dev.start()
116+
117+
const matchRes = await dev.handle(req)
118+
119+
expect(await matchRes?.text()).toBe('Hello from hello.txt')
120+
expect(Object.fromEntries(matchRes?.headers?.entries() ?? [])).toMatchObject({
121+
'cache-control': 'max-age=42',
122+
vary: 'User-Agent',
123+
})
124+
125+
const noMatchRes = await dev.handle(new Request('https://site.netlify/another-path.txt'))
126+
expect(await noMatchRes?.text()).toBe('Hello from another-path.txt')
127+
expect(Object.fromEntries(noMatchRes?.headers?.entries() ?? [])).not.toMatchObject({
128+
'cache-control': 'max-age=42',
129+
vary: 'User-Agent',
130+
})
131+
132+
await fixture.destroy()
133+
})
134+
135+
test('Headers rules matching target of a rewrite to a static file are applied', async () => {
136+
const fixture = new Fixture()
137+
.withFile(
138+
'netlify.toml',
139+
`[build]
140+
publish = "public"
141+
[[headers]]
142+
for = "/from"
143+
[headers.values]
144+
"X-Custom" = "value for from rule"
145+
"X-Custom-From" = "another value for from rule"
146+
[[headers]]
147+
for = "/to.txt"
148+
[headers.values]
149+
"X-Custom" = "value for to rule"
150+
`,
151+
)
152+
.withFile('public/_redirects', `/from /to.txt 200`)
153+
.withFile('public/to.txt', `to.txt content`)
154+
const directory = await fixture.create()
155+
const dev = new NetlifyDev({
156+
projectRoot: directory,
157+
})
158+
await dev.start()
159+
160+
const directRes = await dev.handle(new Request('https://site.netlify/to.txt'))
161+
expect(await directRes?.text()).toBe('to.txt content')
162+
expect(directRes?.headers.get('X-Custom')).toBe('value for to rule')
163+
expect(directRes?.headers.get('X-Custom-From')).toBeNull()
164+
165+
const rewriteRes = await dev.handle(new Request('https://site.netlify/from'))
166+
expect(await rewriteRes?.text()).toBe('to.txt content')
167+
expect(rewriteRes?.headers.get('X-Custom')).toBe('value for to rule')
168+
expect(rewriteRes?.headers.get('X-Custom-From')).toBeNull()
169+
170+
await fixture.destroy()
171+
})
172+
173+
test('Headers rules matching a static file that shadows a function are applied', async () => {
174+
const fixture = new Fixture()
175+
.withFile(
176+
'netlify.toml',
177+
`[build]
178+
publish = "public"
179+
[[headers]]
180+
for = "/shadowed-path.html"
181+
[headers.values]
182+
"X-Custom-Header" = "custom-value"
183+
`,
184+
)
185+
.withFile('public/shadowed-path.html', "Hello from the static file")
186+
.withFile(
187+
'netlify/functions/shadowed-path.mjs',
188+
`export default async () => new Response("Hello from the function");
189+
export const config = { path: "/shadowed-path.html", preferStatic: true };
190+
`,
191+
)
192+
const directory = await fixture.create()
193+
const req = new Request('https://site.netlify/shadowed-path.html')
194+
const dev = new NetlifyDev({
195+
projectRoot: directory,
196+
})
197+
await dev.start()
198+
199+
const res = await dev.handle(req)
200+
expect(await res?.text()).toBe('Hello from the static file')
201+
expect(Object.fromEntries(res?.headers?.entries() ?? [])).toMatchObject({
202+
'x-custom-header': 'custom-value',
203+
})
204+
205+
await fixture.destroy()
206+
})
207+
208+
test('Headers rules matching an unshadowed function on a custom path are not applied', async () => {
209+
const fixture = new Fixture()
210+
.withFile(
211+
'netlify.toml',
212+
`[build]
213+
publish = "public"
214+
[[headers]]
215+
for = "/hello.html"
216+
[headers.values]
217+
"X-Custom-Header" = "custom-value"
218+
`,
219+
)
220+
.withFile('public/hello.html', "Hello from the static file")
221+
.withFile(
222+
'netlify/functions/hello.mjs',
223+
`export default async () => new Response("Hello from the function");
224+
export const config = { path: "/hello.html" };
225+
`,
226+
)
227+
const directory = await fixture.create()
228+
const req = new Request('https://site.netlify/hello.html')
229+
const dev = new NetlifyDev({
230+
projectRoot: directory,
231+
})
232+
await dev.start()
233+
234+
const res = await dev.handle(req)
235+
expect(await res?.text()).toBe('Hello from the function')
236+
expect(res?.headers.get('x-custom-header')).toBeNull()
237+
238+
await fixture.destroy()
239+
})
240+
241+
// TODO(FRB-1834): Implement this test when edge functions are supported
242+
test.todo('Headers rules matching a path are not applied to edge function responses')
243+
91244
test('Invoking a function, updating its contents and invoking it again', async () => {
92245
let fixture = new Fixture()
93246
.withFile(

packages/dev/src/main.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import process from 'node:process'
55
import { resolveConfig } from '@netlify/config'
66
import { ensureNetlifyIgnore, getAPIToken, LocalState, type Logger } from '@netlify/dev-utils'
77
import { FunctionsHandler } from '@netlify/functions/dev'
8+
import { HeadersHandler } from '@netlify/headers'
89
import { RedirectsHandler } from '@netlify/redirects'
910
import { StaticHandler } from '@netlify/static'
1011

@@ -40,6 +41,15 @@ export interface Features {
4041
enabled: boolean
4142
}
4243

44+
/**
45+
* Configuration options for Netlify response headers.
46+
*
47+
* {@link} https://docs.netlify.com/routing/headers/
48+
*/
49+
headers?: {
50+
enabled: boolean
51+
}
52+
4353
/**
4454
* Configuration options for Netlify redirects and rewrites.
4555
*
@@ -78,6 +88,7 @@ export class NetlifyDev {
7888
blobs: boolean
7989
environmentVariables: boolean
8090
functions: boolean
91+
headers: boolean
8192
redirects: boolean
8293
static: boolean
8394
}
@@ -99,6 +110,7 @@ export class NetlifyDev {
99110
blobs: options.blobs?.enabled !== false,
100111
environmentVariables: options.environmentVariables?.enabled !== false,
101112
functions: options.functions?.enabled !== false,
113+
headers: options.headers?.enabled !== false,
102114
redirects: options.redirects?.enabled !== false,
103115
static: options.staticFiles?.enabled !== false,
104116
}
@@ -123,6 +135,16 @@ export class NetlifyDev {
123135
})
124136
: null
125137

138+
// Headers
139+
const headers = this.#features.headers
140+
? new HeadersHandler({
141+
configPath: this.#config?.configPath,
142+
configHeaders: this.#config?.config.headers,
143+
projectDir: this.#projectRoot,
144+
publishDir: this.#config?.config.build.publish ?? undefined,
145+
})
146+
: null
147+
126148
// Redirects
127149
const redirects = this.#features.redirects
128150
? new RedirectsHandler({
@@ -155,7 +177,8 @@ export class NetlifyDev {
155177
const staticMatch = await staticFiles?.match(request)
156178

157179
if (staticMatch) {
158-
return staticMatch.handle()
180+
const response = await staticMatch.handle()
181+
return headers != null ? headers.handle(request, response) : response
159182
}
160183
}
161184

@@ -177,7 +200,12 @@ export class NetlifyDev {
177200
const response = await redirects?.handle(request, redirectMatch, async (maybeStaticFile: Request) => {
178201
const staticMatch = await staticFiles?.match(maybeStaticFile)
179202

180-
return staticMatch?.handle
203+
if (!staticMatch) return
204+
205+
return async () => {
206+
const response = await staticMatch.handle()
207+
return headers != null ? headers.handle(new Request(redirectMatch.target), response) : response
208+
}
181209
})
182210
if (response) {
183211
return response
@@ -187,7 +215,8 @@ export class NetlifyDev {
187215
// 3. Check if the request matches a static file.
188216
const staticMatch = await staticFiles?.match(request)
189217
if (staticMatch) {
190-
return staticMatch.handle()
218+
const response = await staticMatch.handle()
219+
return headers != null ? headers.handle(request, response) : response
191220
}
192221
}
193222

packages/headers/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist

packages/headers/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Changelog

0 commit comments

Comments
 (0)