Skip to content

Commit

Permalink
add util for handling remix headers generally (#810)
Browse files Browse the repository at this point in the history
Co-authored-by: Kent C. Dodds <[email protected]>
  • Loading branch information
nichtsam and kentcdodds authored Jan 17, 2025
1 parent 9fac985 commit 5c5328b
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 14 deletions.
8 changes: 2 additions & 6 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getUserId, logout } from './utils/auth.server.ts'
import { ClientHintCheck, getHints } from './utils/client-hints.tsx'
import { prisma } from './utils/db.server.ts'
import { getEnv } from './utils/env.server.ts'
import { pipeHeaders } from './utils/headers.server.ts'
import { honeypot } from './utils/honeypot.server.ts'
import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx'
import { useNonce } from './utils/nonce-provider.ts'
Expand Down Expand Up @@ -139,12 +140,7 @@ export async function loader({ request }: Route.LoaderArgs) {
)
}

export const headers: Route.HeadersFunction = ({ loaderHeaders }) => {
const headers = {
'Server-Timing': loaderHeaders.get('Server-Timing') ?? '',
}
return headers
}
export const headers: Route.HeadersFunction = pipeHeaders

function Document({
children,
Expand Down
8 changes: 2 additions & 6 deletions app/routes/settings+/profile.connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
providerNames,
} from '#app/utils/connections.tsx'
import { prisma } from '#app/utils/db.server.ts'
import { pipeHeaders } from '#app/utils/headers.server.js'
import { makeTimings } from '#app/utils/timing.server.ts'
import { createToastHeaders } from '#app/utils/toast.server.ts'
import { type Info, type Route } from './+types/profile.connections.ts'
Expand Down Expand Up @@ -84,12 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
)
}

export const headers: Route.HeadersFunction = ({ loaderHeaders }) => {
const headers = {
'Server-Timing': loaderHeaders.get('Server-Timing') ?? '',
}
return headers
}
export const headers: Route.HeadersFunction = pipeHeaders

export async function action({ request }: Route.ActionArgs) {
const userId = await requireUserId(request)
Expand Down
39 changes: 39 additions & 0 deletions app/utils/headers.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { format, parse } from '@tusbar/cache-control'
import { expect, test } from 'vitest'
import { getConservativeCacheControl } from './headers.server.ts'

test('works for basic usecase', () => {
const result = getConservativeCacheControl(
'max-age=3600',
'max-age=1800, s-maxage=600',
'private, max-age=86400',
)

expect(result).toEqual(
format({
maxAge: 1800,
sharedMaxAge: 600,
private: true,
}),
)
})
test('retains boolean directive', () => {
const result = parse(
getConservativeCacheControl('private', 'no-cache,no-store'),
)

expect(result.private).toEqual(true)
expect(result.noCache).toEqual(true)
expect(result.noStore).toEqual(true)
})
test('gets smallest number directive', () => {
const result = parse(
getConservativeCacheControl(
'max-age=10, s-maxage=300',
'max-age=300, s-maxage=600',
),
)

expect(result.maxAge).toEqual(10)
expect(result.sharedMaxAge).toEqual(300)
})
114 changes: 114 additions & 0 deletions app/utils/headers.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { type CacheControlValue, parse, format } from '@tusbar/cache-control'
import { type HeadersArgs } from 'react-router'

/**
* A utility for handling route headers, merging common use-case headers.
*
* This function combines headers by:
* 1. Forwarding headers from the route's loader or action.
* 2. Inheriting headers from the parent.
* 3. Falling back to parent headers (if any) when headers are missing.
*/
export function pipeHeaders({
parentHeaders,
loaderHeaders,
actionHeaders,
errorHeaders,
}: HeadersArgs) {
const headers = new Headers()

// get the one that's actually in use
let currentHeaders: Headers
if (errorHeaders !== undefined) {
currentHeaders = errorHeaders
} else if (loaderHeaders.entries().next().done) {
currentHeaders = actionHeaders
} else {
currentHeaders = loaderHeaders
}

// take in useful headers route loader/action
// pass this point currentHeaders can be ignored
const forwardHeaders = ['Cache-Control', 'Vary', 'Server-Timing']
for (const headerName of forwardHeaders) {
const header = currentHeaders.get(headerName)
if (header) {
headers.set(headerName, header)
}
}

headers.set(
'Cache-Control',
getConservativeCacheControl(
parentHeaders.get('Cache-Control'),
headers.get('Cache-Control'),
),
)

// append useful parent headers
const inheritHeaders = ['Vary', 'Server-Timing']
for (const headerName of inheritHeaders) {
const header = parentHeaders.get(headerName)
if (header) {
headers.append(headerName, header)
}
}

// fallback to parent headers if loader don't have
const fallbackHeaders = ['Cache-Control', 'Vary']
for (const headerName of fallbackHeaders) {
if (headers.has(headerName)) {
continue
}
const fallbackHeader = parentHeaders.get(headerName)
if (fallbackHeader) {
headers.set(headerName, fallbackHeader)
}
}

return headers
}

/**
* Given multiple Cache-Control headers, merge them and get the most conservative one.
*/
export function getConservativeCacheControl(
...cacheControlHeaders: Array<string | null>
): string {
return format(
cacheControlHeaders
.filter(Boolean)
.map((header) => parse(header))
.reduce<CacheControlValue>((acc, current) => {
for (const key in current) {
const directive = key as keyof Required<CacheControlValue> // keyof CacheControl includes functions

const currentValue = current[directive]

switch (typeof currentValue) {
case 'boolean': {
if (currentValue) {
acc[directive] = true as any
}

break
}
case 'number': {
const accValue = acc[directive] as number | undefined

if (accValue === undefined) {
acc[directive] = currentValue as any
} else {
const result = Math.min(accValue, currentValue)
acc[directive] = result as any
}

break
}
}
}

return acc
}, {}),
)
}
6 changes: 4 additions & 2 deletions docs/server-timing.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export async function loader({ params }: Route.LoaderArgs) {
)
}

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

You can
[learn more about `headers` in the Remix docs](https://remix.run/docs/en/main/route/headers)
(note, the Epic Stack has the v2 behavior enabled).
[learn more about `headers` in the React Router docs](https://reactrouter.com/how-to/headers)
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@sentry/node": "^8.47.0",
"@sentry/profiling-node": "^8.47.0",
"@sentry/react": "^8.47.0",
"@tusbar/cache-control": "1.0.2",
"address": "^2.0.3",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
Expand Down

0 comments on commit 5c5328b

Please sign in to comment.