Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/scheduled-publishing-driver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
---

Drive scheduled publishing from a real heartbeat instead of request side effects (#1303).

Content scheduled via the admin now actually transitions to `published` when its time arrives. Previously nothing promoted the row — `status` stayed `scheduled` and `published_at` stayed null forever.

A new sweep (`publishDueContent`) promotes due content and runs alongside the existing cron tick and system cleanup:

- **Node / single-process:** the timer-based scheduler already drives it — no action needed.
- **Cloudflare Workers:** a `scheduled()` handler driven by a Cron Trigger now runs the sweep. The request-driven `PiggybackScheduler` is gone, so there are no maintenance side effects on visitor requests.

`@emdash-cms/cloudflare` ships a Worker entry that wraps Astro's handler with the `scheduled()` handler (`@emdash-cms/cloudflare/worker`, plus `createScheduledHandler()` for hand-assembled Workers). When a cache provider is configured, the handler also purges edge-cache tags for whatever it published, so stale snapshots produced before the scheduled time are evicted.

**Migration for existing Cloudflare sites.** New sites get this from the templates. Existing deployments must update two files:

```ts
// src/worker.ts
export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
```

```jsonc
// wrangler.jsonc
"triggers": { "crons": ["* * * * *"] }
```

Without the Cron Trigger, scheduled publishing and plugin cron do not run on Workers.

Scheduled publishing matches manual publishing exactly: it fires `content:afterPublish` hooks (search indexing, webhooks, syndication), and records the _scheduled_ time as `published_at` on first publication rather than the (later) sweep time. The sweep claims each row atomically before promoting it, so an entry unscheduled or rescheduled just before its time is never published, and overlapping sweeps can't double-publish. Local `astro dev` keeps running the timer-driven sweep even under the Cloudflare adapter (where production relies on the Cron Trigger).
6 changes: 6 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"types": "./dist/sandbox/index.d.mts",
"default": "./dist/sandbox/index.mjs"
},
"./worker": {
"types": "./dist/worker.d.mts",
"default": "./dist/worker.mjs"
},
"./plugins": {
"types": "./dist/plugins/index.d.mts",
"default": "./dist/plugins/index.mjs"
Expand Down Expand Up @@ -77,12 +81,14 @@
"ulidx": "^2.4.1"
},
"peerDependencies": {
"@astrojs/cloudflare": ">=12.0.0",
"@cloudflare/workers-types": ">=4.0.0",
"astro": ">=6.0.0-beta.0",
"kysely": ">=0.28.17"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@astrojs/cloudflare": "catalog:",
"@cloudflare/workers-types": "catalog:",
"publint": "catalog:",
"tsdown": "catalog:",
Expand Down
80 changes: 80 additions & 0 deletions packages/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Cloudflare Worker entry for EmDash sites.
*
* Wraps the Astro Cloudflare server handler with a `scheduled()` handler so a
* Cron Trigger drives scheduled publishing, plugin cron, and system cleanup
* without any request side effects. Re-exports the `PluginBridge` Durable
* Object so the sandbox binding resolves against the entry module.
*
* Templates use this as their entire `src/worker.ts`:
*
* export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
*
* and add a Cron Trigger to wrangler.jsonc:
*
* "triggers": { "crons": ["* * * * *"] }
*
* The `@astrojs/cloudflare/entrypoints/server` import is resolved by the
* consuming app's Astro build (it pulls the build-time `virtual:astro:app`
* module), so this package keeps the adapter external.
*/

// @ts-ignore - resolved against the consuming app's Astro build
import astroHandler from "@astrojs/cloudflare/entrypoints/server";
import { createApp } from "astro/app/entrypoint";
import { runScheduledTasks } from "emdash/middleware";

export { PluginBridge } from "./sandbox/index.js";

// The Astro App wraps the build manifest; reuse one per isolate so each tick
// doesn't re-resolve the cache provider.
let app: ReturnType<typeof createApp> | null = null;

