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

View file

@ -1,5 +1,5 @@
// @ts-check // @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig, envField } from 'astro/config';
import vercel from '@astrojs/vercel'; import vercel from '@astrojs/vercel';
// Static by default (every page prerenders to HTML, zero client JS). // Static by default (every page prerenders to HTML, zero client JS).
@ -11,4 +11,20 @@ export default defineConfig({
adapter: vercel(), adapter: vercel(),
// Prefetch linked pages so SPA-style navigation feels instant. // Prefetch linked pages so SPA-style navigation feels instant.
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' }, 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", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check",
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
@ -19,7 +20,9 @@
"resend": "^6.12.4" "resend": "^6.12.4"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.9",
"@tailwindcss/postcss": "^4.3.0", "@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' }, headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(payload), 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) { if (res.ok && data.ok) {
form.hidden = true; form.hidden = true;
if (okEl) okEl.hidden = false; if (okEl) okEl.hidden = false;

View file

@ -1,5 +1,10 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { Resend } from 'resend'; 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 // Opt this single route into on-demand (serverless) rendering. The rest of the
// site stays static; Vercel emits exactly one function for /api/waitlist. // 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]+$/; 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>) { function json(status: number, body: Record<string, unknown>) {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
status, status,
@ -61,19 +57,27 @@ export const POST: APIRoute = async ({ request }) => {
return wantsJson ? json(200, { ok: true }) : redirect('ok'); 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) { try {
await resend.contacts.create({ // 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, email,
unsubscribed: false, 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({ const { error: sendError } = await resend.emails.send({
from: FROM_EMAIL, from: WAITLIST_FROM_EMAIL,
to: email, to: email,
subject: "You're on the PCBJam waitlist", subject: "You're on the PCBJam waitlist",
text: [ text: [
@ -86,10 +90,18 @@ export const POST: APIRoute = async ({ request }) => {
].join('\n'), ].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'); return wantsJson ? json(200, { ok: true }) : redirect('ok');
} catch (err) { } catch (err) {
console.error('[waitlist] send failed', err); // Defensive: unexpected throw (network error, bad construction).
return wantsJson ? json(500, { ok: false, error: 'send_failed' }) : redirect('error'); console.error('[waitlist] unexpected error', err);
return wantsJson ? json(502, { ok: false, error: 'send_failed' }) : redirect('error');
} }
}; };