-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: gate dynamic plugins behind Worker Loader so free-tier Cloudflare deploys work #2351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ee39810
4ed304d
71988e0
247043d
ebdfba2
3e4e7bf
83a3a3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "create-emdash": patch | ||
| --- | ||
|
|
||
| Scaffolds Cloudflare projects that deploy on the Workers free tier out of the box. The Worker Loader binding — needed only for dynamic plugins, which require the Workers paid plan — now ships commented out. Opt in by answering "yes" to the new dynamic-plugins prompt, or pass `--dynamic-plugins` (use `--no-dynamic-plugins` to keep it off non-interactively). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "emdash": patch | ||
| --- | ||
|
|
||
| Gates the admin marketplace and registry screens behind sandbox availability. On a deployment with no sandbox runner — for example a Cloudflare free-tier site without the Worker Loader binding — the browse and install views are replaced with a prompt explaining that dynamic plugins need Worker Loader (a Workers paid-plan feature) and how to enable it, instead of letting an install fail with a 503. The manifest now reports a `sandboxAvailable` flag. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,18 @@ | ||
| /** | ||
| * Noop sandbox runner for e2e tests. | ||
| * | ||
| * The marketplace admin pages only need `marketplace: true` in the manifest | ||
| * to render browse/detail UI. The sandbox runner is only used at install time. | ||
| * This stub satisfies the config validation without importing cloudflare:workers. | ||
| * The marketplace admin pages need an available sandbox in the manifest to | ||
| * render browse/detail UI. The sandbox runner is only used at install time. | ||
| * This stub satisfies the availability gate without executing plugin code. | ||
| */ | ||
| import { createNoopSandboxRunner } from "emdash"; | ||
| import { NoopSandboxRunner } from "emdash"; | ||
|
|
||
| export { createNoopSandboxRunner as createSandboxRunner }; | ||
| class MarketplaceTestSandboxRunner extends NoopSandboxRunner { | ||
| isAvailable() { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| export function createSandboxRunner() { | ||
| return new MarketplaceTestSandboxRunner(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,18 @@ | ||
| /** | ||
| * Noop sandbox runner for e2e tests. | ||
| * | ||
| * The marketplace admin pages only need `marketplace: true` in the manifest | ||
| * to render browse/detail UI. The sandbox runner is only used at install time. | ||
| * This stub satisfies the config validation without importing cloudflare:workers. | ||
| * The marketplace admin pages need an available sandbox in the manifest to | ||
| * render browse/detail UI. The sandbox runner is only used at install time. | ||
| * This stub satisfies the availability gate without executing plugin code. | ||
| */ | ||
| import { createNoopSandboxRunner } from "emdash"; | ||
| import { NoopSandboxRunner } from "emdash"; | ||
|
|
||
| export { createNoopSandboxRunner as createSandboxRunner }; | ||
| class MarketplaceTestSandboxRunner extends NoopSandboxRunner { | ||
| isAvailable() { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| export function createSandboxRunner() { | ||
| return new MarketplaceTestSandboxRunner(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /** | ||
| * Dynamic Plugins Unavailable | ||
| * | ||
| * Shown in place of the marketplace / registry browse UI when the deployment | ||
| * has no available sandbox runner (`manifest.sandboxAvailable === false`). | ||
| * Rather than let the user browse and hit an error at install time, direct | ||
| * them to the platform-specific setup instructions. | ||
| */ | ||
|
|
||
| import { LinkButton } from "@cloudflare/kumo"; | ||
| import { Trans, useLingui } from "@lingui/react/macro"; | ||
| import { ArrowSquareOut, ShieldWarning } from "@phosphor-icons/react"; | ||
|
|
||
| /** Docs page covering sandbox runner setup (Cloudflare Worker Loader + Node workerd). */ | ||
| const INSTALL_DOCS_URL = "https://docs.emdashcms.com/plugins/installing/"; | ||
|
|
||
| export function DynamicPluginsUnavailable() { | ||
| const { t } = useLingui(); | ||
|
|
||
| return ( | ||
| <div className="mx-auto max-w-2xl"> | ||
| <div className="flex flex-col items-center rounded-lg border bg-kumo-base p-8 text-center"> | ||
| <div className="flex h-12 w-12 items-center justify-center rounded-full bg-kumo-warning/10 text-kumo-warning"> | ||
| <ShieldWarning className="h-6 w-6" aria-hidden="true" /> | ||
| </div> | ||
|
|
||
| <h2 className="mt-4 text-lg font-medium"> | ||
| <Trans>Dynamic plugins aren't available on this deployment</Trans> | ||
| </h2> | ||
|
|
||
| <p className="mt-2 text-sm text-kumo-subtle"> | ||
| <Trans> | ||
| Installing plugins at runtime requires an available sandbox runner. Configure one for | ||
| your deployment platform and redeploy to enable dynamic plugins. | ||
| </Trans> | ||
| </p> | ||
|
|
||
| <LinkButton | ||
| href={INSTALL_DOCS_URL} | ||
| external | ||
| variant="outline" | ||
| icon={<ArrowSquareOut />} | ||
| className="mt-4" | ||
| > | ||
| {t`Learn how to enable dynamic plugins`} | ||
| </LinkButton> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { ContentTypeEditor } from "./components/ContentTypeEditor"; | |||||||||||||||||||||||||||||||||
| import { ContentTypeList } from "./components/ContentTypeList"; | ||||||||||||||||||||||||||||||||||
| import { Dashboard } from "./components/Dashboard"; | ||||||||||||||||||||||||||||||||||
| import { DeviceAuthorizePage } from "./components/DeviceAuthorizePage"; | ||||||||||||||||||||||||||||||||||
| import { DynamicPluginsUnavailable } from "./components/DynamicPluginsUnavailable"; | ||||||||||||||||||||||||||||||||||
| import { InviteAcceptPage } from "./components/InviteAcceptPage"; | ||||||||||||||||||||||||||||||||||
| import { LoginPage } from "./components/LoginPage"; | ||||||||||||||||||||||||||||||||||
| import { MarketplaceBrowse } from "./components/MarketplaceBrowse"; | ||||||||||||||||||||||||||||||||||
|
|
@@ -1643,6 +1644,14 @@ function MarketplaceBrowsePage() { | |||||||||||||||||||||||||||||||||
| return new Set(plugins.map((p) => p.id)); | ||||||||||||||||||||||||||||||||||
| }, [plugins]); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Dynamic plugins run sandboxed — on Cloudflare via Worker Loader (a paid | ||||||||||||||||||||||||||||||||||
| // feature). When the runner isn't available, browsing would only lead to a | ||||||||||||||||||||||||||||||||||
| // 503 at install time, so show the how-to-enable prompt instead. Wait for | ||||||||||||||||||||||||||||||||||
| // the manifest before deciding so we don't flash the prompt on load. | ||||||||||||||||||||||||||||||||||
| if (manifest && manifest.sandboxAvailable === false) { | ||||||||||||||||||||||||||||||||||
| return <DynamicPluginsUnavailable />; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // When `experimental.registry` is configured, the registry browse | ||||||||||||||||||||||||||||||||||
| // replaces the centralized marketplace browse on this route. Existing | ||||||||||||||||||||||||||||||||||
| // sidebar / deep links stay valid; users see the registry without any | ||||||||||||||||||||||||||||||||||
|
|
@@ -1694,6 +1703,13 @@ function MarketplaceDetailPage() { | |||||||||||||||||||||||||||||||||
| return new Set(plugins.map((p) => p.id)); | ||||||||||||||||||||||||||||||||||
| }, [plugins]); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Same gate as the browse route: no sandbox runner means install can't | ||||||||||||||||||||||||||||||||||
| // succeed, so surface the how-to-enable prompt rather than a detail page | ||||||||||||||||||||||||||||||||||
| // whose only action would 503. | ||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] Same as the browse-route gate: the detail route renders
Suggested change
|
||||||||||||||||||||||||||||||||||
| if (manifest && manifest.sandboxAvailable === false) { | ||||||||||||||||||||||||||||||||||
| return <DynamicPluginsUnavailable />; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Discriminate by param shape, not by the manifest flag. A registry | ||||||||||||||||||||||||||||||||||
| // pluginId is always `${handle}/${slug}` and contains exactly one `/`; | ||||||||||||||||||||||||||||||||||
| // a marketplace pluginId is a single segment with no `/`. This keeps | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
|
|
||
| import { render } from "../utils/render.tsx"; | ||
|
|
||
| const { DynamicPluginsUnavailable } = | ||
| await import("../../src/components/DynamicPluginsUnavailable"); | ||
|
|
||
| describe("DynamicPluginsUnavailable", () => { | ||
| it("explains that dynamic plugins aren't available", async () => { | ||
| const screen = await render(<DynamicPluginsUnavailable />); | ||
| await expect | ||
| .element(screen.getByText("Dynamic plugins aren't available on this deployment")) | ||
| .toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("gives platform-neutral sandbox guidance", async () => { | ||
| const screen = await render(<DynamicPluginsUnavailable />); | ||
| await expect | ||
| .element( | ||
| screen.getByText( | ||
| "Installing plugins at runtime requires an available sandbox runner. Configure one for your deployment platform and redeploy to enable dynamic plugins.", | ||
| ), | ||
| ) | ||
| .toBeInTheDocument(); | ||
| await expect | ||
| .element(screen.getByText('"worker_loaders": [{ "binding": "LOADER" }]'), { timeout: 100 }) | ||
| .not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("links to the install docs", async () => { | ||
| const screen = await render(<DynamicPluginsUnavailable />); | ||
| const link = screen.getByRole("link", { name: /enable dynamic plugins/i }); | ||
| await expect | ||
| .element(link) | ||
| .toHaveAttribute("href", "https://docs.emdashcms.com/plugins/installing/"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion] This gate only fires once
manifestis truthy. While the manifest query is pending, the route falls through to<MarketplaceBrowse>, which starts fetching marketplace data. On a free-tier site (sandboxAvailable: false) that data fetch is wasted and the user may see a brief flash of the marketplace before the prompt replaces it.Return a loader while the manifest is pending so the gate actually controls what first appears: