Skip to content

Commit 5c5328b

Browse files
nichtsamkentcdodds
andauthored
add util for handling remix headers generally (#810)
Co-authored-by: Kent C. Dodds <[email protected]>
1 parent 9fac985 commit 5c5328b

File tree

7 files changed

+168
-14
lines changed

7 files changed

+168
-14
lines changed

app/root.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { getUserId, logout } from './utils/auth.server.ts'
4040
import { ClientHintCheck, getHints } from './utils/client-hints.tsx'
4141
import { prisma } from './utils/db.server.ts'
4242
import { getEnv } from './utils/env.server.ts'
43+
import { pipeHeaders } from './utils/headers.server.ts'
4344
import { honeypot } from './utils/honeypot.server.ts'
4445
import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx'
4546
import { useNonce } from './utils/nonce-provider.ts'
@@ -139,12 +140,7 @@ export async function loader({ request }: Route.LoaderArgs) {
139140
)
140141
}
141142

142-
export const headers: Route.HeadersFunction = ({ loaderHeaders }) => {
143-
const headers = {
144-
'Server-Timing': loaderHeaders.get('Server-Timing') ?? '',
145-
}
146-
return headers
147-
}
143+
export const headers: Route.HeadersFunction = pipeHeaders
148144

149145
function Document({
150146
children,

app/routes/settings+/profile.connections.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
providerNames,
2121
} from '#app/utils/connections.tsx'
2222
import { prisma } from '#app/utils/db.server.ts'
23+
import { pipeHeaders } from '#app/utils/headers.server.js'
2324
import { makeTimings } from '#app/utils/timing.server.ts'
2425
import { createToastHeaders } from '#app/utils/toast.server.ts'
2526
import { type Info, type Route } from './+types/profile.connections.ts'
@@ -84,12 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
8485
)
8586
}
8687

87-
export const headers: Route.HeadersFunction = ({ loaderHeaders }) => {
88-
const headers = {
89-
'Server-Timing': loaderHeaders.get('Server-Timing') ?? '',
90-
}
91-
return headers
92-
}
88+
export const headers: Route.HeadersFunction = pipeHeaders
9389

