From 9147ae2470cf6eade353ffca30f358110d663d58 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:11:16 +0300 Subject: [PATCH 01/68] Add files via upload --- .../plugin-email-on-publish/package.json | 9 + .../plugin-email-on-publish/src/index.ts | 216 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/plugins/plugin-email-on-publish/package.json create mode 100644 packages/plugins/plugin-email-on-publish/src/index.ts diff --git a/packages/plugins/plugin-email-on-publish/package.json b/packages/plugins/plugin-email-on-publish/package.json new file mode 100644 index 0000000000..81505146d3 --- /dev/null +++ b/packages/plugins/plugin-email-on-publish/package.json @@ -0,0 +1,9 @@ +{ + "name": "@my-emdash/plugin-email-on-publish", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sandbox": "./src/index.ts" + } +} diff --git a/packages/plugins/plugin-email-on-publish/src/index.ts b/packages/plugins/plugin-email-on-publish/src/index.ts new file mode 100644 index 0000000000..e44ba7b143 --- /dev/null +++ b/packages/plugins/plugin-email-on-publish/src/index.ts @@ -0,0 +1,216 @@ +/** + * plugin-email-on-publish + * + * Sends an email whenever content is published. + * Supports three providers, configured via CF environment variables: + * EMAIL_PROVIDER = "mailchannels" | "resend" | "sendgrid" + * + * Required env vars (set in CF Dashboard → Workers & Pages → Settings → Variables & Secrets): + * + * All providers: + * EMAIL_PROVIDER — which provider to use (mailchannels | resend | sendgrid) + * EMAIL_FROM — sender address (e.g. cms@yourdomain.com) + * EMAIL_TO — recipient address (e.g. you@yourdomain.com) + * + * Resend only: + * RESEND_API_KEY — from resend.com dashboard + * + * SendGrid only: + * SENDGRID_API_KEY — from sendgrid.com dashboard + * + * MailChannels: + * No API key needed — works natively on Cloudflare Workers (free) + */ + +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +// --------------------------------------------------------------------------- +// Provider implementations +// --------------------------------------------------------------------------- + +async function sendViaMailChannels( + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`MailChannels error ${response.status}: ${text}`); + } +} + +async function sendViaResend( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Resend error ${response.status}: ${text}`); + } +} + +async function sendViaSendGrid( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + + // SendGrid returns 202 on success (not 200) + if (response.status !== 202) { + const text = await response.text(); + throw new Error(`SendGrid error ${response.status}: ${text}`); + } +} + +// --------------------------------------------------------------------------- +// Email body builder +// --------------------------------------------------------------------------- + +function buildEmailHtml(title: string, collection: string, id: string): string { + return ` +
+

📢 New content published

+ + + + + + + + + + + + + +
Title${title}
Collection${collection}
ID${id}
+

+ Sent by EmDash plugin-email-on-publish +

+
+ `; +} + +// --------------------------------------------------------------------------- +// Plugin definition +// --------------------------------------------------------------------------- + +export default () => + definePlugin({ + id: "email-on-publish", + version: "1.0.0", + capabilities: ["read:content", "network:fetch"], + + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire on publish + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + + const provider = env.EMAIL_PROVIDER ?? "mailchannels"; + const from = env.EMAIL_FROM; + const to = env.EMAIL_TO; + + // Validate required vars + if (!from || !to) { + ctx.log.error( + "[email-on-publish] Missing EMAIL_FROM or EMAIL_TO env vars" + ); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildEmailHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const apiKey = env.RESEND_API_KEY; + if (!apiKey) { + ctx.log.error( + "[email-on-publish] Missing RESEND_API_KEY env var" + ); + return; + } + await sendViaResend(apiKey, from, to, subject, html); + break; + } + + case "sendgrid": { + const apiKey = env.SENDGRID_API_KEY; + if (!apiKey) { + ctx.log.error( + "[email-on-publish] Missing SENDGRID_API_KEY env var" + ); + return; + } + await sendViaSendGrid(apiKey, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown EMAIL_PROVIDER: "${provider}". Use mailchannels | resend | sendgrid` + ); + return; + } + + ctx.log.info( + `[email-on-publish] Email sent via ${provider} for "${title}"` + ); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Failed to send email: ${err.message}`); + } + }, + }, + }, + }); From 7f2661aba1de3770c0eb9e1a4f43bc81eeab6c16 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:19:46 +0300 Subject: [PATCH 02/68] Add emailOnPublish plugin to Astro config --- demos/cloudflare/astro.config.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 3c7c731cce..013082fd44 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -1,6 +1,8 @@ // @ts-check import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; +import emailOnPublish from "@my-emdash/plugin-email-on-publish"; + import { d1, r2, @@ -72,6 +74,7 @@ export default defineConfig({ plugins: [ // Test plugin that exercises all v2 APIs formsPlugin(), + emailOnPublish(), ], // Sandboxed plugins (run in isolated workers) sandboxed: [webhookNotifierPlugin()], From 24006941fd6b744e97b7c80d1eb56f55bf5f3002 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:21:21 +0300 Subject: [PATCH 03/68] Rename package to @emdash-cms/plugin-email-on-publish --- packages/plugins/plugin-email-on-publish/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-email-on-publish/package.json b/packages/plugins/plugin-email-on-publish/package.json index 81505146d3..0188fd2846 100644 --- a/packages/plugins/plugin-email-on-publish/package.json +++ b/packages/plugins/plugin-email-on-publish/package.json @@ -1,5 +1,5 @@ { - "name": "@my-emdash/plugin-email-on-publish", + "name": "@emdash-cms/plugin-email-on-publish", "version": "1.0.0", "type": "module", "exports": { From 37b9a67cb4d013f4c42f0695747d3861faa94923 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:36:34 +0300 Subject: [PATCH 04/68] Add files via upload --- .../plugin-email-on-publish/src/index.ts | 223 +----------------- .../src/sandbox-entry.ts | 184 +++++++++++++++ 2 files changed, 194 insertions(+), 213 deletions(-) create mode 100644 packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts diff --git a/packages/plugins/plugin-email-on-publish/src/index.ts b/packages/plugins/plugin-email-on-publish/src/index.ts index e44ba7b143..3bfc8d5b56 100644 --- a/packages/plugins/plugin-email-on-publish/src/index.ts +++ b/packages/plugins/plugin-email-on-publish/src/index.ts @@ -1,216 +1,13 @@ -/** - * plugin-email-on-publish - * - * Sends an email whenever content is published. - * Supports three providers, configured via CF environment variables: - * EMAIL_PROVIDER = "mailchannels" | "resend" | "sendgrid" - * - * Required env vars (set in CF Dashboard → Workers & Pages → Settings → Variables & Secrets): - * - * All providers: - * EMAIL_PROVIDER — which provider to use (mailchannels | resend | sendgrid) - * EMAIL_FROM — sender address (e.g. cms@yourdomain.com) - * EMAIL_TO — recipient address (e.g. you@yourdomain.com) - * - * Resend only: - * RESEND_API_KEY — from resend.com dashboard - * - * SendGrid only: - * SENDGRID_API_KEY — from sendgrid.com dashboard - * - * MailChannels: - * No API key needed — works natively on Cloudflare Workers (free) - */ +// src/index.ts — descriptor factory, runs in Vite at build time +// Imported in astro.config.mjs — must be side-effect-free. +import type { PluginDescriptor } from "emdash"; -import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; - -// --------------------------------------------------------------------------- -// Provider implementations -// --------------------------------------------------------------------------- - -async function sendViaMailChannels( - from: string, - to: string, - subject: string, - html: string -): Promise { - const response = await fetch("https://api.mailchannels.net/tx/v1/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`MailChannels error ${response.status}: ${text}`); - } -} - -async function sendViaResend( - apiKey: string, - from: string, - to: string, - subject: string, - html: string -): Promise { - const response = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ from, to, subject, html }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`Resend error ${response.status}: ${text}`); - } -} - -async function sendViaSendGrid( - apiKey: string, - from: string, - to: string, - subject: string, - html: string -): Promise { - const response = await fetch("https://api.sendgrid.com/v3/mail/send", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - - // SendGrid returns 202 on success (not 200) - if (response.status !== 202) { - const text = await response.text(); - throw new Error(`SendGrid error ${response.status}: ${text}`); - } -} - -// --------------------------------------------------------------------------- -// Email body builder -// --------------------------------------------------------------------------- - -function buildEmailHtml(title: string, collection: string, id: string): string { - return ` -
-

📢 New content published

- - - - - - - - - - - - - -
Title${title}
Collection${collection}
ID${id}
-

- Sent by EmDash plugin-email-on-publish -

-
- `; -} - -// --------------------------------------------------------------------------- -// Plugin definition -// --------------------------------------------------------------------------- - -export default () => - definePlugin({ +export function emailOnPublishPlugin(): PluginDescriptor { + return { id: "email-on-publish", version: "1.0.0", - capabilities: ["read:content", "network:fetch"], - - hooks: { - "content:afterSave": { - handler: async (event: any, ctx: PluginContext) => { - // Only fire on publish - if (event.content.status !== "published") return; - - const env = (ctx as any).env ?? {}; - - const provider = env.EMAIL_PROVIDER ?? "mailchannels"; - const from = env.EMAIL_FROM; - const to = env.EMAIL_TO; - - // Validate required vars - if (!from || !to) { - ctx.log.error( - "[email-on-publish] Missing EMAIL_FROM or EMAIL_TO env vars" - ); - return; - } - - const title = event.content.title ?? "Untitled"; - const collection = event.collection ?? "unknown"; - const id = event.content.id ?? ""; - const subject = `Published: ${title}`; - const html = buildEmailHtml(title, collection, id); - - try { - switch (provider) { - case "mailchannels": - await sendViaMailChannels(from, to, subject, html); - break; - - case "resend": { - const apiKey = env.RESEND_API_KEY; - if (!apiKey) { - ctx.log.error( - "[email-on-publish] Missing RESEND_API_KEY env var" - ); - return; - } - await sendViaResend(apiKey, from, to, subject, html); - break; - } - - case "sendgrid": { - const apiKey = env.SENDGRID_API_KEY; - if (!apiKey) { - ctx.log.error( - "[email-on-publish] Missing SENDGRID_API_KEY env var" - ); - return; - } - await sendViaSendGrid(apiKey, from, to, subject, html); - break; - } - - default: - ctx.log.error( - `[email-on-publish] Unknown EMAIL_PROVIDER: "${provider}". Use mailchannels | resend | sendgrid` - ); - return; - } - - ctx.log.info( - `[email-on-publish] Email sent via ${provider} for "${title}"` - ); - } catch (err: any) { - ctx.log.error(`[email-on-publish] Failed to send email: ${err.message}`); - } - }, - }, - }, - }); + format: "standard", + entrypoint: "@emdash-cms/plugin-email-on-publish/sandbox", + options: {}, + }; +} diff --git a/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts b/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts new file mode 100644 index 0000000000..ebea0a62ea --- /dev/null +++ b/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts @@ -0,0 +1,184 @@ +// src/sandbox-entry.ts — plugin definition, runs at request time +// This is the actual plugin logic. Works in both trusted and sandboxed modes. +// Uses only Web APIs (fetch) — no Node.js built-ins. +// +// Configure via CF Dashboard → Workers & Pages → Settings → Variables & Secrets: +// +// EMAIL_PROVIDER mailchannels | resend | sendgrid (default: mailchannels) +// EMAIL_FROM sender address e.g. cms@yourdomain.com +// EMAIL_TO recipient address e.g. you@yourdomain.com +// RESEND_API_KEY required only when EMAIL_PROVIDER=resend +// SENDGRID_API_KEY required only when EMAIL_PROVIDER=sendgrid + +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; + +// --------------------------------------------------------------------------- +// Provider implementations (Web API fetch only — sandbox compatible) +// --------------------------------------------------------------------------- + +async function sendViaMailChannels( + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (!response.ok) { + throw new Error(`MailChannels ${response.status}: ${await response.text()}`); + } +} + +async function sendViaResend( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + if (!response.ok) { + throw new Error(`Resend ${response.status}: ${await response.text()}`); + } +} + +async function sendViaSendGrid( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + // SendGrid returns 202 Accepted on success + if (response.status !== 202) { + throw new Error(`SendGrid ${response.status}: ${await response.text()}`); + } +} + +// --------------------------------------------------------------------------- +// Email HTML builder +// --------------------------------------------------------------------------- + +function buildHtml(title: string, collection: string, id: string): string { + return ` +
+

📢 New content published

+ + + + + + + + + + + + + +
Title${title}
Collection${collection}
ID${id}
+

+ Sent by EmDash · plugin-email-on-publish +

+
`; +} + +// --------------------------------------------------------------------------- +// Plugin definition (default export required) +// --------------------------------------------------------------------------- + +export default definePlugin({ + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire when content transitions to published + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; + const from: string = env.EMAIL_FROM ?? ""; + const to: string = env.EMAIL_TO ?? ""; + + if (!from || !to) { + ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const key = env.RESEND_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); + return; + } + await sendViaResend(key, from, to, subject, html); + break; + } + + case "sendgrid": { + const key = env.SENDGRID_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); + return; + } + await sendViaSendGrid(key, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid` + ); + return; + } + + ctx.log.info( + `[email-on-publish] ✓ Sent via ${provider} — "${title}"` + ); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); + } + }, + }, + }, +}); From 3852536d80414135c7af22ef9b38d1a6a7383822 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:37:01 +0300 Subject: [PATCH 05/68] Add files via upload --- packages/plugins/plugin-email-on-publish/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-email-on-publish/package.json b/packages/plugins/plugin-email-on-publish/package.json index 0188fd2846..f9b2e02f95 100644 --- a/packages/plugins/plugin-email-on-publish/package.json +++ b/packages/plugins/plugin-email-on-publish/package.json @@ -4,6 +4,6 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./sandbox": "./src/index.ts" + "./sandbox": "./src/sandbox-entry.ts" } } From 6ebd16cb4cb08aac69dd53a849e47f64c81aec37 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:37:45 +0300 Subject: [PATCH 06/68] Replace emailOnPublish with emailOnPublishPlugin --- demos/cloudflare/astro.config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 013082fd44..b6c515a6db 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -1,7 +1,7 @@ // @ts-check import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; -import emailOnPublish from "@my-emdash/plugin-email-on-publish"; +import { emailOnPublishPlugin } from "@emdash-cms/plugin-email-on-publish"; import { d1, @@ -74,7 +74,7 @@ export default defineConfig({ plugins: [ // Test plugin that exercises all v2 APIs formsPlugin(), - emailOnPublish(), + emailOnPublishPlugin(), ], // Sandboxed plugins (run in isolated workers) sandboxed: [webhookNotifierPlugin()], From 4f9141d87d61f6986e3da65031be749f7fd27674 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Sun, 19 Apr 2026 18:52:46 +0300 Subject: [PATCH 07/68] fix: update lockfile for email-on-publish plugin --- pnpm-lock.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 293e527016..1181488865 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1337,6 +1337,8 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/plugins/plugin-email-on-publish: {} + packages/plugins/sandboxed-test: dependencies: emdash: From 0ea21f34014e2f55464dd984bfa7ed36e76ef64b Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:59:59 +0300 Subject: [PATCH 08/68] Update emailOnPublishPlugin import path --- demos/cloudflare/astro.config.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index b6c515a6db..74281a4fc4 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -1,8 +1,7 @@ // @ts-check import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; -import { emailOnPublishPlugin } from "@emdash-cms/plugin-email-on-publish"; - +import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; import { d1, r2, From f704ca8ef67f896908709e233df23abda0d95144 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:01:40 +0300 Subject: [PATCH 09/68] Add files via upload --- .../plugins/sandbox-test/email-on-publish.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 demos/cloudflare/plugins/sandbox-test/email-on-publish.ts diff --git a/demos/cloudflare/plugins/sandbox-test/email-on-publish.ts b/demos/cloudflare/plugins/sandbox-test/email-on-publish.ts new file mode 100644 index 0000000000..b0b0bee0c9 --- /dev/null +++ b/demos/cloudflare/plugins/sandbox-test/email-on-publish.ts @@ -0,0 +1,199 @@ +// demos/cloudflare/plugins/email-on-publish.ts +// +// Drop this file at: demos/cloudflare/plugins/email-on-publish.ts +// +// Then in demos/cloudflare/astro.config.mjs: +// import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; +// plugins: [formsPlugin(), emailOnPublishPlugin()], +// +// Set these in CF Dashboard → Workers & Pages → Settings → Variables & Secrets: +// EMAIL_PROVIDER mailchannels | resend | sendgrid (default: mailchannels) +// EMAIL_FROM e.g. onboarding@resend.dev +// EMAIL_TO e.g. you@gmail.com +// RESEND_API_KEY only if EMAIL_PROVIDER=resend +// SENDGRID_API_KEY only if EMAIL_PROVIDER=sendgrid + +import type { PluginDescriptor, PluginContext } from "emdash"; +import { definePlugin } from "emdash"; + +// --------------------------------------------------------------------------- +// Descriptor — runs at build time in Vite, imported by astro.config.mjs +// --------------------------------------------------------------------------- + +export function emailOnPublishPlugin(): PluginDescriptor { + return { + id: "email-on-publish", + version: "1.0.0", + format: "standard", + // Points to this same file as the runtime entrypoint + entrypoint: "./plugins/email-on-publish.ts", + options: {}, + }; +} + +// --------------------------------------------------------------------------- +// Providers — Web API fetch only, no Node.js built-ins +// --------------------------------------------------------------------------- + +async function sendViaMailChannels( + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (!response.ok) { + throw new Error(`MailChannels ${response.status}: ${await response.text()}`); + } +} + +async function sendViaResend( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + if (!response.ok) { + throw new Error(`Resend ${response.status}: ${await response.text()}`); + } +} + +async function sendViaSendGrid( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (response.status !== 202) { + throw new Error(`SendGrid ${response.status}: ${await response.text()}`); + } +} + +// --------------------------------------------------------------------------- +// Email HTML +// --------------------------------------------------------------------------- + +function buildHtml(title: string, collection: string, id: string): string { + return ` +
+

📢 New content published

+ + + + + + + + + + + + + +
Title${title}
Collection${collection}
ID${id}
+

+ Sent by EmDash · plugin-email-on-publish +

+
`; +} + +// --------------------------------------------------------------------------- +// Plugin runtime — default export required by EmDash +// --------------------------------------------------------------------------- + +export default definePlugin({ + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire when content is published + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; + const from: string = env.EMAIL_FROM ?? ""; + const to: string = env.EMAIL_TO ?? ""; + + if (!from || !to) { + ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const key = env.RESEND_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); + return; + } + await sendViaResend(key, from, to, subject, html); + break; + } + + case "sendgrid": { + const key = env.SENDGRID_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); + return; + } + await sendViaSendGrid(key, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid` + ); + return; + } + + ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); + } + }, + }, + }, +}); From fc1f81ce2f4984b128bd6f7945eaaddafe17556f Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:07:38 +0300 Subject: [PATCH 10/68] Add files via upload --- demos/cloudflare/plugins/email-on-publish.ts | 199 +++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 demos/cloudflare/plugins/email-on-publish.ts diff --git a/demos/cloudflare/plugins/email-on-publish.ts b/demos/cloudflare/plugins/email-on-publish.ts new file mode 100644 index 0000000000..b0b0bee0c9 --- /dev/null +++ b/demos/cloudflare/plugins/email-on-publish.ts @@ -0,0 +1,199 @@ +// demos/cloudflare/plugins/email-on-publish.ts +// +// Drop this file at: demos/cloudflare/plugins/email-on-publish.ts +// +// Then in demos/cloudflare/astro.config.mjs: +// import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; +// plugins: [formsPlugin(), emailOnPublishPlugin()], +// +// Set these in CF Dashboard → Workers & Pages → Settings → Variables & Secrets: +// EMAIL_PROVIDER mailchannels | resend | sendgrid (default: mailchannels) +// EMAIL_FROM e.g. onboarding@resend.dev +// EMAIL_TO e.g. you@gmail.com +// RESEND_API_KEY only if EMAIL_PROVIDER=resend +// SENDGRID_API_KEY only if EMAIL_PROVIDER=sendgrid + +import type { PluginDescriptor, PluginContext } from "emdash"; +import { definePlugin } from "emdash"; + +// --------------------------------------------------------------------------- +// Descriptor — runs at build time in Vite, imported by astro.config.mjs +// --------------------------------------------------------------------------- + +export function emailOnPublishPlugin(): PluginDescriptor { + return { + id: "email-on-publish", + version: "1.0.0", + format: "standard", + // Points to this same file as the runtime entrypoint + entrypoint: "./plugins/email-on-publish.ts", + options: {}, + }; +} + +// --------------------------------------------------------------------------- +// Providers — Web API fetch only, no Node.js built-ins +// --------------------------------------------------------------------------- + +async function sendViaMailChannels( + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (!response.ok) { + throw new Error(`MailChannels ${response.status}: ${await response.text()}`); + } +} + +async function sendViaResend( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + if (!response.ok) { + throw new Error(`Resend ${response.status}: ${await response.text()}`); + } +} + +async function sendViaSendGrid( + apiKey: string, + from: string, + to: string, + subject: string, + html: string +): Promise { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (response.status !== 202) { + throw new Error(`SendGrid ${response.status}: ${await response.text()}`); + } +} + +// --------------------------------------------------------------------------- +// Email HTML +// --------------------------------------------------------------------------- + +function buildHtml(title: string, collection: string, id: string): string { + return ` +
+

📢 New content published

+ + + + + + + + + + + + + +
Title${title}
Collection${collection}
ID${id}
+

+ Sent by EmDash · plugin-email-on-publish +

+
`; +} + +// --------------------------------------------------------------------------- +// Plugin runtime — default export required by EmDash +// --------------------------------------------------------------------------- + +export default definePlugin({ + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire when content is published + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; + const from: string = env.EMAIL_FROM ?? ""; + const to: string = env.EMAIL_TO ?? ""; + + if (!from || !to) { + ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const key = env.RESEND_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); + return; + } + await sendViaResend(key, from, to, subject, html); + break; + } + + case "sendgrid": { + const key = env.SENDGRID_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); + return; + } + await sendViaSendGrid(key, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid` + ); + return; + } + + ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); + } + }, + }, + }, +}); From abfc6e4686945b51fd9bebb63f06ae8d903f5986 Mon Sep 17 00:00:00 2001 From: Laith Aljanaideh <161570116+ljanaideh@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:08:22 +0300 Subject: [PATCH 11/68] Delete demos/cloudflare/plugins/sandbox-test/email-on-publish.ts --- .../plugins/sandbox-test/email-on-publish.ts | 199 ------------------ 1 file changed, 199 deletions(-) delete mode 100644 demos/cloudflare/plugins/sandbox-test/email-on-publish.ts diff --git a/demos/cloudflare/plugins/sandbox-test/email-on-publish.ts b/demos/cloudflare/plugins/sandbox-test/email-on-publish.ts deleted file mode 100644 index b0b0bee0c9..0000000000 --- a/demos/cloudflare/plugins/sandbox-test/email-on-publish.ts +++ /dev/null @@ -1,199 +0,0 @@ -// demos/cloudflare/plugins/email-on-publish.ts -// -// Drop this file at: demos/cloudflare/plugins/email-on-publish.ts -// -// Then in demos/cloudflare/astro.config.mjs: -// import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; -// plugins: [formsPlugin(), emailOnPublishPlugin()], -// -// Set these in CF Dashboard → Workers & Pages → Settings → Variables & Secrets: -// EMAIL_PROVIDER mailchannels | resend | sendgrid (default: mailchannels) -// EMAIL_FROM e.g. onboarding@resend.dev -// EMAIL_TO e.g. you@gmail.com -// RESEND_API_KEY only if EMAIL_PROVIDER=resend -// SENDGRID_API_KEY only if EMAIL_PROVIDER=sendgrid - -import type { PluginDescriptor, PluginContext } from "emdash"; -import { definePlugin } from "emdash"; - -// --------------------------------------------------------------------------- -// Descriptor — runs at build time in Vite, imported by astro.config.mjs -// --------------------------------------------------------------------------- - -export function emailOnPublishPlugin(): PluginDescriptor { - return { - id: "email-on-publish", - version: "1.0.0", - format: "standard", - // Points to this same file as the runtime entrypoint - entrypoint: "./plugins/email-on-publish.ts", - options: {}, - }; -} - -// --------------------------------------------------------------------------- -// Providers — Web API fetch only, no Node.js built-ins -// --------------------------------------------------------------------------- - -async function sendViaMailChannels( - from: string, - to: string, - subject: string, - html: string -): Promise { - const response = await fetch("https://api.mailchannels.net/tx/v1/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (!response.ok) { - throw new Error(`MailChannels ${response.status}: ${await response.text()}`); - } -} - -async function sendViaResend( - apiKey: string, - from: string, - to: string, - subject: string, - html: string -): Promise { - const response = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ from, to, subject, html }), - }); - if (!response.ok) { - throw new Error(`Resend ${response.status}: ${await response.text()}`); - } -} - -async function sendViaSendGrid( - apiKey: string, - from: string, - to: string, - subject: string, - html: string -): Promise { - const response = await fetch("https://api.sendgrid.com/v3/mail/send", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (response.status !== 202) { - throw new Error(`SendGrid ${response.status}: ${await response.text()}`); - } -} - -// --------------------------------------------------------------------------- -// Email HTML -// --------------------------------------------------------------------------- - -function buildHtml(title: string, collection: string, id: string): string { - return ` -
-

📢 New content published

- - - - - - - - - - - - - -
Title${title}
Collection${collection}
ID${id}
-

- Sent by EmDash · plugin-email-on-publish -

