Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
87d6e40
use openimg
andrelandgraf Feb 19, 2025
64d45ff
add isAboveFold
andrelandgraf Feb 19, 2025
ec643c5
fmt and review comments
andrelandgraf Feb 19, 2025
4d9eb4e
Update app/routes/users+/$username.tsx
andrelandgraf Feb 19, 2025
3e848a4
Update app/routes/users+/index.tsx
andrelandgraf Feb 19, 2025
5369c82
Update app/routes/users+/$username_+/notes.tsx
andrelandgraf Feb 19, 2025
9fd9a73
Update app/routes/settings+/profile.index.tsx
andrelandgraf Feb 19, 2025
3ae3947
Update app/routes/users+/$username_+/notes.$noteId.tsx
andrelandgraf Feb 19, 2025
f7aac86
Update app/routes/users+/$username_+/__note-editor.tsx
andrelandgraf Feb 19, 2025
cb47439
image docs & image optimization decision doc
andrelandgraf Feb 20, 2025
3d1d0ad
wording changes
andrelandgraf Feb 20, 2025
505685b
fix cache location
andrelandgraf Feb 22, 2025
d9dac31
update docs, add S3 URL to allowlist
andrelandgraf Feb 22, 2025
bc2f913
fix msw warnings and place the images not in a root dir during dev
kentcdodds Feb 22, 2025
2dc20d5
this is what I want
kentcdodds Feb 22, 2025
8cdd396
remove unnecessary note since we have tigris now
kentcdodds Feb 22, 2025
6c26161
a few other updates
kentcdodds Feb 22, 2025
bc1dd11
openimg v0.2
andrelandgraf Feb 22, 2025
ecea2fd
latest openimg, fix types
andrelandgraf Feb 22, 2025
8b68dca
use getSrc to create custom src string for bucket requests
andrelandgraf Feb 23, 2025
2106862
support img download
andrelandgraf Feb 23, 2025
990d8d0
clean up code
andrelandgraf Feb 23, 2025
220c376
fix in openimg
andrelandgraf Feb 23, 2025
1224f4d
reduce code by moving src processing
andrelandgraf Feb 23, 2025
c099b7f
Migrate to objectKey for image references across application
kentcdodds Feb 24, 2025
45b5d3e
Add comment to getImgSrc explaining things
kentcdodds Feb 24, 2025
6e9aa19
Remove redundant Content-Disposition header setting in images route
kentcdodds Feb 24, 2025
c73ba61
fix comment
kentcdodds Feb 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ node_modules
/playwright/.cache/
/tests/fixtures/email/
/tests/fixtures/uploaded/
/tests/fixtures/openimg/
/coverage

/other/cache.db
Expand Down
5 changes: 4 additions & 1 deletion app/components/user-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DropdownMenuItem,
} from './ui/dropdown-menu'
import { Icon } from './ui/icon'
import { Img } from 'openimg/react'

