From 44768cfe5a7d2e1da798178d23733405e11600dd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 28 Aug 2026 20:08:10 -0700 Subject: [PATCH] remove blog mailing list --- CLAUDE.md | 16 +- README.md | 3 +- justfile | 33 +--- op.env | 14 -- scripts/notify-subscribers.ts | 283 --------------------------------- src/components/subscribe.astro | 25 --- src/components/subscribe.tsx | 62 -------- src/layouts/global.astro | 12 -- src/pages/blog/index.astro | 2 +- worker/index.test.ts | 6 - worker/index.ts | 70 +------- wrangler.jsonc | 6 +- 12 files changed, 10 insertions(+), 522 deletions(-) delete mode 100644 op.env delete mode 100644 scripts/notify-subscribers.ts delete mode 100644 src/components/subscribe.astro delete mode 100644 src/components/subscribe.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 76e0915..5a09f0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ just dev-watch # moq.watch on :5173 # Build & Deploy just build # Production build of all three sites just deploy # Deploy all three to Cloudflare (staging by default) -just deploy live # Deploy to production, and email subscribers about new posts +just deploy live # Deploy to production just prod # Build and preview production locally # Code Quality @@ -109,19 +109,7 @@ The player sites read the repo-root `.env.` files that the Astro site uses, so `PUBLIC_RELAY_URL` is the single place the relay is configured. Staging is `new.moq.dev`, `new.moq.pub`, and `new.moq.watch`. -The player sites deploy *before* moq.dev on purpose. Snapshot → moq.dev upload → -announce is effectively a transaction: a failure in the middle leaves posts live -but unannounced, and since the snapshot expires after an hour, a later retry -reads those posts as already-published and never mails them. Keep anything -fallible out from between those three steps. - -**`just deploy live` mails the subscriber list.** `scripts/notify-subscribers.ts` snapshots the slugs in `https://moq.dev/rss.xml` before the upload, then sends a Resend broadcast for every post in the freshly built `dist/rss.xml` that wasn't in that snapshot. Subject and body come from the feed's `title` and `description`. A deploy that adds no posts sends nothing. - -Credentials come from 1Password, so no secret has to sit on disk. `op.env` maps `RESEND_API_KEY` to `op://Corp/Resend/credential` and `op run` resolves it for the duration of the command. That file is committed on purpose: it holds references, not values. Install and sign in once with `brew install 1password-cli && op signin`. - -Without the 1Password CLI the recipe falls back to `RESEND_API_KEY` and `RESEND_SEGMENT_ID` from the ambient environment, and if those are missing too the deploy still succeeds while the script exits non-zero to say the announcement did not go out. `just deploy staging` never announces and never touches 1Password. - -Broadcasts cannot be recalled, so the script refuses to guess: an unreachable or empty live feed, a missing snapshot, or a missing build all skip sending rather than risk mailing the back catalogue. Any local `.mdx` under `src/pages/blog/` ships on the next `just deploy live` and gets announced, drafts included. +The player sites deploy before moq.dev. ## Development Tips diff --git a/README.md b/README.md index 16d6999..b15b74a 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,7 @@ just dev-watch # moq.watch, on :5173 ## Deploy `just deploy` builds and uploads all three sites to Cloudflare, staging by -default; `just deploy live` goes to production and mails subscribers about any -new blog posts. +default; `just deploy live` goes to production. ## License diff --git a/justfile b/justfile index 0213c8b..7e043de 100644 --- a/justfile +++ b/justfile @@ -44,42 +44,11 @@ build mode="live": bun vite build sites/pub --mode {{mode}} bun vite build sites/watch --mode {{mode}} -# Deploy all three sites to Cloudflare -# On `live`, any post that wasn't already on moq.dev gets mailed to subscribers. +# Deploy all three sites to Cloudflare. deploy env="staging": (build env) - # The player sites go first. They're independent of the blog, and the - # snapshot/deploy/announce sequence below is a transaction: a failure between - # the moq.dev upload and the announcement leaves posts live but unannounced, - # and the snapshot expires after an hour, so a later retry sees the new posts - # as already-published and stays silent forever. bun wrangler deploy --config sites/pub/wrangler.jsonc --env {{env}} bun wrangler deploy --config sites/watch/wrangler.jsonc --env {{env}} - - # Record what's live before we replace it, so we can tell what the deploy added. - bun scripts/notify-subscribers.ts snapshot --env {{env}} bun wrangler deploy --env {{env}} - just _announce {{env}} - -# Mail subscribers about anything this deploy published. -# Credentials come from 1Password (see op.env) so no secret has to live on disk. -[private] -_announce env: - #!/usr/bin/env bash - set -euo pipefail - - # Staging never announces, so don't make it depend on 1Password. - if [ "{{env}}" != "live" ]; then - exec bun scripts/notify-subscribers.ts send --env {{env}} - fi - - # Fall back to the ambient environment rather than failing outright: the deploy - # has already happened by now, and the script reports a missing key itself. - if ! command -v op >/dev/null 2>&1; then - echo "[notify] 1Password CLI not found, falling back to the ambient environment." >&2 - exec bun scripts/notify-subscribers.ts send --env {{env}} - fi - - exec op run --env-file=op.env -- bun scripts/notify-subscribers.ts send --env {{env}} dev: bun i diff --git a/op.env b/op.env deleted file mode 100644 index d6d8a85..0000000 --- a/op.env +++ /dev/null @@ -1,14 +0,0 @@ -# Secret references for `just deploy live`, resolved by `op run`. -# -# This file is committed on purpose: it holds pointers, not secrets. `op run` -# swaps each op:// reference for the real value at run time and masks it in the -# output. Nothing here is sensitive on its own. -# -# Requires the 1Password CLI, signed in to an account with the Corp vault: -# brew install 1password-cli && op signin - -RESEND_API_KEY=op://Corp/Resend/credential - -# The "Blog" segment. Not a secret, just an id, so keep it inline rather than -# minting a 1Password item for it. -RESEND_SEGMENT_ID=7bfda95d-9eb6-4f29-b1f8-1c2a748ce2ff diff --git a/scripts/notify-subscribers.ts b/scripts/notify-subscribers.ts deleted file mode 100644 index 0599180..0000000 --- a/scripts/notify-subscribers.ts +++ /dev/null @@ -1,283 +0,0 @@ -#!/usr/bin/env bun -// Sends a Resend broadcast for each blog post that goes live in a deploy. -// -// Invoked by `just deploy live` in two phases, around the wrangler upload: -// -// snapshot - records which posts the live site already serves -// send - mails every post in the new build that the snapshot didn't have -// -// Deploys are manual, so there is no push event to diff against. Both sides of -// the diff are RSS feeds instead: the live one for what subscribers have already -// been told about, and the freshly built dist/rss.xml for what this deploy puts -// up. Reading the build rather than src/pages/blog keeps the mail honest, since -// it announces what actually shipped and reuses the exact title and description -// the feed carries. -// -// Only the `live` env notifies; staging is a no-op. Anything that leaves us -// unsure which posts are new (unreachable feed, missing snapshot, missing build) -// skips sending rather than guessing, because a broadcast cannot be recalled. - -import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const SITE = "https://moq.dev"; -const FROM = "Media over QUIC "; -const BUILT_FEED = "dist/rss.xml"; -const SNAPSHOT = join(tmpdir(), "moq-dev-published-slugs.json"); - -// A snapshot only describes the feed at the moment it was taken. Past this, assume -// the site moved on under us and refuse to treat it as "what was already live". -const SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000; - -const FETCH_TIMEOUT_MS = 15000; - -interface Post { - slug: string; - title: string; - description: string; - url: string; -} - -const [phase, ...rest] = process.argv.slice(2); -const env = flag(rest, "--env") ?? "staging"; - -if (phase === "snapshot") { - await snapshot(); -} else if (phase === "send") { - await send(); -} else { - console.error("Usage: notify-subscribers.ts --env "); - process.exit(2); -} - -// Record the slugs the live site serves right now, before the deploy replaces it. -async function snapshot() { - if (env !== "live") { - // Don't touch a pending live snapshot; a staging deploy is unrelated to it. - console.log(`[notify] env=${env}, skipping (only live announces).`); - return; - } - - // A surviving snapshot means the last deploy died partway through announcing. - // Its baseline predates those posts going live, so it is the only thing that - // still knows they are unannounced. Overwriting it with the current feed, - // which now contains them, would bury them permanently. - const pending = readSnapshot(); - if (pending) { - console.warn(`[notify] resuming the unfinished snapshot from a previous deploy (${pending.slugs.length} posts).`); - return; - } - - rmSync(SNAPSHOT, { force: true }); - - let slugs: string[]; - try { - const res = await fetch(`${SITE}/rss.xml`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - slugs = parseFeed(await res.text()).map((p) => p.slug); - } catch (err) { - // Deploying is still the right thing to do; we just can't safely say what's new. - console.warn(`[notify] could not read ${SITE}/rss.xml: ${err instanceof Error ? err.message : err}`); - console.warn("[notify] no announcement will be sent for this deploy."); - return; - } - - // An empty feed is far more likely to be a broken site than a blog with no posts, - // and treating it as "nothing is live" would mail the entire back catalogue. - if (slugs.length === 0) { - console.warn("[notify] live feed listed no posts, refusing to treat that as an empty blog."); - console.warn("[notify] no announcement will be sent for this deploy."); - return; - } - - writeFileSync(SNAPSHOT, JSON.stringify({ at: Date.now(), slugs })); - console.log(`[notify] ${slugs.length} post(s) already live.`); -} - -// Mail every post this deploy added. -async function send() { - if (env !== "live") return; - - // The snapshot is this run's to-do list, so it survives anything that leaves - // work outstanding and is removed only once there is nothing left to send. - const snap = readSnapshot(); - if (!snap) { - // Either nothing was recorded, or what was there is too old to trust: - // another deploy may have announced since, and reusing it would send twice. - rmSync(SNAPSHOT, { force: true }); - console.warn("[notify] no usable snapshot from this deploy, skipping the announcement."); - return; - } - - if (!existsSync(BUILT_FEED)) { - // Keep the snapshot: a rerun with a real build can still announce these. - console.warn(`[notify] ${BUILT_FEED} is missing, skipping the announcement.`); - return; - } - - const published = new Set(snap.slugs); - const added = parseFeed(readFileSync(BUILT_FEED, "utf8")).filter((p) => !published.has(p.slug)); - - if (added.length === 0) { - rmSync(SNAPSHOT, { force: true }); - console.log("[notify] no new posts."); - return; - } - - console.log(`[notify] announcing ${added.length} new post(s): ${added.map((p) => p.slug).join(", ")}`); - - // Deploy already succeeded, so surface a missing key loudly instead of failing quietly. - const apiKey = requireEnv("RESEND_API_KEY"); - const segmentId = requireEnv("RESEND_SEGMENT_ID"); - - for (const post of added) { - console.log(`Creating broadcast for "${post.title}" → ${post.url}`); - - const create = await fetch("https://api.resend.com/broadcasts", { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - segment_id: segmentId, - from: FROM, - subject: post.title, - html: renderHtml(post), - }), - }); - - if (!create.ok) { - const err = await create.text(); - throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); - } - - const { id } = (await create.json()) as { id: string }; - - const sent = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - }); - - if (!sent.ok) { - const err = await sent.text(); - throw new Error(`Resend broadcast send failed (${sent.status}): ${err}`); - } - - // Checkpoint before the next one. If a later send dies, the retry sees this - // post as already published and mails only the remainder. `at` is carried - // over so checkpointing can't extend the staleness window indefinitely. - published.add(post.slug); - writeFileSync(SNAPSHOT, JSON.stringify({ at: snap.at, slugs: [...published] })); - - console.log(`✓ Sent broadcast ${id} for "${post.title}"`); - } - - // Everything landed, so there is nothing for a rerun to pick up. - rmSync(SNAPSHOT, { force: true }); -} - -// The snapshot, or undefined if there isn't a usable one. Unreadable and expired -// files both read as absent; the caller decides whether to discard or replace. -function readSnapshot(): { at: number; slugs: string[] } | undefined { - if (!existsSync(SNAPSHOT)) return undefined; - - let snap: { at: number; slugs: string[] }; - try { - snap = JSON.parse(readFileSync(SNAPSHOT, "utf8")); - } catch { - return undefined; - } - - if (typeof snap?.at !== "number" || !Array.isArray(snap.slugs)) return undefined; - if (Date.now() - snap.at > SNAPSHOT_MAX_AGE_MS) return undefined; - - return snap; -} - -// Pull the blog items out of an RSS feed. Both the live feed and the built one are -// generated by src/pages/rss.xml.js, so the same shape parses either. -function parseFeed(xml: string): Post[] { - const posts: Post[] = []; - - for (const item of xml.matchAll(/([\s\S]*?)<\/item>/g)) { - const body = item[1]; - const url = tag(body, "link"); - if (!url) continue; - - const slug = url.match(/\/blog\/([^/]+)\/?$/)?.[1]; - if (!slug) continue; - - posts.push({ - slug: decodeURIComponent(slug), - title: unescapeXml(tag(body, "title") ?? slug), - description: unescapeXml(tag(body, "description") ?? ""), - url, - }); - } - - return posts; -} - -function tag(xml: string, name: string): string | undefined { - // CDATA is not emitted today, but @astrojs/rss switches to it whenever a value - // contains markup, so handle both rather than silently dropping such a post. - const match = xml.match(new RegExp(`<${name}>(?:|([\\s\\S]*?))`)); - return match ? (match[1] ?? match[2]) : undefined; -} - -function unescapeXml(s: string): string { - return s - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - -function flag(argv: string[], name: string): string | undefined { - const i = argv.indexOf(name); - return i === -1 ? undefined : argv[i + 1]; -} - -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`Missing env var: ${name} (the deploy succeeded; the announcement did not go out)`); - return v; -} - -function renderHtml({ title, description, url }: Post): string { - const safeTitle = escapeHtml(title); - const safeDescription = escapeHtml(description); - const safeUrl = escapeHtml(url); - return ` - -