-
`; -} - -// --------------------------------------------------------------------------- -// Plugin runtime — default export required by EmDash -// --------------------------------------------------------------------------- - -export default definePlugin({ - hooks: { - "content:afterSave": { - handler: async (event: any, ctx: PluginContext) => { - // Only fire when content is published - if (event.content.status !== "published") return; - - const env = (ctx as any).env ?? {}; - const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; - const from: string = env.EMAIL_FROM ?? ""; - const to: string = env.EMAIL_TO ?? ""; - - if (!from || !to) { - ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); - return; - } - - const title = event.content.title ?? "Untitled"; - const collection = event.collection ?? "unknown"; - const id = event.content.id ?? ""; - const subject = `Published: ${title}`; - const html = buildHtml(title, collection, id); - - try { - switch (provider) { - case "mailchannels": - await sendViaMailChannels(from, to, subject, html); - break; - - case "resend": { - const key = env.RESEND_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); - return; - } - await sendViaResend(key, from, to, subject, html); - break; - } - - case "sendgrid": { - const key = env.SENDGRID_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); - return; - } - await sendViaSendGrid(key, from, to, subject, html); - break; - } - - default: - ctx.log.error( - `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid` - ); - return; - } - - ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); - } catch (err: any) { - ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); - } - }, - }, - }, -}); From 7ea5e615428f8784a80f8263aff43f9b90ab2cf6 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sun, 19 Apr 2026 16:09:03 +0000 Subject: [PATCH 12/68] style: format --- demos/cloudflare/astro.config.mjs | 3 +- demos/cloudflare/plugins/email-on-publish.ts | 250 +++++++++--------- .../plugin-email-on-publish/src/index.ts | 14 +- .../src/sandbox-entry.ts | 238 +++++++++-------- 4 files changed, 252 insertions(+), 253 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 74281a4fc4..8e910b3dd1 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -1,7 +1,6 @@ // @ts-check import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; -import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; import { d1, r2, @@ -16,6 +15,8 @@ import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; +import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; + export default defineConfig({ output: "server", adapter: cloudflare({ diff --git a/demos/cloudflare/plugins/email-on-publish.ts b/demos/cloudflare/plugins/email-on-publish.ts index b0b0bee0c9..a3188556de 100644 --- a/demos/cloudflare/plugins/email-on-publish.ts +++ b/demos/cloudflare/plugins/email-on-publish.ts @@ -21,14 +21,14 @@ import { definePlugin } from "emdash"; // --------------------------------------------------------------------------- export function emailOnPublishPlugin(): PluginDescriptor { - return { - id: "email-on-publish", - version: "1.0.0", - format: "standard", - // Points to this same file as the runtime entrypoint - entrypoint: "./plugins/email-on-publish.ts", - options: {}, - }; + return { + id: "email-on-publish", + version: "1.0.0", + format: "standard", + // Points to this same file as the runtime entrypoint + entrypoint: "./plugins/email-on-publish.ts", + options: {}, + }; } // --------------------------------------------------------------------------- @@ -36,69 +36,69 @@ export function emailOnPublishPlugin(): PluginDescriptor { // --------------------------------------------------------------------------- async function sendViaMailChannels( - from: string, - to: string, - subject: string, - html: string + from: string, + to: string, + subject: string, + html: string, ): Promise { - const response = await fetch("https://api.mailchannels.net/tx/v1/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (!response.ok) { - throw new Error(`MailChannels ${response.status}: ${await response.text()}`); - } + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (!response.ok) { + throw new Error(`MailChannels ${response.status}: ${await response.text()}`); + } } async function sendViaResend( - apiKey: string, - from: string, - to: string, - subject: string, - html: string + apiKey: string, + from: string, + to: string, + subject: string, + html: string, ): Promise { - const response = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ from, to, subject, html }), - }); - if (!response.ok) { - throw new Error(`Resend ${response.status}: ${await response.text()}`); - } + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + if (!response.ok) { + throw new Error(`Resend ${response.status}: ${await response.text()}`); + } } async function sendViaSendGrid( - apiKey: string, - from: string, - to: string, - subject: string, - html: string + apiKey: string, + from: string, + to: string, + subject: string, + html: string, ): Promise { - const response = await fetch("https://api.sendgrid.com/v3/mail/send", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (response.status !== 202) { - throw new Error(`SendGrid ${response.status}: ${await response.text()}`); - } + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (response.status !== 202) { + throw new Error(`SendGrid ${response.status}: ${await response.text()}`); + } } // --------------------------------------------------------------------------- @@ -106,7 +106,7 @@ async function sendViaSendGrid( // --------------------------------------------------------------------------- function buildHtml(title: string, collection: string, id: string): string { - return ` + return `

📢 New content published

@@ -134,66 +134,66 @@ function buildHtml(title: string, collection: string, id: string): string { // --------------------------------------------------------------------------- export default definePlugin({ - hooks: { - "content:afterSave": { - handler: async (event: any, ctx: PluginContext) => { - // Only fire when content is published - if (event.content.status !== "published") return; - - const env = (ctx as any).env ?? {}; - const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; - const from: string = env.EMAIL_FROM ?? ""; - const to: string = env.EMAIL_TO ?? ""; - - if (!from || !to) { - ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); - return; - } - - const title = event.content.title ?? "Untitled"; - const collection = event.collection ?? "unknown"; - const id = event.content.id ?? ""; - const subject = `Published: ${title}`; - const html = buildHtml(title, collection, id); - - try { - switch (provider) { - case "mailchannels": - await sendViaMailChannels(from, to, subject, html); - break; - - case "resend": { - const key = env.RESEND_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); - return; - } - await sendViaResend(key, from, to, subject, html); - break; - } - - case "sendgrid": { - const key = env.SENDGRID_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); - return; - } - await sendViaSendGrid(key, from, to, subject, html); - break; - } - - default: - ctx.log.error( - `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid` - ); - return; - } - - ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); - } catch (err: any) { - ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); - } - }, - }, - }, + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire when content is published + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; + const from: string = env.EMAIL_FROM ?? ""; + const to: string = env.EMAIL_TO ?? ""; + + if (!from || !to) { + ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const key = env.RESEND_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); + return; + } + await sendViaResend(key, from, to, subject, html); + break; + } + + case "sendgrid": { + const key = env.SENDGRID_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); + return; + } + await sendViaSendGrid(key, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid`, + ); + return; + } + + ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); + } + }, + }, + }, }); diff --git a/packages/plugins/plugin-email-on-publish/src/index.ts b/packages/plugins/plugin-email-on-publish/src/index.ts index 3bfc8d5b56..b87f9322af 100644 --- a/packages/plugins/plugin-email-on-publish/src/index.ts +++ b/packages/plugins/plugin-email-on-publish/src/index.ts @@ -3,11 +3,11 @@ import type { PluginDescriptor } from "emdash"; export function emailOnPublishPlugin(): PluginDescriptor { - return { - id: "email-on-publish", - version: "1.0.0", - format: "standard", - entrypoint: "@emdash-cms/plugin-email-on-publish/sandbox", - options: {}, - }; + return { + id: "email-on-publish", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-email-on-publish/sandbox", + options: {}, + }; } diff --git a/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts b/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts index ebea0a62ea..c674735c4a 100644 --- a/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/plugin-email-on-publish/src/sandbox-entry.ts @@ -18,70 +18,70 @@ import type { PluginContext } from "emdash"; // --------------------------------------------------------------------------- async function sendViaMailChannels( - from: string, - to: string, - subject: string, - html: string + from: string, + to: string, + subject: string, + html: string, ): Promise { - const response = await fetch("https://api.mailchannels.net/tx/v1/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (!response.ok) { - throw new Error(`MailChannels ${response.status}: ${await response.text()}`); - } + const response = await fetch("https://api.mailchannels.net/tx/v1/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + if (!response.ok) { + throw new Error(`MailChannels ${response.status}: ${await response.text()}`); + } } async function sendViaResend( - apiKey: string, - from: string, - to: string, - subject: string, - html: string + apiKey: string, + from: string, + to: string, + subject: string, + html: string, ): Promise { - const response = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ from, to, subject, html }), - }); - if (!response.ok) { - throw new Error(`Resend ${response.status}: ${await response.text()}`); - } + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ from, to, subject, html }), + }); + if (!response.ok) { + throw new Error(`Resend ${response.status}: ${await response.text()}`); + } } async function sendViaSendGrid( - apiKey: string, - from: string, - to: string, - subject: string, - html: string + apiKey: string, + from: string, + to: string, + subject: string, + html: string, ): Promise { - const response = await fetch("https://api.sendgrid.com/v3/mail/send", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - // SendGrid returns 202 Accepted on success - if (response.status !== 202) { - throw new Error(`SendGrid ${response.status}: ${await response.text()}`); - } + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: { email: from }, + subject, + content: [{ type: "text/html", value: html }], + }), + }); + // SendGrid returns 202 Accepted on success + if (response.status !== 202) { + throw new Error(`SendGrid ${response.status}: ${await response.text()}`); + } } // --------------------------------------------------------------------------- @@ -89,7 +89,7 @@ async function sendViaSendGrid( // --------------------------------------------------------------------------- function buildHtml(title: string, collection: string, id: string): string { - return ` + return `

📢 New content published

@@ -117,68 +117,66 @@ function buildHtml(title: string, collection: string, id: string): string { // --------------------------------------------------------------------------- export default definePlugin({ - hooks: { - "content:afterSave": { - handler: async (event: any, ctx: PluginContext) => { - // Only fire when content transitions to published - if (event.content.status !== "published") return; - - const env = (ctx as any).env ?? {}; - const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; - const from: string = env.EMAIL_FROM ?? ""; - const to: string = env.EMAIL_TO ?? ""; - - if (!from || !to) { - ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); - return; - } - - const title = event.content.title ?? "Untitled"; - const collection = event.collection ?? "unknown"; - const id = event.content.id ?? ""; - const subject = `Published: ${title}`; - const html = buildHtml(title, collection, id); - - try { - switch (provider) { - case "mailchannels": - await sendViaMailChannels(from, to, subject, html); - break; - - case "resend": { - const key = env.RESEND_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); - return; - } - await sendViaResend(key, from, to, subject, html); - break; - } - - case "sendgrid": { - const key = env.SENDGRID_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); - return; - } - await sendViaSendGrid(key, from, to, subject, html); - break; - } - - default: - ctx.log.error( - `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid` - ); - return; - } - - ctx.log.info( - `[email-on-publish] ✓ Sent via ${provider} — "${title}"` - ); - } catch (err: any) { - ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); - } - }, - }, - }, + hooks: { + "content:afterSave": { + handler: async (event: any, ctx: PluginContext) => { + // Only fire when content transitions to published + if (event.content.status !== "published") return; + + const env = (ctx as any).env ?? {}; + const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; + const from: string = env.EMAIL_FROM ?? ""; + const to: string = env.EMAIL_TO ?? ""; + + if (!from || !to) { + ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); + return; + } + + const title = event.content.title ?? "Untitled"; + const collection = event.collection ?? "unknown"; + const id = event.content.id ?? ""; + const subject = `Published: ${title}`; + const html = buildHtml(title, collection, id); + + try { + switch (provider) { + case "mailchannels": + await sendViaMailChannels(from, to, subject, html); + break; + + case "resend": { + const key = env.RESEND_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); + return; + } + await sendViaResend(key, from, to, subject, html); + break; + } + + case "sendgrid": { + const key = env.SENDGRID_API_KEY; + if (!key) { + ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); + return; + } + await sendViaSendGrid(key, from, to, subject, html); + break; + } + + default: + ctx.log.error( + `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid`, + ); + return; + } + + ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); + } catch (err: any) { + ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); + } + }, + }, + }, }); From e512ded02361514854b5dacb5212300dc75f3802 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Sun, 19 Apr 2026 23:47:56 +0300 Subject: [PATCH 13/68] Add notify-on-publish plugin using Resend - New workspace package @emdash-cms/plugin-notify-on-publish - Sends email via Resend API on content publish - Idempotent via plugin-scoped KV - Wired into demos/cloudflare as trusted plugin --- demos/cloudflare/astro.config.mjs | 9 +- demos/cloudflare/package.json | 5 +- .../plugins/notify-on-publish/package.json | 28 ++++ .../plugins/notify-on-publish/src/index.ts | 28 ++++ .../notify-on-publish/src/sandbox-entry.ts | 132 ++++++++++++++++++ .../plugins/notify-on-publish/tsconfig.json | 9 ++ pnpm-lock.yaml | 76 ++++------ 7 files changed, 231 insertions(+), 56 deletions(-) create mode 100644 packages/plugins/notify-on-publish/package.json create mode 100644 packages/plugins/notify-on-publish/src/index.ts create mode 100644 packages/plugins/notify-on-publish/src/sandbox-entry.ts create mode 100644 packages/plugins/notify-on-publish/tsconfig.json diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 8e910b3dd1..9582acb16a 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -15,7 +15,7 @@ import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; -import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; +import { notifyOnPublishPlugin } from "@emdash-cms/plugin-notify-on-publish"; export default defineConfig({ output: "server", @@ -74,7 +74,12 @@ export default defineConfig({ plugins: [ // Test plugin that exercises all v2 APIs formsPlugin(), - emailOnPublishPlugin(), + notifyOnPublishPlugin({ + recipients: ["editors@example.com"], + collections: ["posts"], + from: "CMS ", + siteUrl: "https://yoursite.com", + }), ], // Sandboxed plugins (run in isolated workers) sandboxed: [webhookNotifierPlugin()], diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index c4e53d2422..d0279e1799 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -18,6 +18,7 @@ "@astrojs/react": "catalog:", "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", + "@emdash-cms/plugin-notify-on-publish": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", @@ -34,7 +35,5 @@ }, "emdash": { "seed": "seed/seed.json" - }, - "peerDependencies": {}, - "optionalDependencies": {} + } } diff --git a/packages/plugins/notify-on-publish/package.json b/packages/plugins/notify-on-publish/package.json new file mode 100644 index 0000000000..115d228c75 --- /dev/null +++ b/packages/plugins/notify-on-publish/package.json @@ -0,0 +1,28 @@ +{ + "name": "@emdash-cms/plugin-notify-on-publish", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./sandbox": { + "types": "./dist/sandbox-entry.d.mts", + "import": "./dist/sandbox-entry.mjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean" + }, + "peerDependencies": { + "emdash": "workspace:*" + }, + "devDependencies": { + "tsdown": "catalog:" + } +} diff --git a/packages/plugins/notify-on-publish/src/index.ts b/packages/plugins/notify-on-publish/src/index.ts new file mode 100644 index 0000000000..3ae85a1840 --- /dev/null +++ b/packages/plugins/notify-on-publish/src/index.ts @@ -0,0 +1,28 @@ +import type { PluginDescriptor } from "emdash"; + +export interface NotifyOnPublishOptions { + /** Recipients for publish notifications */ + recipients: string[]; + /** Only notify for these collections. Omit to notify for all. */ + collections?: string[]; + /** From address — must be a verified Resend sender */ + from?: string; + /** Public site URL, used to build preview links */ + siteUrl?: string; + /** Env var name for Resend API key (default: RESEND_API_KEY) */ + apiKeyEnvVar?: string; +} + +export function notifyOnPublishPlugin( + options: NotifyOnPublishOptions, +): PluginDescriptor { + return { + id: "notify-on-publish", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-notify-on-publish/sandbox", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["api.resend.com"], + options, + }; +} diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts new file mode 100644 index 0000000000..f3b63a5dc2 --- /dev/null +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -0,0 +1,132 @@ +import { definePlugin } from "emdash"; +import type { PluginContext } from "emdash"; +import type { NotifyOnPublishOptions } from "./index.ts"; + +const RESEND_ENDPOINT = "https://api.resend.com/emails"; +const DEFAULT_FROM = "cms@example.com"; +const DEFAULT_API_KEY_ENV = "RESEND_API_KEY"; + +interface ContentSaveEvent { + collection: string; + content: { + id: string; + title?: string; + slug?: string; + status: string; + publishedAt?: string; + }; + previous?: { status?: string }; +} + +export default definePlugin({ + hooks: { + "content:afterSave": { + handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + const opts = ((ctx as any).options ?? {}) as NotifyOnPublishOptions; + + if (opts.collections && !opts.collections.includes(event.collection)) return; + + const nowPublished = event.content.status === "published"; + const wasPublished = event.previous?.status === "published"; + if (!nowPublished || wasPublished) return; + + const recipients = opts.recipients ?? []; + if (recipients.length === 0) { + ctx.log.warn("notify-on-publish: no recipients configured"); + return; + } + + const envVar = opts.apiKeyEnvVar ?? DEFAULT_API_KEY_ENV; + const apiKey = resolveEnv(ctx, envVar); + if (!apiKey) { + ctx.log.error( + `notify-on-publish: ${envVar} not set — add it to .dev.vars or \`wrangler secret put ${envVar}\``, + ); + return; + } + + const kvKey = `sent:${event.collection}:${event.content.id}`; + if (await ctx.kv.get(kvKey)) { + ctx.log.info(`notify-on-publish: already sent for ${kvKey}`); + return; + } + + const title = event.content.title ?? event.content.id; + const slug = event.content.slug ?? event.content.id; + const publishedAt = event.content.publishedAt ?? new Date().toISOString(); + const previewLink = opts.siteUrl + ? `${opts.siteUrl.replace(/\/$/, "")}/${slug}` + : undefined; + + const textBody = [ + `"${title}" was just published.`, + ``, + `Collection: ${event.collection}`, + `Slug: ${slug}`, + `Published: ${publishedAt}`, + previewLink ? `\nView: ${previewLink}` : "", + ].filter(Boolean).join("\n"); + + const htmlBody = ` +
+

Published: ${escapeHtml(title)}

+
+ + + +
Collection${escapeHtml(event.collection)}
Slug${escapeHtml(slug)}
Published${escapeHtml(publishedAt)}
+ ${previewLink ? `

View content →

` : ""} +
`.trim(); + + try { + const res = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: opts.from ?? DEFAULT_FROM, + to: recipients, + subject: `Published: ${title}`, + text: textBody, + html: htmlBody, + }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Resend ${res.status}: ${errText.slice(0, 500)}`); + } + + const { id } = (await res.json()) as { id?: string }; + await ctx.kv.set(kvKey, true, { ttl: 60 * 60 * 24 * 30 }); + + ctx.log.info( + `notify-on-publish: sent to ${recipients.length} recipient(s) for ${event.collection}/${event.content.id} (resend id: ${id ?? "unknown"})`, + ); + } catch (err) { + ctx.log.error( + `notify-on-publish: send failed for ${event.content.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + throw err; + } + }, + }, + }, +}); + +function resolveEnv(ctx: PluginContext, name: string): string | undefined { + const env = (ctx as any).env; + if (env && typeof env[name] === "string") return env[name]; + const g = globalThis as any; + if (typeof g[name] === "string") return g[name]; + if (g.process?.env?.[name]) return g.process.env[name]; + return undefined; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} diff --git a/packages/plugins/notify-on-publish/tsconfig.json b/packages/plugins/notify-on-publish/tsconfig.json new file mode 100644 index 0000000000..f677f8d5eb --- /dev/null +++ b/packages/plugins/notify-on-publish/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1181488865..35c5db7517 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: '@emdash-cms/plugin-forms': specifier: workspace:* version: link:../../packages/plugins/forms + '@emdash-cms/plugin-notify-on-publish': + specifier: workspace:* + version: link:../../packages/plugins/notify-on-publish '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier @@ -760,7 +763,7 @@ importers: version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest-browser-react: specifier: ^2.0.5 version: 2.0.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18) @@ -806,7 +809,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/blocks: dependencies: @@ -861,7 +864,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/blocks/playground: dependencies: @@ -944,7 +947,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/core: dependencies: @@ -1173,7 +1176,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/marketplace: dependencies: @@ -1204,7 +1207,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: 'catalog:' version: 4.80.0(@cloudflare/workers-types@4.20260305.1) @@ -1232,7 +1235,7 @@ importers: version: 19.2.14 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/api-test: dependencies: @@ -1260,7 +1263,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/audit-log: dependencies: @@ -1337,6 +1340,16 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/plugins/notify-on-publish: + dependencies: + emdash: + specifier: workspace:* + version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + packages/plugins/plugin-email-on-publish: {} packages/plugins/sandboxed-test: @@ -1391,7 +1404,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@x402/svm': specifier: ^2.8.0 @@ -12515,7 +12528,7 @@ snapshots: '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -12549,7 +12562,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -17009,7 +17022,7 @@ snapshots: dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -17054,45 +17067,6 @@ snapshots: - tsx - yaml - vitest@4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.10.13 - '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) - jsdom: 26.1.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml - volar-service-css@0.0.68(@volar/language-service@2.4.27): dependencies: vscode-css-languageservice: 6.3.9 From d34c5205726bdd4406bfc76550f2d46b0e2bf43c Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 00:45:11 +0300 Subject: [PATCH 14/68] Configure CF bindings for emdash-laith deployment - Point D1 and R2 to existing resources - Remove demo.emdashcms.com custom route - Disable broken sandboxed webhook-notifier (upstream bug) --- demos/cloudflare/astro.config.mjs | 2 +- demos/cloudflare/wrangler.jsonc | 72 ++++++++++++++----------------- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 9582acb16a..cd21cd7baa 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -82,7 +82,7 @@ export default defineConfig({ }), ], // Sandboxed plugins (run in isolated workers) - sandboxed: [webhookNotifierPlugin()], + sandboxed: [], // Sandbox runner for Cloudflare sandboxRunner: sandbox(), // Plugin marketplace diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 2dc5de41f2..d7d3aa8299 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -1,41 +1,35 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "emdash-demo", - "main": "./src/worker.ts", - "compatibility_date": "2026-01-14", - // disable_nodejs_process_v2 needed until unenv fix lands in Pages - // See: https://github.com/withastro/astro/issues/14511 - "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], - // Static assets served from dist/ - "routes": [ - { - "pattern": "demo.emdashcms.com", - "zone_name": "demo.emdashcms.com", - "custom_domain": true, - }, - ], - // D1 Database binding - "d1_databases": [ - { - "binding": "DB", - "database_name": "emdash_db", - }, - ], - // R2 bucket for media storage - "r2_buckets": [ - { - "binding": "MEDIA", - "bucket_name": "emdash-media", - }, - ], - // Observability - "observability": { - "enabled": true, - }, - // Worker Loader for plugin sandboxing - "worker_loaders": [ - { - "binding": "LOADER", - }, - ], + "$schema": "node_modules/wrangler/config-schema.json", + "name": "emdash-laith", + "main": "./src/worker.ts", + "compatibility_date": "2026-01-14", + "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], + + // D1 Database binding — points to existing my-emdash-site db + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-emdash-site", + "database_id": "ece39c3d-8076-4162-a954-20509aa74d79" + } + ], + + // R2 bucket — points to existing my-emdash-media bucket + "r2_buckets": [ + { + "binding": "MEDIA", + "bucket_name": "my-emdash-media" + } + ], + + "observability": { + "enabled": true + }, + + // Worker Loader for plugin sandboxing + "worker_loaders": [ + { + "binding": "LOADER" + } + ] } From 10eb370b64ba6bc7494d3dc204a9fafa03d2a4da Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 01:07:07 +0300 Subject: [PATCH 15/68] Remove Cloudflare Access auth; use EmDash passkey default --- demos/cloudflare/astro.config.mjs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index cd21cd7baa..5b1c997076 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -4,7 +4,6 @@ import react from "@astrojs/react"; import { d1, r2, - access, sandbox, cloudflareCache, cloudflareImages, @@ -46,16 +45,6 @@ export default defineConfig({ storage: r2({ binding: "MEDIA" }), // Cloudflare Access authentication // Reads CF_ACCESS_AUDIENCE from env (wrangler secret or .dev.vars) - auth: access({ - teamDomain: "cloudflare-cto.cloudflareaccess.com", - autoProvision: true, - defaultRole: 30, // Author - // Map your IdP groups to roles (optional) - // roleMapping: { - // "Admins": 50, - // "Editors": 40, - // }, - }), // Media providers - Cloudflare Images and Stream // Reads from env vars at runtime: CF_ACCOUNT_ID, CF_IMAGES_TOKEN, CF_STREAM_TOKEN // Or customize with accountIdEnvVar/apiTokenEnvVar options From 7c4c3357d5387e8beb7093d6b736b48e779ba571 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 01:17:12 +0300 Subject: [PATCH 16/68] Clean up gitignore and stale files from upstream sync --- .gitignore | 1 + demos/cloudflare/.gitignore | 1 + demos/cloudflare/emdash-env.d.ts | 1 - demos/cloudflare/plugins/email-on-publish.ts | 199 ------------------- 4 files changed, 2 insertions(+), 200 deletions(-) create mode 100644 demos/cloudflare/.gitignore delete mode 100644 demos/cloudflare/plugins/email-on-publish.ts diff --git a/.gitignore b/.gitignore index ed35b4ec73..6bebd59117 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ examples/wp-theme-unit-test/ .perf-query-counts query-counts-out/ +**/.dev.vars diff --git a/demos/cloudflare/.gitignore b/demos/cloudflare/.gitignore new file mode 100644 index 0000000000..babca1bb1d --- /dev/null +++ b/demos/cloudflare/.gitignore @@ -0,0 +1 @@ +.dev.vars diff --git a/demos/cloudflare/emdash-env.d.ts b/demos/cloudflare/emdash-env.d.ts index ea5f02a61b..abb26262fc 100644 --- a/demos/cloudflare/emdash-env.d.ts +++ b/demos/cloudflare/emdash-env.d.ts @@ -10,7 +10,6 @@ export interface Page { slug: string | null; status: string; title: string; - template?: "Default" | "Full Width"; content?: PortableTextBlock[]; createdAt: Date; updatedAt: Date; diff --git a/demos/cloudflare/plugins/email-on-publish.ts b/demos/cloudflare/plugins/email-on-publish.ts deleted file mode 100644 index a3188556de..0000000000 --- a/demos/cloudflare/plugins/email-on-publish.ts +++ /dev/null @@ -1,199 +0,0 @@ -// demos/cloudflare/plugins/email-on-publish.ts -// -// Drop this file at: demos/cloudflare/plugins/email-on-publish.ts -// -// Then in demos/cloudflare/astro.config.mjs: -// import { emailOnPublishPlugin } from "./plugins/email-on-publish.ts"; -// plugins: [formsPlugin(), emailOnPublishPlugin()], -// -// Set these in CF Dashboard → Workers & Pages → Settings → Variables & Secrets: -// EMAIL_PROVIDER mailchannels | resend | sendgrid (default: mailchannels) -// EMAIL_FROM e.g. onboarding@resend.dev -// EMAIL_TO e.g. you@gmail.com -// RESEND_API_KEY only if EMAIL_PROVIDER=resend -// SENDGRID_API_KEY only if EMAIL_PROVIDER=sendgrid - -import type { PluginDescriptor, PluginContext } from "emdash"; -import { definePlugin } from "emdash"; - -// --------------------------------------------------------------------------- -// Descriptor — runs at build time in Vite, imported by astro.config.mjs -// --------------------------------------------------------------------------- - -export function emailOnPublishPlugin(): PluginDescriptor { - return { - id: "email-on-publish", - version: "1.0.0", - format: "standard", - // Points to this same file as the runtime entrypoint - entrypoint: "./plugins/email-on-publish.ts", - options: {}, - }; -} - -// --------------------------------------------------------------------------- -// Providers — Web API fetch only, no Node.js built-ins -// --------------------------------------------------------------------------- - -async function sendViaMailChannels( - from: string, - to: string, - subject: string, - html: string, -): Promise { - const response = await fetch("https://api.mailchannels.net/tx/v1/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (!response.ok) { - throw new Error(`MailChannels ${response.status}: ${await response.text()}`); - } -} - -async function sendViaResend( - apiKey: string, - from: string, - to: string, - subject: string, - html: string, -): Promise { - const response = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ from, to, subject, html }), - }); - if (!response.ok) { - throw new Error(`Resend ${response.status}: ${await response.text()}`); - } -} - -async function sendViaSendGrid( - apiKey: string, - from: string, - to: string, - subject: string, - html: string, -): Promise { - const response = await fetch("https://api.sendgrid.com/v3/mail/send", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - personalizations: [{ to: [{ email: to }] }], - from: { email: from }, - subject, - content: [{ type: "text/html", value: html }], - }), - }); - if (response.status !== 202) { - throw new Error(`SendGrid ${response.status}: ${await response.text()}`); - } -} - -// --------------------------------------------------------------------------- -// Email HTML -// --------------------------------------------------------------------------- - -function buildHtml(title: string, collection: string, id: string): string { - return ` -
-

📢 New content published

- - - - - - - - - - - - - -
Title${title}
Collection${collection}
ID${id}
-

- Sent by EmDash · plugin-email-on-publish -

-
`; -} - -// --------------------------------------------------------------------------- -// Plugin runtime — default export required by EmDash -// --------------------------------------------------------------------------- - -export default definePlugin({ - hooks: { - "content:afterSave": { - handler: async (event: any, ctx: PluginContext) => { - // Only fire when content is published - if (event.content.status !== "published") return; - - const env = (ctx as any).env ?? {}; - const provider: string = env.EMAIL_PROVIDER ?? "mailchannels"; - const from: string = env.EMAIL_FROM ?? ""; - const to: string = env.EMAIL_TO ?? ""; - - if (!from || !to) { - ctx.log.error("[email-on-publish] EMAIL_FROM and EMAIL_TO must be set"); - return; - } - - const title = event.content.title ?? "Untitled"; - const collection = event.collection ?? "unknown"; - const id = event.content.id ?? ""; - const subject = `Published: ${title}`; - const html = buildHtml(title, collection, id); - - try { - switch (provider) { - case "mailchannels": - await sendViaMailChannels(from, to, subject, html); - break; - - case "resend": { - const key = env.RESEND_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] RESEND_API_KEY not set"); - return; - } - await sendViaResend(key, from, to, subject, html); - break; - } - - case "sendgrid": { - const key = env.SENDGRID_API_KEY; - if (!key) { - ctx.log.error("[email-on-publish] SENDGRID_API_KEY not set"); - return; - } - await sendViaSendGrid(key, from, to, subject, html); - break; - } - - default: - ctx.log.error( - `[email-on-publish] Unknown provider "${provider}". Use: mailchannels | resend | sendgrid`, - ); - return; - } - - ctx.log.info(`[email-on-publish] ✓ Sent via ${provider} — "${title}"`); - } catch (err: any) { - ctx.log.error(`[email-on-publish] Send failed: ${err.message}`); - } - }, - }, - }, -}); From d1af944c5dd6db3c73fbbd1304698123a16c1770 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 10:18:30 +0300 Subject: [PATCH 17/68] Disable CF cache adapter blocking publishes --- demos/cloudflare/astro.config.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 5b1c997076..402364898f 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -5,7 +5,7 @@ import { d1, r2, sandbox, - cloudflareCache, +// cloudflareCache, cloudflareImages, cloudflareStream, } from "@emdash-cms/cloudflare"; @@ -79,9 +79,9 @@ export default defineConfig({ }), ], experimental: { - cache: { - provider: cloudflareCache(), - }, + // cache: { + // provider: cloudflareCache(), + // }, routeRules: { "/": { maxAge: 3_600, From 206b4de24751ae16355759100e1b374d8db606b8 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 10:27:35 +0300 Subject: [PATCH 18/68] Wire notify-on-publish recipients/from/siteUrl to env vars --- demos/cloudflare/astro.config.mjs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 402364898f..9b4a923b81 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -6,8 +6,6 @@ import { r2, sandbox, // cloudflareCache, - cloudflareImages, - cloudflareStream, } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; @@ -48,26 +46,21 @@ export default defineConfig({ // Media providers - Cloudflare Images and Stream // Reads from env vars at runtime: CF_ACCOUNT_ID, CF_IMAGES_TOKEN, CF_STREAM_TOKEN // Or customize with accountIdEnvVar/apiTokenEnvVar options - mediaProviders: [ - cloudflareImages({ - accountIdEnvVar: "CF_MEDIA_ACCOUNT_ID", - apiTokenEnvVar: "CF_MEDIA_API_TOKEN", - accountHash: "5LGXGUnHU18h6ehN_xjpXQ", - }), - cloudflareStream({ - accountIdEnvVar: "CF_MEDIA_ACCOUNT_ID", - apiTokenEnvVar: "CF_MEDIA_API_TOKEN", - }), - ], // Trusted plugins (run in host worker) plugins: [ // Test plugin that exercises all v2 APIs - formsPlugin(), + // formsPlugin(), + // notifyOnPublishPlugin({ + // recipients: ["editors@example.com"], + // collections: ["posts"], + // from: "CMS ", + // siteUrl: "https://yoursite.com", + // }), notifyOnPublishPlugin({ - recipients: ["editors@example.com"], + recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), collections: ["posts"], - from: "CMS ", - siteUrl: "https://yoursite.com", + from: process.env.EMAIL_FROM || "onboarding@resend.dev", + siteUrl: process.env.SITE_URL || "https://emdash-laith.laithaljanaideh.workers.dev", }), ], // Sandboxed plugins (run in isolated workers) From 3957c99c6364d48dd52cf9b6b7150f502eac4263 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 10:29:44 +0300 Subject: [PATCH 19/68] Simplify Cloudflare config: remove unused cache + media providers --- demos/cloudflare/astro.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 9b4a923b81..688a60880a 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -49,7 +49,7 @@ export default defineConfig({ // Trusted plugins (run in host worker) plugins: [ // Test plugin that exercises all v2 APIs - // formsPlugin(), + formsPlugin(), // notifyOnPublishPlugin({ // recipients: ["editors@example.com"], // collections: ["posts"], From 7b559dda9298c9937a4a3c91e276e9ca7bb70b81 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 10:38:15 +0300 Subject: [PATCH 20/68] Hardcode notify-on-publish recipients (env vars not available at build) --- demos/cloudflare/astro.config.mjs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 688a60880a..6bb0ac8cc7 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -50,18 +50,18 @@ export default defineConfig({ plugins: [ // Test plugin that exercises all v2 APIs formsPlugin(), - // notifyOnPublishPlugin({ - // recipients: ["editors@example.com"], - // collections: ["posts"], - // from: "CMS ", - // siteUrl: "https://yoursite.com", - // }), notifyOnPublishPlugin({ - recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), - collections: ["posts"], - from: process.env.EMAIL_FROM || "onboarding@resend.dev", - siteUrl: process.env.SITE_URL || "https://emdash-laith.laithaljanaideh.workers.dev", - }), + recipients: ["ljanaideh@atypon.com"], + collections: ["posts"], + from: "onboarding@resend.dev", + siteUrl: "https://emdash-laith.laithaljanaideh.workers.dev", + }), + // notifyOnPublishPlugin({ + // recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), + // collections: ["posts"], + // from: process.env.EMAIL_FROM || "onboarding@resend.dev", + // siteUrl: process.env.SITE_URL || "https://emdash-laith.laithaljanaideh.workers.dev", + // }), ], // Sandboxed plugins (run in isolated workers) sandboxed: [], From 1d2782451079b38e891b7131ef9635c564d9eaed Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 11:13:15 +0300 Subject: [PATCH 21/68] notify-on-publish: read recipient from post email field --- demos/cloudflare/astro.config.mjs | 13 ++- .../plugins/notify-on-publish/src/index.ts | 19 +--- .../notify-on-publish/src/sandbox-entry.ts | 104 +++++++++--------- 3 files changed, 62 insertions(+), 74 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 6bb0ac8cc7..24edf4f80a 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -50,12 +50,13 @@ export default defineConfig({ plugins: [ // Test plugin that exercises all v2 APIs formsPlugin(), - notifyOnPublishPlugin({ - recipients: ["ljanaideh@atypon.com"], - collections: ["posts"], - from: "onboarding@resend.dev", - siteUrl: "https://emdash-laith.laithaljanaideh.workers.dev", - }), + notifyOnPublishPlugin(), + // notifyOnPublishPlugin({ + // recipients: ["ljanaideh@atypon.com"], + // collections: ["posts"], + // from: "onboarding@resend.dev", + // siteUrl: "https://emdash-laith.laithaljanaideh.workers.dev", + // }), // notifyOnPublishPlugin({ // recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), // collections: ["posts"], diff --git a/packages/plugins/notify-on-publish/src/index.ts b/packages/plugins/notify-on-publish/src/index.ts index 3ae85a1840..9a00523722 100644 --- a/packages/plugins/notify-on-publish/src/index.ts +++ b/packages/plugins/notify-on-publish/src/index.ts @@ -1,21 +1,6 @@ import type { PluginDescriptor } from "emdash"; -export interface NotifyOnPublishOptions { - /** Recipients for publish notifications */ - recipients: string[]; - /** Only notify for these collections. Omit to notify for all. */ - collections?: string[]; - /** From address — must be a verified Resend sender */ - from?: string; - /** Public site URL, used to build preview links */ - siteUrl?: string; - /** Env var name for Resend API key (default: RESEND_API_KEY) */ - apiKeyEnvVar?: string; -} - -export function notifyOnPublishPlugin( - options: NotifyOnPublishOptions, -): PluginDescriptor { +export function notifyOnPublishPlugin(): PluginDescriptor { return { id: "notify-on-publish", version: "1.0.0", @@ -23,6 +8,6 @@ export function notifyOnPublishPlugin( entrypoint: "@emdash-cms/plugin-notify-on-publish/sandbox", capabilities: ["read:content", "network:fetch"], allowedHosts: ["api.resend.com"], - options, + options: {}, }; } diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index f3b63a5dc2..dff4a57f92 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -1,10 +1,10 @@ import { definePlugin } from "emdash"; import type { PluginContext } from "emdash"; -import type { NotifyOnPublishOptions } from "./index.ts"; const RESEND_ENDPOINT = "https://api.resend.com/emails"; -const DEFAULT_FROM = "cms@example.com"; +const DEFAULT_FROM = "onboarding@resend.dev"; const DEFAULT_API_KEY_ENV = "RESEND_API_KEY"; +const TARGET_COLLECTION = "posts"; interface ContentSaveEvent { collection: string; @@ -14,6 +14,9 @@ interface ContentSaveEvent { slug?: string; status: string; publishedAt?: string; + email?: string; + data?: Record; + [key: string]: any; }; previous?: { status?: string }; } @@ -22,61 +25,65 @@ export default definePlugin({ hooks: { "content:afterSave": { handler: async (event: ContentSaveEvent, ctx: PluginContext) => { - const opts = ((ctx as any).options ?? {}) as NotifyOnPublishOptions; - - if (opts.collections && !opts.collections.includes(event.collection)) return; + ctx.log.info( + `[notify-on-publish] fired collection=${event.collection} id=${event.content.id} status=${event.content.status} prev=${event.previous?.status ?? "(none)"}`, + ); + if (event.collection !== TARGET_COLLECTION) { + ctx.log.info(`[notify-on-publish] skip: ${event.collection} not ${TARGET_COLLECTION}`); + return; + } const nowPublished = event.content.status === "published"; const wasPublished = event.previous?.status === "published"; - if (!nowPublished || wasPublished) return; + if (!nowPublished || wasPublished) { + ctx.log.info(`[notify-on-publish] skip: not a draft→published transition`); + return; + } + + // Try multiple places where the `email` field might be exposed + const recipient = + (event.content.email as string | undefined) ?? + (event.content.data?.email as string | undefined) ?? + (event.content as any).fields?.email; + + ctx.log.info( + `[notify-on-publish] content keys: ${Object.keys(event.content).join(",")}`, + ); - const recipients = opts.recipients ?? []; - if (recipients.length === 0) { - ctx.log.warn("notify-on-publish: no recipients configured"); + if (!recipient) { + ctx.log.warn( + `[notify-on-publish] skip: post ${event.content.id} has no email field set`, + ); return; } - const envVar = opts.apiKeyEnvVar ?? DEFAULT_API_KEY_ENV; - const apiKey = resolveEnv(ctx, envVar); + const apiKey = resolveEnv(ctx, DEFAULT_API_KEY_ENV); if (!apiKey) { - ctx.log.error( - `notify-on-publish: ${envVar} not set — add it to .dev.vars or \`wrangler secret put ${envVar}\``, - ); + ctx.log.error(`[notify-on-publish] ${DEFAULT_API_KEY_ENV} not set in worker secrets`); return; } const kvKey = `sent:${event.collection}:${event.content.id}`; if (await ctx.kv.get(kvKey)) { - ctx.log.info(`notify-on-publish: already sent for ${kvKey}`); + ctx.log.info(`[notify-on-publish] already sent for ${kvKey}, skipping`); return; } const title = event.content.title ?? event.content.id; const slug = event.content.slug ?? event.content.id; const publishedAt = event.content.publishedAt ?? new Date().toISOString(); - const previewLink = opts.siteUrl - ? `${opts.siteUrl.replace(/\/$/, "")}/${slug}` - : undefined; - - const textBody = [ - `"${title}" was just published.`, - ``, - `Collection: ${event.collection}`, - `Slug: ${slug}`, - `Published: ${publishedAt}`, - previewLink ? `\nView: ${previewLink}` : "", - ].filter(Boolean).join("\n"); - - const htmlBody = ` -
-

Published: ${escapeHtml(title)}

- - - - -
Collection${escapeHtml(event.collection)}
Slug${escapeHtml(slug)}
Published${escapeHtml(publishedAt)}
- ${previewLink ? `

