fix(site): wire Resend waitlist via astro:env, fix TS errors + deprecations

- Move secrets to Astro's typed astro:env/server (schema in astro.config.mjs);
  removes process.env usage and the 3 TS2580 "Cannot find name 'process'" errors
  without adding @types/node.
- Replace deprecated contacts.create({ audienceId }) with segments: [{ id }]
  (env var RESEND_AUDIENCE_ID -> RESEND_SEGMENT_ID).
- Check the { error } return on both Resend calls: contact add is best-effort
  (non-fatal); a failed confirmation send now returns 502 instead of a false 200.
- Type the waitlist fetch result in WaitlistForm.astro.
- Add @astrojs/check + typescript devDeps and an `npm run check` script.

Verified: npm run check (0 errors), npm run build, secret not inlined into the
build output, and a runtime smoke test of the validation/honeypot/GET paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-08 14:26:54 +02:00
commit 37a1df2315
6 changed files with 1031 additions and 26 deletions

View file

@ -6,8 +6,9 @@
# Resend API key (https://resend.com/api-keys). Server-only — never PUBLIC_.
RESEND_API_KEY=
# Resend Audience to add waitlist contacts to (https://resend.com/audiences).
RESEND_AUDIENCE_ID=
# Resend Segment to add waitlist contacts to (https://resend.com/segments).
# (Resend renamed "Audiences" → "Segments"; this is the Segment ID.)
RESEND_SEGMENT_ID=
# Optional: confirmation sender. Domain must be verified in Resend.
# Defaults to "PCBJam <hello@pcbjam.com>".

View file

@ -1,5 +1,5 @@
// @ts-check
import { defineConfig } from 'astro/config';
import { defineConfig, envField } from 'astro/config';
import vercel from '@astrojs/vercel';
// Static by default (every page prerenders to HTML, zero client JS).
@ -11,4 +11,20 @@ export default defineConfig({
adapter: vercel(),
// Prefetch linked pages so SPA-style navigation feels instant.
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
// Typed, validated server secrets for the waitlist endpoint. All optional so
// the build never requires them and the endpoint degrades gracefully when a
// key is absent (see src/pages/api/waitlist.ts). The @astrojs/vercel adapter
// reads these from process.env at runtime — never inlined into the bundle.
env: {
schema: {
RESEND_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
RESEND_SEGMENT_ID: envField.string({ context: 'server', access: 'secret', optional: true }),
WAITLIST_FROM_EMAIL: envField.string({
context: 'server',
access: 'secret',
optional: true,
default: 'PCBJam <hello@pcbjam.com>',
}),
},
},
});

979
site/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"astro": "astro"
},
"dependencies": {
@ -19,7 +20,9 @@
"resend": "^6.12.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@tailwindcss/postcss": "^4.3.0",
"tailwindcss": "^4.3.0"
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
}
}

View file

@ -166,7 +166,7 @@ const hpId = `wl-hp-${source}`;
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
if (res.ok && data.ok) {
form.hidden = true;
if (okEl) okEl.hidden = false;

View file

@ -1,5 +1,10 @@
import type { APIRoute } from 'astro';
import { Resend } from 'resend';
// Typed server secrets (schema in astro.config.mjs). Optional, so RESEND_API_KEY
// and RESEND_SEGMENT_ID are `string | undefined`; WAITLIST_FROM_EMAIL has a
// schema default so it's always a `string`. The Vercel adapter reads these from
// process.env at runtime — never inlined.
import { RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL } from 'astro:env/server';
// Opt this single route into on-demand (serverless) rendering. The rest of the
// site stays static; Vercel emits exactly one function for /api/waitlist.
@ -7,15 +12,6 @@ export const prerender = false;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// Runtime secrets (Vercel injects at runtime). Never prefix with PUBLIC_.
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? import.meta.env.RESEND_API_KEY;
const RESEND_AUDIENCE_ID =
process.env.RESEND_AUDIENCE_ID ?? import.meta.env.RESEND_AUDIENCE_ID;
const FROM_EMAIL =
process.env.WAITLIST_FROM_EMAIL ??
import.meta.env.WAITLIST_FROM_EMAIL ??
'PCBJam <hello@pcbjam.com>';
function json(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), {
status,
@ -61,19 +57,27 @@ export const POST: APIRoute = async ({ request }) => {
return wantsJson ? json(200, { ok: true }) : redirect('ok');
}
try {
const resend = new Resend(RESEND_API_KEY);
const resend = new Resend(RESEND_API_KEY);
if (RESEND_AUDIENCE_ID) {
await resend.contacts.create({
try {
// Add to the segment (the modern name for an "audience"). The SDK returns
// { data, error } and does NOT throw on API errors. A duplicate contact has
// no stable error code (it surfaces as a validation_error), so we treat the
// contact step as best-effort: log any error but never fail the request on
// it — the user-facing promise is the confirmation email below.
if (RESEND_SEGMENT_ID) {
const { error: contactError } = await resend.contacts.create({
email,
unsubscribed: false,
audienceId: RESEND_AUDIENCE_ID,
segments: [{ id: RESEND_SEGMENT_ID }],
});
if (contactError) {
console.error('[waitlist] contacts.create failed (non-fatal)', contactError);
}
}
await resend.emails.send({
from: FROM_EMAIL,
const { error: sendError } = await resend.emails.send({
from: WAITLIST_FROM_EMAIL,
to: email,
subject: "You're on the PCBJam waitlist",
text: [
@ -86,10 +90,18 @@ export const POST: APIRoute = async ({ request }) => {
].join('\n'),
});
// The confirmation send IS the user-facing promise — fail loudly if Resend
// rejected it (bad key, unverified domain, invalid from-address, …).
if (sendError) {
console.error('[waitlist] emails.send failed', sendError);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }) : redirect('error');
}
return wantsJson ? json(200, { ok: true }) : redirect('ok');
} catch (err) {
console.error('[waitlist] send failed', err);
return wantsJson ? json(500, { ok: false, error: 'send_failed' }) : redirect('error');
// Defensive: unexpected throw (network error, bad construction).
console.error('[waitlist] unexpected error', err);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }) : redirect('error');
}
};