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