View content →

` : ""} -
`.trim(); + const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + + ctx.log.info(`[notify-on-publish] sending to=${recipient} from=${from}`); + + const text = `"${title}" was just published.\n\nCollection: ${event.collection}\nSlug: ${slug}\nPublished: ${publishedAt}`; + const html = `
+

Published: ${escapeHtml(title)}

+

+ Collection: ${escapeHtml(event.collection)}
+ Slug: ${escapeHtml(slug)}
+ Published: ${escapeHtml(publishedAt)} +

`; try { const res = await fetch(RESEND_ENDPOINT, { @@ -86,32 +93,27 @@ export default definePlugin({ "Content-Type": "application/json", }, body: JSON.stringify({ - from: opts.from ?? DEFAULT_FROM, - to: recipients, + from, + to: [recipient], subject: `Published: ${title}`, - text: textBody, - html: htmlBody, + text, + html, }), }); if (!res.ok) { const errText = await res.text(); - throw new Error(`Resend ${res.status}: ${errText.slice(0, 500)}`); + ctx.log.error(`[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`); + return; } const { id } = (await res.json()) as { id?: string }; await ctx.kv.set(kvKey, true, { ttl: 60 * 60 * 24 * 30 }); - - ctx.log.info( - `notify-on-publish: sent to ${recipients.length} recipient(s) for ${event.collection}/${event.content.id} (resend id: ${id ?? "unknown"})`, - ); + ctx.log.info(`[notify-on-publish] ✅ sent to ${recipient} (resend id: ${id ?? "unknown"})`); } catch (err) { ctx.log.error( - `notify-on-publish: send failed for ${event.content.id}: ${ - err instanceof Error ? err.message : String(err) - }`, + `[notify-on-publish] send failed: ${err instanceof Error ? err.message : String(err)}`, ); - throw err; } }, }, From 652aa197d56faa5439ce5c545187b2f61d6be6a3 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 12:38:39 +0300 Subject: [PATCH 22/68] notify-on-publish: deeper logging + recursive email discovery --- .../notify-on-publish/src/sandbox-entry.ts | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index dff4a57f92..fc22b68cc0 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -36,19 +36,30 @@ export default definePlugin({ const nowPublished = event.content.status === "published"; const wasPublished = event.previous?.status === "published"; if (!nowPublished || wasPublished) { - ctx.log.info(`[notify-on-publish] skip: not a draft→published transition`); + ctx.log.info(`[notify-on-publish] skip: not a draft->published transition`); return; } - // Try multiple places where the `email` field might be exposed - const recipient = - (event.content.email as string | undefined) ?? - (event.content.data?.email as string | undefined) ?? - (event.content as any).fields?.email; - + // Deep diagnostic logs so we can see exactly where email lives ctx.log.info( `[notify-on-publish] content keys: ${Object.keys(event.content).join(",")}`, ); + ctx.log.info( + `[notify-on-publish] content.data: ${JSON.stringify(event.content.data ?? {}).slice(0, 800)}`, + ); + ctx.log.info( + `[notify-on-publish] content preview: ${JSON.stringify(event.content).slice(0, 1200)}`, + ); + + // Try every plausible path for the email custom field + const recipient = + (event.content.email as string | undefined) ?? + (event.content.data?.email as string | undefined) ?? + (event.content as any).fields?.email ?? + (event.content.data as any)?.fields?.email ?? + (event.content as any).attributes?.email ?? + (event.content as any).customFields?.email ?? + findEmailDeep(event.content); if (!recipient) { ctx.log.warn( @@ -109,7 +120,7 @@ export default definePlugin({ const { id } = (await res.json()) as { id?: string }; await ctx.kv.set(kvKey, true, { ttl: 60 * 60 * 24 * 30 }); - ctx.log.info(`[notify-on-publish] ✅ sent to ${recipient} (resend id: ${id ?? "unknown"})`); + ctx.log.info(`[notify-on-publish] SENT to ${recipient} (resend id: ${id ?? "unknown"})`); } catch (err) { ctx.log.error( `[notify-on-publish] send failed: ${err instanceof Error ? err.message : String(err)}`, @@ -120,6 +131,28 @@ export default definePlugin({ }, }); +// Walk the content tree and return the first value that looks like an email +function findEmailDeep(obj: any, depth = 0): string | undefined { + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { + // Prefer keys actually named "email" + if (key.toLowerCase() === "email") return value; + } + } + // Second pass: accept any email-looking string + for (const value of Object.values(obj)) { + if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { + return value; + } + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; +} + function resolveEnv(ctx: PluginContext, name: string): string | undefined { const env = (ctx as any).env; if (env && typeof env[name] === "string") return env[name]; From 2f0b4d3be34af1affd303f0fa4bdcf133787afe1 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 12:48:54 +0300 Subject: [PATCH 23/68] TEMPORARY: hardcode Resend key for sandbox env debug (REVERT ME) --- .../notify-on-publish/src/sandbox-entry.ts | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index fc22b68cc0..e80fcb1057 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -3,9 +3,11 @@ import type { PluginContext } from "emdash"; const RESEND_ENDPOINT = "https://api.resend.com/emails"; const DEFAULT_FROM = "onboarding@resend.dev"; -const DEFAULT_API_KEY_ENV = "RESEND_API_KEY"; const TARGET_COLLECTION = "posts"; +// TEMPORARY: hardcoded for testing. REVERT before any real deployment. +const HARDCODED_RESEND_KEY = "re_L9za4ENE_NETaV1wTCVbsu1J7bYCeu9tX"; + interface ContentSaveEvent { collection: string; content: { @@ -36,22 +38,10 @@ export default definePlugin({ const nowPublished = event.content.status === "published"; const wasPublished = event.previous?.status === "published"; if (!nowPublished || wasPublished) { - ctx.log.info(`[notify-on-publish] skip: not a draft->published transition`); + ctx.log.info(`[notify-on-publish] skip: not draft->published transition`); return; } - // Deep diagnostic logs so we can see exactly where email lives - ctx.log.info( - `[notify-on-publish] content keys: ${Object.keys(event.content).join(",")}`, - ); - ctx.log.info( - `[notify-on-publish] content.data: ${JSON.stringify(event.content.data ?? {}).slice(0, 800)}`, - ); - ctx.log.info( - `[notify-on-publish] content preview: ${JSON.stringify(event.content).slice(0, 1200)}`, - ); - - // Try every plausible path for the email custom field const recipient = (event.content.email as string | undefined) ?? (event.content.data?.email as string | undefined) ?? @@ -62,17 +52,18 @@ export default definePlugin({ findEmailDeep(event.content); if (!recipient) { - ctx.log.warn( - `[notify-on-publish] skip: post ${event.content.id} has no email field set`, - ); + ctx.log.warn(`[notify-on-publish] skip: post ${event.content.id} has no email field set`); return; } - const apiKey = resolveEnv(ctx, DEFAULT_API_KEY_ENV); - if (!apiKey) { - ctx.log.error(`[notify-on-publish] ${DEFAULT_API_KEY_ENV} not set in worker secrets`); + const apiKey = resolveEnv(ctx, "RESEND_API_KEY") ?? HARDCODED_RESEND_KEY; + if (!apiKey || apiKey === "REPLACE_ME_WITH_YOUR_KEY") { + ctx.log.error(`[notify-on-publish] RESEND_API_KEY not available (env missing AND hardcoded key not replaced)`); return; } + ctx.log.info( + `[notify-on-publish] api key source: ${resolveEnv(ctx, "RESEND_API_KEY") ? "env" : "hardcoded"}`, + ); const kvKey = `sent:${event.collection}:${event.content.id}`; if (await ctx.kv.get(kvKey)) { @@ -131,16 +122,13 @@ export default definePlugin({ }, }); -// Walk the content tree and return the first value that looks like an email function findEmailDeep(obj: any, depth = 0): string | undefined { if (!obj || typeof obj !== "object" || depth > 4) return undefined; for (const [key, value] of Object.entries(obj)) { if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { - // Prefer keys actually named "email" if (key.toLowerCase() === "email") return value; } } - // Second pass: accept any email-looking string for (const value of Object.values(obj)) { if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { return value; From 07a8b9c43be6478ac7ce67f2355b75043ecd49d6 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 12:56:37 +0300 Subject: [PATCH 24/68] debug: wrap sandbox hook in try/catch with verbose logging --- .../notify-on-publish/src/sandbox-entry.ts | 187 ++++++++++-------- 1 file changed, 110 insertions(+), 77 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index e80fcb1057..7fed8df3fc 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -27,94 +27,129 @@ export default definePlugin({ hooks: { "content:afterSave": { handler: async (event: ContentSaveEvent, ctx: PluginContext) => { - ctx.log.info( - `[notify-on-publish] fired collection=${event.collection} id=${event.content.id} status=${event.content.status} prev=${event.previous?.status ?? "(none)"}`, - ); + try { + ctx.log.info( + `[notify-on-publish] fired collection=${event.collection} id=${event.content.id} status=${event.content.status} prev=${event.previous?.status ?? "(none)"}`, + ); - if (event.collection !== TARGET_COLLECTION) { - ctx.log.info(`[notify-on-publish] skip: ${event.collection} not ${TARGET_COLLECTION}`); - return; - } - const nowPublished = event.content.status === "published"; - const wasPublished = event.previous?.status === "published"; - if (!nowPublished || wasPublished) { - ctx.log.info(`[notify-on-publish] skip: not draft->published transition`); - return; - } + if (event.collection !== TARGET_COLLECTION) { + ctx.log.info(`[notify-on-publish] skip: wrong collection`); + return; + } + const nowPublished = event.content.status === "published"; + const wasPublished = event.previous?.status === "published"; + if (!nowPublished || wasPublished) { + ctx.log.info(`[notify-on-publish] skip: not draft->published`); + return; + } - const recipient = - (event.content.email as string | undefined) ?? - (event.content.data?.email as string | undefined) ?? - (event.content as any).fields?.email ?? - (event.content.data as any)?.fields?.email ?? - (event.content as any).attributes?.email ?? - (event.content as any).customFields?.email ?? - findEmailDeep(event.content); - - if (!recipient) { - ctx.log.warn(`[notify-on-publish] skip: post ${event.content.id} has no email field set`); - return; - } + const recipient = + (event.content.email as string | undefined) ?? + (event.content.data?.email as string | undefined) ?? + (event.content as any).fields?.email ?? + (event.content.data as any)?.fields?.email ?? + (event.content as any).attributes?.email ?? + (event.content as any).customFields?.email ?? + findEmailDeep(event.content); - const apiKey = resolveEnv(ctx, "RESEND_API_KEY") ?? HARDCODED_RESEND_KEY; - if (!apiKey || apiKey === "REPLACE_ME_WITH_YOUR_KEY") { - ctx.log.error(`[notify-on-publish] RESEND_API_KEY not available (env missing AND hardcoded key not replaced)`); - return; - } - ctx.log.info( - `[notify-on-publish] api key source: ${resolveEnv(ctx, "RESEND_API_KEY") ? "env" : "hardcoded"}`, - ); - - const kvKey = `sent:${event.collection}:${event.content.id}`; - if (await ctx.kv.get(kvKey)) { - ctx.log.info(`[notify-on-publish] already sent for ${kvKey}, skipping`); - return; - } + ctx.log.info(`[notify-on-publish] recipient resolved: ${recipient ?? "(none)"}`); - const title = event.content.title ?? event.content.id; - const slug = event.content.slug ?? event.content.id; - const publishedAt = event.content.publishedAt ?? new Date().toISOString(); - const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + if (!recipient) { + ctx.log.warn(`[notify-on-publish] skip: no recipient`); + return; + } - ctx.log.info(`[notify-on-publish] sending to=${recipient} from=${from}`); + const apiKey = resolveEnv(ctx, "RESEND_API_KEY") ?? HARDCODED_RESEND_KEY; + if (!apiKey || apiKey === "REPLACE_ME_WITH_YOUR_KEY") { + ctx.log.error(`[notify-on-publish] no api key`); + return; + } + ctx.log.info(`[notify-on-publish] api key source: ${resolveEnv(ctx, "RESEND_API_KEY") ? "env" : "hardcoded"}, length=${apiKey.length}`); + + // Try KV idempotency, but don't block if KV unavailable + const kvKey = `sent:${event.collection}:${event.content.id}`; + let alreadySent = false; + try { + if (ctx.kv && typeof ctx.kv.get === "function") { + alreadySent = (await ctx.kv.get(kvKey)) === true; + ctx.log.info(`[notify-on-publish] kv check: already_sent=${alreadySent}`); + } else { + ctx.log.warn(`[notify-on-publish] kv unavailable (ctx.kv=${typeof ctx.kv}), skipping idempotency check`); + } + } catch (kvErr) { + ctx.log.warn( + `[notify-on-publish] kv.get threw: ${kvErr instanceof Error ? kvErr.message : String(kvErr)}`, + ); + } - const text = `"${title}" was just published.\n\nCollection: ${event.collection}\nSlug: ${slug}\nPublished: ${publishedAt}`; - const html = `
-

Published: ${escapeHtml(title)}

-

- Collection: ${escapeHtml(event.collection)}
- Slug: ${escapeHtml(slug)}
- Published: ${escapeHtml(publishedAt)} -

`; + if (alreadySent) { + ctx.log.info(`[notify-on-publish] already sent, skipping`); + return; + } - try { - const res = await fetch(RESEND_ENDPOINT, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - from, - to: [recipient], - subject: `Published: ${title}`, - text, - html, - }), - }); + const title = event.content.title ?? event.content.id; + const slug = event.content.slug ?? event.content.id; + const publishedAt = event.content.publishedAt ?? new Date().toISOString(); + const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + + ctx.log.info(`[notify-on-publish] about to fetch resend: to=${recipient} from=${from}`); + + const text = `"${title}" was just published.\n\nCollection: ${event.collection}\nSlug: ${slug}\nPublished: ${publishedAt}`; + const html = `
+

Published: ${escapeHtml(title)}

+

Slug: ${escapeHtml(slug)}
Published: ${escapeHtml(publishedAt)}

`; + + let res: Response; + try { + res = await fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from, + to: [recipient], + subject: `Published: ${title}`, + text, + html, + }), + }); + ctx.log.info(`[notify-on-publish] fetch returned status=${res.status}`); + } catch (fetchErr) { + ctx.log.error( + `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`, + ); + return; + } if (!res.ok) { - const errText = await res.text(); + const errText = await res.text().catch(() => "(could not read body)"); ctx.log.error(`[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`); return; } - const { id } = (await res.json()) as { id?: string }; - await ctx.kv.set(kvKey, true, { ttl: 60 * 60 * 24 * 30 }); - ctx.log.info(`[notify-on-publish] SENT to ${recipient} (resend id: ${id ?? "unknown"})`); - } catch (err) { + let respJson: any = {}; + try { + respJson = await res.json(); + } catch { + /* ignore */ + } + ctx.log.info(`[notify-on-publish] SENT to ${recipient} (resend id: ${respJson?.id ?? "unknown"})`); + + // Try to write the KV guard, but don't fail the response if it errors + try { + if (ctx.kv && typeof ctx.kv.set === "function") { + await ctx.kv.set(kvKey, true, { ttl: 60 * 60 * 24 * 30 }); + } + } catch (kvErr) { + ctx.log.warn( + `[notify-on-publish] kv.set threw: ${kvErr instanceof Error ? kvErr.message : String(kvErr)}`, + ); + } + } catch (topErr) { ctx.log.error( - `[notify-on-publish] send failed: ${err instanceof Error ? err.message : String(err)}`, + `[notify-on-publish] top-level error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 500)}` : String(topErr)}`, ); } }, @@ -130,9 +165,7 @@ function findEmailDeep(obj: any, depth = 0): string | undefined { } } for (const value of Object.values(obj)) { - if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { - return value; - } + if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return value; if (value && typeof value === "object") { const nested = findEmailDeep(value, depth + 1); if (nested) return nested; From 63d01e35b4316deea193dcf4ac5271da5b46b8ab Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 13:12:26 +0300 Subject: [PATCH 25/68] debug: swap resend for webhook.site to isolate sandbox fetch issue --- .../plugins/notify-on-publish/src/index.ts | 2 +- .../notify-on-publish/src/sandbox-entry.ts | 162 ++---------------- 2 files changed, 19 insertions(+), 145 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/index.ts b/packages/plugins/notify-on-publish/src/index.ts index 9a00523722..9f286314c3 100644 --- a/packages/plugins/notify-on-publish/src/index.ts +++ b/packages/plugins/notify-on-publish/src/index.ts @@ -7,7 +7,7 @@ export function notifyOnPublishPlugin(): PluginDescriptor { format: "standard", entrypoint: "@emdash-cms/plugin-notify-on-publish/sandbox", capabilities: ["read:content", "network:fetch"], - allowedHosts: ["api.resend.com"], + allowedHosts: ["api.resend.com", "webhook.site"], options: {}, }; } diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index 7fed8df3fc..3a32bcd80c 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -1,23 +1,15 @@ import { definePlugin } from "emdash"; import type { PluginContext } from "emdash"; -const RESEND_ENDPOINT = "https://api.resend.com/emails"; -const DEFAULT_FROM = "onboarding@resend.dev"; +const WEBHOOK_URL = "https://webhook.site/a2ef48e6-d6e5-4127-baf4-efba1924bcf0"; const TARGET_COLLECTION = "posts"; -// TEMPORARY: hardcoded for testing. REVERT before any real deployment. -const HARDCODED_RESEND_KEY = "re_L9za4ENE_NETaV1wTCVbsu1J7bYCeu9tX"; - interface ContentSaveEvent { collection: string; content: { id: string; title?: string; - slug?: string; status: string; - publishedAt?: string; - email?: string; - data?: Record; [key: string]: any; }; previous?: { status?: string }; @@ -28,161 +20,43 @@ export default definePlugin({ "content:afterSave": { handler: async (event: ContentSaveEvent, ctx: PluginContext) => { try { - ctx.log.info( - `[notify-on-publish] fired collection=${event.collection} id=${event.content.id} status=${event.content.status} prev=${event.previous?.status ?? "(none)"}`, - ); - - if (event.collection !== TARGET_COLLECTION) { - ctx.log.info(`[notify-on-publish] skip: wrong collection`); - return; - } - const nowPublished = event.content.status === "published"; - const wasPublished = event.previous?.status === "published"; - if (!nowPublished || wasPublished) { - ctx.log.info(`[notify-on-publish] skip: not draft->published`); - return; - } - - const recipient = - (event.content.email as string | undefined) ?? - (event.content.data?.email as string | undefined) ?? - (event.content as any).fields?.email ?? - (event.content.data as any)?.fields?.email ?? - (event.content as any).attributes?.email ?? - (event.content as any).customFields?.email ?? - findEmailDeep(event.content); - - ctx.log.info(`[notify-on-publish] recipient resolved: ${recipient ?? "(none)"}`); - - if (!recipient) { - ctx.log.warn(`[notify-on-publish] skip: no recipient`); - return; - } - - const apiKey = resolveEnv(ctx, "RESEND_API_KEY") ?? HARDCODED_RESEND_KEY; - if (!apiKey || apiKey === "REPLACE_ME_WITH_YOUR_KEY") { - ctx.log.error(`[notify-on-publish] no api key`); - return; - } - ctx.log.info(`[notify-on-publish] api key source: ${resolveEnv(ctx, "RESEND_API_KEY") ? "env" : "hardcoded"}, length=${apiKey.length}`); + ctx.log.info(`[notify-test] fired id=${event.content.id} status=${event.content.status}`); - // Try KV idempotency, but don't block if KV unavailable - const kvKey = `sent:${event.collection}:${event.content.id}`; - let alreadySent = false; - try { - if (ctx.kv && typeof ctx.kv.get === "function") { - alreadySent = (await ctx.kv.get(kvKey)) === true; - ctx.log.info(`[notify-on-publish] kv check: already_sent=${alreadySent}`); - } else { - ctx.log.warn(`[notify-on-publish] kv unavailable (ctx.kv=${typeof ctx.kv}), skipping idempotency check`); - } - } catch (kvErr) { - ctx.log.warn( - `[notify-on-publish] kv.get threw: ${kvErr instanceof Error ? kvErr.message : String(kvErr)}`, - ); - } + if (event.collection !== TARGET_COLLECTION) return; + if (event.content.status !== "published") return; - if (alreadySent) { - ctx.log.info(`[notify-on-publish] already sent, skipping`); - return; - } - - const title = event.content.title ?? event.content.id; - const slug = event.content.slug ?? event.content.id; - const publishedAt = event.content.publishedAt ?? new Date().toISOString(); - const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; - - ctx.log.info(`[notify-on-publish] about to fetch resend: to=${recipient} from=${from}`); - - const text = `"${title}" was just published.\n\nCollection: ${event.collection}\nSlug: ${slug}\nPublished: ${publishedAt}`; - const html = `
-

Published: ${escapeHtml(title)}

-

Slug: ${escapeHtml(slug)}
Published: ${escapeHtml(publishedAt)}

`; + ctx.log.info(`[notify-test] passed filters, about to fetch ${WEBHOOK_URL}`); + const t0 = Date.now(); let res: Response; try { - res = await fetch(RESEND_ENDPOINT, { + res = await fetch(WEBHOOK_URL, { method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - from, - to: [recipient], - subject: `Published: ${title}`, - text, - html, + id: event.content.id, + title: event.content.title ?? "(no title)", + status: event.content.status, + timestamp: new Date().toISOString(), }), }); - ctx.log.info(`[notify-on-publish] fetch returned status=${res.status}`); + ctx.log.info( + `[notify-test] fetch returned status=${res.status} elapsed_ms=${Date.now() - t0}`, + ); } catch (fetchErr) { ctx.log.error( - `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`, + `[notify-test] fetch threw after ${Date.now() - t0}ms: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, ); return; } - if (!res.ok) { - const errText = await res.text().catch(() => "(could not read body)"); - ctx.log.error(`[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`); - return; - } - - let respJson: any = {}; - try { - respJson = await res.json(); - } catch { - /* ignore */ - } - ctx.log.info(`[notify-on-publish] SENT to ${recipient} (resend id: ${respJson?.id ?? "unknown"})`); - - // Try to write the KV guard, but don't fail the response if it errors - try { - if (ctx.kv && typeof ctx.kv.set === "function") { - await ctx.kv.set(kvKey, true, { ttl: 60 * 60 * 24 * 30 }); - } - } catch (kvErr) { - ctx.log.warn( - `[notify-on-publish] kv.set threw: ${kvErr instanceof Error ? kvErr.message : String(kvErr)}`, - ); - } + ctx.log.info(`[notify-test] done successfully`); } catch (topErr) { ctx.log.error( - `[notify-on-publish] top-level error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 500)}` : String(topErr)}`, + `[notify-test] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}` : String(topErr)}`, ); } }, }, }, }); - -function findEmailDeep(obj: any, depth = 0): string | undefined { - if (!obj || typeof obj !== "object" || depth > 4) return undefined; - for (const [key, value] of Object.entries(obj)) { - if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) { - if (key.toLowerCase() === "email") return value; - } - } - for (const value of Object.values(obj)) { - if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return value; - if (value && typeof value === "object") { - const nested = findEmailDeep(value, depth + 1); - if (nested) return nested; - } - } - return undefined; -} - -function resolveEnv(ctx: PluginContext, name: string): string | undefined { - const env = (ctx as any).env; - if (env && typeof env[name] === "string") return env[name]; - const g = globalThis as any; - if (typeof g[name] === "string") return g[name]; - if (g.process?.env?.[name]) return g.process.env[name]; - return undefined; -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); -} From ac92f5e4620483996586e6e76d27e97286011397 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 13:21:44 +0300 Subject: [PATCH 26/68] debug: use ctx.http.fetch instead of global fetch in sandbox --- .../notify-on-publish/src/sandbox-entry.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index 3a32bcd80c..b0134f57be 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -25,12 +25,20 @@ export default definePlugin({ if (event.collection !== TARGET_COLLECTION) return; if (event.content.status !== "published") return; - ctx.log.info(`[notify-test] passed filters, about to fetch ${WEBHOOK_URL}`); + // Check ctx.http is available (capability: network:fetch) + if (!(ctx as any).http || typeof (ctx as any).http.fetch !== "function") { + ctx.log.error( + `[notify-test] ctx.http unavailable. ctx keys: ${Object.keys(ctx).join(",")}`, + ); + return; + } + + ctx.log.info(`[notify-test] passed filters, about to ctx.http.fetch ${WEBHOOK_URL}`); const t0 = Date.now(); let res: Response; try { - res = await fetch(WEBHOOK_URL, { + res = await (ctx as any).http.fetch(WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -41,11 +49,11 @@ export default definePlugin({ }), }); ctx.log.info( - `[notify-test] fetch returned status=${res.status} elapsed_ms=${Date.now() - t0}`, + `[notify-test] ctx.http.fetch returned status=${res.status} elapsed_ms=${Date.now() - t0}`, ); } catch (fetchErr) { ctx.log.error( - `[notify-test] fetch threw after ${Date.now() - t0}ms: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + `[notify-test] ctx.http.fetch threw after ${Date.now() - t0}ms: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, ); return; } From 66a43d5079f18746233442e2a1b34a486cc638db Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 13:55:23 +0300 Subject: [PATCH 27/68] fix: await content:afterSave/afterPublish hooks so sandboxed plugin fetches complete before response returns --- packages/core/src/emdash-runtime.ts | 55 +++++++++++++++++------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index dd9192aa62..9f9e0274ff 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1654,9 +1654,9 @@ export class EmDashRuntime { bylines: body.bylines, }); - // Run afterSave hooks (fire-and-forget) + // Run afterSave hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true); + await this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true); } return result; @@ -1794,9 +1794,9 @@ export class EmDashRuntime { bylines: bodyWithoutRev.bylines, }); - // Run afterSave hooks (fire-and-forget) + // Run afterSave hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false); + await this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false); } return result; @@ -1881,9 +1881,9 @@ export class EmDashRuntime { async handleContentPublish(collection: string, id: string) { const result = await handleContentPublish(this.db, collection, id); - // Run afterPublish hooks (fire-and-forget) + // Run afterPublish hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection); + await this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection); } return result; @@ -2215,16 +2215,18 @@ export class EmDashRuntime { return true; } - private runAfterSaveHooks( + private async runAfterSaveHooks( content: Record, collection: string, isNew: boolean, - ): void { + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterSave")) { - this.hooks - .runContentAfterSave(content, collection, isNew) - .catch((err) => console.error("EmDash afterSave hook error:", err)); + try { + await this.hooks.runContentAfterSave(content, collection, isNew); + } catch (err) { + console.error("EmDash afterSave hook error:", err); + } } // Sandboxed plugins @@ -2232,9 +2234,11 @@ export class EmDashRuntime { const [id] = pluginKey.split(":"); if (!id || !this.isPluginEnabled(id)) continue; - plugin - .invokeHook("content:afterSave", { content, collection, isNew }) - .catch((err) => console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err)); + try { + await plugin.invokeHook("content:afterSave", { content, collection, isNew }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err); + } } } @@ -2259,12 +2263,17 @@ export class EmDashRuntime { } } - private runAfterPublishHooks(content: Record, collection: string): void { + private async runAfterPublishHooks( + content: Record, + collection: string, + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterPublish")) { - this.hooks - .runContentAfterPublish(content, collection) - .catch((err) => console.error("EmDash afterPublish hook error:", err)); + try { + await this.hooks.runContentAfterPublish(content, collection); + } catch (err) { + console.error("EmDash afterPublish hook error:", err); + } } // Sandboxed plugins @@ -2272,11 +2281,11 @@ export class EmDashRuntime { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; - plugin - .invokeHook("content:afterPublish", { content, collection }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err), - ); + try { + await plugin.invokeHook("content:afterPublish", { content, collection }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${pluginId} afterPublish error:`, err); + } } } From 7b6e7d3c512d6d6771a6af4c7c057e7710723bf1 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 14:13:23 +0300 Subject: [PATCH 28/68] feat: send Resend notification via ctx.http.fetch + forward host secrets (RESEND_API_KEY, EMAIL_FROM) into sandbox env --- packages/cloudflare/src/sandbox/runner.ts | 3 + packages/cloudflare/src/sandbox/wrapper.ts | 6 +- .../notify-on-publish/src/sandbox-entry.ts | 160 ++++++++++++++++-- 3 files changed, 151 insertions(+), 18 deletions(-) diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index b26de38223..978f4ac9fb 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -267,6 +267,9 @@ class CloudflareSandboxedPlugin implements SandboxedPlugin { PLUGIN_VERSION: this.manifest.version || "0.0.0", // Bridge binding for all host operations BRIDGE: bridgeBinding, + // Forward selected host bindings so sandbox plugins can read Worker secrets (wrangler secret put …) + RESEND_API_KEY: (env as Record).RESEND_API_KEY, + EMAIL_FROM: (env as Record).EMAIL_FROM, }, })); } diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 3f74104539..8660ce5548 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -173,7 +173,11 @@ function createContext(env) { site, url, users, - email + email, + env: { + RESEND_API_KEY: env.RESEND_API_KEY, + EMAIL_FROM: env.EMAIL_FROM + } }; } diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index b0134f57be..09d93a7116 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -1,7 +1,8 @@ import { definePlugin } from "emdash"; import type { PluginContext } from "emdash"; -const WEBHOOK_URL = "https://webhook.site/a2ef48e6-d6e5-4127-baf4-efba1924bcf0"; +const RESEND_ENDPOINT = "https://api.resend.com/emails"; +const DEFAULT_FROM = "onboarding@resend.dev"; const TARGET_COLLECTION = "posts"; interface ContentSaveEvent { @@ -9,8 +10,12 @@ interface ContentSaveEvent { content: { id: string; title?: string; + slug?: string; status: string; - [key: string]: any; + publishedAt?: string; + email?: string; + data?: Record; + [key: string]: unknown; }; previous?: { status?: string }; } @@ -20,51 +25,172 @@ export default definePlugin({ "content:afterSave": { handler: async (event: ContentSaveEvent, ctx: PluginContext) => { try { - ctx.log.info(`[notify-test] fired id=${event.content.id} status=${event.content.status}`); + ctx.log.info( + `[notify-on-publish] fired id=${event.content.id} status=${event.content.status} prev=${event.previous?.status ?? "(none)"}`, + ); if (event.collection !== TARGET_COLLECTION) return; if (event.content.status !== "published") return; - // Check ctx.http is available (capability: network:fetch) - if (!(ctx as any).http || typeof (ctx as any).http.fetch !== "function") { + const recipient = + (event.content.email as string | undefined) ?? + (event.content.data?.email as string | undefined) ?? + (event.content as { fields?: { email?: string } }).fields?.email ?? + findEmailDeep(event.content); + + if (!recipient) { + ctx.log.warn(`[notify-on-publish] skip: no email field on post`); + return; + } + + const apiKey = resolveEnv(ctx, "RESEND_API_KEY"); + if (!apiKey) { ctx.log.error( - `[notify-test] ctx.http unavailable. ctx keys: ${Object.keys(ctx).join(",")}`, + `[notify-on-publish] missing RESEND_API_KEY — set Worker secret`, ); return; } + ctx.log.info( + `[notify-on-publish] recipient=${recipient} key_source=env length=${apiKey.length}`, + ); + + const http = (ctx as { http?: { fetch: typeof fetch } }).http; + if (!http?.fetch) { + ctx.log.error(`[notify-on-publish] ctx.http.fetch unavailable`); + return; + } + + const kvKey = `sent:${event.collection}:${event.content.id}`; + let alreadySent = false; + try { + const v = await ctx.kv.get(kvKey); + alreadySent = v === true || v === "true"; + } catch { + /* KV not available — proceed */ + } + if (alreadySent) { + ctx.log.info(`[notify-on-publish] already sent (kv=${kvKey}), skipping`); + return; + } + + const title = String(event.content.title ?? event.content.id); + const slug = String(event.content.slug ?? event.content.id); + const publishedAt = String( + event.content.publishedAt ?? new Date().toISOString(), + ); + const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + + ctx.log.info( + `[notify-on-publish] sending via Resend: to=${recipient} from=${from}`, + ); - ctx.log.info(`[notify-test] passed filters, about to ctx.http.fetch ${WEBHOOK_URL}`); + const text = `"${title}" was just published.\n\nCollection: ${event.collection}\nSlug: ${slug}\nPublished: ${publishedAt}`; + const html = `
+