/**
* Purge edge-cache tags for content the sweep just published. Without a
* request there's no `locals.cache`, so we reach the configured cache provider
* through the Astro App pipeline — the same provider routes invalidate against.
* A no-op when no cache provider is configured.
*/
async function invalidatePublishedTags(
published: ReadonlyArray<{ collection: string; id: string }>,
): Promise<void> {
if (published.length === 0) return;
app ??= createApp();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] createApp() is called with no arguments here. Astro's createApp typically requires a manifest (createApp(manifest)). If astro/app/entrypoint does not provide a zero-arity overload, this will throw at runtime inside the scheduled() handler. The error is caught by the outer .catch(), so the worker won't crash, but edge-cache tag invalidation for published content will silently never run.

Please verify that the Cloudflare adapter build pipeline injects the manifest automatically for astro/app/entrypoint consumers. If not, pass the manifest explicitly (or construct the cache provider directly) so invalidatePublishedTags actually purges stale cache entries.

Suggested change
app ??= createApp();
app ??= createApp(/* manifest if required */);

const provider = await app.pipeline.getCacheProvider();
if (!provider) return;
const tags = [...new Set(published.flatMap((ref) => [ref.collection, ref.id]))];
await provider.invalidate({ tags });
}

/**
* Build a Worker `scheduled()` handler that runs EmDash's scheduled
* maintenance batch and purges edge-cache tags for anything it published.
* Exported for sites that assemble their own Worker object; most sites get it
* via this module's default export.
*/
export function createScheduledHandler(): ExportedHandlerScheduledHandler {
return (_controller, _env, ctx) => {
ctx.waitUntil(
runScheduledTasks()
.then(async ({ published }) => {
await invalidatePublishedTags(published);
if (published.length > 0) {
console.log(`[scheduled] Published ${published.length} scheduled item(s)`);
}
return undefined;
})
.catch((error: unknown) => {
console.error("[scheduled] runScheduledTasks failed:", error);
}),
Comment on lines +58 to +69
);
};
}

// eslint-disable-next-line typescript/no-unsafe-type-assertion -- astroHandler is the adapter's { fetch } worker object; resolved at app-build time
const handler = astroHandler as ExportedHandler;

