Skip to content

EmDash Plugin Pitfalls: 3 Bugs in One File (Real Postmortem)

Ben

Building my first EmDash plugin should have been a one-day project. It became two: one day writing it, one day debugging three bugs that all lived in the same file and all somehow shipped to production despite passing every safety net I had.

This is the postmortem. The bugs were mostly mine. The reason they all survived deploy is partly mine and partly a gap in EmDash plugin DX worth knowing about before you write your own.

The plugin

I built @serpdelta/emdash-plugin against EmDash 0.1.0 — a thin client that displays SerpDelta ranking data inside the EmDash admin UI. About 200 lines of TypeScript, locally-registered standard format, one Block Kit admin route. Locally registered, not sandboxed: the sandboxed runtime caps each invocation at 50ms CPU and 10 subrequests — too tight for any real API work. Worth knowing if you're evaluating EmDash plugins for anything beyond a status badge. Source is on GitHub if you want to follow the commits. If you want the from-scratch tutorial, the EmDash plugin development guide covers the happy path. This post is what happens after.

The three bugs

The first bug was a typo. I hand-rolled a stats block as { type: "stats", stats: [...] } when the renderer expects items: [...]. The admin React component crashed inside React's render phase with "Cannot read properties of undefined (reading 'map')", which the tanstack router CatchBoundary caught and rendered as a generic "Something went wrong!" page. Useless diagnostic. TypeScript should have caught it. It didn't, because I had typed my own blocks as Block = Record<string, unknown> — a local shadow type that defeated all the real interfaces from @emdash-cms/blocks. Shipped as commit b1daa77, fixed by renaming the field — but that cosmetic fix didn't address the underlying type-shadow problem.

The class-fix was right: I migrated the whole file to typed builders from @emdash-cms/blocks/server, added a validateBlocks() runtime guard wrapping every response, and added tsc --noEmit against the plugin source as a deploy preflight step. Field-name drift became a compile error. Good. Shipped as commit ab7b023, v0.2.1.

That fix introduced bug three. While extracting ctx.http.fetch.bind(ctx.http) into a helper called pluginFetch(), I pasted the wrong body into the function. Tail recursion. TypeScript happy. Plugin validate happy. My new tsc preflight passed. Every user with a saved token and selected property got Maximum call stack size exceeded on every dashboard load — wrapped inside a friendly SerpDelta-branded error page, courtesy of my plugin's own try/catch. Fixed in commit ae3b9d9, v0.2.2.

src/sandbox-entry.ts
// What I shipped (v0.2.1 — broken)
function pluginFetch(ctx: PluginContext): typeof fetch {
  if (!ctx.http) throw new Error("ctx.http unavailable");
  return pluginFetch(ctx) as unknown as typeof fetch; // ← infinite recursion
}

// What I meant to ship (v0.2.2 — fixed)
function pluginFetch(ctx: PluginContext): typeof fetch {
  if (!ctx.http) throw new Error("ctx.http unavailable");
  return ctx.http.fetch.bind(ctx.http) as unknown as typeof fetch;
}

Block Kit field-name traps everyone hits

EmDash Block Kit looks like Slack's, but the field names diverge in places that aren't documented. Every entry below is verified against the runtime types and the React renderer source — save yourself the discovery time.

Block / elementWhat you'd write intuitivelyWhat EmDash actually wants
Stats block{ type: "stats", stats: [...] }{ type: "stats", items: [...] }
Button element{ type: "button", text: "Save" }{ type: "button", label: "Save" } — text is silently ignored, button renders empty
BlockResponse redirect{ blocks: [], redirect: "/somewhere" }Not supported. BlockResponse is { blocks, toast? } only. Plugin routes can't return HTTP redirects either — Response objects get wrapped into {"data":{}}.
Section block markdownsection("**bold** with [a link](url)")Renders as literal text. The renderer does children: block.text — no markdown parser, no link rendering. For rich inline content, use children with marks instead.
settingsSchemaadmin: { settingsSchema: { apiKey: { type: "text", ... } } } — expecting a settings UI to appearDeclared in core types but no UI consumer. Zero references in @emdash-cms/admin/dist. You have to build the form yourself with Block Kit and read values via ctx.kv.get("settings:<key>").

Three of these (button label, BlockResponse redirect, settingsSchema no-op) came from another EmDash plugin author who reviewed this post pre-publish — saved the next reader hours.

