feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
import { Resend } from 'resend';
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Waitlist endpoint — a Cloudflare Pages Function.
|
|
|
|
|
*
|
|
|
|
|
* The rest of the site is fully static, so this is the only server-side code we
|
|
|
|
|
* ship. It lives in functions/ rather than src/pages/ because the Astro build
|
|
|
|
|
* has no adapter; Pages routes /api/waitlist here (see public/_routes.json,
|
|
|
|
|
* which keeps every other path a plain static asset request).
|
|
|
|
|
*
|
|
|
|
|
* Secrets arrive as bindings on `context.env`:
|
|
|
|
|
* wrangler pages secret put RESEND_API_KEY --project-name pcbjam-site
|
|
|
|
|
* Locally they come from site/.dev.vars (gitignored). Without RESEND_API_KEY the
|
|
|
|
|
* endpoint still accepts submits (logs + no email), so the form UX is testable
|
|
|
|
|
* without keys.
|
|
|
|
|
*/
|
|
|
|
|
interface Env {
|
|
|
|
|
RESEND_API_KEY?: string;
|
|
|
|
|
RESEND_SEGMENT_ID?: string;
|
|
|
|
|
WAITLIST_FROM_EMAIL?: string;
|
|
|
|
|
WAITLIST_ALLOWED_ORIGINS?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* These two used to be `default:` values in astro.config.mjs's env schema. That
|
|
|
|
|
* schema is gone with the adapter, so they live here — WAITLIST_ALLOWED_ORIGINS
|
|
|
|
|
* especially, since `undefined.split(',')` would throw on every preflight.
|
|
|
|
|
*/
|
|
|
|
|
const DEFAULT_FROM_EMAIL = 'PCBJam <hello@pcbjam.com>';
|
|
|
|
|
const DEFAULT_ALLOWED_ORIGINS = 'https://demo.pcbjam.com';
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
|
|
|
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
|
|
|
2026-07-16 12:46:17 +02:00
|
|
|
/**
|
|
|
|
|
* Redact an address for logs: keep the domain (useful for debugging a
|
|
|
|
|
* misconfigured send) but mask the local-part so logs never hold the raw
|
|
|
|
|
* address.
|
|
|
|
|
*/
|
|
|
|
|
function maskEmail(email: string): string {
|
|
|
|
|
const at = email.indexOf('@');
|
|
|
|
|
if (at <= 0) return '***';
|
|
|
|
|
return `${email.slice(0, 1)}***@${email.slice(at + 1)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Best-effort per-key rate limit — an in-process sliding window that bounds
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
* bursts from one warm isolate. NOT a complete defense (fresh isolates don't
|
|
|
|
|
* share it), so production should ALSO front this with a shared store or a
|
|
|
|
|
* CAPTCHA. Kept dependency-free so it runs.
|
2026-07-16 12:46:17 +02:00
|
|
|
*/
|
|
|
|
|
const RATE_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
|
const RATE_MAX_PER_IP = 5;
|
|
|
|
|
const RATE_MAX_PER_EMAIL = 2;
|
|
|
|
|
const rateHits = new Map<string, number[]>();
|
|
|
|
|
|
|
|
|
|
function rateLimited(key: string, max: number, now: number): boolean {
|
|
|
|
|
const hits = (rateHits.get(key) ?? []).filter((t) => now - t < RATE_WINDOW_MS);
|
|
|
|
|
if (hits.length >= max) {
|
|
|
|
|
rateHits.set(key, hits);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
hits.push(now);
|
|
|
|
|
rateHits.set(key, hits);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
/**
|
|
|
|
|
* Client IP. Cloudflare sets CF-Connecting-IP and it cannot be spoofed by the
|
|
|
|
|
* client; x-forwarded-for is kept as a fallback for `wrangler pages dev` and the
|
|
|
|
|
* unit tests.
|
|
|
|
|
*/
|
2026-07-16 12:46:17 +02:00
|
|
|
function clientIp(request: Request): string {
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
const cf = request.headers.get('cf-connecting-ip');
|
|
|
|
|
if (cf) return cf;
|
2026-07-16 12:46:17 +02:00
|
|
|
const xff = request.headers.get('x-forwarded-for');
|
|
|
|
|
return (xff ? xff.split(',')[0] : '').trim() || 'unknown';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 07:59:09 +02:00
|
|
|
/**
|
|
|
|
|
* CORS headers when the request Origin is in the configured allowlist — lets the
|
|
|
|
|
* static demo (demo.pcbjam.com, no backend of its own) cross-post the form. A
|
|
|
|
|
* same-origin submit sends no Origin and gets no CORS headers (it doesn't need
|
|
|
|
|
* them). The JSON content-type triggers a preflight, hence the OPTIONS handler.
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
*
|
|
|
|
|
* Note this endpoint must be reachable on www WITHOUT a redirect: a CORS
|
|
|
|
|
* preflight cannot follow one, so the demo posts to the canonical www host.
|
2026-06-27 07:59:09 +02:00
|
|
|
*/
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
function allowedOrigins(env: Env): string[] {
|
|
|
|
|
return (env.WAITLIST_ALLOWED_ORIGINS ?? DEFAULT_ALLOWED_ORIGINS)
|
|
|
|
|
.split(',')
|
2026-06-27 07:59:09 +02:00
|
|
|
.map((o) => o.trim())
|
|
|
|
|
.filter(Boolean);
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function corsHeaders(request: Request, env: Env): Record<string, string> {
|
|
|
|
|
const origin = request.headers.get('origin');
|
|
|
|
|
if (!origin) return {};
|
|
|
|
|
if (!allowedOrigins(env).includes(origin)) return {};
|
2026-06-27 07:59:09 +02:00
|
|
|
return {
|
|
|
|
|
'access-control-allow-origin': origin,
|
|
|
|
|
'access-control-allow-methods': 'POST, OPTIONS',
|
|
|
|
|
'access-control-allow-headers': 'content-type',
|
|
|
|
|
'access-control-max-age': '86400',
|
|
|
|
|
vary: 'Origin',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function json(
|
|
|
|
|
status: number,
|
|
|
|
|
body: Record<string, unknown>,
|
|
|
|
|
extra: Record<string, string> = {},
|
|
|
|
|
) {
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
return new Response(JSON.stringify(body), {
|
|
|
|
|
status,
|
2026-06-27 07:59:09 +02:00
|
|
|
headers: { 'content-type': 'application/json', ...extra },
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function redirect(status: 'ok' | 'error') {
|
|
|
|
|
// No-JS native submit: bounce back to the page with a status flag.
|
|
|
|
|
return new Response(null, {
|
|
|
|
|
status: 303,
|
|
|
|
|
headers: { Location: `/?waitlist=${status}#waitlist` },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
export const onRequestOptions: PagesFunction<Env> = ({ request, env }) =>
|
|
|
|
|
new Response(null, { status: 204, headers: corsHeaders(request, env) });
|
2026-06-27 07:59:09 +02:00
|
|
|
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
|
|
|
|
|
const cors = corsHeaders(request, env);
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
const ct = request.headers.get('content-type') ?? '';
|
|
|
|
|
const wantsJson = ct.includes('application/json');
|
|
|
|
|
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
/**
|
|
|
|
|
* Cross-site form-POST guard.
|
|
|
|
|
*
|
|
|
|
|
* Vercel's edge did this for free ("Cross-site POST form submissions are
|
|
|
|
|
* forbidden", HTTP 403) and Cloudflare Pages does not, so it is reproduced here
|
|
|
|
|
* rather than silently lost in the migration. A form-encoded POST carrying a
|
|
|
|
|
* foreign Origin is the classic CSRF shape: unlike fetch(), a cross-site <form>
|
|
|
|
|
* submit needs no CORS permission to be *sent*, so the allowlist above cannot
|
|
|
|
|
* stop it. Without this, any page could sign arbitrary addresses up and have us
|
|
|
|
|
* email them.
|
|
|
|
|
*
|
|
|
|
|
* JSON posts are deliberately exempt — that is the demo's cross-origin path,
|
|
|
|
|
* and it IS governed by the CORS allowlist. Browsers that omit Origin on a
|
|
|
|
|
* same-origin form submit are unaffected: the check only fires when Origin is
|
|
|
|
|
* present and foreign.
|
|
|
|
|
*/
|
|
|
|
|
if (!wantsJson) {
|
|
|
|
|
const origin = request.headers.get('origin');
|
|
|
|
|
if (origin && origin !== new URL(request.url).origin && !allowedOrigins(env).includes(origin)) {
|
|
|
|
|
return new Response('Cross-site POST form submissions are forbidden', {
|
|
|
|
|
status: 403,
|
|
|
|
|
headers: { 'content-type': 'text/plain;charset=UTF-8' },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
let data: Record<string, unknown> = {};
|
|
|
|
|
try {
|
|
|
|
|
data = wantsJson
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
? ((await request.json()) as Record<string, unknown>)
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
: Object.fromEntries(await request.formData());
|
|
|
|
|
} catch {
|
2026-06-27 07:59:09 +02:00
|
|
|
return wantsJson ? json(400, { ok: false, error: 'bad_request' }, cors) : redirect('error');
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const email = String(data.email ?? '').trim().toLowerCase();
|
|
|
|
|
const honeypot = String(data.company_url ?? ''); // hidden field — must stay empty
|
|
|
|
|
const source = String(data.source ?? 'unknown');
|
|
|
|
|
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
// Bot caught by honeypot: silently "succeed" so we don't tip them off. This
|
|
|
|
|
// branch deliberately precedes validation, the rate limiter and any Resend
|
|
|
|
|
// call, which also makes it the one probe that can exercise the full request
|
|
|
|
|
// path against production without side effects.
|
2026-06-27 07:59:09 +02:00
|
|
|
if (honeypot) return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
|
|
|
|
|
if (!EMAIL_RE.test(email) || email.length > 254) {
|
2026-06-27 07:59:09 +02:00
|
|
|
return wantsJson ? json(400, { ok: false, error: 'invalid_email' }, cors) : redirect('error');
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 12:46:17 +02:00
|
|
|
// Rate limit: bound per-IP and per-address submissions. Checked after
|
|
|
|
|
// validation so invalid emails don't consume budget.
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const ip = clientIp(request);
|
|
|
|
|
if (
|
|
|
|
|
rateLimited(`ip:${ip}`, RATE_MAX_PER_IP, now) ||
|
|
|
|
|
rateLimited(`email:${email}`, RATE_MAX_PER_EMAIL, now)
|
|
|
|
|
) {
|
|
|
|
|
return wantsJson
|
|
|
|
|
? json(429, { ok: false, error: 'rate_limited' }, cors)
|
|
|
|
|
: redirect('error');
|
|
|
|
|
}
|
|
|
|
|
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
// No key configured (e.g. local dev without secrets): accept + log, don't 500.
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
if (!env.RESEND_API_KEY) {
|
2026-07-16 12:46:17 +02:00
|
|
|
console.warn(
|
|
|
|
|
`[waitlist] RESEND_API_KEY not set — skipping send. email=${maskEmail(email)} source=${source}`,
|
|
|
|
|
);
|
2026-06-27 07:59:09 +02:00
|
|
|
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
}
|
|
|
|
|
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
const resend = new Resend(env.RESEND_API_KEY);
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
|
2026-06-08 14:26:54 +02:00
|
|
|
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.
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
if (env.RESEND_SEGMENT_ID) {
|
2026-06-08 14:26:54 +02:00
|
|
|
const { error: contactError } = await resend.contacts.create({
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
email,
|
|
|
|
|
unsubscribed: false,
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
segments: [{ id: env.RESEND_SEGMENT_ID }],
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
});
|
2026-06-08 14:26:54 +02:00
|
|
|
if (contactError) {
|
|
|
|
|
console.error('[waitlist] contacts.create failed (non-fatal)', contactError);
|
|
|
|
|
}
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-08 14:26:54 +02:00
|
|
|
const { error: sendError } = await resend.emails.send({
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
from: env.WAITLIST_FROM_EMAIL ?? DEFAULT_FROM_EMAIL,
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
to: email,
|
|
|
|
|
subject: "You're on the PCBJam waitlist",
|
|
|
|
|
text: [
|
|
|
|
|
"You're on the list. 🎉",
|
|
|
|
|
'',
|
feat(site): publish Privacy Policy at /privacy — waitlist coverage, conditional GPC
Replace the /privacy placeholder with the full GDPR-first policy (dated
10 Jun 2026). Beyond the reviewed draft: cover the waitlist end-to-end
(data row, consent basis, marketing section, retention), add a staged-
rollout clause so future-product processing is described honestly, make
the CCPA/GPC commitment conditional (no voluntary promise while nothing
is sold/shared), and fix the CCPA look-back wording.
Point of collection follows suit: the waitlist form microcopy links the
policy, and the confirmation email now offers reply-to-remove, promises
an unsubscribe link in every update, and links /privacy.
2026-06-10 14:13:56 +02:00
|
|
|
"We'll send your early-access invite as seats open in waves, plus the occasional",
|
|
|
|
|
'product update. Every update includes an unsubscribe link — and you can reply',
|
|
|
|
|
'to this email at any time to be taken off the list.',
|
|
|
|
|
'',
|
|
|
|
|
"Didn't sign up? Just reply and we'll remove this address.",
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
'',
|
|
|
|
|
'— The PCBJam team, built by Emergence Engineering',
|
feat(site): publish Privacy Policy at /privacy — waitlist coverage, conditional GPC
Replace the /privacy placeholder with the full GDPR-first policy (dated
10 Jun 2026). Beyond the reviewed draft: cover the waitlist end-to-end
(data row, consent basis, marketing section, retention), add a staged-
rollout clause so future-product processing is described honestly, make
the CCPA/GPC commitment conditional (no voluntary promise while nothing
is sold/shared), and fix the CCPA look-back wording.
Point of collection follows suit: the waitlist form microcopy links the
policy, and the confirmation email now offers reply-to-remove, promises
an unsubscribe link in every update, and links /privacy.
2026-06-10 14:13:56 +02:00
|
|
|
'https://pcbjam.com/privacy',
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
].join('\n'),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-08 14:26:54 +02:00
|
|
|
// 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);
|
2026-06-27 07:59:09 +02:00
|
|
|
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
|
2026-06-08 14:26:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 07:59:09 +02:00
|
|
|
return wantsJson ? json(200, { ok: true }, cors) : redirect('ok');
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
} catch (err) {
|
2026-06-08 14:26:54 +02:00
|
|
|
// Defensive: unexpected throw (network error, bad construction).
|
|
|
|
|
console.error('[waitlist] unexpected error', err);
|
2026-06-27 07:59:09 +02:00
|
|
|
return wantsJson ? json(502, { ok: false, error: 'send_failed' }, cors) : redirect('error');
|
feat(site): 🛬 PCBJam landing page — waitlist, sections, Tailwind
Replace the placeholder homepage with the PCBJam waitlist conversion page
(13 sections from the copy spec), shared chrome, and a real email-capture
backend, keeping the site zero-runtime-JS / zero-web-font.
- Tailwind v4 via @tailwindcss/postcss (the Vite plugin breaks on Astro 6
rolldown-vite); Preflight skipped; brand tokens in :root + @theme inline.
- Waitlist: /api/waitlist serverless route (prerender=false) + Resend, with
dual JSON/303 responses; WaitlistForm progressive enhancement bound on
astro:page-load (survives ClientRouter swaps); honeypot + validation.
- New components (Icon, Placeholder, DemoVideo, SectionBand, Chip, EECredit,
StatBar, Pillar, Step, CheckItem, WaitlistForm) + 13 section components.
- Redesigned Header (sticky, mobile menu, CTA) + 4-col Footer (KiCad
attribution); BaseLayout gains full-bleed mode + OG/canonical meta.
- Assets scaffolded with marked placeholders; EE/generic/client SVGs copied.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:59:13 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// A bare GET (e.g. someone visiting the URL) shouldn't 500.
|
feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.
The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.
Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.
Four behaviour differences were found by measurement and are handled here:
- The blog post's COOP/COEP was already broken in production. vercel.json
scoped the headers to the bare URL, but the page's own canonical is the
trailing-slash form, which served 200 with no isolation headers — so
search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
degraded. public/_headers covers both forms.
- Pages answers unknown URLs with the homepage at HTTP 200 when the
output has no 404.html — a soft-404 that invites indexing junk URLs as
the homepage. Hence src/pages/404.astro.
- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
submissions are forbidden"); Pages does not, and a cross-site <form>
submit needs no CORS permission to be sent, so the allowlist cannot
stop it. The Function reproduces the guard; JSON posts stay exempt as
that is demo.pcbjam.com's allowlisted path.
- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
generated route config, so it is now an explicit _headers rule.
Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.
privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:34:12 +02:00
|
|
|
export const onRequestGet: PagesFunction<Env> = () =>
|
|
|
|
|
json(405, { ok: false, error: 'method_not_allowed' });
|