Skip to content

Commit 1f877fe

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

20 files changed

+789
-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: 39 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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"@netlify/config": "^23.0.4",
5858
"@netlify/dev-utils": "2.2.0",
5959
"@netlify/functions": "3.1.9",
60+
"@netlify/headers": "^0.0.0",
6061
"@netlify/redirects": "1.1.4",
6162
"@netlify/runtime": "2.2.2",
6263
"@netlify/static": "1.1.4"

packages/dev/src/main.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,158 @@ describe('Handling requests', () => {
8888
await fixture.destroy()
8989
})
9090

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