Skip to content

Commit 772f21a

Browse files
authored
feat: support emdash 0.28 (#16)
## Summary Upgrades DashCommerce from emdash 0.6.0 to 0.28.1 (22 minor versions) and propagates the change across the plugin, starter template, and demo build. ## Changes **`@dashcommerce/core`** - `sandbox-entry.ts` builds the plugin via native `definePlugin` (single-arg `RouteContext` handlers) instead of `adaptSandboxEntry`. In 0.28 `adaptSandboxEntry` flattens `routeCtx.request` to `{url,method,headers}` with no body, which would break the Stripe webhook. - Capability names updated to the current vocabulary (`network:request`, `content:read`, `content:write`, `media:read`, `users:read`). - emdash peer range → `>=0.28.0 <0.29.0`. **emdash patch (`patches/emdash@0.28.1.patch`)** - Plugin route handlers may return a raw `Response` (apiSuccess would serialize it to `{}` and drop `Set-Cookie`). - Request body is cloned before parsing and `ctx.request` is left unguarded, so the Stripe webhook can read raw bytes for signature verification. **Starter** - `src/worker.ts` + `wrangler.jsonc` `triggers.crons` so plugin cron (abandoned-cart recovery, dunning, stock-lock sweeps) runs on Cloudflare. - Regenerated `emdash-env.d.ts`. ## Verification - Monorepo typecheck / build / tests: 0 errors, all packages build, 84/84 tests pass. - Starter builds for Node and Cloudflare; generated `entry.mjs` bundles emdash's `scheduled()` handler with the cron trigger. - Dev server: public route Response passthrough, `Set-Cookie` forwarding, and `ctx.input` bodies all confirmed. - Stripe webhook: a fake signature returns `400 Invalid signature`, confirming the raw body is read (no "body already read" crash). - Seed validates and applies cleanly on 0.28. ## Not covered here The Cloudflare demo redeploy (`bun run cf:deploy`) — build artifacts are correct, but it needs a live `wrangler deploy` + a check that the cron trigger fires.
2 parents 6c90c3d + b865dd7 commit 772f21a

12 files changed

Lines changed: 332 additions & 206 deletions

File tree

.changeset/support-emdash-0-28.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@dashcommerce/core": patch
3+
"@dashcommerce/starter": patch
4+
---
5+
6+
Support EmDash 0.28.
7+
8+
Migrate from emdash 0.6 to 0.28.1. The plugin now builds its native
9+
`ResolvedPlugin` via `definePlugin` with single-argument `RouteContext`
10+
handlers (emdash's native route shape) instead of `adaptSandboxEntry`, whose
11+
0.28 form flattens the request and would break the Stripe webhook's raw-body
12+
signature check. Capability names are updated to the current vocabulary
13+
(`network:request`, `content:read`, `content:write`, `media:read`,
14+
`users:read`), and the emdash peer range is now `>=0.28.0 <0.29.0`.
15+
16+
The bundled emdash patch is re-authored for 0.28.1: plugin route handlers may
17+
still return a raw `Response` (cookies, redirects, webhook 200s), and the raw
18+
request body is preserved so `ctx.request.text()` works for Stripe webhook
19+
signature verification.
20+
21+
The starter adds a Cloudflare Worker entry (`src/worker.ts`) plus a Cron
22+
Trigger so plugin cron — abandoned-cart recovery, subscription dunning, and
23+
stock-lock sweeps — runs on Workers (emdash 0.19+ drives cron from a
24+
`scheduled()` handler, not request side effects).

bun.lock

Lines changed: 68 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@
2828
"bun": ">=1.1.0"
2929
},
3030
"patchedDependencies": {
31-
"emdash@0.6.0": "patches/emdash@0.6.0.patch"
31+
"emdash@0.28.1": "patches/emdash@0.28.1.patch"
3232
}
3333
}

