1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-07-03 14:46:37 -04:00

backpedal a bit on caching

This commit is contained in:
2025-04-08 09:38:44 -04:00
parent 53d6f57699
commit 30b6e02b83
17 changed files with 373 additions and 579 deletions

View File

@ -2,8 +2,9 @@ import { NextResponse } from "next/server";
import { unstable_cache as cache } from "next/cache";
import redis from "../../../lib/helpers/redis";
export const GET = async (): Promise<
NextResponse<{
// cache response from the db
const getData = cache(
async (): Promise<{
total: {
hits: number;
};
@ -11,45 +12,42 @@ export const GET = async (): Promise<
slug: string;
hits: number;
}>;
}>
> => {
const { total, pages } = await cache(
async () => {
// get all keys (aka slugs)
const slugs = await redis.scan(0, {
match: "hits:*",
type: "string",
// set an arbitrary yet generous upper limit, just in case...
count: 99,
});
}> => {
// get all keys (aka slugs)
const slugs = await redis.scan(0, {
match: "hits:*",
type: "string",
// set an arbitrary yet generous upper limit, just in case...
count: 99,
});
// get the value (number of hits) for each key (the slug of the page)
const values = await redis.mget<string[]>(...slugs[1]);
// get the value (number of hits) for each key (the slug of the page)
const values = await redis.mget<string[]>(...slugs[1]);
// pair the slugs with their hit values
const pages = slugs[1].map((slug, index) => ({
slug: slug.split(":").pop() as string, // remove the "hits:" prefix
hits: parseInt(values[index], 10),
}));
// pair the slugs with their hit values
const pages = slugs[1].map((slug, index) => ({
slug: slug.split(":").pop() as string, // remove the "hits:" prefix
hits: parseInt(values[index], 10),
}));
// sort descending by hits
pages.sort((a, b) => b.hits - a.hits);
// sort descending by hits
pages.sort((a, b) => b.hits - a.hits);
// calculate total hits
const total = { hits: 0 };
pages.forEach((page) => {
// add these hits to running tally
total.hits += page.hits;
});
// calculate total hits
const total = { hits: 0 };
pages.forEach((page) => {
// add these hits to running tally
total.hits += page.hits;
});
return { total, pages };
},
undefined,
{
revalidate: 1800, // 30 minutes
tags: ["hits"],
}
)();
return { total, pages };
},
undefined,
{
revalidate: 900, // 15 minutes
tags: ["hits"],
}
);
return NextResponse.json({ total, pages });
};
export const GET = async (): Promise<NextResponse<Awaited<ReturnType<typeof getData>>>> =>
NextResponse.json(await getData());

122
app/contact/action.ts Normal file
View File

@ -0,0 +1,122 @@
"use server";
import { headers } from "next/headers";
import * as v from "valibot";
import { Resend } from "resend";
import * as config from "../../lib/config";
const ContactSchema = v.object({
// TODO: replace duplicate error messages with v.message() when released. see:
// https://valibot.dev/api/message/
// https://github.com/fabian-hiller/valibot/blob/main/library/src/methods/message/message.ts
name: v.pipe(v.string("Your name is required."), v.trim(), v.nonEmpty("Your name is required.")),
email: v.pipe(
v.string("Your email address is required."),
v.trim(),
v.nonEmpty("Your email address is required."),
v.email("Invalid email address.")
),
message: v.pipe(
v.string("A message is required."),
v.trim(),
v.nonEmpty("A message is required."),
v.minLength(10, "Your message must be at least 10 characters.")
),
"cf-turnstile-response": v.pipe(
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
v.string("Shoo, bot."),
// form submitted properly but token was missing, might be a forgetful human
v.nonEmpty("Just do the stinkin CAPTCHA, human! 🤖"),
// very rudimentary length check based on Cloudflare's docs
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
v.minLength("XXXX.DUMMY.TOKEN.XXXX".length),
// "A Turnstile token can have up to 2048 characters."
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
v.maxLength(2048),
v.readonly()
),
});
export type ContactInput = v.InferInput<typeof ContactSchema>;
export type ContactState = {
success: boolean;
message: string;
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
};
export const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
// TODO: remove after debugging why automated spam entries are causing 500 errors
console.debug("[contact form] received payload:", payload);
if (!process.env.RESEND_API_KEY) {
throw new Error("[contact form] 'RESEND_API_KEY' is not set.");
}
try {
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
if (!data.success) {
return {
success: false,
message: "Please make sure all fields are filled in.",
errors: v.flatten(data.issues).nested,
};
}
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
let remoteip;
try {
remoteip = (await headers()).get("x-forwarded-for");
} catch {} // eslint-disable-line no-empty
// validate captcha
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: data.output["cf-turnstile-response"],
remoteip,
}),
cache: "no-store",
});
if (!turnstileResponse || !turnstileResponse.ok) {
throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`);
}
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
if (!turnstileData.success) {
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
};
}
if (!process.env.RESEND_FROM_EMAIL) {
// https://resend.com/docs/api-reference/emails/send-email
console.warn("[contact form] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
}
// send email
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send({
from: `${data.output.name} <${process.env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
replyTo: `${data.output.name} <${data.output.email}>`,
to: [config.authorEmail],
subject: `[${config.siteName}] Contact Form Submission`,
text: data.output.message,
});
return { success: true, message: "Thanks! You should hear from me soon." };
} catch (error) {
console.error("[contact form] fatal error:", error);
return {
success: false,
message: "Internal server error. Please try again later or shoot me an email.",
};
}
};

