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",