From 5c5328b38e442d78933d5be8fae21fe317296c29 Mon Sep 17 00:00:00 2001 From: nichtsam <44519206+nichtsam@users.noreply.github.com> Date: Fri, 17 Jan 2025 20:16:13 +0100 Subject: [PATCH] add util for handling remix headers generally (#810) Co-authored-by: Kent C. Dodds --- app/root.tsx | 8 +- app/routes/settings+/profile.connections.tsx | 8 +- app/utils/headers.server.test.ts | 39 +++++++ app/utils/headers.server.ts | 114 +++++++++++++++++++ docs/server-timing.md | 6 +- package-lock.json | 6 + package.json | 1 + 7 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 app/utils/headers.server.test.ts create mode 100644 app/utils/headers.server.ts diff --git a/app/root.tsx b/app/root.tsx index 22103e59..bce9d40a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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' @@ -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, diff --git a/app/routes/settings+/profile.connections.tsx b/app/routes/settings+/profile.connections.tsx index 3c436bb2..4c38725a 100644 --- a/app/routes/settings+/profile.connections.tsx +++ b/app/routes/settings+/profile.connections.tsx @@ -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' @@ -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) diff --git a/app/utils/headers.server.test.ts b/app/utils/headers.server.test.ts new file mode 100644 index 00000000..42b5a1ab --- /dev/null +++ b/app/utils/headers.server.test.ts @@ -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) +}) diff --git a/app/utils/headers.server.ts b/app/utils/headers.server.ts new file mode 100644 index 00000000..26f6818c --- /dev/null +++ b/app/utils/headers.server.ts @@ -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 { + return format( + cacheControlHeaders + .filter(Boolean) + .map((header) => parse(header)) + .reduce((acc, current) => { + for (const key in current) { + const directive = key as keyof Required // 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 + }, {}), + ) +} diff --git a/docs/server-timing.md b/docs/server-timing.md index 6985608d..a850934e 100644 --- a/docs/server-timing.md +++ b/docs/server-timing.md @@ -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 @@ -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) diff --git a/package-lock.json b/package-lock.json index b0d7c521..b95926a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,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", @@ -5513,6 +5514,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@tusbar/cache-control": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tusbar/cache-control/-/cache-control-1.0.2.tgz", + "integrity": "sha512-PXfjYTYBVvMPYCLDWj+xIOA9ITFbbhWCHzLcqUCJ5TPGm4JO4cxpGb7x3K8Q1K1ADgNgfBxLsDcTMVRydtZB9A==" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/package.json b/package.json index cb86b3bf..8b941521 100644 --- a/package.json +++ b/package.json @@ -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",