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
6 changes: 6 additions & 0 deletions .changeset/quiet-workers-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/cloudflare": patch
"emdash": patch
---

Adds `emdash/plugins/host` as a narrow runtime entry for platform sandbox adapters. The Cloudflare Worker loads scheduled maintenance and sandbox bridge dependencies when those capabilities first run, reducing startup CPU while preserving existing Worker exports and behavior.
5 changes: 5 additions & 0 deletions .github/workflows/query-counts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ jobs:
node scripts/query-counts.mjs --target sqlite --update
node scripts/query-counts.mjs --target d1 --update

- name: Check Worker startup closure
run: |
pnpm --dir templates/starter-cloudflare build
pnpm startup:check

- name: Detect snapshot drift
id: drift
run: |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"new": "create-emdash",
"screenshots": "node scripts/screenshot-all-templates.mjs",
"query-counts": "node scripts/query-counts.mjs",
"startup:check": "node scripts/check-worker-startup.mjs",
"locale:extract": "pnpm --filter @emdash-cms/admin locale:extract",
"locale:compile": "pnpm --filter @emdash-cms/admin locale:compile"
},
Expand Down
14 changes: 14 additions & 0 deletions packages/cloudflare/src/sandbox/bridge-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export {
ContentRepository,
CronAccessImpl,
createContentAccess,
createSandboxRouteError,
getSandboxRouteErrorDetails,
OptionsRepository,
PluginStorageRepository,
resolveContentCreateLocale,
StorageSerializationError,
ulid,
} from "emdash/plugins/host";
export { Kysely } from "kysely";
export { D1Dialect } from "kysely-d1";
92 changes: 49 additions & 43 deletions packages/cloudflare/src/sandbox/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,24 @@ import type {
SandboxEmailSendCallback,
VersionedValue,
} from "emdash";
import {
ContentRepository,
CronAccessImpl,
createContentAccess,
createSandboxRouteError,
getSandboxRouteErrorDetails,
ulid,
import type {
ContentItem,
ContentListOptions,
OptionsRepository,
PaginatedResult,
PluginStorageRepository,
StorageSerializationError,
resolveContentCreateLocale,
} from "emdash";
import { Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";
} from "emdash/plugins/host";

import { sandboxHttpFetch } from "./bridge-http.js";
import type { StorageUpdateIfResponse } from "./types.js";

let bridgeRuntimePromise: Promise<typeof import("./bridge-runtime.js")> | undefined;

function loadBridgeRuntime(): Promise<typeof import("./bridge-runtime.js")> {
bridgeRuntimePromise ??= import("./bridge-runtime.js");
return bridgeRuntimePromise;
}

/** Regex to validate collection names (prevent SQL injection) */
const COLLECTION_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
const MISSING_MEDIA_USAGE_ACTIVATION_TABLE_REGEX = /no such table.*_emdash_media_usage_activation/i;
Expand Down Expand Up @@ -228,7 +228,8 @@ export interface PluginBridgeProps {
* 3. Plugins call bridge methods which validate and proxy to the database
*/
export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridgeProps> {
private getOptionsRepo(): OptionsRepository {
private async getOptionsRepo(): Promise<OptionsRepository> {
const { D1Dialect, Kysely, OptionsRepository } = await loadBridgeRuntime();
return new OptionsRepository(
new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) }),
);
Expand All @@ -248,6 +249,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
}

private async assertMediaUsageActivationWriteAllowed(): Promise<void> {
const { createSandboxRouteError, getSandboxRouteErrorDetails } = await loadBridgeRuntime();
try {
const activation = await this.env.DB.prepare(
"SELECT state FROM _emdash_media_usage_activation WHERE task_key = ? LIMIT 1",
Expand All @@ -272,7 +274,8 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
* query/count operations support WHERE/ORDER BY/cursor pagination
* matching in-process and workerd sandbox plugins.
*/
private getStorageRepo(collection: string): PluginStorageRepository {
private async getStorageRepo(collection: string): Promise<PluginStorageRepository> {
const { D1Dialect, Kysely, PluginStorageRepository } = await loadBridgeRuntime();
const { pluginId, storageConfig } = this.ctx.props;
const config = storageConfig?.[collection];
// Merge unique indexes into the indexes list since both are queryable
Expand All @@ -298,7 +301,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
async kvGet(key: string): Promise<unknown> {
const { pluginId } = this.ctx.props;
if (key.startsWith(SETTINGS_KEY_PREFIX)) {
const value = await this.getOptionsRepo().get(this.pluginOptionKey(key));
const value = await (await this.getOptionsRepo()).get(this.pluginOptionKey(key));
if (value !== null) return value;
}
const result = await this.env.DB.prepare(
Expand All @@ -317,7 +320,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
async kvSet(key: string, value: unknown): Promise<void> {
const { pluginId } = this.ctx.props;
if (key.startsWith(SETTINGS_KEY_PREFIX)) {
await this.getOptionsRepo().set(this.pluginOptionKey(key), value);
await (await this.getOptionsRepo()).set(this.pluginOptionKey(key), value);
await this.deleteLegacyKV(key);
return;
}
Expand All @@ -330,10 +333,10 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge

async kvGetVersioned(key: string): Promise<VersionedValue | null> {
if (key.startsWith(SETTINGS_KEY_PREFIX)) {
const value = await this.getOptionsRepo().getVersioned(this.pluginOptionKey(key));
const value = await (await this.getOptionsRepo()).getVersioned(this.pluginOptionKey(key));
if (value !== null) return value;
}
return this.getStorageRepo("__kv").getVersioned(key);
return (await this.getStorageRepo("__kv")).getVersioned(key);
}

async kvCompareAndSet(
Expand All @@ -342,36 +345,33 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
value: unknown,
): Promise<ConditionalWriteResult> {
if (key.startsWith(SETTINGS_KEY_PREFIX)) {
const result = await this.getOptionsRepo().compareAndSet(
this.pluginOptionKey(key),
expectedRevision,
value,
);
const result = await (
await this.getOptionsRepo()
).compareAndSet(this.pluginOptionKey(key), expectedRevision, value);
if (result.applied) await this.deleteLegacyKV(key);
return result;
}
return this.getStorageRepo("__kv").compareAndSet(key, expectedRevision, value);
return (await this.getStorageRepo("__kv")).compareAndSet(key, expectedRevision, value);
}

async kvCompareAndDelete(
key: string,
expectedRevision: string,
): Promise<ConditionalDeleteResult> {
if (key.startsWith(SETTINGS_KEY_PREFIX)) {
const result = await this.getOptionsRepo().compareAndDelete(
this.pluginOptionKey(key),
expectedRevision,
);
const result = await (
await this.getOptionsRepo()
).compareAndDelete(this.pluginOptionKey(key), expectedRevision);
if (result.applied) await this.deleteLegacyKV(key);
return result;
}
return this.getStorageRepo("__kv").compareAndDelete(key, expectedRevision);
return (await this.getStorageRepo("__kv")).compareAndDelete(key, expectedRevision);
}

async kvDelete(key: string): Promise<boolean> {
const { pluginId } = this.ctx.props;
if (key.startsWith(SETTINGS_KEY_PREFIX)) {
const optionDeleted = await this.getOptionsRepo().delete(this.pluginOptionKey(key));
const optionDeleted = await (await this.getOptionsRepo()).delete(this.pluginOptionKey(key));
const legacyDeleted = await this.deleteLegacyKV(key);
return optionDeleted || legacyDeleted;
}
Expand Down Expand Up @@ -401,7 +401,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
? `${optionPrefix}${prefix}`
: null;
if (settingsPrefix) {
for (const [name, value] of await this.getOptionsRepo().getByPrefix(settingsPrefix)) {
for (const [name, value] of await (await this.getOptionsRepo()).getByPrefix(settingsPrefix)) {
entries.set(name.slice(optionPrefix.length), value);
}
}
Expand Down Expand Up @@ -442,7 +442,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!this.ctx.props.storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
return this.getStorageRepo(collection).getVersioned(id);
return (await this.getStorageRepo(collection)).getVersioned(id);
}

async storageCompareAndSet(
Expand All @@ -454,7 +454,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!this.ctx.props.storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
return this.getStorageRepo(collection).compareAndSet(id, expectedRevision, data);
return (await this.getStorageRepo(collection)).compareAndSet(id, expectedRevision, data);
}

async storageCompareAndDelete(
Expand All @@ -465,7 +465,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!this.ctx.props.storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
return this.getStorageRepo(collection).compareAndDelete(id, expectedRevision);
return (await this.getStorageRepo(collection)).compareAndDelete(id, expectedRevision);
}

async storageUpdateIf(
Expand All @@ -477,8 +477,9 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
throw new Error(`Storage collection not declared: ${collection}`);
}
try {
return await this.getStorageRepo(collection).updateIf(id, args);
return await (await this.getStorageRepo(collection)).updateIf(id, args);
} catch (error) {
const { StorageSerializationError } = await loadBridgeRuntime();
if (!(error instanceof StorageSerializationError)) throw error;
return {
__emdashStorageError: {
Expand Down Expand Up @@ -526,7 +527,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
throw new Error(`Storage collection not declared: ${collection}`);
}
// Delegate to PluginStorageRepository for proper WHERE/ORDER BY/cursor support
const repo = this.getStorageRepo(collection);
const repo = await this.getStorageRepo(collection);
const result = await repo.query({
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record<string, unknown>
where: opts.where as never,
Expand All @@ -546,7 +547,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!storageCollections.includes(collection)) {
throw new Error(`Storage collection not declared: ${collection}`);
}
const repo = this.getStorageRepo(collection);
const repo = await this.getStorageRepo(collection);
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record<string, unknown>
return repo.count(where as never);
}
Expand Down Expand Up @@ -614,10 +615,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
// Content Operations - capability-gated
// =========================================================================

async contentGet(
collection: string,
id: string,
): ReturnType<ReturnType<typeof createContentAccess>["get"]> {
async contentGet(collection: string, id: string): Promise<ContentItem | null> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("content:read")) {
throw new Error("Missing capability: content:read");
Expand All @@ -626,6 +624,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!COLLECTION_NAME_REGEX.test(collection)) {
throw new Error(`Invalid collection name: ${collection}`);
}
const { createContentAccess, D1Dialect, Kysely } = await loadBridgeRuntime();
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
try {
return await createContentAccess(db).get(collection, id);
Expand All @@ -641,8 +640,8 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge

async contentList(
collection: string,
opts: Parameters<ReturnType<typeof createContentAccess>["list"]>[1] = {},
): ReturnType<ReturnType<typeof createContentAccess>["list"]> {
opts: ContentListOptions = {},
): Promise<PaginatedResult<ContentItem>> {
const { capabilities } = this.ctx.props;
if (!capabilities.includes("content:read")) {
throw new Error("Missing capability: content:read");
Expand All @@ -651,6 +650,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!COLLECTION_NAME_REGEX.test(collection)) {
throw new Error(`Invalid collection name: ${collection}`);
}
const { createContentAccess, D1Dialect, Kysely } = await loadBridgeRuntime();
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
return createContentAccess(db).list(collection, opts);
}
Expand All @@ -667,6 +667,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!COLLECTION_NAME_REGEX.test(collection)) {
throw new Error(`Invalid collection name: ${collection}`);
}
const { resolveContentCreateLocale, ulid } = await loadBridgeRuntime();
const locale = resolveContentCreateLocale(options?.locale, this.ctx.props.i18nConfig ?? null);
await this.assertMediaUsageActivationWriteAllowed();
const id = ulid();
Expand Down Expand Up @@ -732,6 +733,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!COLLECTION_NAME_REGEX.test(collection)) {
throw new Error(`Invalid collection name: ${collection}`);
}
const { ContentRepository, D1Dialect, Kysely } = await loadBridgeRuntime();
const db = new Kysely<Database>({
dialect: new D1Dialect({ database: this.env.DB }),
});
Expand Down Expand Up @@ -1008,6 +1010,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
if (!this.env.MEDIA) {
throw new Error("Media storage (R2) not configured. Add MEDIA binding to wrangler config.");
}
const { ulid } = await loadBridgeRuntime();

// Validate MIME type — only allow image, video, audio, and PDF
const ALLOWED_MIME_PREFIXES = ["image/", "video/", "audio/", "application/pdf"];
Expand Down Expand Up @@ -1259,6 +1262,7 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
name: string,
opts: { schedule: string; data?: Record<string, unknown> },
): Promise<void> {
const { CronAccessImpl, D1Dialect, Kysely } = await loadBridgeRuntime();
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
await new CronAccessImpl(
db,
Expand All @@ -1269,13 +1273,15 @@ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridge
}

async cronCancel(name: string): Promise<void> {
const { CronAccessImpl, D1Dialect, Kysely } = await loadBridgeRuntime();
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
await new CronAccessImpl(db, this.ctx.props.pluginId, () => cronRescheduleCallback?.()).cancel(
name,
);
}

async cronList(): Promise<CronTaskInfo[]> {
const { CronAccessImpl, D1Dialect, Kysely } = await loadBridgeRuntime();
const db = new Kysely<Database>({ dialect: new D1Dialect({ database: this.env.DB }) });
return new CronAccessImpl(db, this.ctx.props.pluginId, () => cronRescheduleCallback?.()).list();
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@
// @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";
export { PluginBridge } from "./sandbox/bridge.js";

const APP_KEY = Symbol.for("@emdash-cms/cloudflare:astro-app");
const CACHE_PROVIDER_KEY = Symbol.for("@emdash-cms/cloudflare:cache-provider");
Expand Down Expand Up @@ -92,6 +91,7 @@ export function createScheduledHandler(
ctx.waitUntil(
(async () => {
try {
const { runScheduledTasks } = await import("emdash/middleware");
// Invalidate incrementally as each collection batch publishes, so a
// scheduled() invocation killed mid-sweep (CPU/wall-clock limits on a
// large backlog) still purged the cache tags for everything it managed
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/tests/worker-scheduled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock("astro/app/entrypoint", () => ({
}),
}));
vi.mock("emdash/middleware", () => ({ runScheduledTasks: scheduled.general }));
vi.mock("../src/sandbox/index.js", () => ({ PluginBridge: vi.fn() }));
vi.mock("../src/sandbox/bridge.js", () => ({ PluginBridge: vi.fn() }));

import { createScheduledHandler } from "../src/worker.js";

Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default defineConfig({
"src/image-endpoint.ts",
"src/auth/index.ts",
"src/sandbox/index.ts",
"src/sandbox/bridge.ts",
"src/worker.ts",
"src/plugins/index.ts",
// Standalone entry: cloudflareEmail() descriptors reference this module
Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@
"./plugins/adapt-sandbox-entry": {
"types": "./dist/plugins/adapt-sandbox-entry.d.mts",
"default": "./dist/plugins/adapt-sandbox-entry.mjs"
},
"./plugins/host": {
"types": "./dist/plugins/host.d.mts",
"default": "./dist/plugins/host.mjs"
}
},
"imports": {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ export function createViteConfig(
"emdash > zod",
"@emdash-cms/cloudflare > kysely-d1",
// Astro internal deps not covered by @astrojs/cloudflare adapter
"astro/app/entrypoint",
"astro/app/manifest",
...(hasAstroConsoleLogger ? ["astro/logger/console"] : []),
"astro/virtual-modules/middleware.js",
Expand Down
Loading
Loading