export function UserDropdown() {
const user = useUser()
Expand All @@ -25,10 +26,12 @@ export function UserDropdown() {
onClick={(e) => e.preventDefault()}
className="flex items-center gap-2"
>
<img
<Img
className="h-8 w-8 rounded-full object-cover"
alt={user.name ?? user.username}
src={getUserImgSrc(user.image?.id)}
width={256}
height={256}
/>
<span className="text-body-sm font-bold">
{user.name ?? user.username}
Expand Down
5 changes: 3 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
useMatches,
} from 'react-router'
import { HoneypotProvider } from 'remix-utils/honeypot/react'
import { OpenImgContextProvider } from 'openimg/react'
import { type Route } from './+types/root.ts'
import appleTouchIconAssetUrl from './assets/favicons/apple-touch-icon.png'
import faviconAssetUrl from './assets/favicons/favicon.svg'
Expand Down Expand Up @@ -194,7 +195,7 @@ function App() {
useToast(data.toast)

return (
<>
<OpenImgContextProvider optimizerEndpoint="/resources/images">
<div className="flex min-h-screen flex-col justify-between">
<header className="container py-6">
<nav className="flex flex-wrap items-center justify-between gap-4 sm:flex-nowrap md:gap-8">
Expand Down Expand Up @@ -226,7 +227,7 @@ function App() {
</div>
<EpicToaster closeButton position="top-center" theme={theme} />
<EpicProgress />
</>
</OpenImgContextProvider>
)
}

Expand Down
91 changes: 91 additions & 0 deletions app/routes/resources+/images.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { invariantResponse } from '@epic-web/invariant'
import { getImgResponse } from 'openimg/node'
import { prisma } from '#app/utils/db.server.ts'
import { getDomainUrl } from '#app/utils/misc.tsx'
import { getSignedGetRequestInfo } from '#app/utils/storage.server.ts'
import { type Route } from './+types/images.ts'

export async function loader({ request }: Route.LoaderArgs) {
const domain = getDomainUrl(request)
const headers = new Headers()
headers.set('Cache-Control', 'public, max-age=31536000, immutable')
return getImgResponse(request, {
headers,
allowlistedOrigins: [domain, process.env.AWS_ENDPOINT_URL_S3].filter(
Boolean,
),
cacheFolder:
process.env.NODE_ENV === 'production'
? '/data/images'
: './tests/fixtures/openimg',
getImgSource: ({ params }) => {
if (params.src.startsWith('/resources')) {
const searchParams = new URLSearchParams(params.src.split('?')[1])
const userImageId = searchParams.get('userImageId')
if (userImageId) {
return handleUserImage(userImageId)
}
const noteImageId = searchParams.get('noteImageId')
if (noteImageId) {
return handleNoteImage(noteImageId)
}

// Fetch image from resource endpoint
return {
type: 'fetch',
url: domain + params.src,
}
}
if (URL.canParse(params.src)) {
// Fetch image from external URL; will be matched against allowlist
return {
type: 'fetch',
url: params.src,
}
}
// Retrieve image from filesystem (public folder)
if (params.src.startsWith('/assets')) {
// Files managed by Vite
return {
type: 'fs',
path: '.' + params.src,
}
}
// Fallback to files in public folder
return {
type: 'fs',
path: './public' + params.src,
}
},
})
}

async function handleUserImage(userImageId: string) {
const userImage = await prisma.userImage.findUnique({
where: { id: userImageId },
select: { objectKey: true },
})
invariantResponse(userImage, 'User image not found', { status: 404 })

const { url, headers } = getSignedGetRequestInfo(userImage.objectKey)
return {
type: 'fetch',
url,
headers,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need the fetch request you make to include these headers.

I also need getImgSource to support returning a promise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Available via openimg v0.2.0! 🎉

}

async function handleNoteImage(noteImageId: string) {
const noteImage = await prisma.noteImage.findUnique({
where: { id: noteImageId },
select: { objectKey: true },
})
invariantResponse(noteImage, 'Note image not found', { status: 404 })

const { url, headers } = getSignedGetRequestInfo(noteImage.objectKey)
return {
type: 'fetch',
url,
headers,
}
}
24 changes: 0 additions & 24 deletions app/routes/resources+/note-images.$imageId.tsx

This file was deleted.

24 changes: 0 additions & 24 deletions app/routes/resources+/user-images.$imageId.tsx

This file was deleted.

6 changes: 5 additions & 1 deletion app/routes/settings+/profile.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { invariantResponse } from '@epic-web/invariant'
import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { Img } from 'openimg/react'
import { data, Link, useFetcher } from 'react-router'
import { z } from 'zod'
import { ErrorList, Field } from '#app/components/forms.tsx'
Expand Down Expand Up @@ -101,10 +102,13 @@ export default function EditUserProfile({ loaderData }: Route.ComponentProps) {
<div className="flex flex-col gap-12">
<div className="flex justify-center">
<div className="relative h-52 w-52">
<img
<Img
src={getUserImgSrc(loaderData.user.image?.id)}
alt={loaderData.user.username}
className="h-full w-full rounded-full object-cover"
width={832}
height={832}
isAboveFold
/>
<Button
asChild
Expand Down
5 changes: 4 additions & 1 deletion app/routes/users+/$username.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Link,
useLoaderData,
} from 'react-router'
import { Img } from 'openimg/react'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { Spacer } from '#app/components/spacer.tsx'
import { Button } from '#app/components/ui/button.tsx'
Expand Down Expand Up @@ -48,10 +49,12 @@ export default function ProfileRoute() {
<div className="relative w-52">
<div className="absolute -top-40">
<div className="relative">
<img
<Img
src={getUserImgSrc(data.user.image?.id)}
alt={userDisplayName}
className="h-52 w-52 rounded-full object-cover"
width={832}
height={832}
/>
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion app/routes/users+/$username_+/__note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { StatusButton } from '#app/components/ui/status-button.tsx'
import { Textarea } from '#app/components/ui/textarea.tsx'
import { cn, getNoteImgSrc, useIsPending } from '#app/utils/misc.tsx'
import { type Info } from './+types/notes.$noteId_.edit.ts'
import { Img } from 'openimg/react'

const titleMinLength = 1
const titleMaxLength = 100
Expand Down Expand Up @@ -188,10 +189,12 @@ function ImageChooser({ meta }: { meta: FieldMetadata<ImageFieldset> }) {
>
{previewImage ? (
<div className="relative">
<img
<Img
src={previewImage}
alt={altText ?? ''}
className="h-32 w-32 rounded-lg object-cover"
width={512}
height={512}
/>
{existingImage ? null : (
<div className="pointer-events-none absolute -right-0.5 -top-0.5 rotate-12 rounded-sm bg-secondary px-2 py-1 text-xs text-secondary-foreground shadow-md">
Expand Down
5 changes: 4 additions & 1 deletion app/routes/users+/$username_+/notes.$noteId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { redirectWithToast } from '#app/utils/toast.server.ts'
import { userHasPermission, useOptionalUser } from '#app/utils/user.ts'
import { type Route, type Info } from './+types/notes.$noteId.ts'
import { type Info as notesInfo } from './+types/notes.ts'
import { Img } from 'openimg/react'

export async function loader({ params }: Route.LoaderArgs) {
const note = await prisma.note.findUnique({
Expand Down Expand Up @@ -106,10 +107,12 @@ export default function NoteRoute({
{loaderData.note.images.map((image) => (
<li key={image.id}>
<a href={getNoteImgSrc(image.id)}>
<img
<Img
src={getNoteImgSrc(image.id)}
alt={image.altText ?? ''}
className="h-32 w-32 rounded-lg object-cover"
width={512}
height={512}
/>
</a>
</li>
Expand Down
5 changes: 4 additions & 1 deletion app/routes/users+/$username_+/notes.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { invariantResponse } from '@epic-web/invariant'
import { Link, NavLink, Outlet } from 'react-router'
import { Img } from 'openimg/react'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { prisma } from '#app/utils/db.server.ts'
Expand Down Expand Up @@ -39,10 +40,12 @@ export default function NotesRoute({ loaderData }: Route.ComponentProps) {
to={`/users/${loaderData.owner.username}`}
className="flex flex-col items-center justify-center gap-2 bg-muted pb-4 pl-8 pr-4 pt-12 lg:flex-row lg:justify-start lg:gap-4"
>
<img
<Img
src={getUserImgSrc(loaderData.owner.image?.id)}
alt={ownerDisplayName}
className="h-16 w-16 rounded-full object-cover lg:h-24 lg:w-24"
width={256}
height={256}
/>
<h1 className="text-center text-base font-bold md:text-lg lg:text-left lg:text-2xl">
{ownerDisplayName}'s Notes
Expand Down
5 changes: 4 additions & 1 deletion app/routes/users+/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { data, redirect, Link } from 'react-router'
import { z } from 'zod'
import { Img } from 'openimg/react'
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
import { ErrorList } from '#app/components/forms.tsx'
import { SearchBar } from '#app/components/search-bar.tsx'
Expand Down Expand Up @@ -79,10 +80,12 @@ export default function UsersRoute({ loaderData }: Route.ComponentProps) {
to={user.username}
className="flex h-36 w-44 flex-col items-center justify-center rounded-lg bg-muted px-5 py-3"
>
<img
<Img
alt={user.name ?? user.username}
src={getUserImgSrc(user.imageId)}
className="h-16 w-16 rounded-full"
width={256}
height={256}
/>
{user.name ? (
<span className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-body-md">
Expand Down
4 changes: 2 additions & 2 deletions app/utils/misc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { extendTailwindMerge } from 'tailwind-merge'
import { extendedTheme } from './extended-theme.ts'

export function getUserImgSrc(imageId?: string | null) {
return imageId ? `/resources/user-images/${imageId}` : '/img/user.png'
return imageId ? `/resources/images?userImageId=${imageId}` : '/img/user.png'
}

export function getNoteImgSrc(imageId: string) {
return `/resources/note-images/${imageId}`
return `/resources/images?noteImageId=${imageId}`
}

export function getErrorMessage(error: unknown) {
Expand Down
42 changes: 42 additions & 0 deletions docs/decisions/041-image-optimization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Introduce Image Optimization

Date: 2025-02-19

Status: accepted

## Context

As documented in [018-images.md](./018-images.md), the Epic Stack previously
didn't implement image optimization. Both static app images and dynamic user
images were served as is. However, optimizing images significantly improves web
performance by reducing both the browser load time and the byte size of each
image. On the other hand, one of the guiding principles of the Epic Stack is to
limit services (including the self-managed variety). A great middle ground is to
integrate a simple image optimization solution directly into the web server.
This allows each Epic Stack app to immediately utilize image optimization and
serve better web experiences without prescribing a service.

On-demand image optimization with a image optimization endpoint should be
sufficient for most applications and provide value right out of the gate.
However, it is also important that upgrading to a dedicated service shouldn't be
overly complicated and require a ton of changes.

### Using openimg

The goal of openimg is to be easy to use but also highly configurable, so you
can reconfigure it (or replace it) as your app grows. We can start simple by
introducing a new image optimization endpoint and replace `img` elements with
the `Img` component.

## Decision

Introduce an image optimization endpoint using the
[openimg package](https://github.com/andrelandgraf/openimg). We can then use the
`Img` component to query for optimized images and iterate from there.

## Consequences

Serving newly added images will now lead to an image optimization step whenever
a cache miss happens. This increases the image laod time but greatly reduces the
images sizes. On further requests, the load time should also be improved due to
the decreased image sizes.
Loading