From 444f91b6dc22306976bacfc1a3c524b5ec3fd005 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 30 Mar 2025 01:09:30 -0400 Subject: [PATCH] =?UTF-8?q?zod=20=E2=9E=A1=EF=B8=8F=20valibot=20(plus=20mo?= =?UTF-8?q?re=20server=20action=20tracing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/contact/actions.ts | 57 ++++++++---------- app/contact/form.module.css | 6 ++ app/contact/form.tsx | 68 ++++++++++++++------- instrumentation-client.ts | 27 +-------- instrumentation.ts | 23 +++++-- next.config.ts | 3 + package.json | 3 +- pnpm-lock.yaml | 117 +++++++++++++++++++++++++++++++++++- sentry.edge.config.ts | 12 ---- sentry.server.config.ts | 11 ---- 10 files changed, 216 insertions(+), 111 deletions(-) delete mode 100644 sentry.edge.config.ts delete mode 100644 sentry.server.config.ts diff --git a/app/contact/actions.ts b/app/contact/actions.ts index 2c28392b..456396a2 100644 --- a/app/contact/actions.ts +++ b/app/contact/actions.ts @@ -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; + +export type ContactState = { success: boolean; message: string; - errors?: z.inferFormattedError; - payload?: FormData; -}> => { + errors?: v.FlatErrors["nested"]; +}; + +export const sendMessage = async (prevState: ContactState, formData: FormData): Promise => { 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.", }; } } diff --git a/app/contact/form.module.css b/app/contact/form.module.css index f8bec795..9c7b17df 100644 --- a/app/contact/form.module.css +++ b/app/contact/form.module.css @@ -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; } diff --git a/app/contact/form.tsx b/app/contact/form.tsx index 3a9215f5..6e595ae3 100644 --- a/app/contact/form.tsx +++ b/app/contact/form.tsx @@ -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>, FormData>( - sendMessage, - { success: false, message: "" } - ); + const [formState, formAction, pending] = useActionState(sendMessage, { + success: false, + message: "", + }); + + const [formFields, setFormFields] = useState({ + name: "", + email: "", + message: "", + "cf-turnstile-response": "", + }); return (
@@ -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 && {formState.errors.name[0]}} { + setFormFields({ ...formFields, email: e.target.value }); + }} + disabled={pending || formState.success} + className={clsx(styles.input, formState.errors?.email && styles.invalid)} /> + {formState.errors?.email && {formState.errors.email[0]}} { + setFormFields({ ...formFields, message: e.target.value }); + }} + disabled={pending || formState.success} + className={clsx(styles.input, styles.textarea, formState.errors?.message && styles.invalid)} /> + {formState.errors?.message && {formState.errors.message[0]}}
{ Markdown syntax {" "} is allowed here, e.g.: **bold**, _italics_, [ - + links ](https://jarv.is), and `code`. @@ -87,6 +106,9 @@ const ContactForm = () => {
+ {formState.errors?.["cf-turnstile-response"] && ( + {formState.errors["cf-turnstile-response"][0]} + )}
{!formState?.success && ( @@ -109,14 +131,14 @@ const ContactForm = () => { )} )} - {formState?.message && ( -
- {formState?.success ? ( + {formState.message && ( +
+ {formState.success ? ( ) : ( )}{" "} - {formState?.message} + {formState.message}
)}
diff --git a/instrumentation-client.ts b/instrumentation-client.ts index c4975d56..8e5ba2b3 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -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, }); diff --git a/instrumentation.ts b/instrumentation.ts index e2b5d05d..b52175bd 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -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, + }) + ); } }; diff --git a/next.config.ts b/next.config.ts index 979d9dc1..f6623dbd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -212,6 +212,9 @@ const nextPlugins: Array = [ widenClientFileUpload: true, disableLogger: true, telemetry: false, + autoInstrumentAppDirectory: true, + autoInstrumentServerFunctions: true, + autoInstrumentMiddleware: false, bundleSizeOptimizations: { excludeDebugStatements: true, }, diff --git a/package.json b/package.json index 6d9d31ce..f015d757 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 020c2bbb..a7c460bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts deleted file mode 100644 index 4fadc615..00000000 --- a/sentry.edge.config.ts +++ /dev/null @@ -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, -}); diff --git a/sentry.server.config.ts b/sentry.server.config.ts deleted file mode 100644 index e9d1529f..00000000 --- a/sentry.server.config.ts +++ /dev/null @@ -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, -});