packages/core/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"test": "bun test"
6666
},
6767
"peerDependencies": {
68-
"emdash": ">=0.5.0 <0.7.0",
68+
"emdash": ">=0.28.0 <0.29.0",
6969
"react": "^18.0.0 || ^19.0.0",
7070
"react-dom": "^18.0.0 || ^19.0.0"
7171
},
@@ -78,12 +78,12 @@
7878
}
7979
},
8080
"devDependencies": {
81-
"@emdash-cms/admin": "^0.6.0",
81+
"@emdash-cms/admin": "^0.28.1",
8282
"@stripe/react-stripe-js": "^3.0.0",
8383
"@stripe/stripe-js": "^5.0.0",
8484
"@types/react": "^19.0.0",
8585
"@types/react-dom": "^19.0.0",
86-
"emdash": "^0.6.0",
86+
"emdash": "^0.28.1",
8787
"react": "^19.0.0",
8888
"react-dom": "^19.0.0",
8989
"tsdown": "^0.20.0",

packages/core/src/index.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ export function dashcommerce(options: DashCommerceOptions = {}): PluginDescripto
5151
format: "native",
5252
entrypoint: "@dashcommerce/core/sandbox",
5353
capabilities: [
54-
"read:content",
55-
"write:content",
56-
"read:media",
57-
"read:users",
58-
"network:fetch",
54+
"content:read",
55+
"content:write",
56+
"media:read",
57+
"users:read",
58+
"network:request",
5959
"email:send",
6060
],
6161
allowedHosts: ["api.stripe.com", "files.stripe.com"],
@@ -159,11 +159,11 @@ export function dashcommerce(options: DashCommerceOptions = {}): PluginDescripto
159159
id,
160160
version: DASHCOMMERCE_VERSION,
161161
capabilities: [
162-
"read:content",
163-
"write:content",
164-
"read:media",
165-
"read:users",
166-
"network:fetch",
162+
"content:read",
163+
"content:write",
164+
"media:read",
165+
"users:read",
166+
"network:request",
167167
"email:send",
168168
],
169169
allowedHosts: ["api.stripe.com", "files.stripe.com"],

packages/core/src/sandbox-entry.ts

Lines changed: 121 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,33 @@
1010
* - All crypto via `crypto.subtle` (Web Crypto).
1111
*
1212
* Emdash's astro integration calls `createPlugin(options)` at build time
13-
* for native-format plugins. `options` comes from the descriptor's
14-
* `options` field (see src/index.ts). We feed them into
15-
* `adaptSandboxEntry` to produce a `ResolvedPlugin` — the same shape
16-
* standard-format plugins get for free.
13+
* for native-format plugins (`format: "native"` + `entrypoint` in the
14+
* descriptor — see src/index.ts). `options` comes from the descriptor's
15+
* `options` field. We build a native `ResolvedPlugin` with `definePlugin`.
16+
*
17+
* emdash 0.28 note: route handlers are the NATIVE single-arg shape
18+
* `(ctx: RouteContext) => Promise<unknown>`, where `RouteContext` extends
19+
* `PluginContext` — so `ctx` carries both the request (`ctx.request`,
20+
* `ctx.input`) and the plugin surface (`ctx.storage`, `ctx.kv`, `ctx.http`…).
21+
* DashCommerce's route handlers are authored two-arg `(routeCtx, ctx)`; we
22+
* adapt them to the single-arg form below (passing the one context as both).
23+
* We deliberately do NOT use `adaptSandboxEntry` here: it flattens
24+
* `routeCtx.request` to `{ url, method, headers }` with no body, which would
25+
* break the Stripe webhook's raw-body signature verification.
1726
*/
1827

19-
import { adaptSandboxEntry, definePlugin, type PluginDescriptor } from "emdash";
28+
import {
29+
definePlugin,
30+
type FieldWidgetConfig,
31+
type PluginAdminConfig,
32+
type PluginAdminPage,
33+
type PluginCapability,
34+
type PluginContext,
35+
type PluginDescriptor,
36+
type PluginRoute,
37+
type PortableTextBlockConfig,
38+
type RouteContext,
39+
} from "emdash";
2040

2141
import { productBeforeSave } from "./hooks/content";
2242
import { cronHandler } from "./hooks/cron";
@@ -33,59 +53,96 @@ import { subscriptionsPublicRoutes } from "./routes/subscriptions-public";
3353
import { webhookRoutes } from "./routes/webhook";
3454
import { DASHCOMMERCE_STORAGE } from "./storage-collections";
3555

36-
const DEFAULT_CAPABILITIES = [
37-
"read:content",
38-
"write:content",
39-
"read:media",
40-
"read:users",
41-
"network:fetch",
56+
const DEFAULT_CAPABILITIES: PluginCapability[] = [
57+
"content:read",
58+
"content:write",
59+
"media:read",
60+
"users:read",
61+
"network:request",
4262
"email:send",
4363
];
4464
const DEFAULT_ALLOWED_HOSTS = ["api.stripe.com", "files.stripe.com"];
4565

46-
const definition = definePlugin({
47-
hooks: {
48-
"content:beforeSave": {
49-
handler: productBeforeSave,
50-
},
51-
cron: {
52-
handler: cronHandler,
53-
},
54-
"plugin:install": {
55-
handler: onInstall,
56-
},
57-
"plugin:activate": {
58-
handler: onActivate,
59-
},
60-
},
61-
routes: {
62-
...cartRoutes,
63-
...checkoutRoutes,
64-
...configCheckRoutes,
65-
...customerPortalRoutes,
66-
...downloadsRoutes,
67-
...ordersPublicRoutes,
68-
...reviewsPublicRoutes,
69-
...subscriptionsPublicRoutes,
70-
...webhookRoutes,
71-
...adminApiRoutes,
72-
},
73-
});
66+
/**
67+
* DashCommerce's route handlers are authored in the two-arg convention
68+
* `(routeCtx, ctx)` — `routeCtx` for request data (`.request`, `.input`) and
69+
* `ctx` for the plugin surface. In emdash's native format both are the same
70+
* `RouteContext` (which extends `PluginContext`), so a single context serves
71+
* as both. This is the loose entry shape those route maps satisfy.
72+
*/
73+
type CommerceRouteEntry = {
74+
public?: boolean;
75+
input?: PluginRoute["input"];
76+
handler: (routeCtx: RouteContext, ctx: PluginContext) => Promise<unknown>;
77+
};
78+
79+
const HOOKS = {
80+
"content:beforeSave": { handler: productBeforeSave },
81+
cron: { handler: cronHandler },
82+
"plugin:install": { handler: onInstall },
83+
"plugin:activate": { handler: onActivate },
84+
};
85+
86+
const ROUTES = {
87+
...cartRoutes,
88+
...checkoutRoutes,
89+
...configCheckRoutes,
90+
...customerPortalRoutes,
91+
...downloadsRoutes,
92+
...ordersPublicRoutes,
93+
...reviewsPublicRoutes,
94+
...subscriptionsPublicRoutes,
95+
...webhookRoutes,
96+
...adminApiRoutes,
97+
} as unknown as Record<string, CommerceRouteEntry>;
98+
99+
/**
100+
* Adapt the authored two-arg route handlers into emdash's native single-arg
101+
* `PluginRoute` form. `RouteContext` extends `PluginContext`, so the one
102+
* `ctx` is passed as both arguments. `public` and `input` pass through.
103+
*/
104+
function toNativeRoutes(
105+
routes: Record<string, CommerceRouteEntry>,
106+
): Record<string, PluginRoute> {
107+
const out: Record<string, PluginRoute> = {};
108+
for (const [name, route] of Object.entries(routes)) {
109+
out[name] = {
110+
public: route.public,
111+
input: route.input,
112+
handler: (ctx) => route.handler(ctx, ctx),
113+
};
114+
}
115+
return out;
116+
}
117+
118+
/**
119+
* emdash's `PluginDefinition.storage` requires an `indexes` array on every
120+
* collection; the descriptor's `storage` declaration leaves it optional.
121+
* Normalize to the runtime shape.
122+
*/
123+
function toStorageConfig(
124+
decl: NonNullable<PluginDescriptor["storage"]>,
125+
): Record<string, { indexes: string[]; uniqueIndexes?: string[] }> {
126+
const out: Record<string, { indexes: string[]; uniqueIndexes?: string[] }> = {};
127+
for (const [name, cfg] of Object.entries(decl)) {
128+
out[name] = { indexes: cfg.indexes ?? [], uniqueIndexes: cfg.uniqueIndexes };
129+
}
130+
return out;
131+
}
74132

75133
export interface CreatePluginOptions {
76134
id?: string;
77135
version?: string;
78-
capabilities?: string[];
136+
capabilities?: PluginCapability[];
79137
allowedHosts?: string[];
80138
}
81139

82140
/**
83-
* Static admin page list — must match src/index.ts adminPages.
84-
* Defined here so `adaptSandboxEntry` can populate `admin.pages`,
85-
* which the emdash plugin-manager API reads to set `hasAdminPages`
86-
* and show the Settings gear link on the Plugins page.
141+
* Static admin page list — must match src/index.ts adminPages. Populates
142+
* `admin.pages`, which the emdash plugin-manager API reads to set
143+
* `hasAdminPages` and show the Settings gear link on the Plugins page.
87144
*/
88-
const ADMIN_PAGES: PluginDescriptor["adminPages"] = [
145+
const ADMIN_PAGES: PluginAdminPage[] = [
89146
{ path: "/orders", label: "Orders", icon: "shopping-bag" },
90147
{ path: "/customers", label: "Customers", icon: "users" },
91148
{ path: "/coupons", label: "Coupons", icon: "tag" },
@@ -99,7 +156,7 @@ const ADMIN_PAGES: PluginDescriptor["adminPages"] = [
99156
{ path: "/settings", label: "Settings", icon: "settings" },
100157
];
101158

102-
const ADMIN_WIDGETS: PluginDescriptor["adminWidgets"] = [
159+
const ADMIN_WIDGETS: NonNullable<PluginAdminConfig["widgets"]> = [
103160
{ id: "revenue-snapshot", title: "Revenue", size: "half" },
104161
{ id: "low-stock-alerts", title: "Low Stock", size: "half" },
105162
{ id: "recent-orders", title: "Recent Orders", size: "full" },
@@ -110,13 +167,7 @@ const ADMIN_WIDGETS: PluginDescriptor["adminWidgets"] = [
110167
// Custom content-field widgets this plugin provides. The content editor
111168
// resolves `widget: "dashcommerce:<name>"` on a field to the React
112169
// component exported from `admin/entry.tsx` under `fields[name]`.
113-
//
114-
// `adaptSandboxEntry` currently only forwards `adminPages`/`adminWidgets`
115-
// from the descriptor, so we merge these onto `resolved.admin` ourselves
116-
// below. Without that merge the admin manifest wouldn't know the widgets
117-
// exist and the editor would fall back to the default JSON textarea —
118-
// exactly the "prices field is still a text input" symptom.
119-
const FIELD_WIDGETS = [
170+
const FIELD_WIDGETS: FieldWidgetConfig[] = [
120171
{
121172
name: "vendor-select",
122173
label: "Vendor picker",
@@ -129,7 +180,7 @@ const FIELD_WIDGETS = [
129180
},
130181
];
131182

132-
const PORTABLE_TEXT_BLOCKS = [
183+
const PORTABLE_TEXT_BLOCKS: PortableTextBlockConfig[] = [
133184
{
134185
type: "product-embed",
135186
label: "Embed Product",
@@ -151,40 +202,30 @@ const PORTABLE_TEXT_BLOCKS = [
151202
];
152203

153204
/**
154-
* Native-format entry called by emdash at build time with the options
155-
* serialized from the descriptor. Returns a ResolvedPlugin ready for the
205+
* Native-format entry called by emdash with the options serialized from the
206+
* descriptor (see src/index.ts). Returns a `ResolvedPlugin` ready for the
156207
* HookPipeline.
157208
*/
158209
export function createPlugin(options: CreatePluginOptions = {}) {
159-
// adaptSandboxEntry reads id/version/capabilities/allowedHosts/
160-
// storage/adminPages/adminWidgets from the descriptor to build the
161-
// ResolvedPlugin. adminPages must be present so the emdash plugin-manager
162-
// API returns hasAdminPages:true and the Settings link appears.
163-
const descriptor: PluginDescriptor = {
210+
return definePlugin({
164211
id: options.id ?? "dashcommerce",
165212
version: options.version ?? "0.0.0",
166-
entrypoint: "@dashcommerce/core/sandbox",
167213
capabilities: options.capabilities ?? DEFAULT_CAPABILITIES,
168214
allowedHosts: options.allowedHosts ?? DEFAULT_ALLOWED_HOSTS,
169-
storage: DASHCOMMERCE_STORAGE,
170-
adminPages: ADMIN_PAGES,
171-
adminWidgets: ADMIN_WIDGETS,
172-
};
173-
const resolved = adaptSandboxEntry(definition, descriptor);
174-
// Merge fieldWidgets / portableTextBlocks onto the resolved admin
175-
// config. adaptSandboxEntry drops these today; without the merge the
176-
// admin manifest would never learn about `dashcommerce:price-map`
177-
// and the content editor would render the raw JSON textarea.
178-
// biome-ignore lint/suspicious/noExplicitAny: widening PluginAdminConfig to attach fields emdash's type doesn't yet declare
179-
const admin: any = (resolved as unknown as { admin?: unknown }).admin ?? {};
180-
admin.fieldWidgets = FIELD_WIDGETS;
181-
admin.portableTextBlocks = PORTABLE_TEXT_BLOCKS;
182-
(resolved as unknown as { admin: unknown }).admin = admin;
183-
return resolved;
215+
storage: toStorageConfig(DASHCOMMERCE_STORAGE),
216+
hooks: HOOKS,
217+
routes: toNativeRoutes(ROUTES),
218+
admin: {
219+
pages: ADMIN_PAGES,
220+
widgets: ADMIN_WIDGETS,
221+
fieldWidgets: FIELD_WIDGETS,
222+
portableTextBlocks: PORTABLE_TEXT_BLOCKS,
223+
},
224+
});
184225
}
185226

186227
/**
187-
* Default export kept for direct-import consumers + tests that read the
188-
* raw { hooks, routes } definition without going through adaptSandboxEntry.
228+
* Default export kept for direct-import consumers + tests that read the raw
229+
* `{ hooks, routes }` definition without going through `createPlugin`.
189230
*/
190-
export default definition;
231+
export default { hooks: HOOKS, routes: ROUTES };

0 commit comments

Comments
 (0)