From 700dd517fab88799b91c66c9af3ae00f56690d5f 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 1/3] 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 | 103 +++++++++++++++++++ package-lock.json | 6 ++ package.json | 1 + 6 files changed, 155 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 5fdd9ff3..a0e49dda 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -47,6 +47,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' @@ -143,12 +144,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 3c436bb2..ab4dd62e 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/remix.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/remix.server.test.ts b/app/utils/remix.server.test.ts new file mode 100644 index 00000000..6c5d8559 --- /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..6250b1aa --- /dev/null +++ b/app/utils/remix.server.ts @@ -0,0 +1,103 @@ +import { type CacheControlValue, parse, format } from '@tusbar/cache-control' +import { type HeadersArgs } from 'react-router' + +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 (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/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 1ee142b2..eac8a387 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", From f40e9b0cf9db690685c182e4d76a8ef24dc960e1 Mon Sep 17 00:00:00 2001 From: Samuel Jensen <44519206+nichtsam@users.noreply.github.com> Date: Sat, 3 Aug 2024 16:28:02 +0200 Subject: [PATCH 2/3] update headers related docs --- docs/server-timing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/server-timing.md b/docs/server-timing.md index 248f7690..4d66c3c7 100644 --- a/docs/server-timing.md +++ b/docs/server-timing.md @@ -74,6 +74,8 @@ export async function loader({ params }: LoaderFunctionArgs) { ) } +// We have a general headers handler to save you from boilerplating. +// export const headers: HeadersFunction = pipeHeaders export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => { return { 'Server-Timing': combineServerTimings(parentHeaders, loaderHeaders), // <-- 4. Send headers From 8b397373147609f46215ff6ecda84f5dd6687056 Mon Sep 17 00:00:00 2001 From: Samuel Jensen <44519206+nichtsam@users.noreply.github.com> Date: Fri, 17 Jan 2025 00:04:46 +0100 Subject: [PATCH 3/3] update headers util --- app/root.tsx | 2 +- app/routes/settings+/profile.connections.tsx | 2 +- app/utils/headers.server.test.ts | 39 ++++++++++++++++++ .../{remix.server.ts => headers.server.ts} | 11 +++++ app/utils/remix.server.test.ts | 41 ------------------- 5 files changed, 52 insertions(+), 43 deletions(-) create mode 100644 app/utils/headers.server.test.ts rename app/utils/{remix.server.ts => headers.server.ts} (86%) delete mode 100644 app/utils/remix.server.test.ts diff --git a/app/root.tsx b/app/root.tsx index ca1e3c3c..76d91c45 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -43,10 +43,10 @@ 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' -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' diff --git a/app/routes/settings+/profile.connections.tsx b/app/routes/settings+/profile.connections.tsx index ab4dd62e..4c38725a 100644 --- a/app/routes/settings+/profile.connections.tsx +++ b/app/routes/settings+/profile.connections.tsx @@ -20,7 +20,7 @@ import { providerNames, } from '#app/utils/connections.tsx' import { prisma } from '#app/utils/db.server.ts' -import { pipeHeaders } from '#app/utils/remix.server.js' +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' 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/remix.server.ts b/app/utils/headers.server.ts similarity index 86% rename from app/utils/remix.server.ts rename to app/utils/headers.server.ts index 6250b1aa..26f6818c 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/headers.server.ts @@ -1,6 +1,14 @@ 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, @@ -61,6 +69,9 @@ export function pipeHeaders({ return headers } +/** + * Given multiple Cache-Control headers, merge them and get the most conservative one. + */ export function getConservativeCacheControl( ...cacheControlHeaders: Array ): string { diff --git a/app/utils/remix.server.test.ts b/app/utils/remix.server.test.ts deleted file mode 100644 index 6c5d8559..00000000 --- a/app/utils/remix.server.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -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) - }) -})