9490
export async function action({ request }: Route.ActionArgs) {
9591
const userId = await requireUserId(request)

app/utils/headers.server.test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { format, parse } from '@tusbar/cache-control'
2+
import { expect, test } from 'vitest'
3+
import { getConservativeCacheControl } from './headers.server.ts'
4+
5+
test('works for basic usecase', () => {
6+
const result = getConservativeCacheControl(
7+
'max-age=3600',
8+
'max-age=1800, s-maxage=600',
9+
'private, max-age=86400',
10+
)
11+
12+
expect(result).toEqual(
13+
format({
14+
maxAge: 1800,
15+
sharedMaxAge: 600,
16+
private: true,
17+
}),
18+
)
19+
})
20+
test('retains boolean directive', () => {
21+
const result = parse(
22+
getConservativeCacheControl('private', 'no-cache,no-store'),
23+
)
24+
25+
expect(result.private).toEqual(true)
26+
expect(result.noCache).toEqual(true)
27+
expect(result.noStore).toEqual(true)
28+
})
29+
test('gets smallest number directive', () => {
30+
const result = parse(
31+
getConservativeCacheControl(
32+
'max-age=10, s-maxage=300',
33+
'max-age=300, s-maxage=600',
34+
),
35+
)
36+
37+
expect(result.maxAge).toEqual(10)
38+
expect(result.sharedMaxAge).toEqual(300)
39+
})

app/utils/headers.server.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { type CacheControlValue, parse, format } from '@tusbar/cache-control'
2+
import { type HeadersArgs } from 'react-router'
3+
4+
/**
5+
* A utility for handling route headers, merging common use-case headers.
6+
*
7+
* This function combines headers by:
8+
* 1. Forwarding headers from the route's loader or action.
9+
* 2. Inheriting headers from the parent.
10+
* 3. Falling back to parent headers (if any) when headers are missing.
11+
*/
12+
export function pipeHeaders({
13+
parentHeaders,
14+
loaderHeaders,
15+
actionHeaders,
16+
errorHeaders,
17+
}: HeadersArgs) {
18+
const headers = new Headers()
19+
20+
// get the one that's actually in use
21+
let currentHeaders: Headers
22+
if (errorHeaders !== undefined) {
23+
currentHeaders = errorHeaders
24+
} else if (loaderHeaders.entries().next().done) {
25+
currentHeaders = actionHeaders
26+
} else {
27+
currentHeaders = loaderHeaders
28+
}
29+
30+
// take in useful headers route loader/action
31+
// pass this point currentHeaders can be ignored
32+
const forwardHeaders = ['Cache-Control', 'Vary', 'Server-Timing']
33+
for (const headerName of forwardHeaders) {
34+
const header = currentHeaders.get(headerName)
35+
if (header) {
36+
headers.set(headerName, header)
37+
}
38+
}
39+
40+
headers.set(
41+
'Cache-Control',
42+
getConservativeCacheControl(
43+
parentHeaders.get('Cache-Control'),
44+
headers.get('Cache-Control'),
45+
),
46+
)
47+
48+
// append useful parent headers
49+
const inheritHeaders = ['Vary', 'Server-Timing']
50+
for (const headerName of inheritHeaders) {
51+
const header = parentHeaders.get(headerName)
52+
if (header) {
53+
headers.append(headerName, header)
54+
}
55+
}
56+
57+
// fallback to parent headers if loader don't have
58+
const fallbackHeaders = ['Cache-Control', 'Vary']
59+
for (const headerName of fallbackHeaders) {
60+
if (headers.has(headerName)) {
61+
continue
62+
}
63+
const fallbackHeader = parentHeaders.get(headerName)
64+
if (fallbackHeader) {
65+
headers.set(headerName, fallbackHeader)
66+
}
67+
}
68+
69+
return headers
70+
}
71+
72+
/**
73+
* Given multiple Cache-Control headers, merge them and get the most conservative one.
74+
*/
75+
export function getConservativeCacheControl(
76+
...cacheControlHeaders: Array<string | null>
77+
): string {
78+
return format(
79+
cacheControlHeaders
80+
.filter(Boolean)
81+
.map((header) => parse(header))
82+
.reduce<CacheControlValue>((acc, current) => {
83+
for (const key in current) {
84+
const directive = key as keyof Required<CacheControlValue> // keyof CacheControl includes functions
85+
86+
const currentValue = current[directive]
87+
88+
switch (typeof currentValue) {
89+
case 'boolean': {
90+
if (currentValue) {
91+
acc[directive] = true as any
92+
}
93+
94+
break
95+
}
96+
case 'number': {
97+
const accValue = acc[directive] as number | undefined
98+
99+
if (accValue === undefined) {
100+
acc[directive] = currentValue as any
101+
} else {
102+
const result = Math.min(accValue, currentValue)
103+
acc[directive] = result as any
104+
}
105+
106+
break
107+
}
108+
}
109+
}
110+
111+
return acc
112+
}, {}),
113+
)
114+
}

docs/server-timing.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ export async function loader({ params }: Route.LoaderArgs) {
7575
)
7676
}
7777

78+
// We have a general headers handler to save you from boilerplating.
79+
export const headers: HeadersFunction = pipeHeaders
80+
// this is basically what it does though
7881
export const headers: Route.HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
7982
return {
8083
'Server-Timing': combineServerTimings(parentHeaders, loaderHeaders), // <-- 4. Send headers
@@ -83,5 +86,4 @@ export const headers: Route.HeadersFunction = ({ loaderHeaders, parentHeaders })
8386
```
8487

8588
You can
86-
[learn more about `headers` in the Remix docs](https://remix.run/docs/en/main/route/headers)
87-
(note, the Epic Stack has the v2 behavior enabled).
89+
[learn more about `headers` in the React Router docs](https://reactrouter.com/how-to/headers)

package-lock.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"@sentry/node": "^8.47.0",
7070
"@sentry/profiling-node": "^8.47.0",
7171
"@sentry/react": "^8.47.0",
72+
"@tusbar/cache-control": "1.0.2",
7273
"address": "^2.0.3",
7374
"bcryptjs": "^2.4.3",
7475
"better-sqlite3": "^11.7.0",

0 commit comments

Comments
 (0)