export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
7 changes: 6 additions & 1 deletion packages/cloudflare/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default defineConfig({
"src/storage/r2.ts",
"src/auth/index.ts",
"src/sandbox/index.ts",
"src/worker.ts",
"src/plugins/index.ts",
// Media provider runtimes
"src/media/images-runtime.ts",
Expand All @@ -21,5 +22,9 @@ export default defineConfig({
format: ["esm"],
dts: true,
clean: true,
external: ["cloudflare:workers", "cloudflare:email"],
// @astrojs/cloudflare's server entrypoint and `astro/app/entrypoint` both
// resolve the build-time `virtual:astro:app` module — only available in the
// consuming app's Astro build, never here. Keep them external so the bare
// imports survive to be resolved downstream.
external: ["cloudflare:workers", "cloudflare:email", /^@astrojs\/cloudflare/, /^astro($|\/)/],
});
16 changes: 14 additions & 2 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RevisionRepository } from "../../database/repositories/revision.js";
import { SeoRepository } from "../../database/repositories/seo.js";
import {
EmDashValidationError,
ScheduledNotDueError,
InvalidCursorError,
type BylineSummary,
type ContentBylineCredit,
Expand Down Expand Up @@ -1267,13 +1268,13 @@ export async function handleContentPublish(
db: Kysely<Database>,
collection: string,
id: string,
options: { publishedAt?: string } = {},
options: { publishedAt?: string; requireScheduledDue?: boolean } = {},
): Promise<ApiResult<ContentResponse>> {
try {
const item = await withTransaction(db, async (trx) => {
const repo = new ContentRepository(trx);
const resolvedId = (await resolveId(repo, collection, id)) ?? id;
return repo.publish(collection, resolvedId, options.publishedAt);
return repo.publish(collection, resolvedId, options.publishedAt, options.requireScheduledDue);
});

const hasSeo = await collectionHasSeo(db, collection);
Expand All @@ -1284,6 +1285,17 @@ export async function handleContentPublish(
data: { item },
};
} catch (error) {
// The scheduled sweep gates publish on the row still being due; a row
// unscheduled in the meantime is a silent skip, not a failure.
if (error instanceof ScheduledNotDueError) {
return {
success: false,
error: {
code: "NOT_DUE",
message: error.message,
},
};
}
if (error instanceof EmDashValidationError) {
return {
success: false,
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export const RESOLVED_VIRTUAL_SEED_ID = "\0" + VIRTUAL_SEED_ID;
export const VIRTUAL_WAIT_UNTIL_ID = "virtual:emdash/wait-until";
export const RESOLVED_VIRTUAL_WAIT_UNTIL_ID = "\0" + VIRTUAL_WAIT_UNTIL_ID;

export const VIRTUAL_SCHEDULER_ID = "virtual:emdash/scheduler";
export const RESOLVED_VIRTUAL_SCHEDULER_ID = "\0" + VIRTUAL_SCHEDULER_ID;

/**
* Generates the config virtual module.
*/
Expand Down Expand Up @@ -413,6 +416,42 @@ export function generateWaitUntilModule(adapterName: string | undefined): string
return `export const waitUntil = undefined;`;
}

/**
* Generates the scheduler virtual module.
*
* Decides — at build time, from the Astro adapter — whether the runtime gets a
* long-lived timer heartbeat. A *production* Cloudflare build has no persistent
* timers, so the Worker's `scheduled()` handler (a Cron Trigger) drives
* `runScheduledTasks()` instead and this exports `null`. Every other case — any
* other adapter (Node, Bun), and crucially local `astro dev` even under the
* Cloudflare adapter (no Cron Trigger fires in dev) — gets a `NodeCronScheduler`
* factory so plugin cron, scheduled publishing, and cleanup still run.
*
* Keeping the adapter check here — rather than in core's runtime — means the
* runtime has no Cloudflare-specific code path; it just calls `createScheduler`
* if one was injected. Mirrors the wait-until module's approach.
*/
export function generateSchedulerModule(
adapterName: string | undefined,
command: "build" | "serve" | undefined,
): string {
// Only suppress the timer for an actual Cloudflare *build* — that artifact
// runs in workerd where a Cron Trigger drives scheduled work. In `serve`
// (local dev) nothing fires the Cron Trigger, so fall through to the timer.
if (adapterName === "@astrojs/cloudflare" && command !== "serve") {
return `// Serverless build: an external Cron Trigger drives scheduled work.
export const createScheduler = null;
`;
}
return `// Long-lived runtime (or local dev): drive scheduled work from an in-process timer.
import { NodeCronScheduler } from "emdash";

export function createScheduler(executor) {
return new NodeCronScheduler(executor);
}
`;
}

/**
* Generates the seed virtual module.
* Reads the user's seed file at build time (in Node context) and embeds it,
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ import {
RESOLVED_VIRTUAL_SEED_ID,
VIRTUAL_WAIT_UNTIL_ID,
RESOLVED_VIRTUAL_WAIT_UNTIL_ID,
VIRTUAL_SCHEDULER_ID,
RESOLVED_VIRTUAL_SCHEDULER_ID,
generateSeedModule,
generateWaitUntilModule,
generateSchedulerModule,
generateConfigModule,
generateDialectModule,
generateStorageModule,
Expand Down Expand Up @@ -203,6 +206,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
if (id === VIRTUAL_WAIT_UNTIL_ID) {
return RESOLVED_VIRTUAL_WAIT_UNTIL_ID;
}
if (id === VIRTUAL_SCHEDULER_ID) {
return RESOLVED_VIRTUAL_SCHEDULER_ID;
}
},
load(id: string) {
if (id === RESOLVED_VIRTUAL_CONFIG_ID) {
Expand Down Expand Up @@ -271,6 +277,12 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin {
if (id === RESOLVED_VIRTUAL_WAIT_UNTIL_ID) {
return generateWaitUntilModule(astroConfig.adapter?.name);
}
// Generate scheduler module — a NodeCronScheduler factory on
// long-lived runtimes, or null under the Cloudflare adapter where
// a Cron Trigger drives scheduled work instead.
if (id === RESOLVED_VIRTUAL_SCHEDULER_ID) {
return generateSchedulerModule(astroConfig.adapter?.name, viteCommand);
}
},
};
}
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import * as virtualSandboxRunnerModule from "virtual:emdash/sandbox-runner";
// @ts-ignore - virtual module
import { sandboxedPlugins as virtualSandboxedPlugins } from "virtual:emdash/sandboxed-plugins";
// @ts-ignore - virtual module
import { createScheduler as virtualCreateScheduler } from "virtual:emdash/scheduler";
// @ts-ignore - virtual module
import { createStorage as virtualCreateStorage } from "virtual:emdash/storage";

import {
Expand All @@ -37,6 +39,7 @@ import {
type RuntimeDependencies,
type SandboxedPluginEntry,
type MediaProviderEntry,
type CreateSchedulerFn,
} from "../emdash-runtime.js";
import { setI18nConfig } from "../i18n/config.js";
import type { Database, Storage } from "../index.js";
Expand All @@ -50,6 +53,7 @@ import {
type RequestMetrics,
runWithContext,
} from "../request-context.js";
import type { PublishedRef } from "../scheduled-publish.js";
import type { EmDashConfig } from "./integration/runtime.js";
import { createPublicPluginApiRouteHandler } from "./public-plugin-api-routes.js";
import type { EmDashHandlers } from "./types.js";
Expand Down Expand Up @@ -126,6 +130,7 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies {
plugins: getPlugins(),
createDialect: virtualCreateDialect as (config: Record<string, unknown>) => unknown,
createStorage: virtualCreateStorage as ((config: Record<string, unknown>) => Storage) | null,
createScheduler: virtualCreateScheduler as CreateSchedulerFn | null,
sandboxEnabled: sandboxModule.sandboxEnabled as boolean,
sandboxBypassed: (sandboxModule.sandboxBypassed as boolean) ?? false,
sandboxedPluginEntries: (virtualSandboxedPlugins as SandboxedPluginEntry[]) || [],
Expand Down Expand Up @@ -184,6 +189,25 @@ async function getRuntime(
}
}

/**
* Run scheduled maintenance (cron tasks, scheduled publishing, system cleanup)
* outside any request. Resolves the runtime from the build-time virtual config
* and the cached singleton — the same instance request handlers use.
*
* Wired into a platform heartbeat that is not a request: the Cloudflare Worker's
* `scheduled()` handler (Cron Trigger) calls this. On Node the runtime's own
* timer-based scheduler already drives the same work, so this isn't needed there.
*
* Returns the content promoted by the publishing sweep so the caller can purge
* edge-cache tags for it.
*/
export async function runScheduledTasks(): Promise<{ published: PublishedRef[] }> {
const config = getConfig();
if (!config) return { published: [] };
const runtime = await getRuntime(config);
return runtime.runScheduledTasks();
}

/**
* Astro attaches AstroCookies to outgoing responses via a well-known global
* symbol. Cloning a Response (`new Response(body, init)`) drops non-header
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export interface EmDashHandlers {
handleContentPublish: (
collection: string,
id: string,
options?: { publishedAt?: string },
options?: { publishedAt?: string; requireScheduledDue?: boolean },
) => Promise<HandlerResponse>;

handleContentUnpublish: (collection: string, id: string) => Promise<HandlerResponse>;
Expand Down
Loading
Loading