Merge pull request #90 from sambecker/static-optimization

Add optional static optimization
This commit is contained in:
Sam Becker 2024-05-08 12:36:49 -05:00 committed by GitHub
commit cdb2ab5215
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 106 additions and 60 deletions

View File

@ -94,6 +94,8 @@ _⚠ READ BEFORE PROCEEDING_
Application behavior can be changed by configuring the following environment variables:
- `NEXT_PUBLIC_PRO_MODE = 1` enables higher quality image storage (results in increased storage usage)
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES = 1` enables static optimization for pages, i.e., renders pages at build time (results in increased project usage)—⚠️ _Experimental_
- `NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES = 1` enables static optimization for OG images, i.e., renders images at build time (results in increased project usage)—⚠️ _Experimental_
- `NEXT_PUBLIC_BLUR_DISABLED = 1` prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
- `NEXT_PUBLIC_GEO_PRIVACY = 1` disables collection/display of location-based data
- `NEXT_PUBLIC_IGNORE_PRIORITY_ORDER = 1` prevents `priority_order` field affecting photo order

35
pnpm-lock.yaml generated
View File

@ -16,7 +16,7 @@ importers:
version: 3.564.0
'@next/bundle-analyzer':
specifier: 14.2.3
version: 14.2.3(bufferutil@4.0.8)(utf-8-validate@6.0.3)
version: 14.2.3(bufferutil@4.0.8)
'@radix-ui/react-dropdown-menu':
specifier: ^2.0.6
version: 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -103,7 +103,7 @@ importers:
version: 29.7.0(@types/node@20.12.7)
jest-environment-jsdom:
specifier: ^29.7.0
version: 29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.3)
version: 29.7.0(bufferutil@4.0.8)
nanoid:
specifier: ^5.0.7
version: 5.0.7
@ -4185,10 +4185,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
utf-8-validate@6.0.3:
resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==}
engines: {node: '>=6.14.2'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@ -5452,9 +5448,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
'@next/bundle-analyzer@14.2.3(bufferutil@4.0.8)(utf-8-validate@6.0.3)':
'@next/bundle-analyzer@14.2.3(bufferutil@4.0.8)':
dependencies:
webpack-bundle-analyzer: 4.10.1(bufferutil@4.0.8)(utf-8-validate@6.0.3)
webpack-bundle-analyzer: 4.10.1(bufferutil@4.0.8)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@ -7987,7 +7983,7 @@ snapshots:
jest-util: 29.7.0
pretty-format: 29.7.0
jest-environment-jsdom@29.7.0(bufferutil@4.0.8)(utf-8-validate@6.0.3):
jest-environment-jsdom@29.7.0(bufferutil@4.0.8):
dependencies:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
@ -7996,7 +7992,7 @@ snapshots:
'@types/node': 20.12.7
jest-mock: 29.7.0
jest-util: 29.7.0
jsdom: 20.0.3(bufferutil@4.0.8)(utf-8-validate@6.0.3)
jsdom: 20.0.3(bufferutil@4.0.8)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -8225,7 +8221,7 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@20.0.3(bufferutil@4.0.8)(utf-8-validate@6.0.3):
jsdom@20.0.3(bufferutil@4.0.8):
dependencies:
abab: 2.0.6
acorn: 8.11.3
@ -8251,7 +8247,7 @@ snapshots:
whatwg-encoding: 2.0.0
whatwg-mimetype: 3.0.0
whatwg-url: 11.0.0
ws: 8.16.0(bufferutil@4.0.8)(utf-8-validate@6.0.3)
ws: 8.16.0(bufferutil@4.0.8)
xml-name-validator: 4.0.0
transitivePeerDependencies:
- bufferutil
@ -9390,11 +9386,6 @@ snapshots:
dependencies:
react: 18.3.1
utf-8-validate@6.0.3:
dependencies:
node-gyp-build: 4.8.0
optional: true
util-deprecate@1.0.2: {}
uuid@9.0.1: {}
@ -9431,7 +9422,7 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-bundle-analyzer@4.10.1(bufferutil@4.0.8)(utf-8-validate@6.0.3):
webpack-bundle-analyzer@4.10.1(bufferutil@4.0.8):
dependencies:
'@discoveryjs/json-ext': 0.5.7
acorn: 8.11.3
@ -9445,7 +9436,7 @@ snapshots:
opener: 1.5.2
picocolors: 1.0.0
sirv: 2.0.4
ws: 7.5.9(bufferutil@4.0.8)(utf-8-validate@6.0.3)
ws: 7.5.9(bufferutil@4.0.8)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
@ -9529,15 +9520,13 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 3.0.7
ws@7.5.9(bufferutil@4.0.8)(utf-8-validate@6.0.3):
ws@7.5.9(bufferutil@4.0.8):
optionalDependencies:
bufferutil: 4.0.8
utf-8-validate: 6.0.3
ws@8.16.0(bufferutil@4.0.8)(utf-8-validate@6.0.3):
ws@8.16.0(bufferutil@4.0.8):
optionalDependencies:
bufferutil: 4.0.8
utf-8-validate: 6.0.3
xml-name-validator@4.0.0: {}

View File

@ -6,6 +6,8 @@ import {
SITE_TITLE,
} from '@/site/config';
export const dynamic = 'force-dynamic';
export async function GET() {
if (PUBLIC_API_ENABLED) {
const photos = await getPhotosCached({ limit: API_PHOTO_REQUEST_LIMIT });

View File

@ -4,6 +4,19 @@ import PhotoImageResponse from '@/image-response/PhotoImageResponse';
import { getIBMPlexMonoMedium } from '@/site/font';
import { ImageResponse } from 'next/og';
import { getImageResponseCacheControlHeaders } from '@/image-response/cache';
import { STATICALLY_OPTIMIZED_OG_IMAGES } from '@/site/config';
import { GENERATE_STATIC_PARAMS_LIMIT, getPhotoIds } from '@/photo/db';
import { isNextImageReadyBasedOnPhotos } from '@/photo';
export let generateStaticParams:
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_OG_IMAGES) {
generateStaticParams = async () => {
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
return photos.map(photoId => ({ photoId }));
};
}
export async function GET(
_: Request,
@ -22,9 +35,19 @@ export async function GET(
if (!photo) { return new Response('Photo not found', { status: 404 }); }
const { width, height } = IMAGE_OG_DIMENSION;
// Make sure next/image can be reached from absolute urls,
// which may not exist on first pre-render
const isNextImageReady = await isNextImageReadyBasedOnPhotos([photo]);
return new ImageResponse(
<PhotoImageResponse {...{ photo, width, height, fontFamily }} />,
<PhotoImageResponse {...{
photo,
width,
height,
fontFamily,
isNextImageReady,
}} />,
{ width, height, fonts, headers },
);
}

View File

@ -12,6 +12,18 @@ import {
} from '@/site/paths';
import PhotoDetailPage from '@/photo/PhotoDetailPage';
import { getPhotosNearIdCachedCached } from '@/photo/cache';
import { STATICALLY_OPTIMIZED_PAGES } from '@/site/config';
import { GENERATE_STATIC_PARAMS_LIMIT, getPhotoIds } from '@/photo/db';
export let generateStaticParams:
(() => Promise<{ photoId: string }[]>) | undefined = undefined;
if (STATICALLY_OPTIMIZED_PAGES) {
generateStaticParams = async () => {
const photos = await getPhotoIds({ limit: GENERATE_STATIC_PARAMS_LIMIT });
return photos.map(photoId => ({ photoId }));
};
}
interface PhotoProps {
params: { photoId: string }

View File

@ -1,6 +1,4 @@
import { cache } from 'react';
import { auth } from '@/auth';
import { screenForPPR } from '@/utility/ppr';
export const authCachedSafe = cache(() => auth()
.catch(e => screenForPPR(e, null, 'auth')));
export const authCachedSafe = cache(() => auth().catch(() => null));

View File

@ -12,6 +12,7 @@ export default function ExperimentalBadge({
className={clsx(
'text-pink-500 dark:text-white',
'bg-pink-100 dark:bg-pink-600',
'pt-0.5',
className,
)}>
Experimental

View File

@ -12,11 +12,13 @@ export default function PhotoImageResponse({
width,
height,
fontFamily,
isNextImageReady = true,
}: {
photo: Photo
width: NextImageSize
height: number
fontFamily: string
isNextImageReady: boolean
}) {
const model = photo.model
? formatCameraModelTextShort(cameraFromPhoto(photo))
@ -25,7 +27,7 @@ export default function PhotoImageResponse({
return (
<ImageContainer {...{ width, height }}>
<ImagePhotoGrid {...{
photos: [photo],
photos: isNextImageReady ? [photo] : [],
width,
height,
...OG_TEXT_BOTTOM_ALIGNMENT && { imagePosition: 'top' },

View File

@ -16,7 +16,8 @@ import { parameterize } from '@/utility/string';
import { TagsWithMeta } from '@/tag';
import { FilmSimulation, FilmSimulations } from '@/simulation';
import { SHOULD_DEBUG_SQL, PRIORITY_ORDER_ENABLED } from '@/site/config';
import { screenForPPR } from '@/utility/ppr';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
const PHOTO_DEFAULT_LIMIT = 100;
@ -310,7 +311,6 @@ const safelyQueryPhotos = async <T>(
try {
result = await callback();
} catch (e: any) {
screenForPPR(e, undefined, 'neon postgres');
if (MIGRATION_FIELDS_01.some(field => new RegExp(
`column "${field}" of relation "photos" does not exist`,
'i',

View File

@ -27,7 +27,6 @@ import {
isUrlFromCloudflareR2,
} from './cloudflare-r2';
import { PATH_API_PRESIGNED_URL } from '@/site/paths';
import { screenForPPR } from '@/utility/ppr';
export const generateStorageId = () => generateNanoid(16);
@ -193,15 +192,15 @@ const getStorageUrlsForPrefix = async (prefix = '') => {
if (HAS_VERCEL_BLOB_STORAGE) {
urls.push(...await vercelBlobList(prefix)
.catch(e => screenForPPR(e, [], 'vercel blob')));
.catch(() => []));
}
if (HAS_AWS_S3_STORAGE) {
urls.push(...await awsS3List(prefix)
.catch(e => screenForPPR(e, [], 'aws blob')));
.catch(() => []));
}
if (HAS_CLOUDFLARE_R2_STORAGE) {
urls.push(...await cloudflareR2List(prefix)
.catch(e => screenForPPR(e, [], 'cloudflare blob')));
.catch(() => []));
}
return urls

View File

@ -41,6 +41,9 @@ export default function SiteChecklistClient({
showFilmSimulations,
showExifInfo,
isProModeEnabled,
isStaticallyOptimized,
arePagesStaticallyOptimized,
areOGImagesStaticallyOptimized,
isBlurEnabled,
isGeoPrivacyEnabled,
isPriorityOrderEnabled,
@ -119,7 +122,8 @@ export default function SiteChecklistClient({
>
<span className="inline-flex items-center gap-1">
<span className={clsx(
'text-medium',
'text-xs font-medium tracking-wide',
'px-0.5 py-0.5',
'rounded-sm',
'bg-gray-100 dark:bg-gray-800',
)}>
@ -137,10 +141,13 @@ export default function SiteChecklistClient({
const renderSubStatus = (
type: ComponentProps<typeof StatusIcon>['type'],
label: ReactNode,
iconClassName?: string,
) =>
<div className="flex gap-1 -translate-x-1">
<StatusIcon {...{ type }} />
<span>
<span className={iconClassName}>
<StatusIcon {...{ type }} />
</span>
<span className="min-w-0">
{label}
</span>
</div>;
@ -350,6 +357,26 @@ export default function SiteChecklistClient({
higher quality image storage:
{renderEnvVars(['NEXT_PUBLIC_PRO_MODE'])}
</ChecklistRow>
<ChecklistRow
title="Static Optimization"
status={isStaticallyOptimized}
isPending={isPendingPage}
optional
experimental
>
Set environment variable to {'"1"'} to enable static optimization,
i.e., rendering pages and images at build time:
{renderSubStatus(
arePagesStaticallyOptimized ? 'checked' : 'optional',
renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES']),
'translate-y-[4.5px]',
)}
{renderSubStatus(
areOGImagesStaticallyOptimized ? 'checked' : 'optional',
renderEnvVars(['NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES']),
'translate-y-[4.5px]',
)}
</ChecklistRow>
<ChecklistRow
title="Image Blur"
status={isBlurEnabled}

View File

@ -104,6 +104,10 @@ export const CURRENT_STORAGE: StorageType =
export const PRO_MODE_ENABLED =
process.env.NEXT_PUBLIC_PRO_MODE === '1';
export const STATICALLY_OPTIMIZED_PAGES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_PAGES === '1';
export const STATICALLY_OPTIMIZED_OG_IMAGES =
process.env.NEXT_PUBLIC_STATICALLY_OPTIMIZE_OG_IMAGES === '1';
export const BLUR_ENABLED =
process.env.NEXT_PUBLIC_BLUR_DISABLED !== '1';
export const GEO_PRIVACY_ENABLED =
@ -143,10 +147,11 @@ export const CONFIG_CHECKLIST_STATUS = {
hasVercelBlobStorage: HAS_VERCEL_BLOB_STORAGE,
hasCloudflareR2Storage: HAS_CLOUDFLARE_R2_STORAGE,
hasAwsS3Storage: HAS_AWS_S3_STORAGE,
hasStorageProvider:
hasStorageProvider: (
HAS_VERCEL_BLOB_STORAGE ||
HAS_CLOUDFLARE_R2_STORAGE ||
HAS_AWS_S3_STORAGE,
HAS_AWS_S3_STORAGE
),
hasMultipleStorageProviders: HAS_MULTIPLE_STORAGE_PROVIDERS,
currentStorage: CURRENT_STORAGE,
hasAuthSecret: (process.env.AUTH_SECRET ?? '').length > 0,
@ -160,6 +165,12 @@ export const CONFIG_CHECKLIST_STATUS = {
showFilmSimulations: SHOW_FILM_SIMULATIONS,
showExifInfo: SHOW_EXIF_DATA,
isProModeEnabled: PRO_MODE_ENABLED,
isStaticallyOptimized: (
STATICALLY_OPTIMIZED_PAGES ||
STATICALLY_OPTIMIZED_OG_IMAGES
),
arePagesStaticallyOptimized: STATICALLY_OPTIMIZED_PAGES,
areOGImagesStaticallyOptimized: STATICALLY_OPTIMIZED_OG_IMAGES,
isBlurEnabled: BLUR_ENABLED,
isGeoPrivacyEnabled: GEO_PRIVACY_ENABLED,
isAiTextGenerationEnabled: AI_TEXT_GENERATION_ENABLED,
@ -184,3 +195,4 @@ export const IS_SITE_READY =
CONFIG_CHECKLIST_STATUS.hasStorageProvider &&
CONFIG_CHECKLIST_STATUS.hasAuthSecret &&
CONFIG_CHECKLIST_STATUS.hasAdminUser;

View File

@ -1,21 +0,0 @@
export const screenForPPR = <T>(
error: any,
fallback: T,
sourceToLog?: string,
debug?: boolean
): T => {
// PPR errors, if caught, must be re-thrown in order to
// postpone rendering
if (error.sourceError?.message?.includes('ppr-caught-error')) {
if (debug) {
console.log(`${sourceToLog}: throwing error.sourceError`);
}
throw error.sourceError;
} else if (error.message?.includes('ppr-caught-error')) {
if (debug) {
console.log(`${sourceToLog}: throwing error`);
}
throw error;
}
return fallback;
};