View File

@ -6,16 +6,13 @@ import Turnstile from "react-turnstile";
import clsx from "clsx";
import { CheckIcon, XIcon } from "lucide-react";
import Link from "../../components/Link";
import type { ContactInput, ContactState } from "./schema";
import { send, type ContactState, type ContactInput } from "./action";
import styles from "./form.module.css";
const ContactForm = ({
serverAction,
}: {
serverAction: (state: ContactState, payload: FormData) => Promise<ContactState>;
}) => {
const [formState, formAction, pending] = useActionState<ContactState, FormData>(serverAction, {
const ContactForm = () => {
const [formState, formAction, pending] = useActionState<ContactState, FormData>(send, {
success: false,
message: "",
});

View File

@ -1,13 +1,8 @@
import { headers } from "next/headers";
import * as v from "valibot";
import { Resend } from "resend";
import PageTitle from "../../components/PageTitle";
import Link from "../../components/Link";
import { addMetadata } from "../../lib/helpers/metadata";
import * as config from "../../lib/config";
import ContactForm from "./form";
import ContactSchema, { type ContactState } from "./schema";
export const metadata = addMetadata({
title: "Contact Me",
@ -17,84 +12,6 @@ export const metadata = addMetadata({
},
});
const send = async (state: ContactState, payload: FormData): Promise<ContactState> => {
"use server";
// TODO: remove after debugging why automated spam entries are causing 500 errors
console.debug("[contact form] received payload:", payload);
if (!process.env.RESEND_API_KEY) {
throw new Error("[contact form] 'RESEND_API_KEY' is not set.");
}
try {
const data = v.safeParse(ContactSchema, Object.fromEntries(payload));
if (!data.success) {
return {
success: false,
message: "Please make sure all fields are filled in.",
errors: v.flatten(data.issues).nested,
};
}
// try to get the client IP (for turnstile accuracy, not logging!) but no biggie if we can't
let remoteip;
try {
remoteip = (await headers()).get("x-forwarded-for");
} catch {} // eslint-disable-line no-empty
// validate captcha
const turnstileResponse = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: data.output["cf-turnstile-response"],
remoteip,
}),
cache: "no-store",
});
if (!turnstileResponse || !turnstileResponse.ok) {
throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`);
}
const turnstileData = (await turnstileResponse.json()) as { success: boolean };
if (!turnstileData.success) {
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
};
}
if (!process.env.RESEND_FROM_EMAIL) {
// https://resend.com/docs/api-reference/emails/send-email
console.warn("[contact form] 'RESEND_FROM_EMAIL' is not set, falling back to onboarding@resend.dev.");
}
// send email
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send({
from: `${data.output.name} <${process.env.RESEND_FROM_EMAIL || "onboarding@resend.dev"}>`,
replyTo: `${data.output.name} <${data.output.email}>`,
to: [config.authorEmail],
subject: `[${config.siteName}] Contact Form Submission`,
text: data.output.message,
});
return { success: true, message: "Thanks! You should hear from me soon." };
} catch (error) {
console.error("[contact form] fatal error:", error);
return {
success: false,
message: "Internal server error. Please try again later or shoot me an email.",
};
}
};
const Page = () => {
return (
<div
@ -127,7 +44,7 @@ const Page = () => {
.
</p>
<ContactForm serverAction={send} />
<ContactForm />
</div>
);
};

View File

@ -1,43 +0,0 @@
import * as v from "valibot";
export const ContactSchema = v.object({
// TODO: replace duplicate error messages with v.message() when released. see:
// https://valibot.dev/api/message/
// https://github.com/fabian-hiller/valibot/blob/main/library/src/methods/message/message.ts
name: v.pipe(v.string("Your name is required."), v.trim(), v.nonEmpty("Your name is required.")),
email: v.pipe(
v.string("Your email address is required."),
v.trim(),
v.nonEmpty("Your email address is required."),
v.email("Invalid email address.")
),
message: v.pipe(
v.string("A message is required."),
v.trim(),
v.nonEmpty("A message is required."),
v.minLength(10, "Your message must be at least 10 characters.")
),
"cf-turnstile-response": v.pipe(
// token wasn't submitted at _all_, most likely a direct POST request by a spam bot
v.string("Shoo, bot."),
// form submitted properly but token was missing, might be a forgetful human
v.nonEmpty("Just do the stinkin CAPTCHA, human! 🤖"),
// very rudimentary length check based on Cloudflare's docs
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
v.minLength("XXXX.DUMMY.TOKEN.XXXX".length),
// "A Turnstile token can have up to 2048 characters."
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
v.maxLength(2048),
v.readonly()
),
});
export type ContactInput = v.InferInput<typeof ContactSchema>;
export type ContactState = {
success: boolean;
message: string;
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
};
export default ContactSchema;

View File

@ -1,3 +1,4 @@
import { NextResponse } from "next/server";
import { buildFeed } from "../../lib/helpers/build-feed";
export const dynamic = "force-static";
@ -5,7 +6,7 @@ export const dynamic = "force-static";
export const GET = async () => {
const feed = await buildFeed();
return new Response(feed.atom1(), {
return new NextResponse(feed.atom1(), {
headers: {
"content-type": "application/atom+xml; charset=utf-8",
},

View File

@ -1,3 +1,4 @@
import { NextResponse } from "next/server";
import { buildFeed } from "../../lib/helpers/build-feed";
export const dynamic = "force-static";
@ -5,7 +6,7 @@ export const dynamic = "force-static";
export const GET = async () => {
const feed = await buildFeed();
return new Response(feed.rss2(), {
return new NextResponse(feed.rss2(), {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},

View File

@ -1,6 +1,8 @@
import * as config from "../lib/config";
import type { MetadataRoute } from "next";
export const dynamic = "force-static";
const manifest = (): MetadataRoute.Manifest => {
return {
name: config.siteName,

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import hash from "@emotion/hash";
import { rgba } from "polished";
import { LockIcon } from "lucide-react";
@ -9,26 +10,35 @@ import styles from "./page.module.css";
const Link = ({
lightColor,
darkColor,
className,
children,
...rest
}: ComponentPropsWithoutRef<typeof UnstyledLink> & {
lightColor: string;
darkColor: string;
lightColor?: string;
darkColor?: string;
}) => {
const uniqueId = hash(`${lightColor},${darkColor}`);
if (lightColor && darkColor) {
const uniqueId = hash(`${lightColor},${darkColor}`);
return (
<UnstyledLink className={clsx(`t_${uniqueId}`, className)} {...rest}>
{children}
<style
// workaround to have react combine all of these inline styles into a single <style> tag up top, see:
// https://react.dev/reference/react-dom/components/style#rendering-an-inline-css-stylesheet
href={uniqueId}
precedence={styles.page}
>
{`.t_${uniqueId}{--colors-link:${lightColor};--colors-link-underline:${rgba(lightColor, 0.4)}}[data-theme="dark"] .t_${uniqueId}{--colors-link:${darkColor};--colors-link-underline:${rgba(darkColor, 0.4)}}`}
</style>
</UnstyledLink>
);
}
return (
<UnstyledLink className={`t_${uniqueId}`} {...rest}>
<UnstyledLink className={className} {...rest}>
{children}
<style
// workaround to have react combine all of these inline styles into a single <style> tag up top, see:
// https://react.dev/reference/react-dom/components/style#rendering-an-inline-css-stylesheet
href={uniqueId}
precedence={styles.page}
>
{`.t_${uniqueId}{--colors-link:${lightColor};--colors-link-underline:${rgba(lightColor, 0.4)}}[data-theme="dark"] .t_${uniqueId}{--colors-link:${darkColor};--colors-link-underline:${rgba(darkColor, 0.4)}}`}
</style>
</UnstyledLink>
);
};

View File

@ -36,7 +36,8 @@ const sitemap = async (): Promise<MetadataRoute.Sitemap> => {
});
});
(await getFrontMatter()).forEach((post) => {
const frontmatter = await getFrontMatter();
frontmatter.forEach((post) => {
routes.push({
url: post.permalink,
// pull lastModified from front matter date