Skip to content

Commit dca313e

Browse files
authored
feat: add support for headers config (#200)
* feat: add support for headers config * docs: add headers feature to readme * docs: add headers package to root readme * refactor: avoid multiple headers handler nil checks * chore: pin workspace deps * fix: log headers parse errors with provided logger * ci: add @netlify/headers to release workflow
1 parent 2a0d943 commit dca313e

25 files changed

+811
-3
lines changed

.github/workflows/release-please.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ jobs:
6262
if: ${{ steps.release.outputs['packages/functions--release_created'] }}
6363
env:
6464
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
65+
- run: npm publish packages/headers/ --provenance --access=public
66+
if: ${{ steps.release.outputs['packages/headers--release_created'] }}
67+
env:
68+
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
6569
- run: npm publish packages/redirects/ --provenance --access=public
6670
if: ${{ steps.release.outputs['packages/redirects--release_created'] }}
6771
env:

.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.1",
55
"packages/dev-utils": "2.2.0",
66
"packages/functions": "3.1.10",
7+
"packages/headers": {},
78
"packages/otel": "1.1.0",
89
"packages/redirects": "1.1.4",
910
"packages/runtime": "2.2.2",

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ npm run build --workspaces=true
2828
| 🛠️ [@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) |
2929
| 🔧 [@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) |
3030
|[@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) |
31+
| 📋 [@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) |
3132
| 🔍 [@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) |
3233
| 🔄 [@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) |
3334
| 🏛️ [@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) |

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/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export { watchDebounced } from './lib/watch-debounced.js'
1414
export { EventInspector } from './test/event_inspector.js'
1515
export { MockFetch } from './test/fetch.js'
1616
export { Fixture } from './test/fixture.js'
17+
export { createMockLogger } from './test/logger.js'

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

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

@@ -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 = 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-utils/src/test/logger.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { Logger } from '../lib/logger.js'
2+
3+
export const createMockLogger = (): Logger => ({
4+
log: () => {},
5+
warn: () => {},
6+
error: () => {},
7+
})

packages/dev/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@netlify/config": "^23.0.7",
5757
"@netlify/dev-utils": "2.2.0",
5858
"@netlify/functions": "3.1.10",
59+
"@netlify/headers": "0.0.0",
5960
"@netlify/redirects": "1.1.4",
6061
"@netlify/runtime": "2.2.2",
6162
"@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(

0 commit comments

Comments
 (0)