Why every safety net let them through

Here's the table I now keep in front of me when reasoning about plugin error handling.

LayerCatchesMissed because
Typed buildersBlock field-name driftBug three wasn't in block construction
validateBlocks() runtime guardBlock shape errors at response timeHandler crashed before building blocks
tsc --noEmit preflightType errors in plugin sourceTail recursion is type-valid
Public-page smoke testsSite-wide 500sNever hit a plugin admin route
Plugin's own try/catch + errorScreen()Anything elseWorked perfectly. That's the problem.

The last row is the meta-lesson. My catch-all error path turned a RangeError into a 200 OK BlockResponse with a friendly error message in the body. Every monitoring layer thought the plugin was healthy. The user saw a SerpDelta-branded page that looked like graceful degradation and was actually a hard crash dressed up nicely.

A plugin's catch-all error handler is a symptom, not a feature. Every time it fires in production, something is broken — even though the response code says otherwise.
— The lesson I wish someone had handed me on day one

The prevention stack I built afterward

Five layers, each catching what the previous one missed:

  1. Typed builders + validateBlocks(). Block shape can't drift silently.
  2. tsc --noEmit against plugin source in the deploy script. Type errors fail the deploy before bundling.
  3. Bundle anti-pattern grep. After astro build, grep the worker bundle for known bug-shape strings. Currently catches return pluginFetch(ctx); literally. Add a new entry every time you ship a runtime fix for a bug none of the earlier guards caught.
  4. Plugin route smoke test. POST to /_emdash/api/plugins/<id>/admin unauthenticated; expects 401, which proves the route is mounted. Optional authenticated probe asserts the response has no error indicators in the rendered blocks.
  5. Log loudly in errorScreen(). ctx.log.error the full error with stack before rendering anything. The log is the only artifact that survives the friendly UI.

What to do if you're writing an EmDash plugin

Do this on day one

  • Import blocks and elements from @emdash-cms/blocks/server. Never hand-roll a block literal. Your IDE will catch field-name typos.
  • Wrap every BlockResponse in validateBlocks() before returning. Free runtime safety net.
  • Smoke-test the plugin route in your deploy script. Public-page smokes don't exercise plugin code.

Treat your error path as an alarm

  • Log the full error before rendering an error screen. ctx.log.error is your only telemetry.
  • If you ever see your plugin's friendly error UI in production, treat it like a 500.
  • Don't dress crashes up. The catch path should be loud and minimal.

What I want from EmDash core

  1. Run tsc --noEmit as part of npx emdash plugin validate, not just tsdown transpilation. Type-invalid plugins should not pass validation. This is the strongest single change — it would have caught the entire class of bugs in this post at the validate step before they ever shipped.
  2. Better runtime error messages. Make validateBlocks() the default in the standard plugin runtime so malformed blocks fail at the server boundary instead of crashing the React renderer. Guard block.X.map() calls in the renderer so a missing items field produces "Block validation error: missing items" inline instead of a generic CatchBoundary page.

The bigger lesson

The bugs were mostly mine. The prevention gaps weren't. EmDash plugin DX is at v0.1 — workable, opinionated, occasionally unforgiving — and every early adopter discovers the same potholes. If you're weighing whether EmDash is production-ready or evaluating it as a developer , treat the plugin layer as an early-adopter zone with sharp edges. The tools work. The guardrails are still being built — partly by the EmDash team, partly by us.

And the lesson generalizes past EmDash. Any plugin architecture with a graceful error fallback risks turning crashes into invisible 200s. WordPress hits it. VS Code extensions hit it. Every plugin author who builds a polite error UI eventually ships a bug that lives behind the politeness. Build the prevention stack early. Treat the catch path as an alarm, not a comfort. New to EmDash? Start with what EmDash actually is , build your first site , and skim the MCP server — the other plugin integration surface — before you ship your own.

Tested against EmDash 0.1.0, @emdash-cms/blocks 0.1.0, @emdash-cms/admin 0.1.0, @serpdelta/emdash-plugin 0.2.3. Deployed via @astrojs/cloudflare on the Cloudflare Workers free plan. Field-name claims and runtime behavior verified against the @emdash-cms/blocks TypeScript types and the React renderer source on 2026-04-08. Plugin DX evolves quickly at v0.1 — re-verify against current source if you're reading this in a different week.