fix(core): guide plugin route handlers to ctx.input on consumed body (#1293)#1555
Conversation
…mdash-cms#1293) EmDash parses a plugin route's request body once and exposes it as ctx.input, leaving ctx.request's stream consumed. A handler that instinctively called ctx.request.json() (or .text()/.formData()/etc.) got the runtime's opaque "Body has already been read" error with no hint about ctx.input. Wrap the request handed to handlers in a Proxy that intercepts the body-reading methods and throws an actionable [emdash] message naming ctx.input. Non-body members pass through unchanged; function members are bound to the underlying request so methods like clone() and the sandbox-entry adapter's headers.forEach keep working. Also update the plugin docs that demonstrated the ctx.request.json() footgun. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: fbe8ed4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
This is the right fix for the right problem. EmDash pre-parses the request body for plugin routes and hands the same Request object to handlers, so calling ctx.request.json() re-reads a consumed stream and produces an opaque platform error. Wrapping the handler-facing request in a Proxy that intercepts only the body-reading methods, passes everything else through, and throws an actionable [emdash] message pointing to ctx.input is a clean, additive approach that matches the architecture.
I read the diff, the full packages/core/src/plugins/routes.ts, the new unit tests, and traced the callers (PluginManager.invokeRoute, handlePluginApiRoute, adapt-sandbox-entry.ts). The guarded request is correctly applied only in the trusted-plugin route path; the sandboxed route path already forwards a serialized { url, method, headers, meta } shape, so it is unaffected. The docs are updated to steer plugin authors toward ctx.input, and a changeset is present.
One tiny non-blocking note: the RouteContext.request docstring in packages/core/src/plugins/types.ts still says “Original request,” but it is now a guarded Proxy of the original. Worth updating the comment to match the new semantics.
I did not run the test suite/linter/build; this review is static. Based on the code, the implementation is sound and I would sign off.
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
What does this PR do?
EmDash parses a plugin route's request body once before the handler runs and exposes it as
ctx.input, leavingctx.request's stream consumed. A handler that instinctively calledctx.request.json()(or.text()/.formData()/.arrayBuffer()/.blob()) re-read a spent stream and got the runtime's opaqueBody has already been readerror with no mention ofctx.input.This wraps the request handed to handlers in a
Proxythat intercepts the body-reading methods and throws an actionable[emdash]message namingctx.input. Every non-body member (url,method,headers,signal, …) passes through unchanged, and function members are bound to the underlying request so methods likeclone()and the sandbox-entry adapter'sheaders.forEachkeep working. Also updates the two plugin docs that demonstrated thectx.request.json()footgun.Closes #1293
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (targeted:plugins/routes.test.ts+ full plugins unit/integration suites — 702 tests)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
New tests assert every guarded body method rejects with a message containing
ctx.inputand[emdash], thatctx.inputstill holds the parsed body, and thaturl/method/headers.get/headers.forEachstill pass through the guard. Plugins suites: 702 passed.