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:
parent
98155496ba
commit
37a1df2315
6 changed files with 1031 additions and 26 deletions
|
|
@ -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>".
|
||||||
|
|
|
||||||
|
|
@ -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
979
site/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue