diff --git a/app/root.tsx b/app/root.tsx
index 108806968..408918e0c 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -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'
@@ -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,
diff --git a/app/routes/settings+/profile.connections.tsx b/app/routes/settings+/profile.connections.tsx
index d2418edaf..3874b1d6d 100644
--- a/app/routes/settings+/profile.connections.tsx
+++ b/app/routes/settings+/profile.connections.tsx
@@ -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: Connections,
@@ -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)
@@ -197,7 +193,7 @@ function Connection({
status={
deleteFetcher.state !== 'idle'
? 'pending'
- : deleteFetcher.data?.status ?? 'idle'
+ : (deleteFetcher.data?.status ?? 'idle')
}
>
diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts
new file mode 100644
index 000000000..422eff6de
--- /dev/null
+++ b/app/utils/remix.server.test.ts
@@ -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()
+ })
+})
diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts
new file mode 100644
index 000000000..e864a8f99
--- /dev/null
+++ b/app/utils/remix.server.ts
@@ -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 {
+ return stringify(
+ cacheControlHeaders
+ .filter(Boolean)
+ .map((header) => parse(header))
+ .reduce((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
+ }, {}),
+ )
+}
diff --git a/package-lock.json b/package-lock.json
index f1b10ab17..29a2b6085 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,6 +32,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",
@@ -8903,6 +8904,11 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/cache-control-parser": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/cache-control-parser/-/cache-control-parser-2.0.6.tgz",
+ "integrity": "sha512-N4rxCk7V8NLfUVONXG0d7S4IyTQh3KEDW5k2I4CAcEUcMQCmVkfAMn37JSWfUQudiR883vDBy5XM5+TS2Xo7uQ=="
+ },
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
diff --git a/package.json b/package.json
index 418c8bba2..4e1bb173c 100644
--- a/package.json
+++ b/package.json
@@ -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",