1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-04-26 08:05:23 -04:00

zod ➡️ valibot (plus more server action tracing)

This commit is contained in:
Jake Jarvis 2025-03-30 01:09:30 -04:00
parent 87a24a98f0
commit 444f91b6dc
Signed by: jake
SSH Key Fingerprint: SHA256:nCkvAjYA6XaSPUqc4TfbBQTpzr8Xj7ritg/sGInCdkc
10 changed files with 216 additions and 111 deletions

View File

@ -1,28 +1,27 @@
"use server";
import { headers } from "next/headers";
import { z } from "zod";
import * as v from "valibot";
import { Resend } from "resend";
import * as Sentry from "@sentry/nextjs";
import * as config from "../../lib/config";
const schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
email: z.string().email({ message: "Invalid email address" }),
message: z.string().min(1, { message: "Message is required" }),
["cf-turnstile-response"]: z.string().min(1, { message: "CAPTCHA not completed" }),
const ContactSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty("Your name is required.")),
email: v.pipe(v.string(), v.nonEmpty("Your email address is required."), v.email("Invalid email address.")),
message: v.pipe(v.string(), v.nonEmpty("A message is required.")),
"cf-turnstile-response": v.pipe(v.string(), v.nonEmpty("Just do the stinkin CAPTCHA! 🤖")),
});
export const sendMessage = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prevState: any,
formData: FormData
): Promise<{
export type ContactInput = v.InferInput<typeof ContactSchema>;
export type ContactState = {
success: boolean;
message: string;
errors?: z.inferFormattedError<typeof schema>;
payload?: FormData;
}> => {
errors?: v.FlatErrors<typeof ContactSchema>["nested"];
};
export const sendMessage = async (prevState: ContactState, formData: FormData): Promise<ContactState> => {
return await Sentry.withServerActionInstrumentation(
"sendMessage",
{
@ -32,17 +31,16 @@ export const sendMessage = async (
},
async () => {
try {
const validatedFields = schema.safeParse(Object.fromEntries(formData));
const data = v.safeParse(ContactSchema, Object.fromEntries(formData));
// backup to client-side validations just in case someone squeezes through without them
if (!validatedFields.success) {
console.debug("[contact form] validation error:", validatedFields.error.flatten());
// send raw valibot result to Sentry for debugging
Sentry.captureMessage(JSON.stringify(data), "debug");
if (!data.success) {
return {
success: false,
message: "Please make sure that all fields are properly filled in.",
errors: validatedFields.error.format(),
payload: formData,
message: "Please make sure all fields are filled in.",
errors: v.flatten(data.issues).nested,
};
}
@ -52,14 +50,14 @@ export const sendMessage = async (
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: validatedFields.data["cf-turnstile-response"],
response: data.output["cf-turnstile-response"],
remoteip: (await headers()).get("x-forwarded-for") || "",
}),
cache: "no-store",
signal: AbortSignal.timeout(5000), // 5 second timeout
});
if (!turnstileResponse.ok) {
if (!turnstileResponse || !turnstileResponse.ok) {
throw new Error(`[contact form] turnstile validation failed: ${turnstileResponse.status}`);
}
@ -69,7 +67,6 @@ export const sendMessage = async (
return {
success: false,
message: "Did you complete the CAPTCHA? (If you're human, that is...)",
payload: formData,
};
}
@ -80,22 +77,20 @@ export const sendMessage = async (
// send email
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: `${validatedFields.data.name} <${process.env.RESEND_FROM_EMAIL ?? "onboarding@resend.dev"}>`,
replyTo: `${validatedFields.data.name} <${validatedFields.data.email}>`,
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: validatedFields.data.message,
text: data.output.message,
});
return { success: true, message: "Thanks! You should hear from me soon.", payload: formData };
return { success: true, message: "Thanks! You should hear from me soon." };
} catch (error) {
Sentry.captureException(error);
return {
success: false,
message: "Internal server error... Try again later or shoot me an old-fashioned email?",
errors: error instanceof z.ZodError ? error.format() : undefined,
payload: formData,
message: "Internal server error. Please try again later or shoot me an email.",
};
}
}

View File

@ -24,9 +24,15 @@
border-color: var(--colors-error);
}
.fieldError {
font-size: 0.9em;
color: var(--colors-error);
}
.actionRow {
display: flex;
align-items: center;
margin-top: 0.6em;
min-height: 3.75em;
}

View File

@ -1,20 +1,27 @@
"use client";
import { useActionState } from "react";
import { useActionState, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import Turnstile from "react-turnstile";
import clsx from "clsx";
import { CheckIcon, XIcon } from "lucide-react";
import Link from "../../components/Link";
import { sendMessage } from "./actions";
import { sendMessage, type ContactInput, type ContactState } from "./actions";
import styles from "./form.module.css";
const ContactForm = () => {
const [formState, formAction, pending] = useActionState<Awaited<ReturnType<typeof sendMessage>>, FormData>(
sendMessage,
{ success: false, message: "" }
);
const [formState, formAction, pending] = useActionState<ContactState, FormData>(sendMessage, {
success: false,
message: "",
});
const [formFields, setFormFields] = useState<ContactInput>({
name: "",
email: "",
message: "",
"cf-turnstile-response": "",
});
return (
<form action={formAction}>
@ -22,32 +29,44 @@ const ContactForm = () => {
type="text"
name="name"
placeholder="Name"
required
className={clsx(styles.input, formState?.errors?.name && styles.invalid)}
defaultValue={(formState?.payload?.get("name") || "") as string}
disabled={formState?.success}
// required
value={formFields.name}
onChange={(e) => {
setFormFields({ ...formFields, name: e.target.value });
}}
disabled={pending || formState.success}
className={clsx(styles.input, formState.errors?.name && styles.invalid)}
/>
{formState.errors?.name && <span className={styles.fieldError}>{formState.errors.name[0]}</span>}
<input
type="email"
name="email"
placeholder="Email"
required
// required
inputMode="email"
className={clsx(styles.input, formState?.errors?.email && styles.invalid)}
defaultValue={(formState?.payload?.get("email") || "") as string}
disabled={formState?.success}
value={formFields.email}
onChange={(e) => {
setFormFields({ ...formFields, email: e.target.value });
}}
disabled={pending || formState.success}
className={clsx(styles.input, formState.errors?.email && styles.invalid)}
/>
{formState.errors?.email && <span className={styles.fieldError}>{formState.errors.email[0]}</span>}
<TextareaAutosize
name="message"
placeholder="Write something..."
minRows={5}
required
className={clsx(styles.input, styles.textarea, formState?.errors?.message && styles.invalid)}
defaultValue={(formState?.payload?.get("message") || "") as string}
disabled={formState?.success}
// required
value={formFields.message}
onChange={(e) => {
setFormFields({ ...formFields, message: e.target.value });
}}
disabled={pending || formState.success}
className={clsx(styles.input, styles.textarea, formState.errors?.message && styles.invalid)}
/>
{formState.errors?.message && <span className={styles.fieldError}>{formState.errors.message[0]}</span>}
<div
style={{
@ -78,7 +97,7 @@ const ContactForm = () => {
Markdown syntax
</Link>{" "}
is allowed here, e.g.: <strong>**bold**</strong>, <em>_italics_</em>, [
<Link href="https://jarv.is" plain>
<Link href="https://jarv.is" plain openInNewTab>
links
</Link>
](https://jarv.is), and <code>`code`</code>.
@ -87,6 +106,9 @@ const ContactForm = () => {
<div style={{ margin: "1em 0" }}>
<Turnstile sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"} fixedSize />
</div>
{formState.errors?.["cf-turnstile-response"] && (
<span className={styles.fieldError}>{formState.errors["cf-turnstile-response"][0]}</span>
)}
<div className={styles.actionRow}>
{!formState?.success && (
@ -109,14 +131,14 @@ const ContactForm = () => {
)}
</button>
)}
{formState?.message && (
<div className={clsx(styles.result, formState?.success ? styles.success : styles.error)}>
{formState?.success ? (
{formState.message && (
<div className={clsx(styles.result, formState.success ? styles.success : styles.error)}>
{formState.success ? (
<CheckIcon size="1.3em" className={styles.resultIcon} />
) : (
<XIcon size="1.3em" className={styles.resultIcon} />
)}{" "}
{formState?.message}
{formState.message}
</div>
)}
</div>

View File

@ -1,29 +1,8 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN,
integrations: [
Sentry.replayIntegration({
networkDetailAllowUrls: [process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL!],
networkRequestHeaders: ["referer", "origin", "user-agent", "x-upstream-proxy"],
networkResponseHeaders: [
"location",
"x-matched-path",
"x-nextjs-prerender",
"x-vercel-cache",
"x-vercel-id",
"x-vercel-error",
"x-rewrite-url",
"x-got-milk",
],
}),
],
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV,
integrations: [Sentry.browserTracingIntegration(), Sentry.httpClientIntegration()],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
debug: false,
});

View File

@ -2,12 +2,23 @@ import * as Sentry from "@sentry/nextjs";
export const onRequestError = Sentry.captureRequestError;
export const register = async () => {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
export const register = () => {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV,
integrations: [Sentry.captureConsoleIntegration()],
tracesSampleRate: 1.0,
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#normalizeDepth
normalizeDepth: 5,
});
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
if (process.env.NEXT_RUNTIME === "nodejs") {
// filesystem is only available in nodejs runtime
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/integrations/fs/
Sentry.addIntegration(
Sentry.fsIntegration({
recordFilePaths: true,
})
);
}
};

View File

@ -212,6 +212,9 @@ const nextPlugins: Array<NextPlugin | [NextPlugin, any]> = [
widenClientFileUpload: true,
disableLogger: true,
telemetry: false,
autoInstrumentAppDirectory: true,
autoInstrumentServerFunctions: true,
autoInstrumentMiddleware: false,
bundleSizeOptimizations: {
excludeDebugStatements: true,
},

View File

@ -69,12 +69,13 @@
"to-vfile": "^8.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"zod": "^3.24.2"
"valibot": "^1.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.23.0",
"@jakejarvis/eslint-config": "^4.0.7",
"@sentry/cli": "^2.43.0",
"@types/comma-number": "^2.1.2",
"@types/mdx": "^2.0.13",
"@types/node": "^22.13.14",

117
pnpm-lock.yaml generated
View File

@ -164,9 +164,9 @@ importers:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
zod:
specifier: ^3.24.2
version: 3.24.2
valibot:
specifier: ^1.0.0
version: 1.0.0(typescript@5.8.2)
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.1
@ -177,6 +177,9 @@ importers:
'@jakejarvis/eslint-config':
specifier: ^4.0.7
version: 4.0.7(eslint@9.23.0)
'@sentry/cli':
specifier: ^2.43.0
version: 2.43.0
'@types/comma-number':
specifier: ^2.1.2
version: 2.1.2
@ -1168,47 +1171,99 @@ packages:
engines: {node: '>=10'}
os: [darwin]
'@sentry/cli-darwin@2.43.0':
resolution: {integrity: sha512-0MYvRHJowXOMNY5W6XF4p9GQNH3LuQ+IHAQwVbZOsfwnEv8e20rf9BiPPzmJ9sIjZSWYR4yIqm6dBp6ABJFbGQ==}
engines: {node: '>=10'}
os: [darwin]
'@sentry/cli-linux-arm64@2.42.2':
resolution: {integrity: sha512-BOxzI7sgEU5Dhq3o4SblFXdE9zScpz6EXc5Zwr1UDZvzgXZGosUtKVc7d1LmkrHP8Q2o18HcDWtF3WvJRb5Zpw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux, freebsd]
'@sentry/cli-linux-arm64@2.43.0':
resolution: {integrity: sha512-7URSaNjbEJQZyYJ33XK3pVKl6PU2oO9ETF6R/4Cz2FmU3fecACLKVldv7+OuNl9aspLZ62mnPMDvT732/Fp2Ug==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux, freebsd]
'@sentry/cli-linux-arm@2.42.2':
resolution: {integrity: sha512-7udCw+YL9lwq+9eL3WLspvnuG+k5Icg92YE7zsteTzWLwgPVzaxeZD2f8hwhsu+wmL+jNqbpCRmktPteh3i2mg==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux, freebsd]
'@sentry/cli-linux-arm@2.43.0':
resolution: {integrity: sha512-c2Fwb6HrFL1nbaGV4uRhHC1wEJPR+wfpKN5y06PgSNNbd10YrECAB3tqBHXC8CEmhuDyFR+ORGZ7VbswfCWEEQ==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux, freebsd]
'@sentry/cli-linux-i686@2.42.2':
resolution: {integrity: sha512-Sw/dQp5ZPvKnq3/y7wIJyxTUJYPGoTX/YeMbDs8BzDlu9to2LWV3K3r7hE7W1Lpbaw4tSquUHiQjP5QHCOS7aQ==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [linux, freebsd]
'@sentry/cli-linux-i686@2.43.0':
resolution: {integrity: sha512-bFo/tpMZeMJ275HPGmAENREchnBxhALOOpZAphSyalUu3pGZ+EETEtlSLrKyVNJo26Dye5W7GlrYUV9+rkyCtg==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [linux, freebsd]
'@sentry/cli-linux-x64@2.42.2':
resolution: {integrity: sha512-mU4zUspAal6TIwlNLBV5oq6yYqiENnCWSxtSQVzWs0Jyq97wtqGNG9U+QrnwjJZ+ta/hvye9fvL2X25D/RxHQw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux, freebsd]
'@sentry/cli-linux-x64@2.43.0':
resolution: {integrity: sha512-EbAmKXUNU/Ii4pNGVRCepU6ks1M43wStMKx3pibrUTllrrCwqYKyPxRRdoFYySHkduwCxnoKZcLEg9vWZ3qS6A==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux, freebsd]
'@sentry/cli-win32-arm64@2.43.0':
resolution: {integrity: sha512-KmJRCdQQGLSErJvrcGcN+yWo68m+5OdluhyJHsVYMOQknwu8YMOWLm12EIa+4t4GclDvwg5xcxLccCuiWMJUZw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@sentry/cli-win32-i686@2.42.2':
resolution: {integrity: sha512-iHvFHPGqgJMNqXJoQpqttfsv2GI3cGodeTq4aoVLU/BT3+hXzbV0x1VpvvEhncJkDgDicJpFLM8sEPHb3b8abw==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [win32]
'@sentry/cli-win32-i686@2.43.0':
resolution: {integrity: sha512-ZWxZdOyZX7NJ/CTskzg+dJ2xTpobFLXVNMOMq0HiwdhqXP2zYYJzKnIt3mHNJYA40zYFODGSgxIamodjpB8BuA==}
engines: {node: '>=10'}
cpu: [x86, ia32]
os: [win32]
'@sentry/cli-win32-x64@2.42.2':
resolution: {integrity: sha512-vPPGHjYoaGmfrU7xhfFxG7qlTBacroz5NdT+0FmDn6692D8IvpNXl1K+eV3Kag44ipJBBeR8g1HRJyx/F/9ACw==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@sentry/cli-win32-x64@2.43.0':
resolution: {integrity: sha512-S/IRQYAziEnjpyROhnqzTqShDq3m8jcevXx+q5f49uQnFbfYcTgS1sdrEPqqao/K2boOWbffxYtTkvBiB/piQQ==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@sentry/cli@2.42.2':
resolution: {integrity: sha512-spb7S/RUumCGyiSTg8DlrCX4bivCNmU/A1hcfkwuciTFGu8l5CDc2I6jJWWZw8/0enDGxuj5XujgXvU5tr4bxg==}
engines: {node: '>= 10'}
hasBin: true
'@sentry/cli@2.43.0':
resolution: {integrity: sha512-gBE3bkx+PBJxopTrzIJLX4xHe5S0w87q5frIveWKDZ5ulVIU6YWnVumay0y07RIEweUEj3IYva1qH6HG2abfiA==}
engines: {node: '>= 10'}
hasBin: true
'@sentry/core@9.10.1':
resolution: {integrity: sha512-TE2zZV3Od4131mZNgFo2Mv4aKU8FXxL0s96yqRvmV+8AU57mJoycMXBnmNSYfWuDICbPJTVAp+3bYMXwX7N5YA==}
engines: {node: '>=18'}
@ -4377,6 +4432,14 @@ packages:
engines: {node: '>=8'}
hasBin: true
valibot@1.0.0:
resolution: {integrity: sha512-1Hc0ihzWxBar6NGeZv7fPLY0QuxFMyxwYR2sF1Blu7Wq7EnremwY2W02tit2ij2VJT8HcSkHAQqmFfl77f73Yw==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
typescript:
optional: true
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@ -5528,24 +5591,48 @@ snapshots:
'@sentry/cli-darwin@2.42.2':
optional: true
'@sentry/cli-darwin@2.43.0':
optional: true
'@sentry/cli-linux-arm64@2.42.2':
optional: true
'@sentry/cli-linux-arm64@2.43.0':
optional: true
'@sentry/cli-linux-arm@2.42.2':
optional: true
'@sentry/cli-linux-arm@2.43.0':
optional: true
'@sentry/cli-linux-i686@2.42.2':
optional: true
'@sentry/cli-linux-i686@2.43.0':
optional: true
'@sentry/cli-linux-x64@2.42.2':
optional: true
'@sentry/cli-linux-x64@2.43.0':
optional: true
'@sentry/cli-win32-arm64@2.43.0':
optional: true
'@sentry/cli-win32-i686@2.42.2':
optional: true
'@sentry/cli-win32-i686@2.43.0':
optional: true
'@sentry/cli-win32-x64@2.42.2':
optional: true
'@sentry/cli-win32-x64@2.43.0':
optional: true
'@sentry/cli@2.42.2':
dependencies:
https-proxy-agent: 5.0.1
@ -5565,6 +5652,26 @@ snapshots:
- encoding
- supports-color
'@sentry/cli@2.43.0':
dependencies:
https-proxy-agent: 5.0.1
node-fetch: 2.7.0
progress: 2.0.3
proxy-from-env: 1.1.0
which: 2.0.2
optionalDependencies:
'@sentry/cli-darwin': 2.43.0
'@sentry/cli-linux-arm': 2.43.0
'@sentry/cli-linux-arm64': 2.43.0
'@sentry/cli-linux-i686': 2.43.0
'@sentry/cli-linux-x64': 2.43.0
'@sentry/cli-win32-arm64': 2.43.0
'@sentry/cli-win32-i686': 2.43.0
'@sentry/cli-win32-x64': 2.43.0
transitivePeerDependencies:
- encoding
- supports-color
'@sentry/core@9.10.1': {}
'@sentry/nextjs@9.10.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.0-canary.25(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@19.0.0-beta-aeaed83-20250323)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.98.0)':
@ -9672,6 +9779,10 @@ snapshots:
kleur: 4.1.5
sade: 1.8.1
valibot@1.0.0(typescript@5.8.2):
optionalDependencies:
typescript: 5.8.2
validate-npm-package-license@3.0.4:
dependencies:
spdx-correct: 3.2.0

View File

@ -1,12 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
debug: false,
});

View File

@ -1,11 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
debug: false,
});