Skip to content

Commit

Permalink
add util for handling remix headers generally
Browse files Browse the repository at this point in the history
  • Loading branch information
nichtsam committed Aug 3, 2024
1 parent b9a2fed commit b3eae5f
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 13 deletions.
8 changes: 2 additions & 6 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { getEnv } from './utils/env.server.ts'
import { honeypot } from './utils/honeypot.server.ts'
import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.tsx'
import { useNonce } from './utils/nonce-provider.ts'
import { pipeHeaders } from './utils/remix.server.ts'
import { type Theme, getTheme } from './utils/theme.server.ts'
import { makeTimings, time } from './utils/timing.server.ts'
import { getToast } from './utils/toast.server.ts'
Expand Down Expand Up @@ -140,12 +141,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
)
}

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

function Document({
children,
Expand Down
10 changes: 3 additions & 7 deletions app/routes/settings+/profile.connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { prisma } from '#app/utils/db.server.ts'
import { makeTimings } from '#app/utils/timing.server.ts'
import { createToastHeaders } from '#app/utils/toast.server.ts'
import { type BreadcrumbHandle } from './profile.tsx'
import { pipeHeaders } from '#app/utils/remix.server.js'

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

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

export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request)
Expand Down Expand Up @@ -197,7 +193,7 @@ function Connection({
status={
deleteFetcher.state !== 'idle'
? 'pending'
: deleteFetcher.data?.status ?? 'idle'
: (deleteFetcher.data?.status ?? 'idle')
}
>
<Icon name="cross-1" />
Expand Down
53 changes: 53 additions & 0 deletions app/utils/remix.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import cacheControl from 'cache-control-parser'
import { describe, expect, test } from 'vitest'
import { getConservativeCacheControl } from './remix.server.ts'

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

expect(result).toEqual(
cacheControl.stringify({
'max-age': 1800,
's-maxage': 600,
private: true,
}),
)
})
test('retains boolean directive', () => {
const result = cacheControl.parse(
getConservativeCacheControl('private', 'no-cache,no-store'),
)

expect(result.private).toEqual(true)
expect(result['no-cache']).toEqual(true)
expect(result['no-store']).toEqual(true)
})
test('gets smallest number directive', () => {
const result = cacheControl.parse(
getConservativeCacheControl(
'max-age=10, s-maxage=300',
'max-age=300, s-maxage=600',
),
)

expect(result['max-age']).toEqual(10)
expect(result['s-maxage']).toEqual(300)
})
test('lets unset directives remain unset', () => {
const result = cacheControl.parse(
getConservativeCacheControl(
'max-age=3600',
'max-age=1800, s-maxage=600',
'private, max-age=86400',
),
)

expect(result['must-revalidate']).toBeUndefined()
expect(result['stale-while-revalidate']).toBeUndefined()
})
})
106 changes: 106 additions & 0 deletions app/utils/remix.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { type HeadersArgs } from '@remix-run/node'
import { parse, stringify, type CacheControl } from 'cache-control-parser'

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
}

export function getConservativeCacheControl(
...cacheControlHeaders: Array<string | null>
): string {
return stringify(
cacheControlHeaders
.filter(Boolean)
.map((header) => parse(header))
.reduce<CacheControl>((acc, current) => {
let directive: keyof CacheControl
for (directive in current) {
const currentValue = current[directive]

// ts-expect-error because typescript doesn't know it's the same directive.
switch (typeof currentValue) {
case 'boolean': {
if (currentValue) {
// @ts-expect-error
acc[directive] = true
}

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

if (accValue === undefined) {
// @ts-expect-error
acc[directive] = currentValue
} else {
const result = Math.min(accValue, currentValue)
// @ts-expect-error
acc[directive] = result
}

break
}
}
}

return acc
}, {}),
)
}
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 @@
"address": "^2.0.3",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.1.2",
"cache-control-parser": "^2.0.6",
"chalk": "^5.3.0",
"class-variance-authority": "^0.7.0",
"close-with-grace": "^1.3.0",
Expand Down

0 comments on commit b3eae5f

Please sign in to comment.