From d59aacd7458833a0e5efde65003969f418200036 Mon Sep 17 00:00:00 2001 From: Samuel Jensen <44519206+nichtsam@users.noreply.github.com> Date: Sat, 3 Aug 2024 15:50:07 +0200 Subject: [PATCH] add util for handling remix headers generally --- app/root.tsx | 8 +- app/routes/settings+/profile.connections.tsx | 8 +- app/utils/remix.server.test.ts | 41 ++++++++ app/utils/remix.server.ts | 102 +++++++++++++++++++ package-lock.json | 6 ++ package.json | 1 + 6 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 app/utils/remix.server.test.ts create mode 100644 app/utils/remix.server.ts diff --git a/app/root.tsx b/app/root.tsx index 9ba6c23e..a207322d 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -49,6 +49,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' @@ -145,12 +146,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 91c47956..3874b1d6 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) diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts new file mode 100644 index 00000000..7273179a --- /dev/null +++ b/app/utils/remix.server.test.ts @@ -0,0 +1,41 @@ +import { format, parse } from '@tusbar/cache-control' +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( + 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/remix.server.ts b/app/utils/remix.server.ts new file mode 100644 index 00000000..d30934e4 --- /dev/null +++ b/app/utils/remix.server.ts @@ -0,0 +1,102 @@ +import { type HeadersArgs } from '@remix-run/node' +import { type CacheControlValue, parse, format } from '@tusbar/cache-control' + +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 format( + cacheControlHeaders + .filter(Boolean) + .map((header) => parse(header)) + .reduce((acc, current) => { + for (let 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) { + acc[directive] = true + } + + break + } + case 'number': { + const accValue = acc[directive] as number | undefined + + if (accValue === undefined) { + acc[directive] = currentValue + } else { + const result = Math.min(accValue, currentValue) + acc[directive] = result + } + + break + } + } + } + + return acc + }, {}), + ) +} diff --git a/package-lock.json b/package-lock.json index 2ce306c9..ccf896f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@remix-run/react": "2.13.1", "@sentry/profiling-node": "^8.35.0", "@sentry/remix": "^8.35.0", + "@tusbar/cache-control": "1.0.2", "address": "^2.0.3", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.4.0", @@ -6477,6 +6478,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/acorn": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", diff --git a/package.json b/package.json index 87a2bb50..f5b0f32b 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@remix-run/react": "2.13.1", "@sentry/profiling-node": "^8.35.0", "@sentry/remix": "^8.35.0", + "@tusbar/cache-control": "1.0.2", "address": "^2.0.3", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.4.0",