Published: ${escapeHtml(title)}

+

+ Collection: ${escapeHtml(event.collection)}
+ Slug: ${escapeHtml(slug)}
+ Published: ${escapeHtml(publishedAt)} +

`; const t0 = Date.now(); let res: Response; try { - res = await (ctx as any).http.fetch(WEBHOOK_URL, { + res = await http.fetch(RESEND_ENDPOINT, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, body: JSON.stringify({ - id: event.content.id, - title: event.content.title ?? "(no title)", - status: event.content.status, - timestamp: new Date().toISOString(), + from, + to: [recipient], + subject: `Published: ${title}`, + text, + html, }), }); ctx.log.info( - `[notify-test] ctx.http.fetch returned status=${res.status} elapsed_ms=${Date.now() - t0}`, + `[notify-on-publish] Resend status=${res.status} elapsed_ms=${Date.now() - t0}`, ); } catch (fetchErr) { ctx.log.error( - `[notify-test] ctx.http.fetch threw after ${Date.now() - t0}ms: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`, ); return; } - ctx.log.info(`[notify-test] done successfully`); + if (!res.ok) { + const errText = await res.text().catch(() => "(unreadable)"); + ctx.log.error( + `[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`, + ); + return; + } + + let respJson: { id?: string } = {}; + try { + respJson = (await res.json()) as { id?: string }; + } catch { + /* ignore */ + } + ctx.log.info( + `[notify-on-publish] SENT to=${recipient} resend_id=${respJson?.id ?? "unknown"}`, + ); + + try { + await ctx.kv.set(kvKey, true); + } catch { + /* ignore */ + } } catch (topErr) { ctx.log.error( - `[notify-test] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}` : String(topErr)}`, + `[notify-on-publish] top error: ${topErr instanceof Error ? topErr.message : String(topErr)}`, ); } }, }, }, }); + +function findEmailDeep(obj: unknown, depth = 0): string | undefined { + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + if ( + typeof value === "string" && + key.toLowerCase() === "email" && + /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) + ) { + return value; + } + } + for (const value of Object.values(record)) { + if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) + return value; + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; +} + +function resolveEnv(ctx: PluginContext, name: string): string | undefined { + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; + return undefined; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} From b263550ff074eb6aa5976af32e0b6d128271a972 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 14:46:16 +0300 Subject: [PATCH 29/68] feat(notify-on-publish): email on every save of published posts (remove idempotency) Made-with: Cursor --- .../notify-on-publish/src/sandbox-entry.ts | 83 +++++++------------ 1 file changed, 29 insertions(+), 54 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index 09d93a7116..b620cac531 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -14,8 +14,8 @@ interface ContentSaveEvent { status: string; publishedAt?: string; email?: string; - data?: Record; - [key: string]: unknown; + data?: Record; + [key: string]: any; }; previous?: { status?: string }; } @@ -35,7 +35,7 @@ export default definePlugin({ const recipient = (event.content.email as string | undefined) ?? (event.content.data?.email as string | undefined) ?? - (event.content as { fields?: { email?: string } }).fields?.email ?? + (event.content as any).fields?.email ?? findEmailDeep(event.content); if (!recipient) { @@ -45,52 +45,36 @@ export default definePlugin({ const apiKey = resolveEnv(ctx, "RESEND_API_KEY"); if (!apiKey) { - ctx.log.error( - `[notify-on-publish] missing RESEND_API_KEY — set Worker secret`, - ); + ctx.log.error(`[notify-on-publish] RESEND_API_KEY not in ctx.env`); return; } - ctx.log.info( - `[notify-on-publish] recipient=${recipient} key_source=env length=${apiKey.length}`, - ); - const http = (ctx as { http?: { fetch: typeof fetch } }).http; + const http = (ctx as any).http; if (!http?.fetch) { ctx.log.error(`[notify-on-publish] ctx.http.fetch unavailable`); return; } - const kvKey = `sent:${event.collection}:${event.content.id}`; - let alreadySent = false; - try { - const v = await ctx.kv.get(kvKey); - alreadySent = v === true || v === "true"; - } catch { - /* KV not available — proceed */ - } - if (alreadySent) { - ctx.log.info(`[notify-on-publish] already sent (kv=${kvKey}), skipping`); - return; - } - - const title = String(event.content.title ?? event.content.id); - const slug = String(event.content.slug ?? event.content.id); - const publishedAt = String( - event.content.publishedAt ?? new Date().toISOString(), - ); + const title = event.content.title ?? event.content.id; + const slug = event.content.slug ?? event.content.id; + const publishedAt = event.content.publishedAt ?? new Date().toISOString(); const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; ctx.log.info( - `[notify-on-publish] sending via Resend: to=${recipient} from=${from}`, + `[notify-on-publish] sending: to=${recipient} from=${from} subject="Published: ${title}"`, ); - const text = `"${title}" was just published.\n\nCollection: ${event.collection}\nSlug: ${slug}\nPublished: ${publishedAt}`; + const text = `"${title}" was just published or updated. + +Collection: ${event.collection} +Slug: ${slug} +Last published: ${publishedAt}`; const html = `

Published: ${escapeHtml(title)}

Collection: ${escapeHtml(event.collection)}
Slug: ${escapeHtml(slug)}
- Published: ${escapeHtml(publishedAt)} + Last published: ${escapeHtml(publishedAt)}

`; const t0 = Date.now(); @@ -115,37 +99,31 @@ export default definePlugin({ ); } catch (fetchErr) { ctx.log.error( - `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`, + `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, ); return; } if (!res.ok) { - const errText = await res.text().catch(() => "(unreadable)"); + const errText = await res.text().catch(() => "(body unreadable)"); ctx.log.error( `[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`, ); return; } - let respJson: { id?: string } = {}; + let respJson: any = {}; try { - respJson = (await res.json()) as { id?: string }; + respJson = await res.json(); } catch { /* ignore */ } ctx.log.info( `[notify-on-publish] SENT to=${recipient} resend_id=${respJson?.id ?? "unknown"}`, ); - - try { - await ctx.kv.set(kvKey, true); - } catch { - /* ignore */ - } } catch (topErr) { ctx.log.error( - `[notify-on-publish] top error: ${topErr instanceof Error ? topErr.message : String(topErr)}`, + `[notify-on-publish] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, ); } }, @@ -153,10 +131,9 @@ export default definePlugin({ }, }); -function findEmailDeep(obj: unknown, depth = 0): string | undefined { +function findEmailDeep(obj: any, depth = 0): string | undefined { if (!obj || typeof obj !== "object" || depth > 4) return undefined; - const record = obj as Record; - for (const [key, value] of Object.entries(record)) { + for (const [key, value] of Object.entries(obj)) { if ( typeof value === "string" && key.toLowerCase() === "email" && @@ -165,9 +142,8 @@ function findEmailDeep(obj: unknown, depth = 0): string | undefined { return value; } } - for (const value of Object.values(record)) { - if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) - return value; + for (const value of Object.values(obj)) { + if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return value; if (value && typeof value === "object") { const nested = findEmailDeep(value, depth + 1); if (nested) return nested; @@ -177,12 +153,11 @@ function findEmailDeep(obj: unknown, depth = 0): string | undefined { } function resolveEnv(ctx: PluginContext, name: string): string | undefined { - const env = (ctx as { env?: Record }).env; - if (env && typeof env[name] === "string") return env[name] as string; - const g = globalThis as unknown as Record; - if (typeof g[name] === "string") return g[name] as string; - const proc = g.process as { env?: Record } | undefined; - if (proc?.env?.[name]) return proc.env[name]; + const env = (ctx as any).env; + if (env && typeof env[name] === "string") return env[name]; + const g = globalThis as any; + if (typeof g[name] === "string") return g[name]; + if (g.process?.env?.[name]) return g.process.env[name]; return undefined; } From ee49b580d493840081cece62956d310abed18b4e Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 15:04:17 +0300 Subject: [PATCH 30/68] feat(notify-on-publish): switch to content:afterPublish hook (fires on publish + republish, not on save) Made-with: Cursor --- .../notify-on-publish/src/sandbox-entry.ts | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index b620cac531..dc351ad9c8 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -1,42 +1,37 @@ import { definePlugin } from "emdash"; -import type { PluginContext } from "emdash"; +import type { ContentPublishStateChangeEvent, PluginContext } from "emdash"; const RESEND_ENDPOINT = "https://api.resend.com/emails"; const DEFAULT_FROM = "onboarding@resend.dev"; const TARGET_COLLECTION = "posts"; -interface ContentSaveEvent { - collection: string; - content: { - id: string; - title?: string; - slug?: string; - status: string; - publishedAt?: string; - email?: string; - data?: Record; - [key: string]: any; - }; - previous?: { status?: string }; -} - export default definePlugin({ hooks: { - "content:afterSave": { - handler: async (event: ContentSaveEvent, ctx: PluginContext) => { + "content:afterPublish": { + handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { + const content = event.content as { + id?: string; + title?: string; + slug?: string; + publishedAt?: string; + email?: string; + data?: Record; + fields?: { email?: string }; + [key: string]: unknown; + }; + try { ctx.log.info( - `[notify-on-publish] fired id=${event.content.id} status=${event.content.status} prev=${event.previous?.status ?? "(none)"}`, + `[notify-on-publish] fired id=${content.id ?? "(none)"} collection=${event.collection}`, ); if (event.collection !== TARGET_COLLECTION) return; - if (event.content.status !== "published") return; const recipient = - (event.content.email as string | undefined) ?? - (event.content.data?.email as string | undefined) ?? - (event.content as any).fields?.email ?? - findEmailDeep(event.content); + (content.email as string | undefined) ?? + (content.data?.email as string | undefined) ?? + content.fields?.email ?? + findEmailDeep(content); if (!recipient) { ctx.log.warn(`[notify-on-publish] skip: no email field on post`); @@ -49,32 +44,35 @@ export default definePlugin({ return; } - const http = (ctx as any).http; + const http = (ctx as { http?: { fetch: typeof fetch } }).http; if (!http?.fetch) { ctx.log.error(`[notify-on-publish] ctx.http.fetch unavailable`); return; } - const title = event.content.title ?? event.content.id; - const slug = event.content.slug ?? event.content.id; - const publishedAt = event.content.publishedAt ?? new Date().toISOString(); + const title = content.title ?? content.id ?? "(untitled)"; + const slug = content.slug ?? content.id ?? ""; + const publishedAt = + typeof content.publishedAt === "string" + ? content.publishedAt + : new Date().toISOString(); const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; ctx.log.info( `[notify-on-publish] sending: to=${recipient} from=${from} subject="Published: ${title}"`, ); - const text = `"${title}" was just published or updated. + const text = `"${title}" was just published. Collection: ${event.collection} Slug: ${slug} -Last published: ${publishedAt}`; +Published: ${publishedAt}`; const html = `
-

Published: ${escapeHtml(title)}

+

Published: ${escapeHtml(String(title))}

Collection: ${escapeHtml(event.collection)}
- Slug: ${escapeHtml(slug)}
- Last published: ${escapeHtml(publishedAt)} + Slug: ${escapeHtml(String(slug))}
+ Published: ${escapeHtml(String(publishedAt))}

`; const t0 = Date.now(); @@ -112,9 +110,9 @@ Last published: ${publishedAt}`; return; } - let respJson: any = {}; + let respJson: { id?: string } = {}; try { - respJson = await res.json(); + respJson = (await res.json()) as { id?: string }; } catch { /* ignore */ } @@ -131,9 +129,10 @@ Last published: ${publishedAt}`; }, }); -function findEmailDeep(obj: any, depth = 0): string | undefined { +function findEmailDeep(obj: unknown, depth = 0): string | undefined { if (!obj || typeof obj !== "object" || depth > 4) return undefined; - for (const [key, value] of Object.entries(obj)) { + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { if ( typeof value === "string" && key.toLowerCase() === "email" && @@ -142,8 +141,9 @@ function findEmailDeep(obj: any, depth = 0): string | undefined { return value; } } - for (const value of Object.values(obj)) { - if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) return value; + for (const value of Object.values(record)) { + if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) + return value; if (value && typeof value === "object") { const nested = findEmailDeep(value, depth + 1); if (nested) return nested; @@ -153,11 +153,12 @@ function findEmailDeep(obj: any, depth = 0): string | undefined { } function resolveEnv(ctx: PluginContext, name: string): string | undefined { - const env = (ctx as any).env; - if (env && typeof env[name] === "string") return env[name]; - const g = globalThis as any; - if (typeof g[name] === "string") return g[name]; - if (g.process?.env?.[name]) return g.process.env[name]; + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; return undefined; } From 43591488d9684128594fe3fb69a7c44983bcd927 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 15:17:21 +0300 Subject: [PATCH 31/68] fix(core): await content:afterDelete/afterUnpublish/media:afterUpload hooks for CF Workers sandbox fetch lifetime Made-with: Cursor --- packages/core/src/emdash-runtime.ts | 71 +++++++++++++++++------------ 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 9f9e0274ff..03289504af 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1832,9 +1832,9 @@ export class EmDashRuntime { // Delete the content const result = await handleContentDelete(this.db, collection, id); - // Run afterDelete hooks (fire-and-forget) + // Run afterDelete hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success) { - this.runAfterDeleteHooks(id, collection, false); + await this.runAfterDeleteHooks(id, collection, false); } return result; @@ -1860,7 +1860,7 @@ export class EmDashRuntime { // Run afterDelete hooks so plugins (e.g. AI Search) can clean up if (result.success) { - this.runAfterDeleteHooks(id, collection, true); + await this.runAfterDeleteHooks(id, collection, true); } return result; @@ -1892,9 +1892,9 @@ export class EmDashRuntime { async handleContentUnpublish(collection: string, id: string) { const result = await handleContentUnpublish(this.db, collection, id); - // Run afterUnpublish hooks (fire-and-forget) + // Run afterUnpublish hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && result.data) { - this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection); + await this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection); } return result; @@ -1966,7 +1966,7 @@ export class EmDashRuntime { // Create the media record const result = await handleMediaCreate(this.db, processedInput); - // Run afterUpload hooks (fire-and-forget) + // Run afterUpload hooks (awaited — required for CF Workers sandbox fetch lifetime) if (result.success && this.hooks.hasHooks("media:afterUpload")) { const item = result.data.item; const mediaItem: MediaItem = { @@ -1977,9 +1977,11 @@ export class EmDashRuntime { url: `/media/${item.id}/${item.filename}`, createdAt: item.createdAt, }; - this.hooks - .runMediaAfterUpload(mediaItem) - .catch((err) => console.error("EmDash afterUpload hook error:", err)); + try { + await this.hooks.runMediaAfterUpload(mediaItem); + } catch (err) { + console.error("EmDash afterUpload hook error:", err); + } } return result; @@ -2242,24 +2244,30 @@ export class EmDashRuntime { } } - private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void { + private async runAfterDeleteHooks( + id: string, + collection: string, + permanent: boolean, + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterDelete")) { - this.hooks - .runContentAfterDelete(id, collection, permanent) - .catch((err) => console.error("EmDash afterDelete hook error:", err)); + try { + await this.hooks.runContentAfterDelete(id, collection, permanent); + } catch (err) { + console.error("EmDash afterDelete hook error:", err); + } } - // Sandboxed plugins + // Sandboxed plugins (awaited — required for CF Workers sandbox fetch lifetime) for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; - plugin - .invokeHook("content:afterDelete", { id, collection, permanent }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err), - ); + try { + await plugin.invokeHook("content:afterDelete", { id, collection, permanent }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err); + } } } @@ -2289,24 +2297,29 @@ export class EmDashRuntime { } } - private runAfterUnpublishHooks(content: Record, collection: string): void { + private async runAfterUnpublishHooks( + content: Record, + collection: string, + ): Promise { // Trusted plugins if (this.hooks.hasHooks("content:afterUnpublish")) { - this.hooks - .runContentAfterUnpublish(content, collection) - .catch((err) => console.error("EmDash afterUnpublish hook error:", err)); + try { + await this.hooks.runContentAfterUnpublish(content, collection); + } catch (err) { + console.error("EmDash afterUnpublish hook error:", err); + } } - // Sandboxed plugins + // Sandboxed plugins (awaited — required for CF Workers sandbox fetch lifetime) for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; - plugin - .invokeHook("content:afterUnpublish", { content, collection }) - .catch((err) => - console.error(`EmDash: Sandboxed plugin ${pluginId} afterUnpublish error:`, err), - ); + try { + await plugin.invokeHook("content:afterUnpublish", { content, collection }); + } catch (err) { + console.error(`EmDash: Sandboxed plugin ${pluginId} afterUnpublish error:`, err); + } } } From 87666b66d31bb198082c1c4e5d5acb4cdf6ee86d Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 15:30:00 +0300 Subject: [PATCH 32/68] feat(notify-on-publish): opt-in per-collection via email field; support multiple recipients (comma/space separated, array) Made-with: Cursor --- .../notify-on-publish/src/sandbox-entry.ts | 99 +++++++++++++------ 1 file changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index dc351ad9c8..aca2ef3f07 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -3,7 +3,6 @@ import type { ContentPublishStateChangeEvent, PluginContext } from "emdash"; const RESEND_ENDPOINT = "https://api.resend.com/emails"; const DEFAULT_FROM = "onboarding@resend.dev"; -const TARGET_COLLECTION = "posts"; export default definePlugin({ hooks: { @@ -14,7 +13,7 @@ export default definePlugin({ title?: string; slug?: string; publishedAt?: string; - email?: string; + email?: string | string[]; data?: Record; fields?: { email?: string }; [key: string]: unknown; @@ -22,19 +21,20 @@ export default definePlugin({ try { ctx.log.info( - `[notify-on-publish] fired id=${content.id ?? "(none)"} collection=${event.collection}`, + `[notify-on-publish] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, ); - if (event.collection !== TARGET_COLLECTION) return; - - const recipient = - (content.email as string | undefined) ?? - (content.data?.email as string | undefined) ?? + const rawRecipient = + content.email ?? + content.data?.email ?? content.fields?.email ?? findEmailDeep(content); - if (!recipient) { - ctx.log.warn(`[notify-on-publish] skip: no email field on post`); + const recipients = normalizeRecipients(rawRecipient); + if (recipients.length === 0) { + ctx.log.info( + `[notify-on-publish] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, + ); return; } @@ -50,16 +50,17 @@ export default definePlugin({ return; } - const title = content.title ?? content.id ?? "(untitled)"; - const slug = content.slug ?? content.id ?? ""; + const title = String(content.title ?? content.id ?? "(untitled)"); + const slug = String(content.slug ?? content.id ?? ""); const publishedAt = typeof content.publishedAt === "string" ? content.publishedAt : new Date().toISOString(); const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + const collectionLabel = capitalize(event.collection); ctx.log.info( - `[notify-on-publish] sending: to=${recipient} from=${from} subject="Published: ${title}"`, + `[notify-on-publish] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, ); const text = `"${title}" was just published. @@ -68,11 +69,11 @@ Collection: ${event.collection} Slug: ${slug} Published: ${publishedAt}`; const html = `
-

Published: ${escapeHtml(String(title))}

+

${escapeHtml(collectionLabel)} published: ${escapeHtml(title)}

Collection: ${escapeHtml(event.collection)}
- Slug: ${escapeHtml(String(slug))}
- Published: ${escapeHtml(String(publishedAt))} + Slug: ${escapeHtml(slug)}
+ Published: ${escapeHtml(publishedAt)}

`; const t0 = Date.now(); @@ -86,8 +87,8 @@ Published: ${publishedAt}`; }, body: JSON.stringify({ from, - to: [recipient], - subject: `Published: ${title}`, + to: recipients, + subject: `${collectionLabel} published: ${title}`, text, html, }), @@ -117,7 +118,7 @@ Published: ${publishedAt}`; /* ignore */ } ctx.log.info( - `[notify-on-publish] SENT to=${recipient} resend_id=${respJson?.id ?? "unknown"}`, + `[notify-on-publish] SENT to=[${recipients.join(", ")}] resend_id=${respJson?.id ?? "unknown"}`, ); } catch (topErr) { ctx.log.error( @@ -129,21 +130,58 @@ Published: ${publishedAt}`; }, }); -function findEmailDeep(obj: unknown, depth = 0): string | undefined { +const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; + +/** + * Accepts: + * - undefined / null / empty → [] + * - "alice@x.com" → ["alice@x.com"] + * - "alice@x.com, bob@y.com; carol@z.com" → 3 addresses + * - ["alice@x.com", "bob@y.com"] → as-is (validated) + * Deduplicates and validates each. + */ +function normalizeRecipients(raw: unknown): string[] { + if (!raw) return []; + const candidates: string[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === "string") candidates.push(...splitList(item)); + } + } else if (typeof raw === "string") { + candidates.push(...splitList(raw)); + } + const seen = new Set(); + const out: string[] = []; + for (const c of candidates) { + const trimmed = c.trim(); + if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; +} + +function splitList(s: string): string[] { + return s.split(/[,;\s]+/).filter(Boolean); +} + +function findEmailDeep(obj: unknown, depth = 0): string | string[] | undefined { if (!obj || typeof obj !== "object" || depth > 4) return undefined; const record = obj as Record; for (const [key, value] of Object.entries(record)) { - if ( - typeof value === "string" && - key.toLowerCase() === "email" && - /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value) - ) { - return value; + const k = key.toLowerCase(); + if (k === "email" || k === "emails") { + if (typeof value === "string" && normalizeRecipients(value).length > 0) { + return value; + } + if (Array.isArray(value) && normalizeRecipients(value).length > 0) { + return value as string[]; + } } } for (const value of Object.values(record)) { - if (typeof value === "string" && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) - return value; if (value && typeof value === "object") { const nested = findEmailDeep(value, depth + 1); if (nested) return nested; @@ -170,3 +208,8 @@ function escapeHtml(s: string): string { .replace(/"/g, """) .replace(/'/g, "'"); } + +function capitalize(s: string): string { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); +} From 414d22edafeecf6216b72146f4bfb180cb20a1a7 Mon Sep 17 00:00:00 2001 From: ljanaideh Date: Mon, 20 Apr 2026 16:10:46 +0300 Subject: [PATCH 33/68] feat: add notify-postmark plugin + forward POSTMARK_SERVER_TOKEN/POSTMARK_FROM into sandbox Made-with: Cursor --- demos/cloudflare/astro.config.mjs | 2 + demos/cloudflare/package.json | 1 + packages/cloudflare/src/sandbox/runner.ts | 2 + packages/cloudflare/src/sandbox/wrapper.ts | 4 +- packages/plugins/notify-postmark/package.json | 28 +++ packages/plugins/notify-postmark/src/index.ts | 13 ++ .../notify-postmark/src/sandbox-entry.ts | 210 ++++++++++++++++++ .../plugins/notify-postmark/tsconfig.json | 9 + pnpm-lock.yaml | 82 +++++-- 9 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 packages/plugins/notify-postmark/package.json create mode 100644 packages/plugins/notify-postmark/src/index.ts create mode 100644 packages/plugins/notify-postmark/src/sandbox-entry.ts create mode 100644 packages/plugins/notify-postmark/tsconfig.json diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 24edf4f80a..d311dda501 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -13,6 +13,7 @@ import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; import { notifyOnPublishPlugin } from "@emdash-cms/plugin-notify-on-publish"; +import { notifyPostmarkPlugin } from "@emdash-cms/plugin-notify-postmark"; export default defineConfig({ output: "server", @@ -51,6 +52,7 @@ export default defineConfig({ // Test plugin that exercises all v2 APIs formsPlugin(), notifyOnPublishPlugin(), + notifyPostmarkPlugin(), // notifyOnPublishPlugin({ // recipients: ["ljanaideh@atypon.com"], // collections: ["posts"], diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index d0279e1799..a1514d0d75 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -19,6 +19,7 @@ "@emdash-cms/cloudflare": "workspace:*", "@emdash-cms/plugin-forms": "workspace:*", "@emdash-cms/plugin-notify-on-publish": "workspace:*", + "@emdash-cms/plugin-notify-postmark": "workspace:*", "@emdash-cms/plugin-webhook-notifier": "workspace:*", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index 978f4ac9fb..5aad8d584d 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -270,6 +270,8 @@ class CloudflareSandboxedPlugin implements SandboxedPlugin { // Forward selected host bindings so sandbox plugins can read Worker secrets (wrangler secret put …) RESEND_API_KEY: (env as Record).RESEND_API_KEY, EMAIL_FROM: (env as Record).EMAIL_FROM, + POSTMARK_SERVER_TOKEN: (env as Record).POSTMARK_SERVER_TOKEN, + POSTMARK_FROM: (env as Record).POSTMARK_FROM, }, })); } diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 8660ce5548..5e910176ea 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -176,7 +176,9 @@ function createContext(env) { email, env: { RESEND_API_KEY: env.RESEND_API_KEY, - EMAIL_FROM: env.EMAIL_FROM + EMAIL_FROM: env.EMAIL_FROM, + POSTMARK_SERVER_TOKEN: env.POSTMARK_SERVER_TOKEN, + POSTMARK_FROM: env.POSTMARK_FROM } }; } diff --git a/packages/plugins/notify-postmark/package.json b/packages/plugins/notify-postmark/package.json new file mode 100644 index 0000000000..7a1ef302f6 --- /dev/null +++ b/packages/plugins/notify-postmark/package.json @@ -0,0 +1,28 @@ +{ + "name": "@emdash-cms/plugin-notify-postmark", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./sandbox": { + "types": "./dist/sandbox-entry.d.mts", + "import": "./dist/sandbox-entry.mjs" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean" + }, + "peerDependencies": { + "emdash": "workspace:*" + }, + "devDependencies": { + "tsdown": "catalog:" + } +} diff --git a/packages/plugins/notify-postmark/src/index.ts b/packages/plugins/notify-postmark/src/index.ts new file mode 100644 index 0000000000..2d93fe888b --- /dev/null +++ b/packages/plugins/notify-postmark/src/index.ts @@ -0,0 +1,13 @@ +import type { PluginDescriptor } from "emdash"; + +export function notifyPostmarkPlugin(): PluginDescriptor { + return { + id: "notify-postmark", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-notify-postmark/sandbox", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["api.postmarkapp.com"], + options: {}, + }; +} diff --git a/packages/plugins/notify-postmark/src/sandbox-entry.ts b/packages/plugins/notify-postmark/src/sandbox-entry.ts new file mode 100644 index 0000000000..f89d6e8d23 --- /dev/null +++ b/packages/plugins/notify-postmark/src/sandbox-entry.ts @@ -0,0 +1,210 @@ +import { definePlugin } from "emdash"; +import type { ContentPublishStateChangeEvent, PluginContext } from "emdash"; + +const POSTMARK_ENDPOINT = "https://api.postmarkapp.com/email"; +/** Postmark requires a verified sender signature or domain */ +const DEFAULT_FROM = "notifications@example.com"; + +export default definePlugin({ + hooks: { + "content:afterPublish": { + handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { + const content = event.content as { + id?: string; + title?: string; + slug?: string; + publishedAt?: string; + email?: string | string[]; + data?: Record; + fields?: { email?: string }; + [key: string]: unknown; + }; + + try { + ctx.log.info( + `[notify-postmark] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, + ); + + const rawRecipient = + content.email ?? + content.data?.email ?? + content.fields?.email ?? + findEmailDeep(content); + + const recipients = normalizeRecipients(rawRecipient); + if (recipients.length === 0) { + ctx.log.info( + `[notify-postmark] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, + ); + return; + } + + const apiKey = resolveEnv(ctx, "POSTMARK_SERVER_TOKEN"); + if (!apiKey) { + ctx.log.error(`[notify-postmark] POSTMARK_SERVER_TOKEN not in ctx.env`); + return; + } + + const http = (ctx as { http?: { fetch: typeof fetch } }).http; + if (!http?.fetch) { + ctx.log.error(`[notify-postmark] ctx.http.fetch unavailable`); + return; + } + + const title = String(content.title ?? content.id ?? "(untitled)"); + const slug = String(content.slug ?? content.id ?? ""); + const publishedAt = + typeof content.publishedAt === "string" + ? content.publishedAt + : new Date().toISOString(); + const from = resolveEnv(ctx, "POSTMARK_FROM") ?? DEFAULT_FROM; + const collectionLabel = capitalize(event.collection); + + ctx.log.info( + `[notify-postmark] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, + ); + + const text = `"${title}" was just published. + +Collection: ${event.collection} +Slug: ${slug} +Published: ${publishedAt}`; + const html = `
+

${escapeHtml(collectionLabel)} published: ${escapeHtml(title)}

+

+ Collection: ${escapeHtml(event.collection)}
+ Slug: ${escapeHtml(slug)}
+ Published: ${escapeHtml(publishedAt)} +

`; + + const t0 = Date.now(); + let res: Response; + try { + res = await http.fetch(POSTMARK_ENDPOINT, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": apiKey, + }, + body: JSON.stringify({ + From: from, + To: recipients.join(", "), + Subject: `${collectionLabel} published: ${title}`, + TextBody: text, + HtmlBody: html, + MessageStream: "outbound", + }), + }); + ctx.log.info( + `[notify-postmark] Postmark status=${res.status} elapsed_ms=${Date.now() - t0}`, + ); + } catch (fetchErr) { + ctx.log.error( + `[notify-postmark] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + ); + return; + } + + if (!res.ok) { + const errText = await res.text().catch(() => "(body unreadable)"); + ctx.log.error( + `[notify-postmark] Postmark ${res.status}: ${errText.slice(0, 500)}`, + ); + return; + } + + let respJson: { MessageID?: string } = {}; + try { + respJson = (await res.json()) as { MessageID?: string }; + } catch { + /* ignore */ + } + ctx.log.info( + `[notify-postmark] SENT to=[${recipients.join(", ")}] MessageID=${respJson?.MessageID ?? "unknown"}`, + ); + } catch (topErr) { + ctx.log.error( + `[notify-postmark] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, + ); + } + }, + }, + }, +}); + +const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; + +function normalizeRecipients(raw: unknown): string[] { + if (!raw) return []; + const candidates: string[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === "string") candidates.push(...splitList(item)); + } + } else if (typeof raw === "string") { + candidates.push(...splitList(raw)); + } + const seen = new Set(); + const out: string[] = []; + for (const c of candidates) { + const trimmed = c.trim(); + if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; +} + +function splitList(s: string): string[] { + return s.split(/[,;\s]+/).filter(Boolean); +} + +function findEmailDeep(obj: unknown, depth = 0): string | string[] | undefined { + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + const k = key.toLowerCase(); + if (k === "email" || k === "emails") { + if (typeof value === "string" && normalizeRecipients(value).length > 0) { + return value; + } + if (Array.isArray(value) && normalizeRecipients(value).length > 0) { + return value as string[]; + } + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; +} + +function resolveEnv(ctx: PluginContext, name: string): string | undefined { + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; + return undefined; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function capitalize(s: string): string { + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/packages/plugins/notify-postmark/tsconfig.json b/packages/plugins/notify-postmark/tsconfig.json new file mode 100644 index 0000000000..f677f8d5eb --- /dev/null +++ b/packages/plugins/notify-postmark/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f0e5b6c9..34292377ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,9 @@ importers: '@emdash-cms/plugin-notify-on-publish': specifier: workspace:* version: link:../../packages/plugins/notify-on-publish + '@emdash-cms/plugin-notify-postmark': + specifier: workspace:* + version: link:../../packages/plugins/notify-postmark '@emdash-cms/plugin-webhook-notifier': specifier: workspace:* version: link:../../packages/plugins/webhook-notifier @@ -803,7 +806,7 @@ importers: version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vitest-browser-react: specifier: ^2.0.5 version: 2.0.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18) @@ -849,7 +852,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/blocks: dependencies: @@ -904,7 +907,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/blocks/playground: dependencies: @@ -987,7 +990,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/core: dependencies: @@ -1216,7 +1219,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/marketplace: dependencies: @@ -1247,7 +1250,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: 'catalog:' version: 4.80.0(@cloudflare/workers-types@4.20260305.1) @@ -1275,7 +1278,7 @@ importers: version: 19.2.14 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/api-test: dependencies: @@ -1303,7 +1306,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/audit-log: dependencies: @@ -1390,6 +1393,16 @@ importers: specifier: 'catalog:' version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + packages/plugins/notify-postmark: + dependencies: + emdash: + specifier: workspace:* + version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + packages/plugins/plugin-email-on-publish: {} packages/plugins/sandboxed-test: @@ -1444,7 +1457,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@x402/svm': specifier: ^2.8.0 @@ -1816,7 +1829,7 @@ packages: wrangler: ^4.61.1 '@astrojs/cloudflare@https://pkg.pr.new/@astrojs/cloudflare@94d342d': - resolution: {tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} + resolution: {integrity: sha512-Bt+G512Dr1SqYdsza6HOLP2azfHg0m5UE0s6SBGX77g+ThFV95Nai5boyM8HO3jVpqwVPPh+5ycMptjrtzv7Yg==, tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} version: 13.1.10 peerDependencies: astro: ^6.0.0 @@ -1918,7 +1931,7 @@ packages: engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} '@astrojs/telemetry@https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d': - resolution: {tarball: https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d} + resolution: {integrity: sha512-xfarx9l9HW3YpytsM2OpnD3aADtxueYWk6xg81PmVRLxfszskZzoaPVvZwfmqnpIxjBP1tOF1RLVaS10TwnNLQ==, tarball: https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d} version: 3.3.0 engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} @@ -5375,7 +5388,7 @@ packages: hasBin: true astro@https://pkg.pr.new/astro@94d342d: - resolution: {tarball: https://pkg.pr.new/astro@94d342d} + resolution: {integrity: sha512-1XlhRGRCQP4L5KPZUgSRCKOD28aKiGYQ8TBAxBIJvFV/HUuct3eHvc7sY/krhhCAju81JMlvbWU+1XVzltgZTQ==, tarball: https://pkg.pr.new/astro@94d342d} version: 6.1.7 engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -12730,7 +12743,7 @@ snapshots: '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw @@ -12764,7 +12777,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -17337,7 +17350,7 @@ snapshots: dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(@vitest/ui@4.0.17)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) @@ -17382,6 +17395,45 @@ snapshots: - tsx - yaml + vitest@4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.13 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + volar-service-css@0.0.68(@volar/language-service@2.4.27): dependencies: vscode-css-languageservice: 6.3.9 From 92f30c8ad59b3afc6e45f16f3cdf38fe925061ec Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 12:39:26 +0000 Subject: [PATCH 34/68] feat(cloudflare): add Hyperdrive adapter and switch demo-cloudflare to PostgreSQL Adds hyperdrive() database adapter to @emdash-cms/cloudflare, backed by a module-scoped pg.Pool using env[binding].connectionString. Switches demos/cloudflare from D1 to Hyperdrive (config ID c0e7da6f, hyperdrive-demo). Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/astro.config.mjs | 8 ++-- demos/cloudflare/worker-configuration.d.ts | 2 +- demos/cloudflare/wrangler.jsonc | 9 ++-- packages/cloudflare/package.json | 6 +++ packages/cloudflare/src/db/hyperdrive.ts | 51 ++++++++++++++++++++++ packages/cloudflare/src/index.ts | 41 +++++++++++++++++ packages/cloudflare/tsdown.config.ts | 1 + pnpm-lock.yaml | 8 +++- 8 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 packages/cloudflare/src/db/hyperdrive.ts diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index d311dda501..6242ed0f2e 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -2,7 +2,7 @@ import cloudflare from "@astrojs/cloudflare"; import react from "@astrojs/react"; import { - d1, + hyperdrive, r2, sandbox, // cloudflareCache, @@ -36,10 +36,8 @@ export default defineConfig({ integrations: [ react(), emdash({ - // D1 database - binding name must match wrangler.jsonc - // session: "auto" enables read replicas (nearest replica for anon, - // bookmark-based consistency for authenticated users) - database: d1({ binding: "DB", session: "auto" }), + // Hyperdrive database — binding name must match wrangler.jsonc + database: hyperdrive({ binding: "HYPERDRIVE" }), // R2 storage for media storage: r2({ binding: "MEDIA" }), // Cloudflare Access authentication diff --git a/demos/cloudflare/worker-configuration.d.ts b/demos/cloudflare/worker-configuration.d.ts index 629f0c7371..41d411e963 100644 --- a/demos/cloudflare/worker-configuration.d.ts +++ b/demos/cloudflare/worker-configuration.d.ts @@ -7,7 +7,7 @@ declare namespace Cloudflare { } interface Env { MEDIA: R2Bucket; - DB: D1Database; + HYPERDRIVE: Hyperdrive; LOADER: WorkerLoader; CF_ACCESS_AUDIENCE: string; CF_MEDIA_API_TOKEN: string; diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index d7d3aa8299..1cc75b94ae 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -5,12 +5,11 @@ "compatibility_date": "2026-01-14", "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], - // D1 Database binding — points to existing my-emdash-site db - "d1_databases": [ + // Hyperdrive binding — hyperdrive-demo config + "hyperdrive": [ { - "binding": "DB", - "database_name": "my-emdash-site", - "database_id": "ece39c3d-8076-4162-a954-20509aa74d79" + "binding": "HYPERDRIVE", + "id": "c0e7da6ff81341669b313664bff6a3d4" } ], diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 1d4683777f..d46975fcac 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -17,6 +17,10 @@ "types": "./dist/db/d1.d.mts", "default": "./dist/db/d1.mjs" }, + "./db/hyperdrive": { + "types": "./dist/db/hyperdrive.d.mts", + "default": "./dist/db/hyperdrive.mjs" + }, "./db/do": { "types": "./dist/db/do.d.mts", "default": "./dist/db/do.mjs" @@ -74,6 +78,7 @@ "emdash": "workspace:*", "jose": "^6.1.3", "kysely-d1": "^0.4.0", + "pg": "^8.0.0", "ulidx": "^2.4.1" }, "peerDependencies": { @@ -84,6 +89,7 @@ "devDependencies": { "@arethetypeswrong/cli": "catalog:", "@cloudflare/workers-types": "catalog:", + "@types/pg": "^8.16.0", "publint": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts new file mode 100644 index 0000000000..e9b7a79eef --- /dev/null +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -0,0 +1,51 @@ +/** + * Cloudflare Hyperdrive runtime adapter - RUNTIME ENTRY + * + * Creates a Kysely PostgresDialect backed by a module-scoped pg.Pool that + * connects through Hyperdrive's connection proxy. + * + * Do NOT import this at config time — use { hyperdrive } from "@emdash-cms/cloudflare" instead. + */ + +import { env } from "cloudflare:workers"; +import { PostgresDialect } from "kysely"; +import { Pool } from "pg"; + +interface HyperdriveConfig { + binding: string; + pool?: { max?: number }; +} + +interface HyperdriveBinding { + connectionString: string; +} + +// Module-scope singleton pools keyed by binding name. +// Stored on globalThis to survive Vite SSR module duplication (see CLAUDE.md). +const POOL_KEY = Symbol.for("emdash.hyperdrive.pools"); +if (!(globalThis as Record)[POOL_KEY]) { + (globalThis as Record)[POOL_KEY] = new Map(); +} +const pools = (globalThis as Record)[POOL_KEY] as Map; + +export function createDialect(config: HyperdriveConfig): PostgresDialect { + let pool = pools.get(config.binding); + if (!pool) { + const binding = (env as Record)[config.binding] as + | HyperdriveBinding + | undefined; + if (!binding) { + throw new Error( + `Hyperdrive binding "${config.binding}" not found in environment. ` + + `Add it to your wrangler.jsonc:\n\n` + + ` "hyperdrive": [{ "binding": "${config.binding}", "id": "your-hyperdrive-config-id" }]`, + ); + } + pool = new Pool({ + connectionString: binding.connectionString, + max: config.pool?.max ?? 5, + }); + pools.set(config.binding, pool); + } + return new PostgresDialect({ pool }); +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5009ae379c..9e854b29b2 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -3,6 +3,7 @@ * * Cloudflare adapters for EmDash: * - D1 database adapter + * - Hyperdrive database adapter (PostgreSQL via Hyperdrive) * - R2 storage adapter * - Cloudflare Access authentication * - Worker Loader sandbox for plugins @@ -169,6 +170,46 @@ export function d1(config: D1Config): DatabaseDescriptor { export type { PreviewDOConfig } from "./db/do-types.js"; +/** + * Hyperdrive configuration + */ +export interface HyperdriveConfig { + /** + * Name of the Hyperdrive binding in wrangler.jsonc + */ + binding: string; + + /** + * pg.Pool size. Hyperdrive handles connection pooling externally; + * keep this small (default: 5). + */ + pool?: { max?: number }; +} + +/** + * Cloudflare Hyperdrive database adapter + * + * For Cloudflare Workers connecting to PostgreSQL via Hyperdrive. + * Uses a module-scoped pg.Pool backed by env[binding].connectionString. + * + * Requires a Hyperdrive binding in wrangler.jsonc: + * ```jsonc + * "hyperdrive": [{ "binding": "HYPERDRIVE", "id": "your-config-id" }] + * ``` + * + * @example + * ```ts + * database: hyperdrive({ binding: "HYPERDRIVE" }) + * ``` + */ +export function hyperdrive(config: HyperdriveConfig): DatabaseDescriptor { + return { + entrypoint: "@emdash-cms/cloudflare/db/hyperdrive", + config, + type: "postgres", + }; +} + /** * Durable Object preview database adapter * diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index 2e524c7b44..00b3c59635 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ "src/index.ts", "src/db/d1.ts", + "src/db/hyperdrive.ts", "src/db/do.ts", "src/db/playground.ts", "src/db/playground-middleware.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34292377ae..2824c882d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -969,6 +969,9 @@ importers: kysely-d1: specifier: ^0.4.0 version: 0.4.0(kysely@0.27.6) + pg: + specifier: ^8.0.0 + version: 8.18.0 ulidx: specifier: ^2.4.1 version: 2.4.1 @@ -979,6 +982,9 @@ importers: '@cloudflare/workers-types': specifier: 'catalog:' version: 4.20260305.1 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 publint: specifier: 'catalog:' version: 0.3.17 @@ -3099,7 +3105,7 @@ packages: resolution: {integrity: sha512-yTCCjuQapvRz6S30B8DyqHu1WYsbYRCww6uNsmbQU4GQVf5gJzJSB60qUHj+qBSxReLtRL/mhmhYhrIc9jVFTw==} '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc': - resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} + resolution: {integrity: sha512-k8sHBM7S10HBa39fxsJcOGYMGrbru5UZ9vMS4kmCa9o6dJTUP6rt3zKVEs7uEsHAYasoXyiC6wre2Jiqs3X+zQ==, tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} version: 0.1.1 engines: {node: '>=18.17.0'} From 536eb675d24677635582ba3be1eb462e82d27083 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 12:39:26 +0000 Subject: [PATCH 35/68] fix(cloudflare): add Hyperdrive localConnectionString for build-time miniflare astro build via @cloudflare/vite-plugin requires a localConnectionString on the Hyperdrive binding so miniflare can initialize the simulation. No actual DB connection is made at build time. Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/wrangler.jsonc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 1cc75b94ae..394e028853 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -9,7 +9,9 @@ "hyperdrive": [ { "binding": "HYPERDRIVE", - "id": "c0e7da6ff81341669b313664bff6a3d4" + "id": "c0e7da6ff81341669b313664bff6a3d4", + // Used by miniflare during astro build — not a live connection at build time + "localConnectionString": "postgres://emdash_app:emdash_app@database-1.chsx5yoqeoyq.us-east-1.rds.amazonaws.com:5432/emdash_dev" } ], From f669187ae933aa0dcc893ad14a07861ed7790578 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 12:55:49 +0000 Subject: [PATCH 36/68] fix(cloudflare): use env var for Hyperdrive local connection string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove localConnectionString from wrangler.jsonc — set CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE in the Cloudflare Pages build environment instead. Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/wrangler.jsonc | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 394e028853..1cc75b94ae 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -9,9 +9,7 @@ "hyperdrive": [ { "binding": "HYPERDRIVE", - "id": "c0e7da6ff81341669b313664bff6a3d4", - // Used by miniflare during astro build — not a live connection at build time - "localConnectionString": "postgres://emdash_app:emdash_app@database-1.chsx5yoqeoyq.us-east-1.rds.amazonaws.com:5432/emdash_dev" + "id": "c0e7da6ff81341669b313664bff6a3d4" } ], From bcfe1442c8da034cf2b5be2aaeab64ad7b5485a0 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 13:15:40 +0000 Subject: [PATCH 37/68] fix(hyperdrive): disable pg SSL layer to avoid TLS-within-TLS in Workers Hyperdrive terminates TLS before the pg connection reaches the worker. Passing ssl:false prevents pg from wrapping the connection in a second TLS layer, which causes a handshake failure in the Cloudflare Workers runtime. Co-Authored-By: Claude Sonnet 4.6 --- packages/cloudflare/src/db/hyperdrive.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index e9b7a79eef..5109b1eaed 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -44,6 +44,9 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { pool = new Pool({ connectionString: binding.connectionString, max: config.pool?.max ?? 5, + // Hyperdrive handles TLS termination; disable pg's SSL layer to avoid + // TLS-within-TLS in the Workers runtime. + ssl: false, }); pools.set(config.binding, pool); } From df9f51c03212be8bad7041b41ee2d1e943c1d23a Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 13:32:09 +0000 Subject: [PATCH 38/68] feat(demo-cloudflare): add bootstrap-postgres script to initialize RDS schema Runs EmDash migrations directly against PostgreSQL from a local machine using DATABASE_URL, bypassing Cloudflare Hyperdrive. Required for first- time setup before the Worker can handle requests. DATABASE_URL="postgres://..." pnpm --filter @emdash-cms/demo-cloudflare db:bootstrap Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/package.json | 1 + .../cloudflare/scripts/bootstrap-postgres.mjs | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 demos/cloudflare/scripts/bootstrap-postgres.mjs diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index a1514d0d75..2c04a3f51d 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -9,6 +9,7 @@ "build:all": "pnpm run --filter @emdash-cms/demo-cloudflare... build", "preview": "astro preview", "deploy": "pnpm build:all && wrangler deploy", + "db:bootstrap": "node scripts/bootstrap-postgres.mjs", "db:create": "wrangler d1 create emdash-demo", "db:reset:remote": "./scripts/reset-db.sh", "typecheck": "astro check" diff --git a/demos/cloudflare/scripts/bootstrap-postgres.mjs b/demos/cloudflare/scripts/bootstrap-postgres.mjs new file mode 100644 index 0000000000..7a664e41e5 --- /dev/null +++ b/demos/cloudflare/scripts/bootstrap-postgres.mjs @@ -0,0 +1,45 @@ +/** + * Bootstrap script — runs EmDash migrations against a PostgreSQL database. + * + * Run once from your local machine before the first Cloudflare deployment: + * + * DATABASE_URL="postgres://emdash_app:@:5432/emdash_dev" \ + * node scripts/bootstrap-postgres.mjs + */ + +import { Kysely } from "kysely"; +import { PostgresDialect } from "kysely"; +import pg from "pg"; +import { runMigrations } from "emdash/db"; + +const { Pool } = pg; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("Error: DATABASE_URL environment variable is required."); + console.error( + ' Example: DATABASE_URL="postgres://user:pass@host:5432/db" node scripts/bootstrap-postgres.mjs', + ); + process.exit(1); +} + +console.log("Connecting to PostgreSQL..."); +const pool = new Pool({ connectionString, max: 1 }); + +const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); + +try { + console.log("Running migrations..."); + const { applied } = await runMigrations(db); + + if (applied.length === 0) { + console.log("No new migrations to apply — database is already up to date."); + } else { + console.log(`Applied ${applied.length} migration(s):`); + for (const m of applied) { + console.log(` ✓ ${m}`); + } + } +} finally { + await pool.end(); +} From ee173b2934778cf6a72702d8872a1c567198c491 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 13:48:48 +0000 Subject: [PATCH 39/68] feat(demo-cloudflare): run bootstrap migrations as part of deploy Runs DB migrations against RDS before every wrangler deploy so the schema is always up to date by the time the Worker goes live. Requires DATABASE_URL to be set in the Cloudflare Pages build env vars. Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index 2c04a3f51d..7d48ef2cd7 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -8,7 +8,7 @@ "build": "astro build", "build:all": "pnpm run --filter @emdash-cms/demo-cloudflare... build", "preview": "astro preview", - "deploy": "pnpm build:all && wrangler deploy", + "deploy": "node scripts/bootstrap-postgres.mjs && pnpm build:all && wrangler deploy", "db:bootstrap": "node scripts/bootstrap-postgres.mjs", "db:create": "wrangler d1 create emdash-demo", "db:reset:remote": "./scripts/reset-db.sh", From e442599bdb10d93197797779b2212128b986aa8d Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 13:53:12 +0000 Subject: [PATCH 40/68] fix(demo-cloudflare): add kysely + pg as direct deps for bootstrap script The bootstrap script imports kysely and pg directly. Without them as explicit dependencies in demos/cloudflare, Node.js cannot resolve them in the Cloudflare Pages build environment. Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/package.json | 2 ++ demos/cloudflare/scripts/bootstrap-postgres.mjs | 17 ++++++----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index 7d48ef2cd7..dc2d510d11 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -15,6 +15,8 @@ "typecheck": "astro check" }, "dependencies": { + "kysely": "^0.27.0", + "pg": "^8.0.0", "@astrojs/cloudflare": "catalog:", "@astrojs/react": "catalog:", "@emdash-cms/cloudflare": "workspace:*", diff --git a/demos/cloudflare/scripts/bootstrap-postgres.mjs b/demos/cloudflare/scripts/bootstrap-postgres.mjs index 7a664e41e5..2c875327da 100644 --- a/demos/cloudflare/scripts/bootstrap-postgres.mjs +++ b/demos/cloudflare/scripts/bootstrap-postgres.mjs @@ -1,14 +1,12 @@ /** * Bootstrap script — runs EmDash migrations against a PostgreSQL database. * - * Run once from your local machine before the first Cloudflare deployment: + * Run as part of the deploy command, or manually: * - * DATABASE_URL="postgres://emdash_app:@:5432/emdash_dev" \ - * node scripts/bootstrap-postgres.mjs + * DATABASE_URL="postgres://user:pass@host:5432/db" node scripts/bootstrap-postgres.mjs */ -import { Kysely } from "kysely"; -import { PostgresDialect } from "kysely"; +import { Kysely, PostgresDialect } from "kysely"; import pg from "pg"; import { runMigrations } from "emdash/db"; @@ -17,15 +15,12 @@ const { Pool } = pg; const connectionString = process.env.DATABASE_URL; if (!connectionString) { console.error("Error: DATABASE_URL environment variable is required."); - console.error( - ' Example: DATABASE_URL="postgres://user:pass@host:5432/db" node scripts/bootstrap-postgres.mjs', - ); process.exit(1); } console.log("Connecting to PostgreSQL..."); -const pool = new Pool({ connectionString, max: 1 }); - +const ssl = process.env.DATABASE_SSL === "false" ? false : { rejectUnauthorized: false }; +const pool = new Pool({ connectionString, max: 1, ssl }); const db = new Kysely({ dialect: new PostgresDialect({ pool }) }); try { @@ -33,7 +28,7 @@ try { const { applied } = await runMigrations(db); if (applied.length === 0) { - console.log("No new migrations to apply — database is already up to date."); + console.log("No new migrations — database is already up to date."); } else { console.log(`Applied ${applied.length} migration(s):`); for (const m of applied) { From 6a92057f2b30a01156683dfcde85dbc0d6bd0918 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 13:57:02 +0000 Subject: [PATCH 41/68] chore: update lockfile with kysely + pg deps for demo-cloudflare Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2824c882d7..88d96666f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,12 @@ importers: emdash: specifier: workspace:* version: link:../../packages/core + kysely: + specifier: ^0.27.0 + version: 0.27.6 + pg: + specifier: ^8.0.0 + version: 8.18.0 react: specifier: 'catalog:' version: 19.2.4 @@ -1937,7 +1943,7 @@ packages: engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} '@astrojs/telemetry@https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d': - resolution: {integrity: sha512-xfarx9l9HW3YpytsM2OpnD3aADtxueYWk6xg81PmVRLxfszskZzoaPVvZwfmqnpIxjBP1tOF1RLVaS10TwnNLQ==, tarball: https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d} + resolution: {tarball: https://pkg.pr.new/withastro/astro/@astrojs/telemetry@94d342d} version: 3.3.0 engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} From afed4dd991f4234b0e9c34d5c2cb284fcbfd3413 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 14:00:53 +0000 Subject: [PATCH 42/68] fix(deploy): build packages before running bootstrap migration script emdash/db is imported by the bootstrap script, so packages must be built first or node throws ERR_MODULE_NOT_FOUND on emdash/dist/db/index.mjs. Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index dc2d510d11..9efb3faa9c 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -8,7 +8,7 @@ "build": "astro build", "build:all": "pnpm run --filter @emdash-cms/demo-cloudflare... build", "preview": "astro preview", - "deploy": "node scripts/bootstrap-postgres.mjs && pnpm build:all && wrangler deploy", + "deploy": "pnpm build:all && node scripts/bootstrap-postgres.mjs && wrangler deploy", "db:bootstrap": "node scripts/bootstrap-postgres.mjs", "db:create": "wrangler d1 create emdash-demo", "db:reset:remote": "./scripts/reset-db.sh", From 4c68bc6bbfeb5f0013bf3fbc835b173996491354 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 14:16:50 +0000 Subject: [PATCH 43/68] chore: trigger build From f4d514ef080d6ab055bbd6f419dd7fbf2288d83a Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 14:19:21 +0000 Subject: [PATCH 44/68] chore: trigger build (simplify deploy script to just wrangler deploy) --- demos/cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/cloudflare/package.json b/demos/cloudflare/package.json index 9efb3faa9c..8e2270357b 100644 --- a/demos/cloudflare/package.json +++ b/demos/cloudflare/package.json @@ -8,7 +8,7 @@ "build": "astro build", "build:all": "pnpm run --filter @emdash-cms/demo-cloudflare... build", "preview": "astro preview", - "deploy": "pnpm build:all && node scripts/bootstrap-postgres.mjs && wrangler deploy", + "deploy": "wrangler deploy", "db:bootstrap": "node scripts/bootstrap-postgres.mjs", "db:create": "wrangler d1 create emdash-demo", "db:reset:remote": "./scripts/reset-db.sh", From b586fb70f882ac646850d9d13da9dd34eb5ea3f6 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 14:39:49 +0000 Subject: [PATCH 45/68] chore: trigger CI build From cef950b6705b172bec27cf4164ac0031553e2e91 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 14:55:42 +0000 Subject: [PATCH 46/68] fix(cloudflare): add connectionTimeoutMillis to Hyperdrive pg pool Without a timeout, pg waits indefinitely when Hyperdrive cannot reach the backend database, causing the Workers runtime to cancel the isolate with a "hung" error. 10 s gives a real error message in logs instead. Co-Authored-By: Claude Sonnet 4.6 --- packages/cloudflare/src/db/hyperdrive.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 5109b1eaed..45f2b0b765 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -47,6 +47,9 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { // Hyperdrive handles TLS termination; disable pg's SSL layer to avoid // TLS-within-TLS in the Workers runtime. ssl: false, + // Without a timeout pg waits forever when Hyperdrive can't reach the + // backend, causing the Workers runtime to cancel the hung isolate. + connectionTimeoutMillis: 10_000, }); pools.set(config.binding, pool); } From f81df0778f65daa34ff5d3a472bca2f6de186772 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Mon, 4 May 2026 15:10:54 +0000 Subject: [PATCH 47/68] chore: trigger CI deploy (connectionTimeoutMillis + RDS public access) From 0a76c5a816a93aab73b163d34e7e275c1d6cbb8c Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 07:21:48 +0000 Subject: [PATCH 48/68] chore: trigger build against new RDS (emdash-demo) From c45a837fbf41c320b9a6c3d18fa3ec5c81f76780 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 10:26:09 +0300 Subject: [PATCH 49/68] chore: retrigger build (fix DATABASE_URL sslmode) From 0698dbc581c996abae07368049367cc5e11ed1a2 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 11:41:10 +0300 Subject: [PATCH 50/68] fix(cloudflare): new Hyperdrive config + SESSION KV binding --- demos/cloudflare/wrangler.jsonc | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 1cc75b94ae..2204d20ea4 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -5,11 +5,11 @@ "compatibility_date": "2026-01-14", "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], - // Hyperdrive binding — hyperdrive-demo config + // Hyperdrive binding — emdash-pg config pointing to emdash-demo RDS "hyperdrive": [ { "binding": "HYPERDRIVE", - "id": "c0e7da6ff81341669b313664bff6a3d4" + "id": "2b7fc91df2d24a7cb7e434120d82060f" } ], @@ -25,6 +25,14 @@ "enabled": true }, + // KV namespace for Astro session storage + "kv_namespaces": [ + { + "binding": "SESSION", + "id": "0516c5af42c24460b6a9eba751ffc0e3" + } + ], + // Worker Loader for plugin sandboxing "worker_loaders": [ { From 11605e6cfd9696ddbd13843eaa4454015b76a60e Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 11:10:36 +0000 Subject: [PATCH 51/68] fix(cloudflare): replace pg-pool timeout with Promise.race for Workers compat pg-pool's connectionTimeoutMillis fires a timer then calls stream.destroy() to abort the pending TCP connect. stream.destroy() is a no-op in the Cloudflare Workers Node.js compat layer, so the connect callback is never called and the Worker hangs until the runtime cancels the isolate. Override pool.connect() with a Promise.race against a manual setTimeout so the deadline fires reliably without depending on stream.destroy(). Also adds a console.log with the connection string prefix for wrangler tail diagnostics. Co-Authored-By: Claude Sonnet 4.6 --- packages/cloudflare/src/db/hyperdrive.ts | 38 +++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 45f2b0b765..4adba94bd2 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -28,6 +28,12 @@ if (!(globalThis as Record)[POOL_KEY]) { } const pools = (globalThis as Record)[POOL_KEY] as Map; +// How long (ms) to wait for a Hyperdrive TCP connect before giving up. +// pg-pool's built-in connectionTimeoutMillis relies on stream.destroy(), which +// does not terminate pending connections in the Workers Node.js compat layer — +// so we enforce the deadline ourselves via Promise.race. +const CONNECT_TIMEOUT_MS = 8_000; + export function createDialect(config: HyperdriveConfig): PostgresDialect { let pool = pools.get(config.binding); if (!pool) { @@ -41,16 +47,40 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { ` "hyperdrive": [{ "binding": "${config.binding}", "id": "your-hyperdrive-config-id" }]`, ); } + const cs = binding.connectionString; + console.log(`[hyperdrive] init binding=${config.binding} cs_prefix=${cs.slice(0, 30)}...`); pool = new Pool({ - connectionString: binding.connectionString, + connectionString: cs, max: config.pool?.max ?? 5, // Hyperdrive handles TLS termination; disable pg's SSL layer to avoid // TLS-within-TLS in the Workers runtime. ssl: false, - // Without a timeout pg waits forever when Hyperdrive can't reach the - // backend, causing the Workers runtime to cancel the hung isolate. - connectionTimeoutMillis: 10_000, }); + + // pg-pool's connectionTimeoutMillis fires a timer then calls + // stream.destroy() to abort the in-flight TCP connect. stream.destroy() + // is a no-op in the Workers Node.js compat layer, so the connect callback + // is never called and the Worker hangs. We override pool.connect() with a + // Promise.race to enforce the deadline reliably. + const _origConnect = pool.connect.bind(pool); + // eslint-disable-next-line typescript-eslint(no-explicit-any) -- duck-type override on pg Pool + (pool as any).connect = () => + Promise.race([ + _origConnect(), + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `[hyperdrive] connect timeout after ${CONNECT_TIMEOUT_MS}ms — ` + + `check Hyperdrive binding "${config.binding}" and RDS reachability`, + ), + ), + CONNECT_TIMEOUT_MS, + ), + ), + ]); + pools.set(config.binding, pool); } return new PostgresDialect({ pool }); From 30691de42ebe76b89a05a38bfad1d1e2871fb5df Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 11:33:27 +0000 Subject: [PATCH 52/68] fix(cloudflare): disable pg-pool idle timeout to prevent reconnect hangs in Workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg-pool's default 10-second idleTimeoutMillis removes idle connections after inactivity, then calls client.end() outside a request context. When the next request arrives and the pool is empty, a new TCP connect to Hyperdrive is attempted — but reconnection hangs in the Workers Node.js compat layer. Setting idleTimeoutMillis: 0 keeps connections alive for the Worker isolate's lifetime (TCP connections DO persist within an isolate between requests), so the pool never needs to reconnect during normal operation. The Promise.race 8-second timeout wrapper remains as a safety net for the edge case where a reconnect is unavoidable (dead connection replacement). Co-Authored-By: Claude Sonnet 4.6 --- packages/cloudflare/src/db/hyperdrive.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 4adba94bd2..ee1d807088 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -55,6 +55,12 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { // Hyperdrive handles TLS termination; disable pg's SSL layer to avoid // TLS-within-TLS in the Workers runtime. ssl: false, + // Disable idle timeout: pg-pool's idle cleanup calls client.end() outside + // a request context, and reconnecting to Hyperdrive hangs in the Workers + // Node.js compat layer. Keep the connection alive for the isolate's + // lifetime instead (TCP connections persist within an isolate). + idleTimeoutMillis: 0, + allowExitOnIdle: false, }); // pg-pool's connectionTimeoutMillis fires a timer then calls From 53ec8f17cbb51d48ad46eb8fbae8dd4f417101a2 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 11:48:33 +0000 Subject: [PATCH 53/68] debug: add middleware trace logs to identify hang location Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/astro/middleware.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 315e9c4698..0a01adf355 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -257,7 +257,9 @@ export const onRequest = defineMiddleware(async (context, next) => { // and the full doInit path need this, and the session store is network-backed // (KV / Durable Object) so we want to avoid re-fetching on the hot path. // Skipped entirely for prerendered requests — they have no session. + console.log(`[mw] ${request.method} ${url.pathname} — fetching session`); const sessionUser = context.isPrerendered ? null : await context.session?.get("user"); + console.log(`[mw] ${url.pathname} — session done, isEmDash=${isEmDashRoute}`); if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) { if (!sessionUser && !playgroundDb) { @@ -327,6 +329,7 @@ export const onRequest = defineMiddleware(async (context, next) => { url, }); const runAnon = async () => { + console.log(`[mw] ${url.pathname} — anon next()`); const t0 = performance.now(); const response = await next(); timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" }); @@ -481,8 +484,10 @@ export const onRequest = defineMiddleware(async (context, next) => { }); const renderAndFinalize = async () => { + console.log(`[mw] ${url.pathname} — calling next()`); const t0 = performance.now(); const response = await next(); + console.log(`[mw] ${url.pathname} — next() done in ${Math.round(performance.now() - t0)}ms`); timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" }); timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" }); return finalizeResponse(response, timings); From c572fdafd08adc3122ae2b8f434b6c04064eb799 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 11:59:29 +0000 Subject: [PATCH 54/68] debug: add trace logs in admin.astro to locate hang Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/astro/routes/admin.astro | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/astro/routes/admin.astro b/packages/core/src/astro/routes/admin.astro index c22f0a6ec9..22e656ceb5 100644 --- a/packages/core/src/astro/routes/admin.astro +++ b/packages/core/src/astro/routes/admin.astro @@ -17,7 +17,9 @@ import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/loc const resolvedLocale = resolveLocale(Astro.request); const resolvedDir = getLocaleDir(resolvedLocale); +console.log(`[admin] loading messages for locale=${resolvedLocale}`); const messages = await loadMessages(resolvedLocale); +console.log(`[admin] messages loaded, rendering template`); --- From d513e3e863ea0edcb763b5ca228d10ff597413aa Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Tue, 5 May 2026 12:16:29 +0000 Subject: [PATCH 55/68] fix(cloudflare): use fresh pg.Client per query to fix Hyperdrive reconnect hangs in Workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pg-pool's connectionTimeoutMillis relies on stream.destroy() which is a no-op in the Workers Node.js compat layer — stalled connect() calls never abort. Replacing the module-scoped Pool with a fake pool that creates a fresh pg.Client on every connect() call sidesteps both the broken socket timeout and the stale connectionString problem (Hyperdrive CS is per-request-context; a cached Pool bakes in the first request's CS and hangs on subsequent isolate reuse). Co-Authored-By: Claude Sonnet 4.6 --- packages/cloudflare/src/db/hyperdrive.ts | 122 +++++++++++++---------- 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index ee1d807088..fcbd81765f 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -1,15 +1,26 @@ /** * Cloudflare Hyperdrive runtime adapter - RUNTIME ENTRY * - * Creates a Kysely PostgresDialect backed by a module-scoped pg.Pool that - * connects through Hyperdrive's connection proxy. + * Creates a Kysely PostgresDialect that establishes a fresh pg.Client for + * every query. Hyperdrive handles connection pooling to the real database on + * the Cloudflare network; the Worker only needs an ephemeral connection to + * Hyperdrive's local proxy per query. + * + * WHY NOT a module-scoped Pool? + * Hyperdrive may provide a different connectionString per Worker request + * (or per isolate startup). A cached Pool bakes in the first request's CS; + * subsequent connect() calls on the stale Pool hang indefinitely in the + * Workers Node.js compat layer when the endpoint has changed or the idle + * connection was closed server-side. Creating a fresh Client per query + * re-reads env.HYPERDRIVE.connectionString every time, which is always + * current regardless of when the isolate started. * * Do NOT import this at config time — use { hyperdrive } from "@emdash-cms/cloudflare" instead. */ import { env } from "cloudflare:workers"; import { PostgresDialect } from "kysely"; -import { Pool } from "pg"; +import { Client, type Pool } from "pg"; interface HyperdriveConfig { binding: string; @@ -20,59 +31,56 @@ interface HyperdriveBinding { connectionString: string; } -// Module-scope singleton pools keyed by binding name. -// Stored on globalThis to survive Vite SSR module duplication (see CLAUDE.md). -const POOL_KEY = Symbol.for("emdash.hyperdrive.pools"); -if (!(globalThis as Record)[POOL_KEY]) { - (globalThis as Record)[POOL_KEY] = new Map(); -} -const pools = (globalThis as Record)[POOL_KEY] as Map; - -// How long (ms) to wait for a Hyperdrive TCP connect before giving up. -// pg-pool's built-in connectionTimeoutMillis relies on stream.destroy(), which -// does not terminate pending connections in the Workers Node.js compat layer — -// so we enforce the deadline ourselves via Promise.race. +// How long to wait for a Hyperdrive TCP connect before giving up. const CONNECT_TIMEOUT_MS = 8_000; +function getBinding(bindingName: string): HyperdriveBinding { + const binding = (env as Record)[bindingName] as HyperdriveBinding | undefined; + if (!binding) { + throw new Error( + `Hyperdrive binding "${bindingName}" not found in environment. ` + + `Add it to your wrangler.jsonc:\n\n` + + ` "hyperdrive": [{ "binding": "${bindingName}", "id": "your-hyperdrive-config-id" }]`, + ); + } + return binding; +} + export function createDialect(config: HyperdriveConfig): PostgresDialect { - let pool = pools.get(config.binding); - if (!pool) { - const binding = (env as Record)[config.binding] as - | HyperdriveBinding - | undefined; - if (!binding) { - throw new Error( - `Hyperdrive binding "${config.binding}" not found in environment. ` + - `Add it to your wrangler.jsonc:\n\n` + - ` "hyperdrive": [{ "binding": "${config.binding}", "id": "your-hyperdrive-config-id" }]`, - ); - } - const cs = binding.connectionString; - console.log(`[hyperdrive] init binding=${config.binding} cs_prefix=${cs.slice(0, 30)}...`); - pool = new Pool({ - connectionString: cs, - max: config.pool?.max ?? 5, - // Hyperdrive handles TLS termination; disable pg's SSL layer to avoid - // TLS-within-TLS in the Workers runtime. - ssl: false, - // Disable idle timeout: pg-pool's idle cleanup calls client.end() outside - // a request context, and reconnecting to Hyperdrive hangs in the Workers - // Node.js compat layer. Keep the connection alive for the isolate's - // lifetime instead (TCP connections persist within an isolate). - idleTimeoutMillis: 0, - allowExitOnIdle: false, - }); + // Validate the binding exists at dialect creation time. + const initial = getBinding(config.binding); + console.log( + `[hyperdrive] createDialect binding=${config.binding} cs_prefix=${initial.connectionString.slice(0, 30)}...`, + ); + + // Fake pool: Kysely only needs connect() + end(). + // We re-read env.HYPERDRIVE.connectionString on every connect() so we + // always use the current CS, even if it changes between requests. + const fakePool = { + connect: async (): Promise Promise }> => { + const binding = getBinding(config.binding); + const cs = binding.connectionString; + console.log(`[hyperdrive] connect() cs_prefix=${cs.slice(0, 30)}...`); - // pg-pool's connectionTimeoutMillis fires a timer then calls - // stream.destroy() to abort the in-flight TCP connect. stream.destroy() - // is a no-op in the Workers Node.js compat layer, so the connect callback - // is never called and the Worker hangs. We override pool.connect() with a - // Promise.race to enforce the deadline reliably. - const _origConnect = pool.connect.bind(pool); - // eslint-disable-next-line typescript-eslint(no-explicit-any) -- duck-type override on pg Pool - (pool as any).connect = () => - Promise.race([ - _origConnect(), + const connectPromise = (async () => { + const client = new Client({ + connectionString: cs, + // Hyperdrive handles TLS to the database; the Worker connects + // to Hyperdrive's local proxy without TLS. + ssl: false, + }); + await client.connect(); + // Kysely calls release() when it's done with the connection. + // We close the Client rather than returning it to a pool. + (client as Client & { release: (destroy?: boolean) => Promise }).release = + async (_destroy?: boolean) => { + await client.end().catch(() => {}); + }; + return client as Client & { release: (destroy?: boolean) => Promise }; + })(); + + return Promise.race([ + connectPromise, new Promise((_, reject) => setTimeout( () => @@ -86,8 +94,12 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { ), ), ]); + }, + // Called by Kysely.destroy() — nothing to clean up. + end: async (): Promise => {}, + }; - pools.set(config.binding, pool); - } - return new PostgresDialect({ pool }); + // Cast: Kysely only uses connect() + end() at runtime; the full Pool type + // is not required. + return new PostgresDialect({ pool: fakePool as unknown as Pool }); } From d5dc34dde4f7e18817ff90417775a0de216d7720 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 09:24:30 +0000 Subject: [PATCH 56/68] fix(core): add idleTimeoutMillis + connectionTimeoutMillis to postgres pool config Without connection timeout the pool hangs indefinitely when exhausted, causing cascading 500s. Without idle timeout connections leak. Defaults: 10s idle, 5s connection (fail-fast on exhaustion). Both are now user-configurable via the pool option in the postgres() adapter config. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/db/adapters.ts | 12 +++++++++++- packages/core/src/db/postgres.ts | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/db/adapters.ts b/packages/core/src/db/adapters.ts index bf2d7859be..c23a4d1159 100644 --- a/packages/core/src/db/adapters.ts +++ b/packages/core/src/db/adapters.ts @@ -118,7 +118,17 @@ export interface PostgresConfig { user?: string; password?: string; ssl?: boolean; - pool?: { min?: number; max?: number }; + pool?: { + min?: number; + max?: number; + /** Milliseconds before an idle connection is closed. Default: 10000. */ + idleTimeoutMillis?: number; + /** + * Milliseconds to wait for a connection before failing. Default: 5000. + * Set this to avoid indefinite hangs when the pool is exhausted. + */ + connectionTimeoutMillis?: number; + }; } /** diff --git a/packages/core/src/db/postgres.ts b/packages/core/src/db/postgres.ts index 421b4b07b0..043df1ef14 100644 --- a/packages/core/src/db/postgres.ts +++ b/packages/core/src/db/postgres.ts @@ -24,6 +24,8 @@ export function createDialect(config: PostgresConfig): PostgresDialect { ssl: config.ssl, min: config.pool?.min ?? 0, max: config.pool?.max ?? 10, + idleTimeoutMillis: config.pool?.idleTimeoutMillis ?? 10_000, + connectionTimeoutMillis: config.pool?.connectionTimeoutMillis ?? 5_000, }); return new PostgresDialect({ pool }); From a4183dab92f53c6333de57a86c74390f8b7ed261 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 10:00:51 +0000 Subject: [PATCH 57/68] fix(core): read created collection inside transaction to avoid post-commit visibility gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On PostgreSQL/Hyperdrive, reading the just-inserted collection row from this.db after withTransaction() completes can return null, even though the row was committed — this causes a spurious CREATE_FAILED error and breaks the setup wizard. The pattern was already established in createField (with the same 'connection mutex deadlock' comment). Moving the post-insert read inside the transaction (using trx) guarantees the row is always visible. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/schema/registry.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 5d2e74bb3a..06a7aa630e 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -138,6 +138,8 @@ export class SchemaRegistry { // Derive hasSeo from supports array if not explicitly set const hasSeo = input.hasSeo ?? input.supports?.includes("seo") ?? false; + let collection: Collection | null = null; + await withTransaction(this.db, async (trx) => { await trx .insertInto("_emdash_collections") @@ -158,9 +160,21 @@ export class SchemaRegistry { // Create the content table for this collection await this.createContentTable(input.slug, trx); + + // Read via trx (not this.db) to avoid connection mutex deadlock on + // PostgreSQL/Hyperdrive where reading from this.db after a transaction + // may not see the just-committed row. Matches the pattern in createField. + const row = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", input.slug) + .selectAll() + .executeTakeFirst(); + + if (row) { + collection = this.mapCollectionRow(row); + } }); - const collection = await this.getCollection(input.slug); if (!collection) { throw new SchemaError("Failed to create collection", "CREATE_FAILED"); } From 7b354d6e2fdc45d3968cb28a028641d01fe7e06f Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 10:16:19 +0000 Subject: [PATCH 58/68] fix(core): move createField reads inside transaction to bypass Hyperdrive query cache Hyperdrive caches SELECT results for up to 60s and does not invalidate the cache for writes inside explicit transactions. When a collection is created (via withTransaction) and then createField immediately queries the collection via this.db, Hyperdrive returns the stale cached empty result, causing COLLECTION_NOT_FOUND. Moving all pre-transaction reads (getCollection, getField, maxSort) inside the createField transaction bypasses the cache, matching the existing approach in createCollection. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/schema/registry.ts | 60 +++++++++++++++++----------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 06a7aa630e..02380db130 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -341,39 +341,53 @@ export class SchemaRegistry { * Create a new field */ async createField(collectionSlug: string, input: CreateFieldInput): Promise { - const collection = await this.getCollection(collectionSlug); - if (!collection) { - throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); - } - - // Validate slug + // Validate slug before any DB work this.validateSlug(input.slug, "field"); if (RESERVED_FIELD_SLUGS.includes(input.slug)) { throw new SchemaError(`Field slug "${input.slug}" is reserved`, "RESERVED_SLUG"); } - // Check if field already exists - const existing = await this.getField(collectionSlug, input.slug); - if (existing) { - throw new SchemaError( - `Field "${input.slug}" already exists in collection "${collectionSlug}"`, - "FIELD_EXISTS", - ); - } - const id = ulid(); const columnType = FIELD_TYPE_TO_COLUMN[input.type]; - // Get max sort order - const maxSort = await this.db - .selectFrom("_emdash_fields") - .where("collection_id", "=", collection.id) - .select((eb) => eb.fn.max("sort_order").as("max")) - .executeTakeFirst(); + return withTransaction(this.db, async (trx) => { + // Read collection via trx to avoid Hyperdrive query cache returning a + // stale empty result when the collection was just created in a prior + // transaction. Transactional reads bypass the Hyperdrive cache. + const collection = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", collectionSlug) + .selectAll() + .executeTakeFirst(); - const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1; + if (!collection) { + throw new SchemaError(`Collection "${collectionSlug}" not found`, "COLLECTION_NOT_FOUND"); + } + + // Check if field already exists (via trx for the same cache-bypass reason) + const existingField = await trx + .selectFrom("_emdash_fields") + .where("collection_id", "=", collection.id) + .where("slug", "=", input.slug) + .selectAll() + .executeTakeFirst(); + + if (existingField) { + throw new SchemaError( + `Field "${input.slug}" already exists in collection "${collectionSlug}"`, + "FIELD_EXISTS", + ); + } + + // Get max sort order (via trx) + const maxSort = await trx + .selectFrom("_emdash_fields") + .where("collection_id", "=", collection.id) + .select((eb) => eb.fn.max("sort_order").as("max")) + .executeTakeFirst(); + + const sortOrder = input.sortOrder ?? (maxSort?.max ?? -1) + 1; - return withTransaction(this.db, async (trx) => { // Insert field record await trx .insertInto("_emdash_fields") From 962df5a38a11b251cfcdd08a26850637606cb65e Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 10:48:00 +0000 Subject: [PATCH 59/68] fix(core): move collection existence checks inside transactions to bypass Hyperdrive query cache applySeed previously called registry.getCollection() as a non-transactional pre-check before createCollection(), which caused Hyperdrive to cache the empty result. Subsequent transactional reads in createField() correctly bypass the cache, but the existence check in createCollection() was also non-transactional. Changes: - applySeed: remove the getCollection() pre-check; attempt createCollection() directly and catch SchemaError(COLLECTION_EXISTS) for skip/update handling - SchemaRegistry.createCollection: move the existence check inside withTransaction so it uses the same transactional read path as createField Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/schema/registry.ts | 17 ++++++---- packages/core/src/seed/apply.ts | 50 ++++++++++++++++------------ 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 02380db130..183baa76f3 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -124,12 +124,6 @@ export class SchemaRegistry { throw new SchemaError(`Collection slug "${input.slug}" is reserved`, "RESERVED_SLUG"); } - // Check if collection already exists - const existing = await this.getCollection(input.slug); - if (existing) { - throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); - } - const id = ulid(); // Insert collection record and create content table in a transaction @@ -141,6 +135,17 @@ export class SchemaRegistry { let collection: Collection | null = null; await withTransaction(this.db, async (trx) => { + // Check existence inside the transaction so transactional reads bypass + // the Hyperdrive query cache and see the current DB state. + const existing = await trx + .selectFrom("_emdash_collections") + .where("slug", "=", input.slug) + .select("id") + .executeTakeFirst(); + if (existing) { + throw new SchemaError(`Collection "${input.slug}" already exists`, "COLLECTION_EXISTS"); + } + await trx .insertInto("_emdash_collections") .values({ diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 3fb42030a4..8bef917cdc 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -19,7 +19,7 @@ import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; import type { MediaValue } from "../fields/types.js"; import { ssrfSafeFetch, validateExternalUrl } from "../import/ssrf.js"; -import { SchemaRegistry } from "../schema/registry.js"; +import { SchemaError, SchemaRegistry } from "../schema/registry.js"; import { FTSManager } from "../search/fts-manager.js"; import { setSiteSettings } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; @@ -123,14 +123,36 @@ export async function applySeed( const registry = new SchemaRegistry(db); for (const collection of seed.collections) { - // Check if collection exists - const existing = await registry.getCollection(collection.slug); - - if (existing) { + // Attempt to create the collection directly. createCollection does its + // own existence check inside a transaction, so we avoid a non-transactional + // pre-check here that Hyperdrive could cache as empty immediately after a + // prior collection was created in the same request. + let collectionExisted = false; + + try { + await registry.createCollection({ + slug: collection.slug, + label: collection.label, + labelSingular: collection.labelSingular, + description: collection.description, + icon: collection.icon, + supports: collection.supports || [], + source: "seed", + urlPattern: collection.urlPattern, + commentsEnabled: collection.commentsEnabled, + }); + result.collections.created++; + } catch (err) { + if (!(err instanceof SchemaError) || err.code !== "COLLECTION_EXISTS") { + throw err; + } + collectionExisted = true; if (onConflict === "error") { - throw new Error(`Conflict: collection "${collection.slug}" already exists`); + throw new Error(`Conflict: collection "${collection.slug}" already exists`, { cause: err }); } + } + if (collectionExisted) { if (onConflict === "update") { await registry.updateCollection(collection.slug, { label: collection.label, @@ -183,21 +205,7 @@ export async function applySeed( continue; } - // Create collection - await registry.createCollection({ - slug: collection.slug, - label: collection.label, - labelSingular: collection.labelSingular, - description: collection.description, - icon: collection.icon, - supports: collection.supports || [], - source: "seed", - urlPattern: collection.urlPattern, - commentsEnabled: collection.commentsEnabled, - }); - result.collections.created++; - - // Create fields + // Create fields (collection was just created above) for (const field of collection.fields) { await registry.createField(collection.slug, { slug: field.slug, From 544459f165e012cf0e17c8a9371a6af412e18ac0 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 11:06:54 +0000 Subject: [PATCH 60/68] fix(core): avoid Hyperdrive cache poisoning in taxonomy def creation during seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the non-transactional pre-check (SELECT → INSERT) pattern for taxonomy defs with an attempt-then-catch pattern: INSERT directly and catch duplicate-key errors (PostgreSQL 23505, SQLite SQLITE_CONSTRAINT_UNIQUE, libSQL message). The old SELECT pre-check ran as a standalone (non-transactional) query, so Hyperdrive could cache the empty result. If the setup wizard re-ran within the ~60s cache TTL after a partial failure, the cached null result would cause the INSERT to be attempted again, producing a unique-constraint violation. Also adds isDuplicateKeyError() helper (covers all three supported dialects). Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/seed/apply.ts | 79 +++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 8bef917cdc..3dc3b438c2 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -36,6 +36,21 @@ import type { const FILE_EXTENSION_PATTERN = /\.([a-z0-9]+)(?:\?|$)/i; import { validateSeed } from "./validate.js"; +/** + * Returns true if the error is a unique-constraint / duplicate-key violation. + * Detects across PostgreSQL (code 23505), better-sqlite3 (SQLITE_CONSTRAINT_UNIQUE), + * and libSQL/D1 (message substring). + */ +function isDuplicateKeyError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { code?: string }; + return ( + e.code === "23505" || + e.code === "SQLITE_CONSTRAINT_UNIQUE" || + (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) + ); +} + /** Pattern to remove file extensions */ const EXTENSION_PATTERN = /\.[^.]+$/; @@ -227,37 +242,17 @@ export async function applySeed( // 4-5. Taxonomies if (seed.taxonomies) { for (const taxonomy of seed.taxonomies) { - // Check if taxonomy definition exists - const existingDef = await db - .selectFrom("_emdash_taxonomy_defs") - .selectAll() - .where("name", "=", taxonomy.name) - .executeTakeFirst(); - - if (existingDef) { - if (onConflict === "error") { - throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`); - } - if (onConflict === "update") { - await db - .updateTable("_emdash_taxonomy_defs") - .set({ - label: taxonomy.label, - label_singular: taxonomy.labelSingular ?? null, - hierarchical: taxonomy.hierarchical ? 1 : 0, - collections: JSON.stringify(taxonomy.collections), - }) - .where("id", "=", existingDef.id) - .execute(); - // Taxonomy defs don't track an "updated" counter -- just the definition is updated - } - // skip: do nothing for the definition - } else { - // Create taxonomy definition + // Attempt INSERT first rather than SELECT then INSERT to avoid + // Hyperdrive caching the pre-check as empty right before the INSERT. + // Transactional reads bypass the Hyperdrive cache; standalone SELECTs + // do not, so a pre-check executed moments after a prior collection + // INSERT can return a stale null and then re-insert a duplicate row. + const defId = ulid(); + try { await db .insertInto("_emdash_taxonomy_defs") .values({ - id: ulid(), + id: defId, name: taxonomy.name, label: taxonomy.label, label_singular: taxonomy.labelSingular ?? null, @@ -266,6 +261,34 @@ export async function applySeed( }) .execute(); result.taxonomies.created++; + } catch (insertErr) { + if (!isDuplicateKeyError(insertErr)) throw insertErr; + // Row already exists — handle per onConflict + if (onConflict === "error") { + throw new Error(`Conflict: taxonomy "${taxonomy.name}" already exists`, { + cause: insertErr, + }); + } + if (onConflict === "update") { + const existingDef = await db + .selectFrom("_emdash_taxonomy_defs") + .select("id") + .where("name", "=", taxonomy.name) + .executeTakeFirst(); + if (existingDef) { + await db + .updateTable("_emdash_taxonomy_defs") + .set({ + label: taxonomy.label, + label_singular: taxonomy.labelSingular ?? null, + hierarchical: taxonomy.hierarchical ? 1 : 0, + collections: JSON.stringify(taxonomy.collections), + }) + .where("id", "=", existingDef.id) + .execute(); + } + } + // skip: do nothing for the definition } // Create terms (if provided) From b7412c518755b3b7637d077f9915314b7dc6d9ce Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 11:19:29 +0000 Subject: [PATCH 61/68] fix(core): quote \"window\" column in rate-limit SQL + fix taxonomy term Hyperdrive cache rate-limit.ts: `window` is a reserved keyword in PostgreSQL. The upsert and cleanup queries used it unquoted, producing "syntax error at or near 'window'" on every passkey options request. Quote it as \"window\" in the INSERT column list, ON CONFLICT clause, and DELETE WHERE condition. seed/apply.ts: Apply the same attempt-then-catch pattern to flat and hierarchical taxonomy term creation. The previous findBySlug() pre-check is a non-transactional SELECT that Hyperdrive caches as empty; on a retry within the ~60s TTL the INSERT would hit a unique-constraint violation on (name, slug). Now we INSERT first and catch duplicates (PostgreSQL 23505, SQLite SQLITE_CONSTRAINT_UNIQUE) to handle skip/update/error per onConflict. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/auth/rate-limit.ts | 9 ++-- packages/core/src/seed/apply.ts | 73 ++++++++++++++++------------ 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/core/src/auth/rate-limit.ts b/packages/core/src/auth/rate-limit.ts index 2710be0e30..a6833cefab 100644 --- a/packages/core/src/auth/rate-limit.ts +++ b/packages/core/src/auth/rate-limit.ts @@ -61,11 +61,12 @@ export async function checkRateLimit( ).toISOString(); const key = `${ip}:${endpoint}`; - // Atomic upsert: insert or increment, return current count + // Atomic upsert: insert or increment, return current count. + // "window" must be quoted — it is a reserved keyword in PostgreSQL. const result = await sql<{ count: number }>` - INSERT INTO _emdash_rate_limits (key, window, count) + INSERT INTO _emdash_rate_limits (key, "window", count) VALUES (${key}, ${windowStart}, 1) - ON CONFLICT (key, window) + ON CONFLICT (key, "window") DO UPDATE SET count = _emdash_rate_limits.count + 1 RETURNING count `.execute(db); @@ -151,7 +152,7 @@ export async function cleanupExpiredRateLimits( const cutoff = new Date(Date.now() - maxAgeSeconds * 1000).toISOString(); const result = await sql` - DELETE FROM _emdash_rate_limits WHERE window < ${cutoff} + DELETE FROM _emdash_rate_limits WHERE "window" < ${cutoff} `.execute(db); return Number(result.numAffectedRows ?? 0); diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 3dc3b438c2..6a0706894b 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -301,29 +301,33 @@ export async function applySeed( } else { // Flat taxonomy - create all terms for (const term of taxonomy.terms) { - const existing = await termRepo.findBySlug(taxonomy.name, term.slug); - if (existing) { + try { + await termRepo.create({ + name: taxonomy.name, + slug: term.slug, + label: term.label, + data: term.description ? { description: term.description } : undefined, + }); + result.taxonomies.terms++; + } catch (createErr) { + if (!isDuplicateKeyError(createErr)) throw createErr; if (onConflict === "error") { throw new Error( `Conflict: taxonomy term "${term.slug}" in "${taxonomy.name}" already exists`, + { cause: createErr }, ); } if (onConflict === "update") { - await termRepo.update(existing.id, { - label: term.label, - data: term.description ? { description: term.description } : {}, - }); - result.taxonomies.terms++; + const existing = await termRepo.findBySlug(taxonomy.name, term.slug); + if (existing) { + await termRepo.update(existing.id, { + label: term.label, + data: term.description ? { description: term.description } : {}, + }); + result.taxonomies.terms++; + } } // skip: do nothing - } else { - await termRepo.create({ - name: taxonomy.name, - slug: term.slug, - label: term.label, - data: term.description ? { description: term.description } : undefined, - }); - result.taxonomies.terms++; } } } @@ -716,23 +720,7 @@ async function applyHierarchicalTerms( if (!term.parent || slugToId.has(term.parent)) { const parentId = term.parent ? slugToId.get(term.parent) : undefined; - const existing = await termRepo.findBySlug(taxonomyName, term.slug); - if (existing) { - if (onConflict === "error") { - throw new Error( - `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`, - ); - } - if (onConflict === "update") { - await termRepo.update(existing.id, { - label: term.label, - parentId, - data: term.description ? { description: term.description } : {}, - }); - result.taxonomies.terms++; - } - slugToId.set(term.slug, existing.id); - } else { + try { const created = await termRepo.create({ name: taxonomyName, slug: term.slug, @@ -742,6 +730,27 @@ async function applyHierarchicalTerms( }); slugToId.set(term.slug, created.id); result.taxonomies.terms++; + } catch (createErr) { + if (!isDuplicateKeyError(createErr)) throw createErr; + if (onConflict === "error") { + throw new Error( + `Conflict: taxonomy term "${term.slug}" in "${taxonomyName}" already exists`, + { cause: createErr }, + ); + } + // Resolve ID for parent-child chain regardless of skip/update + const existing = await termRepo.findBySlug(taxonomyName, term.slug); + if (existing) { + if (onConflict === "update") { + await termRepo.update(existing.id, { + label: term.label, + parentId, + data: term.description ? { description: term.description } : {}, + }); + result.taxonomies.terms++; + } + slugToId.set(term.slug, existing.id); + } } processedThisPass.push(term.slug); From a7e951241771e74fa7c18b108dabb1af34c6b811 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 12:01:00 +0000 Subject: [PATCH 62/68] fix(core): wrap setup reads in transactions to bypass Hyperdrive query cache All four setup/auth-middleware reads that follow recent writes (setup_complete, setup_state, user count) were non-transactional and subject to Hyperdrive's read-replica cache. This caused two regressions: 1. admin-verify returned 400 on the first attempt because emdash:setup_state was served from cache as null, failing the step !== "admin" guard. 2. After a successful verify, the setup middleware kept redirecting to setup because emdash:setup_complete was served from cache as null. Fix: wrap every state-read in withTransaction so Hyperdrive routes the query to the primary rather than a cached replica. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/astro/middleware/setup.ts | 68 +++++++------- .../astro/routes/api/setup/admin-verify.ts | 59 +++++++++--- .../core/src/astro/routes/api/setup/index.ts | 21 ++++- .../core/src/astro/routes/api/setup/status.ts | 90 ++++++++++--------- 4 files changed, 148 insertions(+), 90 deletions(-) diff --git a/packages/core/src/astro/middleware/setup.ts b/packages/core/src/astro/middleware/setup.ts index 704afc9f9b..d91b2c1d16 100644 --- a/packages/core/src/astro/middleware/setup.ts +++ b/packages/core/src/astro/middleware/setup.ts @@ -15,6 +15,7 @@ import { defineMiddleware } from "astro:middleware"; import { getAuthMode } from "../../auth/mode.js"; +import { withTransaction } from "../../db/transaction.js"; export const onRequest = defineMiddleware(async (context, next) => { // Only check setup on admin routes (but not the setup page itself) @@ -31,52 +32,51 @@ export const onRequest = defineMiddleware(async (context, next) => { } try { - // Check setup_complete flag - const setupComplete = await emdash.db - .selectFrom("options") - .select("value") - .where("name", "=", "emdash:setup_complete") - .executeTakeFirst(); + // Read setup_complete and user count in a single transaction so + // Hyperdrive bypasses its query cache and we always see the values + // written by the preceding setup/admin-verify request. + const { isComplete, userCount } = await withTransaction(emdash.db, async (trx) => { + const completeRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); - // Value is JSON-encoded, parse it. Accepts both boolean true and string "true" - const isComplete = - setupComplete && - (() => { - try { - const parsed = JSON.parse(setupComplete.value); - return parsed === true || parsed === "true"; - } catch { - return false; - } - })(); + const complete = + completeRow && + (() => { + try { + const parsed = JSON.parse(completeRow.value); + return parsed === true || parsed === "true"; + } catch { + return false; + } + })(); + + const countResult = await trx + .selectFrom("users") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirst(); + + return { isComplete: complete, userCount: Number(countResult?.count ?? 0) }; + }); if (!isComplete) { - // Redirect to setup wizard return context.redirect("/_emdash/admin/setup"); } - // Check auth mode - user verification differs by mode const authMode = getAuthMode(emdash.config); - // In passkey mode, verify users exist - // In Access mode, skip this check - first user is created on first Access login - if (authMode.type === "passkey") { - // Setup is marked complete, but verify users exist - // This catches edge case where setup_complete is true but no users - const userCount = await emdash.db - .selectFrom("users") - .select((eb) => eb.fn.countAll().as("count")) - .executeTakeFirstOrThrow(); - - if (userCount.count === 0) { - // No users - need to complete admin creation - return context.redirect("/_emdash/admin/setup"); - } + if (authMode.type === "passkey" && userCount === 0) { + return context.redirect("/_emdash/admin/setup"); } } catch (error) { // If the options table doesn't exist yet, redirect to setup // This handles fresh installations where migrations haven't run - if (error instanceof Error && error.message.includes("no such table")) { + if ( + error instanceof Error && + (error.message.includes("no such table") || error.message.includes("does not exist")) + ) { return context.redirect("/_emdash/admin/setup"); } diff --git a/packages/core/src/astro/routes/api/setup/admin-verify.ts b/packages/core/src/astro/routes/api/setup/admin-verify.ts index b8197ffad7..7299657720 100644 --- a/packages/core/src/astro/routes/api/setup/admin-verify.ts +++ b/packages/core/src/astro/routes/api/setup/admin-verify.ts @@ -19,6 +19,7 @@ import { setupAdminVerifyBody } from "#api/schemas.js"; import { createChallengeStore } from "#auth/challenge-store.js"; import { getPasskeyConfig } from "#auth/passkey-config.js"; import { OptionsRepository } from "#db/repositories/options.js"; +import { withTransaction } from "#db/transaction.js"; export const POST: APIRoute = async ({ request, locals }) => { const { emdash } = locals; @@ -28,29 +29,67 @@ export const POST: APIRoute = async ({ request, locals }) => { } try { - // Check if setup is already complete - const options = new OptionsRepository(emdash.db); - const setupComplete = await options.get("emdash:setup_complete"); + // Read all setup-state values in a single transaction so Hyperdrive + // bypasses its query cache and we always see the values written by the + // preceding admin-options request in the same setup flow. + const { setupComplete, userCount, setupState } = await withTransaction( + emdash.db, + async (trx) => { + const completeRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); + const sc = completeRow + ? (() => { + try { + return JSON.parse(completeRow.value); + } catch { + return null; + } + })() + : null; + + const countResult = await trx + .selectFrom("users") + .select((eb) => eb.fn.countAll().as("count")) + .executeTakeFirst(); + const uc = countResult?.count ?? 0; + + const stateRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_state") + .executeTakeFirst(); + const ss = stateRow + ? (() => { + try { + return JSON.parse(stateRow.value); + } catch { + return null; + } + })() + : null; + + return { setupComplete: sc, userCount: uc, setupState: ss }; + }, + ); if (setupComplete === true || setupComplete === "true") { return apiError("SETUP_COMPLETE", "Setup already complete", 400); } - // Check if any users exist - const adapter = createKyselyAdapter(emdash.db); - const userCount = await adapter.countUsers(); - if (userCount > 0) { return apiError("ADMIN_EXISTS", "Admin user already exists", 400); } - // Get setup state - const setupState = await options.get("emdash:setup_state"); - if (!setupState || setupState.step !== "admin") { return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400); } + const adapter = createKyselyAdapter(emdash.db); + const options = new OptionsRepository(emdash.db); + // Parse request body const body = await parseBody(request, setupAdminVerifyBody); if (isParseError(body)) return body; diff --git a/packages/core/src/astro/routes/api/setup/index.ts b/packages/core/src/astro/routes/api/setup/index.ts index c4b246ebaf..8161ea6ee1 100644 --- a/packages/core/src/astro/routes/api/setup/index.ts +++ b/packages/core/src/astro/routes/api/setup/index.ts @@ -15,6 +15,7 @@ import { setupBody } from "#api/schemas.js"; import { getAuthMode } from "#auth/mode.js"; import { runMigrations } from "#db/migrations/runner.js"; import { OptionsRepository } from "#db/repositories/options.js"; +import { withTransaction } from "#db/transaction.js"; import { applySeed } from "#seed/apply.js"; import { loadSeed } from "#seed/load.js"; import { validateSeed } from "#seed/validate.js"; @@ -28,11 +29,27 @@ export const POST: APIRoute = async ({ request, url, locals }) => { try { // Guard: reject if setup has already been completed. + // Use a transaction so Hyperdrive bypasses its query cache and we see + // the true value rather than a stale null from a recent write. // The options table may not exist on first-ever setup (pre-migration), // so a query failure means setup hasn't run yet — allow it to proceed. try { - const options = new OptionsRepository(emdash.db); - const setupComplete = await options.get("emdash:setup_complete"); + const setupCompleteRow = await withTransaction(emdash.db, async (trx) => + trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(), + ); + const setupComplete = setupCompleteRow + ? (() => { + try { + return JSON.parse(setupCompleteRow.value); + } catch { + return null; + } + })() + : null; if (setupComplete === true || setupComplete === "true") { return apiError("ALREADY_CONFIGURED", "Setup has already been completed", 409); diff --git a/packages/core/src/astro/routes/api/setup/status.ts b/packages/core/src/astro/routes/api/setup/status.ts index 4f9c068b89..c75c0cf5bb 100644 --- a/packages/core/src/astro/routes/api/setup/status.ts +++ b/packages/core/src/astro/routes/api/setup/status.ts @@ -10,6 +10,7 @@ export const prerender = false; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { getAuthMode } from "#auth/mode.js"; +import { withTransaction } from "#db/transaction.js"; import { loadUserSeed } from "#seed/load.js"; export const GET: APIRoute = async ({ locals }) => { @@ -20,36 +21,49 @@ export const GET: APIRoute = async ({ locals }) => { } try { - // Check if setup is complete - const setupComplete = await emdash.db - .selectFrom("options") - .select("value") - .where("name", "=", "emdash:setup_complete") - .executeTakeFirst(); - - // Value is JSON-encoded, parse it. Accepts both boolean true and string "true" - const isComplete = - setupComplete && - (() => { - try { - const parsed = JSON.parse(setupComplete.value); - return parsed === true || parsed === "true"; - } catch { - return false; - } - })(); - - // Also check if users exist - let hasUsers = false; - try { - const userCount = await emdash.db + // Read all setup-state values in a single transaction so Hyperdrive + // bypasses its query cache and we always see the latest written values. + const { isComplete, hasUsers, setupState } = await withTransaction(emdash.db, async (trx) => { + const completeRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); + + const complete = + completeRow && + (() => { + try { + const parsed = JSON.parse(completeRow.value); + return parsed === true || parsed === "true"; + } catch { + return false; + } + })(); + + const countResult = await trx .selectFrom("users") .select((eb) => eb.fn.countAll().as("count")) - .executeTakeFirstOrThrow(); - hasUsers = userCount.count > 0; - } catch { - // Users table might not exist yet - } + .executeTakeFirst(); + const foundUsers = Number(countResult?.count ?? 0) > 0; + + const stateRow = await trx + .selectFrom("options") + .select("value") + .where("name", "=", "emdash:setup_state") + .executeTakeFirst(); + const state = stateRow + ? (() => { + try { + return JSON.parse(stateRow.value); + } catch { + return null; + } + })() + : null; + + return { isComplete: complete, hasUsers: foundUsers, setupState: state }; + }); // Setup is complete only if flag is set AND users exist if (isComplete && hasUsers) { @@ -62,23 +76,11 @@ export const GET: APIRoute = async ({ locals }) => { // step: "start" | "site" | "admin" | "complete" let step: "start" | "site" | "admin" = "start"; - // Get setup state if it exists - const setupState = await emdash.db - .selectFrom("options") - .select("value") - .where("name", "=", "emdash:setup_state") - .executeTakeFirst(); - if (setupState) { - try { - const state = JSON.parse(setupState.value); - if (state.step === "admin") { - step = "admin"; - } else if (state.step === "site") { - step = "site"; - } - } catch { - // Invalid state, stay at start + if (setupState.step === "admin") { + step = "admin"; + } else if (setupState.step === "site") { + step = "site"; } } From 6a03c9a171d8c310dc0ad99a811a6ae807ed5e5a Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 12:05:24 +0000 Subject: [PATCH 63/68] fix(core): correct import path for withTransaction in setup middleware Used relative ../../db/transaction.js which doesn't exist (the alias #db/* maps to src/database/*). Correct relative path is ../../database/transaction.js. Co-Authored-By: Claude Sonnet 4.6 --- packages/core/src/astro/middleware/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/astro/middleware/setup.ts b/packages/core/src/astro/middleware/setup.ts index d91b2c1d16..0d4dda2287 100644 --- a/packages/core/src/astro/middleware/setup.ts +++ b/packages/core/src/astro/middleware/setup.ts @@ -15,7 +15,7 @@ import { defineMiddleware } from "astro:middleware"; import { getAuthMode } from "../../auth/mode.js"; -import { withTransaction } from "../../db/transaction.js"; +import { withTransaction } from "../../database/transaction.js"; export const onRequest = defineMiddleware(async (context, next) => { // Only check setup on admin routes (but not the setup page itself) From ff6cac7210014102cd9d583cb65dfc7faeee6fc3 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Wed, 6 May 2026 12:06:34 +0000 Subject: [PATCH 64/68] style: format --- demos/cloudflare/astro.config.mjs | 37 +- .../cloudflare/scripts/bootstrap-postgres.mjs | 2 +- demos/cloudflare/wrangler.jsonc | 70 ++-- packages/cloudflare/src/db/hyperdrive.ts | 9 +- packages/core/src/astro/middleware.ts | 4 +- packages/core/src/seed/apply.ts | 4 +- .../plugins/notify-on-publish/src/index.ts | 18 +- .../notify-on-publish/src/sandbox-entry.ts | 331 +++++++++-------- .../plugins/notify-on-publish/tsconfig.json | 14 +- packages/plugins/notify-postmark/src/index.ts | 18 +- .../notify-postmark/src/sandbox-entry.ts | 335 +++++++++--------- .../plugins/notify-postmark/tsconfig.json | 14 +- 12 files changed, 425 insertions(+), 431 deletions(-) diff --git a/demos/cloudflare/astro.config.mjs b/demos/cloudflare/astro.config.mjs index 6242ed0f2e..d89d105328 100644 --- a/demos/cloudflare/astro.config.mjs +++ b/demos/cloudflare/astro.config.mjs @@ -5,16 +5,15 @@ import { hyperdrive, r2, sandbox, -// cloudflareCache, + // cloudflareCache, } from "@emdash-cms/cloudflare"; import { formsPlugin } from "@emdash-cms/plugin-forms"; +import { notifyOnPublishPlugin } from "@emdash-cms/plugin-notify-on-publish"; +import { notifyPostmarkPlugin } from "@emdash-cms/plugin-notify-postmark"; import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier"; import { defineConfig, fontProviders } from "astro/config"; import emdash from "emdash/astro"; -import { notifyOnPublishPlugin } from "@emdash-cms/plugin-notify-on-publish"; -import { notifyPostmarkPlugin } from "@emdash-cms/plugin-notify-postmark"; - export default defineConfig({ output: "server", adapter: cloudflare({ @@ -51,18 +50,18 @@ export default defineConfig({ formsPlugin(), notifyOnPublishPlugin(), notifyPostmarkPlugin(), - // notifyOnPublishPlugin({ - // recipients: ["ljanaideh@atypon.com"], - // collections: ["posts"], - // from: "onboarding@resend.dev", - // siteUrl: "https://emdash-laith.laithaljanaideh.workers.dev", - // }), - // notifyOnPublishPlugin({ - // recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), - // collections: ["posts"], - // from: process.env.EMAIL_FROM || "onboarding@resend.dev", - // siteUrl: process.env.SITE_URL || "https://emdash-laith.laithaljanaideh.workers.dev", - // }), + // notifyOnPublishPlugin({ + // recipients: ["ljanaideh@atypon.com"], + // collections: ["posts"], + // from: "onboarding@resend.dev", + // siteUrl: "https://emdash-laith.laithaljanaideh.workers.dev", + // }), + // notifyOnPublishPlugin({ + // recipients: (process.env.EMAIL_TO || "").split(",").map(s => s.trim()).filter(Boolean), + // collections: ["posts"], + // from: process.env.EMAIL_FROM || "onboarding@resend.dev", + // siteUrl: process.env.SITE_URL || "https://emdash-laith.laithaljanaideh.workers.dev", + // }), ], // Sandboxed plugins (run in isolated workers) sandboxed: [], @@ -73,9 +72,9 @@ export default defineConfig({ }), ], experimental: { - // cache: { - // provider: cloudflareCache(), - // }, + // cache: { + // provider: cloudflareCache(), + // }, routeRules: { "/": { maxAge: 3_600, diff --git a/demos/cloudflare/scripts/bootstrap-postgres.mjs b/demos/cloudflare/scripts/bootstrap-postgres.mjs index 2c875327da..d8fb0b1d21 100644 --- a/demos/cloudflare/scripts/bootstrap-postgres.mjs +++ b/demos/cloudflare/scripts/bootstrap-postgres.mjs @@ -6,9 +6,9 @@ * DATABASE_URL="postgres://user:pass@host:5432/db" node scripts/bootstrap-postgres.mjs */ +import { runMigrations } from "emdash/db"; import { Kysely, PostgresDialect } from "kysely"; import pg from "pg"; -import { runMigrations } from "emdash/db"; const { Pool } = pg; diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 2204d20ea4..7e9e73ece9 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -1,42 +1,42 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "emdash-laith", - "main": "./src/worker.ts", - "compatibility_date": "2026-01-14", - "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], + "$schema": "node_modules/wrangler/config-schema.json", + "name": "emdash-laith", + "main": "./src/worker.ts", + "compatibility_date": "2026-01-14", + "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], - // Hyperdrive binding — emdash-pg config pointing to emdash-demo RDS - "hyperdrive": [ - { - "binding": "HYPERDRIVE", - "id": "2b7fc91df2d24a7cb7e434120d82060f" - } - ], + // Hyperdrive binding — emdash-pg config pointing to emdash-demo RDS + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "2b7fc91df2d24a7cb7e434120d82060f", + }, + ], - // R2 bucket — points to existing my-emdash-media bucket - "r2_buckets": [ - { - "binding": "MEDIA", - "bucket_name": "my-emdash-media" - } - ], + // R2 bucket — points to existing my-emdash-media bucket + "r2_buckets": [ + { + "binding": "MEDIA", + "bucket_name": "my-emdash-media", + }, + ], - "observability": { - "enabled": true - }, + "observability": { + "enabled": true, + }, - // KV namespace for Astro session storage - "kv_namespaces": [ - { - "binding": "SESSION", - "id": "0516c5af42c24460b6a9eba751ffc0e3" - } - ], + // KV namespace for Astro session storage + "kv_namespaces": [ + { + "binding": "SESSION", + "id": "0516c5af42c24460b6a9eba751ffc0e3", + }, + ], - // Worker Loader for plugin sandboxing - "worker_loaders": [ - { - "binding": "LOADER" - } - ] + // Worker Loader for plugin sandboxing + "worker_loaders": [ + { + "binding": "LOADER", + }, + ], } diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index fcbd81765f..449eb7514c 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -72,10 +72,11 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { await client.connect(); // Kysely calls release() when it's done with the connection. // We close the Client rather than returning it to a pool. - (client as Client & { release: (destroy?: boolean) => Promise }).release = - async (_destroy?: boolean) => { - await client.end().catch(() => {}); - }; + (client as Client & { release: (destroy?: boolean) => Promise }).release = async ( + _destroy?: boolean, + ) => { + await client.end().catch(() => {}); + }; return client as Client & { release: (destroy?: boolean) => Promise }; })(); diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 0a01adf355..e401dd4b07 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -487,7 +487,9 @@ export const onRequest = defineMiddleware(async (context, next) => { console.log(`[mw] ${url.pathname} — calling next()`); const t0 = performance.now(); const response = await next(); - console.log(`[mw] ${url.pathname} — next() done in ${Math.round(performance.now() - t0)}ms`); + console.log( + `[mw] ${url.pathname} — next() done in ${Math.round(performance.now() - t0)}ms`, + ); timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" }); timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" }); return finalizeResponse(response, timings); diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 6a0706894b..2779b3f6d3 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -163,7 +163,9 @@ export async function applySeed( } collectionExisted = true; if (onConflict === "error") { - throw new Error(`Conflict: collection "${collection.slug}" already exists`, { cause: err }); + throw new Error(`Conflict: collection "${collection.slug}" already exists`, { + cause: err, + }); } } diff --git a/packages/plugins/notify-on-publish/src/index.ts b/packages/plugins/notify-on-publish/src/index.ts index 9f286314c3..f2faf387d9 100644 --- a/packages/plugins/notify-on-publish/src/index.ts +++ b/packages/plugins/notify-on-publish/src/index.ts @@ -1,13 +1,13 @@ import type { PluginDescriptor } from "emdash"; export function notifyOnPublishPlugin(): PluginDescriptor { - return { - id: "notify-on-publish", - version: "1.0.0", - format: "standard", - entrypoint: "@emdash-cms/plugin-notify-on-publish/sandbox", - capabilities: ["read:content", "network:fetch"], - allowedHosts: ["api.resend.com", "webhook.site"], - options: {}, - }; + return { + id: "notify-on-publish", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-notify-on-publish/sandbox", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["api.resend.com", "webhook.site"], + options: {}, + }; } diff --git a/packages/plugins/notify-on-publish/src/sandbox-entry.ts b/packages/plugins/notify-on-publish/src/sandbox-entry.ts index aca2ef3f07..7d1d8d369f 100644 --- a/packages/plugins/notify-on-publish/src/sandbox-entry.ts +++ b/packages/plugins/notify-on-publish/src/sandbox-entry.ts @@ -5,70 +5,67 @@ const RESEND_ENDPOINT = "https://api.resend.com/emails"; const DEFAULT_FROM = "onboarding@resend.dev"; export default definePlugin({ - hooks: { - "content:afterPublish": { - handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { - const content = event.content as { - id?: string; - title?: string; - slug?: string; - publishedAt?: string; - email?: string | string[]; - data?: Record; - fields?: { email?: string }; - [key: string]: unknown; - }; - - try { - ctx.log.info( - `[notify-on-publish] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, - ); - - const rawRecipient = - content.email ?? - content.data?.email ?? - content.fields?.email ?? - findEmailDeep(content); - - const recipients = normalizeRecipients(rawRecipient); - if (recipients.length === 0) { - ctx.log.info( - `[notify-on-publish] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, - ); - return; - } - - const apiKey = resolveEnv(ctx, "RESEND_API_KEY"); - if (!apiKey) { - ctx.log.error(`[notify-on-publish] RESEND_API_KEY not in ctx.env`); - return; - } - - const http = (ctx as { http?: { fetch: typeof fetch } }).http; - if (!http?.fetch) { - ctx.log.error(`[notify-on-publish] ctx.http.fetch unavailable`); - return; - } - - const title = String(content.title ?? content.id ?? "(untitled)"); - const slug = String(content.slug ?? content.id ?? ""); - const publishedAt = - typeof content.publishedAt === "string" - ? content.publishedAt - : new Date().toISOString(); - const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; - const collectionLabel = capitalize(event.collection); - - ctx.log.info( - `[notify-on-publish] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, - ); - - const text = `"${title}" was just published. + hooks: { + "content:afterPublish": { + handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { + const content = event.content as { + id?: string; + title?: string; + slug?: string; + publishedAt?: string; + email?: string | string[]; + data?: Record; + fields?: { email?: string }; + [key: string]: unknown; + }; + + try { + ctx.log.info( + `[notify-on-publish] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, + ); + + const rawRecipient = + content.email ?? content.data?.email ?? content.fields?.email ?? findEmailDeep(content); + + const recipients = normalizeRecipients(rawRecipient); + if (recipients.length === 0) { + ctx.log.info( + `[notify-on-publish] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, + ); + return; + } + + const apiKey = resolveEnv(ctx, "RESEND_API_KEY"); + if (!apiKey) { + ctx.log.error(`[notify-on-publish] RESEND_API_KEY not in ctx.env`); + return; + } + + const http = (ctx as { http?: { fetch: typeof fetch } }).http; + if (!http?.fetch) { + ctx.log.error(`[notify-on-publish] ctx.http.fetch unavailable`); + return; + } + + const title = String(content.title ?? content.id ?? "(untitled)"); + const slug = String(content.slug ?? content.id ?? ""); + const publishedAt = + typeof content.publishedAt === "string" + ? content.publishedAt + : new Date().toISOString(); + const from = resolveEnv(ctx, "EMAIL_FROM") ?? DEFAULT_FROM; + const collectionLabel = capitalize(event.collection); + + ctx.log.info( + `[notify-on-publish] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, + ); + + const text = `"${title}" was just published. Collection: ${event.collection} Slug: ${slug} Published: ${publishedAt}`; - const html = `
+ const html = `

${escapeHtml(collectionLabel)} published: ${escapeHtml(title)}

Collection: ${escapeHtml(event.collection)}
@@ -76,58 +73,56 @@ Published: ${publishedAt}`; Published: ${escapeHtml(publishedAt)}

`; - const t0 = Date.now(); - let res: Response; - try { - res = await http.fetch(RESEND_ENDPOINT, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - from, - to: recipients, - subject: `${collectionLabel} published: ${title}`, - text, - html, - }), - }); - ctx.log.info( - `[notify-on-publish] Resend status=${res.status} elapsed_ms=${Date.now() - t0}`, - ); - } catch (fetchErr) { - ctx.log.error( - `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, - ); - return; - } - - if (!res.ok) { - const errText = await res.text().catch(() => "(body unreadable)"); - ctx.log.error( - `[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`, - ); - return; - } - - let respJson: { id?: string } = {}; - try { - respJson = (await res.json()) as { id?: string }; - } catch { - /* ignore */ - } - ctx.log.info( - `[notify-on-publish] SENT to=[${recipients.join(", ")}] resend_id=${respJson?.id ?? "unknown"}`, - ); - } catch (topErr) { - ctx.log.error( - `[notify-on-publish] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, - ); - } - }, - }, - }, + const t0 = Date.now(); + let res: Response; + try { + res = await http.fetch(RESEND_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from, + to: recipients, + subject: `${collectionLabel} published: ${title}`, + text, + html, + }), + }); + ctx.log.info( + `[notify-on-publish] Resend status=${res.status} elapsed_ms=${Date.now() - t0}`, + ); + } catch (fetchErr) { + ctx.log.error( + `[notify-on-publish] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + ); + return; + } + + if (!res.ok) { + const errText = await res.text().catch(() => "(body unreadable)"); + ctx.log.error(`[notify-on-publish] Resend ${res.status}: ${errText.slice(0, 500)}`); + return; + } + + let respJson: { id?: string } = {}; + try { + respJson = (await res.json()) as { id?: string }; + } catch { + /* ignore */ + } + ctx.log.info( + `[notify-on-publish] SENT to=[${recipients.join(", ")}] resend_id=${respJson?.id ?? "unknown"}`, + ); + } catch (topErr) { + ctx.log.error( + `[notify-on-publish] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, + ); + } + }, + }, + }, }); const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; @@ -141,75 +136,75 @@ const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; * Deduplicates and validates each. */ function normalizeRecipients(raw: unknown): string[] { - if (!raw) return []; - const candidates: string[] = []; - if (Array.isArray(raw)) { - for (const item of raw) { - if (typeof item === "string") candidates.push(...splitList(item)); - } - } else if (typeof raw === "string") { - candidates.push(...splitList(raw)); - } - const seen = new Set(); - const out: string[] = []; - for (const c of candidates) { - const trimmed = c.trim(); - if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; - const key = trimmed.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(trimmed); - } - return out; + if (!raw) return []; + const candidates: string[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === "string") candidates.push(...splitList(item)); + } + } else if (typeof raw === "string") { + candidates.push(...splitList(raw)); + } + const seen = new Set(); + const out: string[] = []; + for (const c of candidates) { + const trimmed = c.trim(); + if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; } function splitList(s: string): string[] { - return s.split(/[,;\s]+/).filter(Boolean); + return s.split(/[,;\s]+/).filter(Boolean); } function findEmailDeep(obj: unknown, depth = 0): string | string[] | undefined { - if (!obj || typeof obj !== "object" || depth > 4) return undefined; - const record = obj as Record; - for (const [key, value] of Object.entries(record)) { - const k = key.toLowerCase(); - if (k === "email" || k === "emails") { - if (typeof value === "string" && normalizeRecipients(value).length > 0) { - return value; - } - if (Array.isArray(value) && normalizeRecipients(value).length > 0) { - return value as string[]; - } - } - } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - const nested = findEmailDeep(value, depth + 1); - if (nested) return nested; - } - } - return undefined; + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + const k = key.toLowerCase(); + if (k === "email" || k === "emails") { + if (typeof value === "string" && normalizeRecipients(value).length > 0) { + return value; + } + if (Array.isArray(value) && normalizeRecipients(value).length > 0) { + return value as string[]; + } + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; } function resolveEnv(ctx: PluginContext, name: string): string | undefined { - const env = (ctx as { env?: Record }).env; - if (env && typeof env[name] === "string") return env[name] as string; - const g = globalThis as unknown as Record; - if (typeof g[name] === "string") return g[name] as string; - const proc = g.process as { env?: Record } | undefined; - if (proc?.env?.[name]) return proc.env[name]; - return undefined; + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; + return undefined; } function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } function capitalize(s: string): string { - if (!s) return s; - return s.charAt(0).toUpperCase() + s.slice(1); + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/packages/plugins/notify-on-publish/tsconfig.json b/packages/plugins/notify-on-publish/tsconfig.json index f677f8d5eb..f7304871d7 100644 --- a/packages/plugins/notify-on-publish/tsconfig.json +++ b/packages/plugins/notify-on-publish/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/plugins/notify-postmark/src/index.ts b/packages/plugins/notify-postmark/src/index.ts index 2d93fe888b..e88462f31a 100644 --- a/packages/plugins/notify-postmark/src/index.ts +++ b/packages/plugins/notify-postmark/src/index.ts @@ -1,13 +1,13 @@ import type { PluginDescriptor } from "emdash"; export function notifyPostmarkPlugin(): PluginDescriptor { - return { - id: "notify-postmark", - version: "1.0.0", - format: "standard", - entrypoint: "@emdash-cms/plugin-notify-postmark/sandbox", - capabilities: ["read:content", "network:fetch"], - allowedHosts: ["api.postmarkapp.com"], - options: {}, - }; + return { + id: "notify-postmark", + version: "1.0.0", + format: "standard", + entrypoint: "@emdash-cms/plugin-notify-postmark/sandbox", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["api.postmarkapp.com"], + options: {}, + }; } diff --git a/packages/plugins/notify-postmark/src/sandbox-entry.ts b/packages/plugins/notify-postmark/src/sandbox-entry.ts index f89d6e8d23..4b413881f7 100644 --- a/packages/plugins/notify-postmark/src/sandbox-entry.ts +++ b/packages/plugins/notify-postmark/src/sandbox-entry.ts @@ -6,70 +6,67 @@ const POSTMARK_ENDPOINT = "https://api.postmarkapp.com/email"; const DEFAULT_FROM = "notifications@example.com"; export default definePlugin({ - hooks: { - "content:afterPublish": { - handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { - const content = event.content as { - id?: string; - title?: string; - slug?: string; - publishedAt?: string; - email?: string | string[]; - data?: Record; - fields?: { email?: string }; - [key: string]: unknown; - }; - - try { - ctx.log.info( - `[notify-postmark] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, - ); - - const rawRecipient = - content.email ?? - content.data?.email ?? - content.fields?.email ?? - findEmailDeep(content); - - const recipients = normalizeRecipients(rawRecipient); - if (recipients.length === 0) { - ctx.log.info( - `[notify-postmark] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, - ); - return; - } - - const apiKey = resolveEnv(ctx, "POSTMARK_SERVER_TOKEN"); - if (!apiKey) { - ctx.log.error(`[notify-postmark] POSTMARK_SERVER_TOKEN not in ctx.env`); - return; - } - - const http = (ctx as { http?: { fetch: typeof fetch } }).http; - if (!http?.fetch) { - ctx.log.error(`[notify-postmark] ctx.http.fetch unavailable`); - return; - } - - const title = String(content.title ?? content.id ?? "(untitled)"); - const slug = String(content.slug ?? content.id ?? ""); - const publishedAt = - typeof content.publishedAt === "string" - ? content.publishedAt - : new Date().toISOString(); - const from = resolveEnv(ctx, "POSTMARK_FROM") ?? DEFAULT_FROM; - const collectionLabel = capitalize(event.collection); - - ctx.log.info( - `[notify-postmark] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, - ); - - const text = `"${title}" was just published. + hooks: { + "content:afterPublish": { + handler: async (event: ContentPublishStateChangeEvent, ctx: PluginContext) => { + const content = event.content as { + id?: string; + title?: string; + slug?: string; + publishedAt?: string; + email?: string | string[]; + data?: Record; + fields?: { email?: string }; + [key: string]: unknown; + }; + + try { + ctx.log.info( + `[notify-postmark] fired collection=${event.collection} id=${content.id ?? "(no-id)"}`, + ); + + const rawRecipient = + content.email ?? content.data?.email ?? content.fields?.email ?? findEmailDeep(content); + + const recipients = normalizeRecipients(rawRecipient); + if (recipients.length === 0) { + ctx.log.info( + `[notify-postmark] skip: ${event.collection}/${content.id ?? "(no-id)"} has no email field (opt-in)`, + ); + return; + } + + const apiKey = resolveEnv(ctx, "POSTMARK_SERVER_TOKEN"); + if (!apiKey) { + ctx.log.error(`[notify-postmark] POSTMARK_SERVER_TOKEN not in ctx.env`); + return; + } + + const http = (ctx as { http?: { fetch: typeof fetch } }).http; + if (!http?.fetch) { + ctx.log.error(`[notify-postmark] ctx.http.fetch unavailable`); + return; + } + + const title = String(content.title ?? content.id ?? "(untitled)"); + const slug = String(content.slug ?? content.id ?? ""); + const publishedAt = + typeof content.publishedAt === "string" + ? content.publishedAt + : new Date().toISOString(); + const from = resolveEnv(ctx, "POSTMARK_FROM") ?? DEFAULT_FROM; + const collectionLabel = capitalize(event.collection); + + ctx.log.info( + `[notify-postmark] sending: collection=${event.collection} to=[${recipients.join(", ")}] from=${from}`, + ); + + const text = `"${title}" was just published. Collection: ${event.collection} Slug: ${slug} Published: ${publishedAt}`; - const html = `
+ const html = `

${escapeHtml(collectionLabel)} published: ${escapeHtml(title)}

Collection: ${escapeHtml(event.collection)}
@@ -77,134 +74,132 @@ Published: ${publishedAt}`; Published: ${escapeHtml(publishedAt)}

`; - const t0 = Date.now(); - let res: Response; - try { - res = await http.fetch(POSTMARK_ENDPOINT, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "X-Postmark-Server-Token": apiKey, - }, - body: JSON.stringify({ - From: from, - To: recipients.join(", "), - Subject: `${collectionLabel} published: ${title}`, - TextBody: text, - HtmlBody: html, - MessageStream: "outbound", - }), - }); - ctx.log.info( - `[notify-postmark] Postmark status=${res.status} elapsed_ms=${Date.now() - t0}`, - ); - } catch (fetchErr) { - ctx.log.error( - `[notify-postmark] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, - ); - return; - } - - if (!res.ok) { - const errText = await res.text().catch(() => "(body unreadable)"); - ctx.log.error( - `[notify-postmark] Postmark ${res.status}: ${errText.slice(0, 500)}`, - ); - return; - } - - let respJson: { MessageID?: string } = {}; - try { - respJson = (await res.json()) as { MessageID?: string }; - } catch { - /* ignore */ - } - ctx.log.info( - `[notify-postmark] SENT to=[${recipients.join(", ")}] MessageID=${respJson?.MessageID ?? "unknown"}`, - ); - } catch (topErr) { - ctx.log.error( - `[notify-postmark] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, - ); - } - }, - }, - }, + const t0 = Date.now(); + let res: Response; + try { + res = await http.fetch(POSTMARK_ENDPOINT, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": apiKey, + }, + body: JSON.stringify({ + From: from, + To: recipients.join(", "), + Subject: `${collectionLabel} published: ${title}`, + TextBody: text, + HtmlBody: html, + MessageStream: "outbound", + }), + }); + ctx.log.info( + `[notify-postmark] Postmark status=${res.status} elapsed_ms=${Date.now() - t0}`, + ); + } catch (fetchErr) { + ctx.log.error( + `[notify-postmark] fetch threw: ${fetchErr instanceof Error ? `${fetchErr.name}: ${fetchErr.message}` : String(fetchErr)}`, + ); + return; + } + + if (!res.ok) { + const errText = await res.text().catch(() => "(body unreadable)"); + ctx.log.error(`[notify-postmark] Postmark ${res.status}: ${errText.slice(0, 500)}`); + return; + } + + let respJson: { MessageID?: string } = {}; + try { + respJson = (await res.json()) as { MessageID?: string }; + } catch { + /* ignore */ + } + ctx.log.info( + `[notify-postmark] SENT to=[${recipients.join(", ")}] MessageID=${respJson?.MessageID ?? "unknown"}`, + ); + } catch (topErr) { + ctx.log.error( + `[notify-postmark] top error: ${topErr instanceof Error ? `${topErr.name}: ${topErr.message}\n${topErr.stack?.slice(0, 400)}` : String(topErr)}`, + ); + } + }, + }, + }, }); const EMAIL_REGEX = /^[^@\s,]+@[^@\s,]+\.[^@\s,]+$/; function normalizeRecipients(raw: unknown): string[] { - if (!raw) return []; - const candidates: string[] = []; - if (Array.isArray(raw)) { - for (const item of raw) { - if (typeof item === "string") candidates.push(...splitList(item)); - } - } else if (typeof raw === "string") { - candidates.push(...splitList(raw)); - } - const seen = new Set(); - const out: string[] = []; - for (const c of candidates) { - const trimmed = c.trim(); - if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; - const key = trimmed.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(trimmed); - } - return out; + if (!raw) return []; + const candidates: string[] = []; + if (Array.isArray(raw)) { + for (const item of raw) { + if (typeof item === "string") candidates.push(...splitList(item)); + } + } else if (typeof raw === "string") { + candidates.push(...splitList(raw)); + } + const seen = new Set(); + const out: string[] = []; + for (const c of candidates) { + const trimmed = c.trim(); + if (!trimmed || !EMAIL_REGEX.test(trimmed)) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(trimmed); + } + return out; } function splitList(s: string): string[] { - return s.split(/[,;\s]+/).filter(Boolean); + return s.split(/[,;\s]+/).filter(Boolean); } function findEmailDeep(obj: unknown, depth = 0): string | string[] | undefined { - if (!obj || typeof obj !== "object" || depth > 4) return undefined; - const record = obj as Record; - for (const [key, value] of Object.entries(record)) { - const k = key.toLowerCase(); - if (k === "email" || k === "emails") { - if (typeof value === "string" && normalizeRecipients(value).length > 0) { - return value; - } - if (Array.isArray(value) && normalizeRecipients(value).length > 0) { - return value as string[]; - } - } - } - for (const value of Object.values(record)) { - if (value && typeof value === "object") { - const nested = findEmailDeep(value, depth + 1); - if (nested) return nested; - } - } - return undefined; + if (!obj || typeof obj !== "object" || depth > 4) return undefined; + const record = obj as Record; + for (const [key, value] of Object.entries(record)) { + const k = key.toLowerCase(); + if (k === "email" || k === "emails") { + if (typeof value === "string" && normalizeRecipients(value).length > 0) { + return value; + } + if (Array.isArray(value) && normalizeRecipients(value).length > 0) { + return value as string[]; + } + } + } + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + const nested = findEmailDeep(value, depth + 1); + if (nested) return nested; + } + } + return undefined; } function resolveEnv(ctx: PluginContext, name: string): string | undefined { - const env = (ctx as { env?: Record }).env; - if (env && typeof env[name] === "string") return env[name] as string; - const g = globalThis as unknown as Record; - if (typeof g[name] === "string") return g[name] as string; - const proc = g.process as { env?: Record } | undefined; - if (proc?.env?.[name]) return proc.env[name]; - return undefined; + const env = (ctx as { env?: Record }).env; + if (env && typeof env[name] === "string") return env[name] as string; + const g = globalThis as unknown as Record; + if (typeof g[name] === "string") return g[name] as string; + const proc = g.process as { env?: Record } | undefined; + if (proc?.env?.[name]) return proc.env[name]; + return undefined; } function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } function capitalize(s: string): string { - if (!s) return s; - return s.charAt(0).toUpperCase() + s.slice(1); + if (!s) return s; + return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/packages/plugins/notify-postmark/tsconfig.json b/packages/plugins/notify-postmark/tsconfig.json index f677f8d5eb..f7304871d7 100644 --- a/packages/plugins/notify-postmark/tsconfig.json +++ b/packages/plugins/notify-postmark/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "../tsconfig.base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } From 6be0e79fcc3f6c4d143092e1073c940bdb32dfc7 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 12:37:08 +0000 Subject: [PATCH 65/68] chore: remove debug console.log statements added during diagnosis Removes 9 temporary trace logs from middleware.ts, admin.astro, and hyperdrive.ts that were added to locate the Hyperdrive hang. Setup and login are confirmed working; logs are no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- packages/cloudflare/src/db/hyperdrive.ts | 6 +----- packages/core/src/astro/middleware.ts | 7 ------- packages/core/src/astro/routes/admin.astro | 2 -- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/cloudflare/src/db/hyperdrive.ts b/packages/cloudflare/src/db/hyperdrive.ts index 449eb7514c..577ffecffb 100644 --- a/packages/cloudflare/src/db/hyperdrive.ts +++ b/packages/cloudflare/src/db/hyperdrive.ts @@ -48,10 +48,7 @@ function getBinding(bindingName: string): HyperdriveBinding { export function createDialect(config: HyperdriveConfig): PostgresDialect { // Validate the binding exists at dialect creation time. - const initial = getBinding(config.binding); - console.log( - `[hyperdrive] createDialect binding=${config.binding} cs_prefix=${initial.connectionString.slice(0, 30)}...`, - ); + getBinding(config.binding); // Fake pool: Kysely only needs connect() + end(). // We re-read env.HYPERDRIVE.connectionString on every connect() so we @@ -60,7 +57,6 @@ export function createDialect(config: HyperdriveConfig): PostgresDialect { connect: async (): Promise Promise }> => { const binding = getBinding(config.binding); const cs = binding.connectionString; - console.log(`[hyperdrive] connect() cs_prefix=${cs.slice(0, 30)}...`); const connectPromise = (async () => { const client = new Client({ diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index e401dd4b07..315e9c4698 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -257,9 +257,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // and the full doInit path need this, and the session store is network-backed // (KV / Durable Object) so we want to avoid re-fetching on the hot path. // Skipped entirely for prerendered requests — they have no session. - console.log(`[mw] ${request.method} ${url.pathname} — fetching session`); const sessionUser = context.isPrerendered ? null : await context.session?.get("user"); - console.log(`[mw] ${url.pathname} — session done, isEmDash=${isEmDashRoute}`); if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) { if (!sessionUser && !playgroundDb) { @@ -329,7 +327,6 @@ export const onRequest = defineMiddleware(async (context, next) => { url, }); const runAnon = async () => { - console.log(`[mw] ${url.pathname} — anon next()`); const t0 = performance.now(); const response = await next(); timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" }); @@ -484,12 +481,8 @@ export const onRequest = defineMiddleware(async (context, next) => { }); const renderAndFinalize = async () => { - console.log(`[mw] ${url.pathname} — calling next()`); const t0 = performance.now(); const response = await next(); - console.log( - `[mw] ${url.pathname} — next() done in ${Math.round(performance.now() - t0)}ms`, - ); timings.push({ name: "render", dur: performance.now() - t0, desc: "Page render" }); timings.push({ name: "mw", dur: performance.now() - mwStart, desc: "Total middleware" }); return finalizeResponse(response, timings); diff --git a/packages/core/src/astro/routes/admin.astro b/packages/core/src/astro/routes/admin.astro index 22e656ceb5..c22f0a6ec9 100644 --- a/packages/core/src/astro/routes/admin.astro +++ b/packages/core/src/astro/routes/admin.astro @@ -17,9 +17,7 @@ import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/loc const resolvedLocale = resolveLocale(Astro.request); const resolvedDir = getLocaleDir(resolvedLocale); -console.log(`[admin] loading messages for locale=${resolvedLocale}`); const messages = await loadMessages(resolvedLocale); -console.log(`[admin] messages loaded, rendering template`); --- From 7c3bcf92dea37f5dab0162ae36b1865592914f6a Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Wed, 6 May 2026 13:19:06 +0000 Subject: [PATCH 66/68] docs: add Hyperdrive + PostgreSQL deployment guide and skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full deployment reference doc covering: - Setup steps (RDS, Hyperdrive config, wrangler binding, astro config) - Pool sizing formula and production-safe defaults - The Hyperdrive read-cache problem and the withTransaction fix pattern - INSERT-then-catch-duplicate pattern for idempotent seed operations - PostgreSQL vs SQLite differences (reserved keywords, JSON operators) - SSL configuration, PgBouncer guidance, observability metrics Also adds a skills/hyperdrive-postgresql skill for AI agents debugging Hyperdrive deployments — covers all bug patterns found during the Cloudflare Workers + AWS RDS deployment work on this branch. Co-Authored-By: Claude Sonnet 4.6 --- .../docs/deployment/hyperdrive-postgresql.mdx | 316 ++++++++++++++++++ skills/hyperdrive-postgresql/SKILL.md | 196 +++++++++++ 2 files changed, 512 insertions(+) create mode 100644 docs/src/content/docs/deployment/hyperdrive-postgresql.mdx create mode 100644 skills/hyperdrive-postgresql/SKILL.md diff --git a/docs/src/content/docs/deployment/hyperdrive-postgresql.mdx b/docs/src/content/docs/deployment/hyperdrive-postgresql.mdx new file mode 100644 index 0000000000..791f575fca --- /dev/null +++ b/docs/src/content/docs/deployment/hyperdrive-postgresql.mdx @@ -0,0 +1,316 @@ +--- +title: PostgreSQL on Cloudflare Workers (Hyperdrive) +description: Deploy EmDash on Cloudflare Workers with a PostgreSQL database via Cloudflare Hyperdrive, including pool configuration, caching gotchas, and production safeguards. +--- + +import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components"; + +This guide covers deploying EmDash on Cloudflare Workers using [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) to connect to a PostgreSQL database (AWS RDS, Supabase, Neon, or any PostgreSQL provider). + +## How It Works + +Hyperdrive sits between your Worker and PostgreSQL. It maintains a warm connection pool at the Cloudflare edge, so each Worker request opens a fast local connection to Hyperdrive rather than a slow cross-region TCP handshake to your database. + +``` +Browser → Cloudflare Worker → Hyperdrive (edge pool) → PostgreSQL (AWS RDS / Supabase / Neon) +``` + +EmDash's `@emdash-cms/cloudflare` package provides a Hyperdrive-aware Kysely dialect that creates a fresh `pg.Client` per query — re-reading `env.HYPERDRIVE.connectionString` each time — so the Worker never holds stale connections. + +## Setup + + + +1. **Create a PostgreSQL database** + + Any provider works. For AWS RDS: + - Create a PostgreSQL 15+ instance + - Set **Publicly Accessible = yes** (or configure VPC peering) + - Open port `5432` to Cloudflare's IP ranges in your security group + - Note: endpoint, port, database name, username, password + +2. **Create a Hyperdrive config** + + ```bash + npx wrangler hyperdrive create emdash-prod \ + --connection-string "postgresql://user:pass@your-db.example.com:5432/dbname" + ``` + + Save the returned Hyperdrive config ID. + +3. **Add the binding to `wrangler.jsonc`** + + ```jsonc + { + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "your-hyperdrive-config-id" + } + ] + } + ``` + +4. **Configure EmDash in `astro.config.mjs`** + + ```js + import { defineConfig } from "astro/config"; + import cloudflare from "@astrojs/cloudflare"; + import emdash from "emdash"; + import { hyperdrive } from "@emdash-cms/cloudflare"; + + export default defineConfig({ + adapter: cloudflare({ platformProxy: { enabled: true } }), + integrations: [ + emdash({ + database: hyperdrive({ + binding: "HYPERDRIVE", + pool: { + min: 2, + max: 5, + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 5_000, + }, + }), + }), + ], + }); + ``` + +5. **Set local dev connection string** + + Create `.dev.vars` in your project root: + + ```env + HYPERDRIVE_LOCAL_CONNECTION_STRING=postgresql://user:pass@your-db.example.com:5432/dbname + ``` + +6. **Generate TypeScript types** + + ```bash + npx wrangler types + ``` + + This generates `worker-configuration.d.ts` with the correct `HYPERDRIVE` binding type. + +7. **Deploy** + + ```bash + npx wrangler deploy + ``` + + Then visit `/_emdash/admin/setup` in a regular browser window (not incognito — passkeys require credential storage) to run the setup wizard. + + + +## Pool Configuration + +The pool settings passed to `hyperdrive()` control how EmDash manages connections **per Worker isolate**. Size them using this formula: + +``` +(pods × processes × pool.max) + background_connections + admin_connections < max_connections × 0.7 +``` + +| Setting | Recommended | Why | +|---|---|---| +| `min` | `2` | Keeps 2 connections warm, absorbs cold-start bursts without TCP/TLS overhead | +| `max` | `5` | Tight ceiling per pod — leave room to scale horizontally | +| `idleTimeoutMillis` | `10000` | Release idle connections after 10s | +| `connectionTimeoutMillis` | `5000` | Fail fast when pool is exhausted — never hang | + + + +### Worked example + +10 Worker instances, 1 process each, `pool.max = 5`: + +``` +(10 × 1 × 5) + 5 (background) + 10 (admin) = 65 +max_connections = 200 → 70% cap = 140 +65 < 140 ✅ safe +``` + +At `max: 5` you can safely run ~18 pods against a `max_connections = 200` PostgreSQL instance before needing PgBouncer. + +## PostgreSQL Server Configuration + +Set these in `postgresql.conf` or your provider's configuration panel: + +```ini +max_connections = 200 +statement_timeout = 30000 # kill queries running > 30s +idle_in_transaction_session_timeout = 10000 # kill idle-in-transaction sessions after 10s +shared_buffers = 256MB # ~25% of RAM +work_mem = 8MB # per query, per sort +``` + +`idle_in_transaction_session_timeout` is critical: without it, a client that opens a transaction and crashes before closing it holds its locks and connection slot indefinitely. + +## SSL Configuration + + + + ```env + DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require + ``` + + Hyperdrive handles TLS between the Worker and Hyperdrive's proxy. The `pg.Client` inside the Worker connects to Hyperdrive's local endpoint without TLS (`ssl: false`). This is correct — do not override it. + + + Some managed providers use self-signed certificates. Pass this when creating the Hyperdrive config: + + ```bash + npx wrangler hyperdrive create emdash-prod \ + --connection-string "postgresql://..." \ + --caching-disabled # optional, if you want full cache control + ``` + + And in the pool config: + ```js + pool: { + ssl: { rejectUnauthorized: false }, + } + ``` + + + + + +## The Hyperdrive Caching Problem + + + +Hyperdrive caches **non-transactional reads** for up to ~60 seconds at the nearest edge replica. This is invisible in development (SQLite has no cache layer) and only manifests in production. + +**The pattern it breaks:** any code that reads state immediately after writing it. + +``` +Request A: writes setup_complete = true +Request B: reads setup_complete → gets cached null → wrong branch taken +``` + +**The fix:** wrap every read that follows a recent write in `withTransaction`. Kysely transactions force Hyperdrive to route to the primary, bypassing the cache. + +```ts +// WRONG — may return stale cached value +const row = await db + .selectFrom("options") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst(); + +// RIGHT — bypasses Hyperdrive cache +const row = await withTransaction(db, (trx) => + trx + .selectFrom("options") + .where("name", "=", "emdash:setup_complete") + .executeTakeFirst() +); +``` + +### INSERT-then-catch instead of SELECT-then-INSERT + +Pre-check `SELECT` queries are cached. If you check for existence before inserting, the cached null will cause duplicate inserts. Let the database enforce uniqueness atomically: + +```ts +// WRONG — SELECT is cached, INSERT duplicates +const existing = await db + .selectFrom("collections") + .where("slug", "=", slug) + .executeTakeFirst(); +if (!existing) await db.insertInto("collections").values({...}).execute(); + +// RIGHT — atomic, cache-safe +try { + await db.insertInto("collections").values({...}).execute(); +} catch (err) { + if (isDuplicateKeyError(err)) return; // already exists, fine + throw err; +} +``` + +Use this helper to detect duplicates across all three drivers: + +```ts +function isDuplicateKeyError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { code?: string }; + return ( + e.code === "23505" || // PostgreSQL + e.code === "SQLITE_CONSTRAINT_UNIQUE" || // better-sqlite3 + e.message.includes("UNIQUE constraint failed") // libSQL + ); +} +``` + +## PostgreSQL vs SQLite Differences + +| Topic | SQLite (dev) | PostgreSQL (prod via Hyperdrive) | +|---|---|---| +| JSON extraction | `json_extract(data, '$.field')` | `data->>'field'` | +| Reserved keywords | Lenient | Strict — quote `"window"`, `"order"`, etc. in raw SQL | +| Read-after-write | Immediate | Must use transaction to bypass Hyperdrive cache | +| Duplicate detection | `SQLITE_CONSTRAINT_UNIQUE` | Error code `23505` | +| Booleans | `0` / `1` | `true` / `false` or `0` / `1` | + +### Reserved keywords in raw SQL + +PostgreSQL enforces reserved keywords strictly. `window` is a common one that works in SQLite but breaks in PostgreSQL: + +```sql +-- WRONG — works in SQLite, syntax error in PostgreSQL +INSERT INTO _emdash_rate_limits (key, window, count) VALUES (...) + +-- RIGHT — always quote column names that are keywords +INSERT INTO _emdash_rate_limits (key, "window", count) VALUES (...) +``` + +## PgBouncer (When You Need It) + +Add PgBouncer when `pods × pool.max > 100` (roughly 20+ pods at `max: 5`). + +```ini +pool_mode = transaction # correct for EmDash's stateless SSR +max_client_conn = 1000 +default_pool_size = 20 +``` + +**Transaction pooling limitations** — do not use these features in EmDash routes when behind PgBouncer in transaction mode: + +- `SET` session variables +- Named prepared statements +- `LISTEN` / `NOTIFY` +- Advisory locks held across queries +- Cursors held open across statements + +EmDash's Kysely-based handlers use anonymous queries and are compatible with transaction pooling out of the box. + +## Observability + +Track these metrics in production: + +| Metric | What it tells you | +|---|---| +| `pg_stat_activity` count | Live connection usage vs `max_connections` | +| Pool waiting clients | Queue depth — early warning of pool exhaustion | +| Query duration p99 | Slow queries holding connections | +| Connection error rate | Pool exhaustion or database instability | + +## Failure Mode Strategy + +| Failure | Without safeguards | With safeguards | +|---|---|---| +| Pool exhausted | Request hangs until OS timeout | Returns 503 via `connectionTimeoutMillis` | +| Query timeout | Request hangs | Returns 503 via `statement_timeout` | +| DB unreachable | 500 with stack trace | Returns 503 with `Retry-After` | + +Always return **503** (Service Unavailable), not 500. 503 signals to load balancers that the request is safe to retry. 500 signals a bug. + +## Passkey Registration + +Passkey registration does not work in incognito/private browsing mode — Chrome and Edge do not save credentials to the OS credential store in incognito. Use a regular browser window when completing the setup wizard. diff --git a/skills/hyperdrive-postgresql/SKILL.md b/skills/hyperdrive-postgresql/SKILL.md new file mode 100644 index 0000000000..9fbfff6f95 --- /dev/null +++ b/skills/hyperdrive-postgresql/SKILL.md @@ -0,0 +1,196 @@ +--- +name: hyperdrive-postgresql +description: Debug and fix EmDash deployments on Cloudflare Workers with Hyperdrive + PostgreSQL. Use when hitting connection hangs, stale read bugs, duplicate key errors during seed, setup wizard failures, or passkey auth errors on Cloudflare Workers. +--- + +# Hyperdrive + PostgreSQL Deployment Skill + +You are helping debug or set up EmDash running on Cloudflare Workers with Cloudflare Hyperdrive connecting to a PostgreSQL database (AWS RDS, Supabase, Neon, etc.). + +## Architecture + +``` +Browser → Cloudflare Worker → Hyperdrive (edge pool) → PostgreSQL +``` + +Hyperdrive maintains warm connections at the edge. The Worker creates a fresh `pg.Client` per query (not a Pool) — re-reading `env.HYPERDRIVE.connectionString` each time to handle CS rotation. + +## The One Rule That Explains Most Bugs + +**Hyperdrive caches non-transactional reads for ~60 seconds.** + +Any `SELECT` that is not inside `BEGIN...COMMIT` is served from a read replica cache. This is invisible in development (SQLite has no cache) and only shows up in production. + +**Pattern that breaks:** +1. Request A writes a value +2. Request B reads it → gets cached stale value → takes wrong branch + +**Fix:** wrap every read that follows a recent write in `withTransaction`: + +```ts +// WRONG +const row = await db.selectFrom("options").where("name", "=", "key").executeTakeFirst(); + +// RIGHT — transaction bypasses Hyperdrive cache +const row = await withTransaction(db, (trx) => + trx.selectFrom("options").where("name", "=", "key").executeTakeFirst() +); +``` + +## Diagnosing Common Errors + +### Setup wizard fails mid-flow / collection not found after creation + +**Cause:** `createField` or `applySeed` does a non-transactional existence check after a recent write. + +**Fix:** All reads inside `createCollection`, `createField`, and `applySeed` must use `withTransaction`. Pre-check SELECTs must be replaced with INSERT-then-catch-duplicate. + +```ts +// WRONG — SELECT served from cache, INSERT duplicates +const existing = await db.selectFrom("t").where("slug", "=", slug).executeTakeFirst(); +if (!existing) await db.insertInto("t").values({...}).execute(); + +// RIGHT +try { + await db.insertInto("t").values({...}).execute(); +} catch (err) { + if (isDuplicateKeyError(err)) return; + throw err; +} + +function isDuplicateKeyError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const e = err as Error & { code?: string }; + return ( + e.code === "23505" || + e.code === "SQLITE_CONSTRAINT_UNIQUE" || + e.message.includes("UNIQUE constraint failed") + ); +} +``` + +### Passkey verify returns 400 on first attempt ("needs to create the key twice") + +**Cause:** `admin-verify.ts` reads `emdash:setup_state` non-transactionally. Hyperdrive serves stale null → step check fails. + +**Fix:** Wrap all three guard reads (`setup_complete`, `userCount`, `setup_state`) in a single `withTransaction` at the top of the POST handler. + +### Setup redirect loop after successful verify + +**Cause:** `middleware/setup.ts` reads `emdash:setup_complete` non-transactionally. After verify sets it to true, the middleware still sees cached null and redirects back to setup. + +**Fix:** Wrap `setup_complete` and `userCount` reads in `withTransaction` in the setup middleware. + +### SQL syntax error: `syntax error at or near "window"` + +**Cause:** `window` is a PostgreSQL reserved keyword. Works unquoted in SQLite, breaks in PostgreSQL. + +**Fix:** Quote it in all raw SQL: +```sql +-- WRONG +INSERT INTO _emdash_rate_limits (key, window, count) ... +ON CONFLICT (key, window) DO UPDATE ... + +-- RIGHT +INSERT INTO _emdash_rate_limits (key, "window", count) ... +ON CONFLICT (key, "window") DO UPDATE ... +``` + +Check every raw SQL string for other reserved keywords: `order`, `group`, `user`, `table`, `index`, `select`, `where`, etc. + +### Worker hangs on DB connect (no timeout, no error) + +**Cause:** A cached `pg.Pool` baked in a stale Hyperdrive `connectionString`. Subsequent `connect()` calls hang indefinitely in Workers Node.js compat layer. + +**Fix:** Use a fresh `pg.Client` per query, not a module-scoped Pool. The `hyperdrive.ts` dialect does this — never revert to a Pool singleton. + +### Duplicate key errors during seed (`_emdash_collections`, `_emdash_taxonomy_defs`, `taxonomies`) + +**Cause:** Seed ran partially before, or Hyperdrive cached the pre-check SELECT as null, causing a second INSERT. + +**Fix:** +1. Clean up the partial DB state +2. Switch from SELECT-then-INSERT to INSERT-then-catch-duplicate (see above) +3. All seed reads must use `withTransaction` + +## Files to Check for Hyperdrive Cache Bugs + +When a read returns stale data after a write, check these files in order: + +| File | Reads that need `withTransaction` | +|---|---| +| `packages/core/src/schema/registry.ts` | Collection existence in `createCollection`; all reads in `createField` | +| `packages/core/src/seed/apply.ts` | Collection, taxonomy def, and term existence checks | +| `packages/core/src/astro/routes/api/setup/admin-verify.ts` | `setup_complete`, `userCount`, `setup_state` | +| `packages/core/src/astro/middleware/setup.ts` | `setup_complete`, `userCount` | +| `packages/core/src/astro/routes/api/setup/status.ts` | `setup_complete`, `userCount`, `setup_state` | +| `packages/core/src/astro/routes/api/setup/index.ts` | `setup_complete` guard | + +## Setup Checklist (Fresh Deployment) + +``` +1. AWS RDS: PostgreSQL 15+, port 5432 open, publicly accessible +2. Hyperdrive: `npx wrangler hyperdrive create` → save ID +3. wrangler.jsonc: add hyperdrive binding +4. astro.config.mjs: use hyperdrive() with pool.min=2, pool.max=5, timeouts +5. .dev.vars: HYPERDRIVE_LOCAL_CONNECTION_STRING +6. npx wrangler types → worker-configuration.d.ts +7. npx wrangler deploy +8. Visit /_emdash/admin/setup in a REGULAR browser window (not incognito) +``` + +## Pool Sizing Formula + +``` +(pods × processes × pool.max) + background + admin < max_connections × 0.7 + +Safe defaults: pool.min=2, pool.max=5 +Max pods at max_connections=200: ~18 pods before PgBouncer needed +``` + +## PostgreSQL Server Settings + +```ini +max_connections = 200 +statement_timeout = 30000 +idle_in_transaction_session_timeout = 10000 +shared_buffers = 256MB +work_mem = 8MB +``` + +## Import Path Gotcha + +In `packages/core/src/astro/middleware/`, the `#db/*` alias is NOT available. Use relative paths: + +```ts +// WRONG (alias not resolved in middleware build) +import { withTransaction } from "#db/transaction.js"; + +// RIGHT +import { withTransaction } from "../../database/transaction.js"; +``` + +`#db/*` maps to `src/database/*` — the directory is `database`, not `db`. + +## Passkey Notes + +- Passkey registration **does not work in incognito/private browsing**. Chrome/Edge do not save credentials to the OS store in incognito. +- Always use a regular browser window for setup wizard and initial login. +- After DB cleanup between test runs, also clear saved passkeys from the browser's password manager. + +## DB Cleanup SQL (Between Test Runs) + +```sql +DELETE FROM _emdash_fields; +DELETE FROM _emdash_collections WHERE slug IN ('posts', 'pages'); +DROP TABLE IF EXISTS ec_posts; +DROP TABLE IF EXISTS ec_pages; +DELETE FROM _emdash_taxonomy_defs; +DELETE FROM taxonomies; +DELETE FROM options WHERE name IN ('emdash:setup_complete', 'emdash:setup_state', 'emdash:site_title', 'emdash:site_url'); +DELETE FROM users; +DELETE FROM credentials; +DELETE FROM auth_tokens; +DELETE FROM auth_challenges; +DELETE FROM _emdash_rate_limits; +``` From 71cffe121c2bd1be8f6f8a8cc29aff82a55e4e60 Mon Sep 17 00:00:00 2001 From: Laith Al-Janaideh Date: Thu, 7 May 2026 10:42:32 +0000 Subject: [PATCH 67/68] chore(cloudflare): update Hyperdrive binding to private instance Switches the Hyperdrive ID in wrangler.jsonc and the post-provision reminder in outputs.tf to the new private Hyperdrive config (01b192bf33194ecda6ad2aa1b2f2f8d2). Co-Authored-By: Claude Sonnet 4.6 --- demos/cloudflare/terraform/outputs.tf | 60 +++++++++++++++++++++++++++ demos/cloudflare/wrangler.jsonc | 2 +- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 demos/cloudflare/terraform/outputs.tf diff --git a/demos/cloudflare/terraform/outputs.tf b/demos/cloudflare/terraform/outputs.tf new file mode 100644 index 0000000000..67576d9569 --- /dev/null +++ b/demos/cloudflare/terraform/outputs.tf @@ -0,0 +1,60 @@ +output "rds_endpoint" { + description = "RDS instance hostname" + value = aws_db_instance.emdash.address +} + +output "rds_port" { + description = "RDS port" + value = aws_db_instance.emdash.port +} + +output "rds_db_name" { + description = "Database name" + value = aws_db_instance.emdash.db_name +} + +output "connection_string" { + description = "DATABASE_URL for bootstrap script (uses master user — swap to emdash_app after setup)" + value = "postgres://${var.master_username}:PASSWORD@${aws_db_instance.emdash.address}:${aws_db_instance.emdash.port}/${var.db_name}?sslmode=require" + sensitive = false +} + +output "hyperdrive_origin" { + description = "Host to use when running: wrangler hyperdrive update --origin-host " + value = aws_db_instance.emdash.address +} + +output "post_provision_steps" { + description = "Reminder of manual steps after terraform apply" + value = <<-EOT + + ── Post-provision checklist ──────────────────────────────────────────── + + 1. Connect as master user and create the app user: + + psql "postgres://${var.master_username}:PASSWORD@${aws_db_instance.emdash.address}:5432/${var.db_name}?sslmode=require" + + CREATE USER emdash_app WITH PASSWORD 'your-app-password'; + GRANT CONNECT ON DATABASE ${var.db_name} TO emdash_app; + GRANT CREATE ON SCHEMA public TO emdash_app; + GRANT USAGE ON SCHEMA public TO emdash_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO emdash_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO emdash_app; + + 2. Update Cloudflare Hyperdrive to point at the new endpoint: + + wrangler hyperdrive update 01b192bf33194ecda6ad2aa1b2f2f8d2 \ + --origin-host ${aws_db_instance.emdash.address} \ + --origin-port 5432 \ + --database ${var.db_name} \ + --origin-user emdash_app + + 3. Update DATABASE_URL in Cloudflare Pages env vars: + + postgres://emdash_app:PASSWORD@${aws_db_instance.emdash.address}:5432/${var.db_name}?sslmode=require + + 4. Push a new commit to trigger the Pages build (which runs migrations). + + ──────────────────────────────────────────────────────────────────────── + EOT +} diff --git a/demos/cloudflare/wrangler.jsonc b/demos/cloudflare/wrangler.jsonc index 7e9e73ece9..6c65b758d3 100644 --- a/demos/cloudflare/wrangler.jsonc +++ b/demos/cloudflare/wrangler.jsonc @@ -9,7 +9,7 @@ "hyperdrive": [ { "binding": "HYPERDRIVE", - "id": "2b7fc91df2d24a7cb7e434120d82060f", + "id": "01b192bf33194ecda6ad2aa1b2f2f8d2", }, ], From 4858ca182ad284ed73e70790d00c69c21dfd2c4b Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Thu, 7 May 2026 11:11:01 +0000 Subject: [PATCH 68/68] style: format --- skills/hyperdrive-postgresql/SKILL.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/skills/hyperdrive-postgresql/SKILL.md b/skills/hyperdrive-postgresql/SKILL.md index 9fbfff6f95..97473fda66 100644 --- a/skills/hyperdrive-postgresql/SKILL.md +++ b/skills/hyperdrive-postgresql/SKILL.md @@ -22,6 +22,7 @@ Hyperdrive maintains warm connections at the edge. The Worker creates a fresh `p Any `SELECT` that is not inside `BEGIN...COMMIT` is served from a read replica cache. This is invisible in development (SQLite has no cache) and only shows up in production. **Pattern that breaks:** + 1. Request A writes a value 2. Request B reads it → gets cached stale value → takes wrong branch @@ -33,7 +34,7 @@ const row = await db.selectFrom("options").where("name", "=", "key").executeTake // RIGHT — transaction bypasses Hyperdrive cache const row = await withTransaction(db, (trx) => - trx.selectFrom("options").where("name", "=", "key").executeTakeFirst() + trx.selectFrom("options").where("name", "=", "key").executeTakeFirst(), ); ``` @@ -86,6 +87,7 @@ function isDuplicateKeyError(err: unknown): boolean { **Cause:** `window` is a PostgreSQL reserved keyword. Works unquoted in SQLite, breaks in PostgreSQL. **Fix:** Quote it in all raw SQL: + ```sql -- WRONG INSERT INTO _emdash_rate_limits (key, window, count) ... @@ -109,6 +111,7 @@ Check every raw SQL string for other reserved keywords: `order`, `group`, `user` **Cause:** Seed ran partially before, or Hyperdrive cached the pre-check SELECT as null, causing a second INSERT. **Fix:** + 1. Clean up the partial DB state 2. Switch from SELECT-then-INSERT to INSERT-then-catch-duplicate (see above) 3. All seed reads must use `withTransaction` @@ -117,14 +120,14 @@ Check every raw SQL string for other reserved keywords: `order`, `group`, `user` When a read returns stale data after a write, check these files in order: -| File | Reads that need `withTransaction` | -|---|---| -| `packages/core/src/schema/registry.ts` | Collection existence in `createCollection`; all reads in `createField` | -| `packages/core/src/seed/apply.ts` | Collection, taxonomy def, and term existence checks | -| `packages/core/src/astro/routes/api/setup/admin-verify.ts` | `setup_complete`, `userCount`, `setup_state` | -| `packages/core/src/astro/middleware/setup.ts` | `setup_complete`, `userCount` | -| `packages/core/src/astro/routes/api/setup/status.ts` | `setup_complete`, `userCount`, `setup_state` | -| `packages/core/src/astro/routes/api/setup/index.ts` | `setup_complete` guard | +| File | Reads that need `withTransaction` | +| ---------------------------------------------------------- | ---------------------------------------------------------------------- | +| `packages/core/src/schema/registry.ts` | Collection existence in `createCollection`; all reads in `createField` | +| `packages/core/src/seed/apply.ts` | Collection, taxonomy def, and term existence checks | +| `packages/core/src/astro/routes/api/setup/admin-verify.ts` | `setup_complete`, `userCount`, `setup_state` | +| `packages/core/src/astro/middleware/setup.ts` | `setup_complete`, `userCount` | +| `packages/core/src/astro/routes/api/setup/status.ts` | `setup_complete`, `userCount`, `setup_state` | +| `packages/core/src/astro/routes/api/setup/index.ts` | `setup_complete` guard | ## Setup Checklist (Fresh Deployment)