${safeTitle}

- ${safeDescription ? `

${safeDescription}

` : ""} -

- Read it on moq.dev → -

-

Or open it directly: ${safeUrl}

-`; -} - -function escapeHtml(s: string): string { - return s.replace(/[&<>"']/g, (c) => { - switch (c) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - case '"': - return """; - default: - return "'"; - } - }); -} diff --git a/src/components/subscribe.astro b/src/components/subscribe.astro deleted file mode 100644 index 7ed8e1d..0000000 --- a/src/components/subscribe.astro +++ /dev/null @@ -1,25 +0,0 @@ ---- -import Subscribe from "@/components/subscribe.tsx"; ---- - - diff --git a/src/components/subscribe.tsx b/src/components/subscribe.tsx deleted file mode 100644 index 3e95a33..0000000 --- a/src/components/subscribe.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { createSignal } from "solid-js"; - -type State = "idle" | "submitting" | "success" | "error"; - -export default function Subscribe() { - const [email, setEmail] = createSignal(""); - const [state, setState] = createSignal("idle"); - const [error, setError] = createSignal(""); - - const handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); - setState("submitting"); - setError(""); - - try { - const res = await fetch("/api/subscribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email() }), - }); - - if (res.ok) { - setState("success"); - } else { - const body = (await res.json().catch(() => ({}))) as { error?: string }; - setError(body.error ?? "Something went wrong. Try again?"); - setState("error"); - } - } catch { - setError("Couldn't reach the server. Try again?"); - setState("error"); - } - }; - - return ( - <> - {state() === "success" ? ( -

Thanks! You'll get an email when a new post goes up.

- ) : ( -
- setEmail(e.currentTarget.value)} - disabled={state() === "submitting"} - class="flex-1 rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-slate-100 placeholder-slate-500 transition-colors focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 disabled:cursor-not-allowed disabled:opacity-60" - /> - -
- )} - {state() === "error" &&

{error()}

} - - ); -} diff --git a/src/layouts/global.astro b/src/layouts/global.astro index 4cd274d..e04bf82 100644 --- a/src/layouts/global.astro +++ b/src/layouts/global.astro @@ -3,35 +3,24 @@ import "./global.css"; // Imported after global.css so the theme's .hljs-* color rules land later in // the cascade and beat the prose plugin's .markdown :where(pre code) { color: inherit }. import "highlight.js/styles/atom-one-dark.css"; -import Subscribe from "@/components/subscribe.astro"; // NOTE: This is magically used as the type for Astro.props interface Props { title: string; description: string; - // Show the signup form. Blog posts get it automatically; other pages opt in, - // since it has no business interrupting the demo and player pages. - subscribe?: boolean; - frontmatter?: { title: string; date: string; description: string; cover?: string; - subscribe?: boolean; }; } let { title, frontmatter, description } = Astro.props; -const { subscribe } = Astro.props; if (frontmatter?.title) title = frontmatter.title; if (frontmatter?.description) description = frontmatter.description; -// `date` is what makes a page a blog post. An explicit flag, from either a prop -// (.astro pages) or frontmatter (.mdx pages), wins over that default. -const showSubscribe = subscribe ?? frontmatter?.subscribe ?? Boolean(frontmatter?.date); - const siteUrl = Astro.site?.toString().replace(/\/$/, "") ?? "https://moq.dev"; const pageUrl = new URL(Astro.url.pathname, siteUrl).toString(); const fullTitle = title ? `${title} - Media over QUIC` : "Media over QUIC"; @@ -134,7 +123,6 @@ const proUrl = import.meta.env.MODE === "staging" ? "https://moq.wtf" : "https:/ ) } - {showSubscribe && } diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 285cb93..8fb0b71 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -20,7 +20,7 @@ posts.sort((a, b) => { }); --- - +

Blog Posts

diff --git a/worker/index.test.ts b/worker/index.test.ts index 549e71d..9905f12 100644 --- a/worker/index.test.ts +++ b/worker/index.test.ts @@ -43,10 +43,4 @@ describe("fetch", () => { await send("https://moq.dev/blog/", env); expect(env.seen).toEqual(["/blog/"]); }); - - test("rejects a non-POST subscribe", async () => { - const env = assets(); - expect((await send("https://moq.dev/api/subscribe", env)).status).toBe(405); - expect(env.seen).toEqual([]); - }); }); diff --git a/worker/index.ts b/worker/index.ts index b636e2e..4bcc09f 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,6 +1,6 @@ // Cloudflare Worker entry. Static asset requests fall through to the ASSETS -// binding (Workers-with-Static-Assets). Only /api/*, the de.moq.dev rewrite, -// and the Go vanity import paths are handled here. +// binding (Workers-with-Static-Assets). Only the de.moq.dev rewrite and the Go +// vanity import paths are handled here. // // Note that the Worker only sees a request if wrangler.jsonc says so: an asset // miss is answered with the 404 page rather than falling through, so every path @@ -10,23 +10,12 @@ import { vanity } from "./vanity"; interface Env { ASSETS: { fetch: (request: Request) => Promise }; - RESEND_API_KEY: string; - RESEND_SEGMENT_ID: string; } -const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; - export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - if (url.pathname === "/api/subscribe") { - if (request.method !== "POST") { - return new Response("Method Not Allowed", { status: 405 }); - } - return handleSubscribe(request, env); - } - // de.moq.dev is the DEMOQED page, which lives at /de in the static build. const alreadyRewritten = url.pathname === "/de" || url.pathname.startsWith("/de/"); if (url.hostname.split(".")[0] === "de" && !alreadyRewritten) { @@ -34,7 +23,6 @@ export default { rewritten.pathname = `/de${url.pathname}`; return env.ASSETS.fetch(new Request(rewritten, request)); } - // `go get moq.dev/moq` and friends, served from a mirror repo rather than // by this site. After the rewrite above, so it only ever answers for the // host the import paths are published under. @@ -44,57 +32,3 @@ export default { return env.ASSETS.fetch(request); }, }; - -async function handleSubscribe(request: Request, env: Env): Promise { - let email: unknown; - try { - const body = (await request.json()) as { email?: unknown }; - email = body.email; - } catch { - return json({ error: "invalid body" }, 400); - } - - if (typeof email !== "string" || !EMAIL_RE.test(email)) { - return json({ error: "invalid email" }, 400); - } - - let res: Response; - try { - res = await fetch("https://api.resend.com/contacts", { - method: "POST", - headers: { - Authorization: `Bearer ${env.RESEND_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email, - unsubscribed: false, - // Objects, not bare ids. A string here is a 422 that used to be reported - // to the visitor as success, so every signup was dropped on the floor. - segments: [{ id: env.RESEND_SEGMENT_ID }], - }), - }); - } catch (err) { - console.error(`Resend POST /contacts → fetch threw: ${err}`); - return json({ error: "subscribe failed" }, 502); - } - - // Resend upserts contacts: re-subscribing an existing address returns 201 with - // the same contact id, so there is no duplicate to hide and no reason to - // swallow a 4xx. Anything not ok is a real failure (malformed body, unknown - // segment), and calling those success is what silently discarded every signup. - // Only log status + request id, not the body (which may contain the email). - if (!res.ok) { - console.error(`Resend POST /contacts → ${res.status} (request-id: ${res.headers.get("x-request-id") ?? "n/a"})`); - return json({ error: "subscribe failed" }, 502); - } - - return json({ ok: true }, 200); -} - -function json(body: unknown, status: number): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} diff --git a/wrangler.jsonc b/wrangler.jsonc index 92b58c0..c6a2cff 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -15,12 +15,12 @@ "binding": "ASSETS", // Static assets are normally served before the Worker ever runs, which - // would skip the de.moq.dev -> /de rewrite for "/", swallow POSTs to - // /api/* with a bare 405, and answer the Go vanity paths (worker/vanity.ts) + // would skip the de.moq.dev -> /de rewrite for "/" and answer the Go + // vanity paths (worker/vanity.ts) // with the 404 page, since a miss lands there rather than falling through. // The /moq* wildcard covers every module, so adding one to that table // doesn't mean editing this list. Everything else short-circuits to assets. - "run_worker_first": ["/", "/api/*", "/moq*"] + "run_worker_first": ["/", "/moq*"] }, // Environment-specific configurations