Skip to content

Commit b3eae5f

Browse files
committed
add util for handling remix headers generally
1 parent b9a2fed commit b3eae5f

File tree

6 files changed

+171
-13
lines changed

6 files changed

+171
-13
lines changed

app/root.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { getEnv } from './utils/env.server.ts'
4343
import { honeypot } from './utils/honeypot.server.ts'
4444
import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx'
4545
import { useNonce } from './utils/nonce-provider.ts'
46+
import { pipeHeaders } from './utils/remix.server.ts'
4647
import { type Theme, getTheme } from './utils/theme.server.ts'
4748
import { makeTimings, time } from './utils/timing.server.ts'
4849
import { getToast } from './utils/toast.server.ts'
@@ -140,12 +141,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
140141
)
141142
}
142143

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

150146
function Document({
151147
children,

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

+3-7
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { prisma } from '#app/utils/db.server.ts'
3030
import { makeTimings } from '#app/utils/timing.server.ts'
3131
import { createToastHeaders } from '#app/utils/toast.server.ts'
3232
import { type BreadcrumbHandle } from './profile.tsx'
33+
import { pipeHeaders } from '#app/utils/remix.server.js'
3334

3435
export const handle: BreadcrumbHandle & SEOHandle = {
3536
breadcrumb: <Icon name="link-2">Connections</Icon>,
@@ -90,12 +91,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
9091
)
9192
}
9293

93-
export const headers: HeadersFunction = ({ loaderHeaders }) => {
94-
const headers = {
95-
'Server-Timing': loaderHeaders.get('Server-Timing') ?? '',
96-
}
97-
return headers
98-
}
94+
export const headers: HeadersFunction = pipeHeaders
9995

10096
export async function action({ request }: ActionFunctionArgs) {
10197
const userId = await requireUserId(request)
@@ -197,7 +193,7 @@ function Connection({
197193
status={
198194
deleteFetcher.state !== 'idle'
199195
? 'pending'
200-
: deleteFetcher.data?.status ?? 'idle'
196+
: (deleteFetcher.data?.status ?? 'idle')
201197
}
202198
>
203199
<Icon name="cross-1" />

app/utils/remix.server.test.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import cacheControl from 'cache-control-parser'
2+
import { describe, expect, test } from 'vitest'
3+
import { getConservativeCacheControl } from './remix.server.ts'
4+
5+
describe('getConservativeCacheControl', () => {
6+
test('works for basic usecase', () => {
7+
const result = getConservativeCacheControl(
8+
'max-age=3600',
9+
'max-age=1800, s-maxage=600',
10+
'private, max-age=86400',
11+
)
12+
13+
expect(result).toEqual(
14+
cacheControl.stringify({
15+
'max-age': 1800,
16+
's-maxage': 600,
17+
private: true,
18+
}),
19+
)
20+
})
21+
test('retains boolean directive', () => {
22+
const result = cacheControl.parse(
23+
getConservativeCacheControl('private', 'no-cache,no-store'),
24+
)
25+
26+
expect(result.private).toEqual(true)
27+
expect(result['no-cache']).toEqual(true)
28+
expect(result['no-store']).toEqual(true)
29+
})
30+
test('gets smallest number directive', () => {
31+
const result = cacheControl.parse(
32+
getConservativeCacheControl(
33+
'max-age=10, s-maxage=300',
34+
'max-age=300, s-maxage=600',
35+
),
36+
)
37+
38+
expect(result['max-age']).toEqual(10)
39+
expect(result['s-maxage']).toEqual(300)
40+
})
41+
test('lets unset directives remain unset', () => {
42+
const result = cacheControl.parse(
43+
getConservativeCacheControl(
44+
'max-age=3600',
45+
'max-age=1800, s-maxage=600',
46+
'private, max-age=86400',
47+
),
48+
)
49+
50+
expect(result['must-revalidate']).toBeUndefined()
51+
expect(result['stale-while-revalidate']).toBeUndefined()
52+
})
53+
})

app/utils/remix.server.ts

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

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
"address": "^2.0.3",
7070
"bcryptjs": "^2.4.3",
7171
"better-sqlite3": "^11.1.2",
72+
"cache-control-parser": "^2.0.6",
7273
"chalk": "^5.3.0",
7374
"class-variance-authority": "^0.7.0",
7475
"close-with-grace": "^1.3.0",

0 commit comments

Comments
 (0)