@emdash-cms/cloudflare 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.d.mts +81 -0
- package/dist/auth/index.mjs +147 -0
- package/dist/cache/config.d.mts +52 -0
- package/dist/cache/config.mjs +55 -0
- package/dist/cache/runtime.d.mts +40 -0
- package/dist/cache/runtime.mjs +191 -0
- package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
- package/dist/db/d1.d.mts +43 -0
- package/dist/db/d1.mjs +74 -0
- package/dist/db/do.d.mts +96 -0
- package/dist/db/do.mjs +489 -0
- package/dist/db/playground-middleware.d.mts +20 -0
- package/dist/db/playground-middleware.mjs +533 -0
- package/dist/db/playground.d.mts +39 -0
- package/dist/db/playground.mjs +26 -0
- package/dist/do-class-DY2Ba2RJ.mjs +174 -0
- package/dist/do-class-x5Xh_G62.d.mts +73 -0
- package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
- package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
- package/dist/do-types-CY0G0oyh.d.mts +14 -0
- package/dist/images-4RT9Ag8_.d.mts +76 -0
- package/dist/index.d.mts +200 -0
- package/dist/index.mjs +214 -0
- package/dist/media/images-runtime.d.mts +10 -0
- package/dist/media/images-runtime.mjs +215 -0
- package/dist/media/stream-runtime.d.mts +10 -0
- package/dist/media/stream-runtime.mjs +218 -0
- package/dist/plugins/index.d.mts +32 -0
- package/dist/plugins/index.mjs +163 -0
- package/dist/sandbox/index.d.mts +255 -0
- package/dist/sandbox/index.mjs +945 -0
- package/dist/storage/r2.d.mts +31 -0
- package/dist/storage/r2.mjs +116 -0
- package/dist/stream-DdbcvKi0.d.mts +78 -0
- package/package.json +109 -0
- package/src/auth/cloudflare-access.ts +303 -0
- package/src/auth/index.ts +16 -0
- package/src/cache/config.ts +81 -0
- package/src/cache/runtime.ts +328 -0
- package/src/cloudflare.d.ts +31 -0
- package/src/db/d1-introspector.ts +120 -0
- package/src/db/d1.ts +112 -0
- package/src/db/do-class.ts +275 -0
- package/src/db/do-dialect.ts +125 -0
- package/src/db/do-playground-routes.ts +65 -0
- package/src/db/do-preview-routes.ts +48 -0
- package/src/db/do-preview-sign.ts +100 -0
- package/src/db/do-preview.ts +268 -0
- package/src/db/do-types.ts +12 -0
- package/src/db/do.ts +62 -0
- package/src/db/playground-middleware.ts +340 -0
- package/src/db/playground-toolbar.ts +341 -0
- package/src/db/playground.ts +49 -0
- package/src/db/preview-toolbar.ts +220 -0
- package/src/index.ts +285 -0
- package/src/media/images-runtime.ts +353 -0
- package/src/media/images.ts +114 -0
- package/src/media/stream-runtime.ts +392 -0
- package/src/media/stream.ts +118 -0
- package/src/plugins/index.ts +7 -0
- package/src/plugins/vectorize-search.ts +393 -0
- package/src/sandbox/bridge.ts +1008 -0
- package/src/sandbox/index.ts +13 -0
- package/src/sandbox/runner.ts +357 -0
- package/src/sandbox/types.ts +181 -0
- package/src/sandbox/wrapper.ts +238 -0
- package/src/storage/r2.ts +200 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
import { WorkerEntrypoint, env, exports } from "cloudflare:workers";
|
|
2
|
+
import { ulid } from "emdash";
|
|
3
|
+
|
|
4
|
+
//#region src/sandbox/bridge.ts
|
|
5
|
+
/**
|
|
6
|
+
* PluginBridge WorkerEntrypoint
|
|
7
|
+
*
|
|
8
|
+
* Provides controlled access to database operations for sandboxed plugins.
|
|
9
|
+
* The sandbox gets a SERVICE BINDING to this entrypoint, not direct DB access.
|
|
10
|
+
* All operations are validated and scoped to the plugin.
|
|
11
|
+
*
|
|
12
|
+
*/
|
|
13
|
+
/** Regex to validate collection names (prevent SQL injection) */
|
|
14
|
+
const COLLECTION_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
|
|
15
|
+
/** Regex to validate file extensions (simple alphanumeric, 1-10 chars) */
|
|
16
|
+
const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i;
|
|
17
|
+
/** System columns that plugins cannot directly write to */
|
|
18
|
+
const SYSTEM_COLUMNS = new Set([
|
|
19
|
+
"id",
|
|
20
|
+
"slug",
|
|
21
|
+
"status",
|
|
22
|
+
"author_id",
|
|
23
|
+
"created_at",
|
|
24
|
+
"updated_at",
|
|
25
|
+
"published_at",
|
|
26
|
+
"scheduled_at",
|
|
27
|
+
"deleted_at",
|
|
28
|
+
"version",
|
|
29
|
+
"live_revision_id",
|
|
30
|
+
"draft_revision_id"
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Module-level email send callback.
|
|
34
|
+
*
|
|
35
|
+
* The bridge runs in the host process (same worker), so we can use a
|
|
36
|
+
* module-level callback that the runner sets before creating bridge bindings.
|
|
37
|
+
* This avoids the need to pass non-serializable functions through props.
|
|
38
|
+
*
|
|
39
|
+
* @see runner.ts setEmailSendCallback()
|
|
40
|
+
*/
|
|
41
|
+
let emailSendCallback = null;
|
|
42
|
+
/**
|
|
43
|
+
* Set the email send callback for all bridge instances.
|
|
44
|
+
* Called by the runner when the EmailPipeline is available.
|
|
45
|
+
*/
|
|
46
|
+
function setEmailSendCallback(callback) {
|
|
47
|
+
emailSendCallback = callback;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Serialize a value for D1 storage.
|
|
51
|
+
* Mirrors core's serializeValue: objects/arrays → JSON strings,
|
|
52
|
+
* booleans → 0/1, null/undefined → null, everything else passthrough.
|
|
53
|
+
*/
|
|
54
|
+
function serializeValue(value) {
|
|
55
|
+
if (value === null || value === void 0) return null;
|
|
56
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
57
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Deserialize a row from D1 into a content response shape.
|
|
62
|
+
* Extracts system columns and bundles remaining columns into data.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* Deserialize a row from D1 into a ContentItem matching core's plugin API.
|
|
66
|
+
* Extracts system columns, deserializes JSON fields, and returns the
|
|
67
|
+
* canonical shape: { id, type, data, createdAt, updatedAt }.
|
|
68
|
+
*/
|
|
69
|
+
function rowToContentItem(collection, row) {
|
|
70
|
+
const data = {};
|
|
71
|
+
for (const [key, value] of Object.entries(row)) if (!SYSTEM_COLUMNS.has(key)) {
|
|
72
|
+
if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) try {
|
|
73
|
+
data[key] = JSON.parse(value);
|
|
74
|
+
} catch {
|
|
75
|
+
data[key] = value;
|
|
76
|
+
}
|
|
77
|
+
else if (value !== null) data[key] = value;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
id: typeof row.id === "string" ? row.id : String(row.id),
|
|
81
|
+
type: collection,
|
|
82
|
+
data,
|
|
83
|
+
createdAt: typeof row.created_at === "string" ? row.created_at : (/* @__PURE__ */ new Date()).toISOString(),
|
|
84
|
+
updatedAt: typeof row.updated_at === "string" ? row.updated_at : (/* @__PURE__ */ new Date()).toISOString()
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* PluginBridge WorkerEntrypoint
|
|
89
|
+
*
|
|
90
|
+
* Provides the context API to sandboxed plugins via RPC.
|
|
91
|
+
* All methods validate capabilities and scope operations to the plugin.
|
|
92
|
+
*
|
|
93
|
+
* Usage:
|
|
94
|
+
* 1. Export this class from your worker entrypoint
|
|
95
|
+
* 2. Sandboxed plugins get a binding to it via ctx.exports.PluginBridge({...})
|
|
96
|
+
* 3. Plugins call bridge methods which validate and proxy to the database
|
|
97
|
+
*/
|
|
98
|
+
var PluginBridge = class extends WorkerEntrypoint {
|
|
99
|
+
/**
|
|
100
|
+
* KV operations use _plugin_storage with a special "__kv" collection.
|
|
101
|
+
* This provides consistent storage across sandboxed and non-sandboxed modes.
|
|
102
|
+
*/
|
|
103
|
+
async kvGet(key) {
|
|
104
|
+
const { pluginId } = this.ctx.props;
|
|
105
|
+
const result = await this.env.DB.prepare("SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id = ?").bind(pluginId, key).first();
|
|
106
|
+
if (!result) return null;
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(result.data);
|
|
109
|
+
} catch {
|
|
110
|
+
return result.data;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async kvSet(key, value) {
|
|
114
|
+
const { pluginId } = this.ctx.props;
|
|
115
|
+
await this.env.DB.prepare("INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, '__kv', ?, ?, datetime('now'))").bind(pluginId, key, JSON.stringify(value)).run();
|
|
116
|
+
}
|
|
117
|
+
async kvDelete(key) {
|
|
118
|
+
const { pluginId } = this.ctx.props;
|
|
119
|
+
return ((await this.env.DB.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id = ?").bind(pluginId, key).run()).meta?.changes ?? 0) > 0;
|
|
120
|
+
}
|
|
121
|
+
async kvList(prefix = "") {
|
|
122
|
+
const { pluginId } = this.ctx.props;
|
|
123
|
+
return ((await this.env.DB.prepare("SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id LIKE ?").bind(pluginId, prefix + "%").all()).results ?? []).map((row) => ({
|
|
124
|
+
key: row.id,
|
|
125
|
+
value: JSON.parse(row.data)
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
async storageGet(collection, id) {
|
|
129
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
130
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
131
|
+
const result = await this.env.DB.prepare("SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(pluginId, collection, id).first();
|
|
132
|
+
if (!result) return null;
|
|
133
|
+
return JSON.parse(result.data);
|
|
134
|
+
}
|
|
135
|
+
async storagePut(collection, id, data) {
|
|
136
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
137
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
138
|
+
await this.env.DB.prepare("INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))").bind(pluginId, collection, id, JSON.stringify(data)).run();
|
|
139
|
+
}
|
|
140
|
+
async storageDelete(collection, id) {
|
|
141
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
142
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
143
|
+
return ((await this.env.DB.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(pluginId, collection, id).run()).meta?.changes ?? 0) > 0;
|
|
144
|
+
}
|
|
145
|
+
async storageQuery(collection, opts = {}) {
|
|
146
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
147
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
148
|
+
const limit = Math.min(opts.limit ?? 50, 1e3);
|
|
149
|
+
const results = await this.env.DB.prepare("SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? LIMIT ?").bind(pluginId, collection, limit + 1).all();
|
|
150
|
+
const items = (results.results ?? []).slice(0, limit).map((row) => ({
|
|
151
|
+
id: row.id,
|
|
152
|
+
data: JSON.parse(row.data)
|
|
153
|
+
}));
|
|
154
|
+
return {
|
|
155
|
+
items,
|
|
156
|
+
hasMore: (results.results ?? []).length > limit,
|
|
157
|
+
cursor: items.length > 0 ? items.at(-1).id : void 0
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async storageCount(collection) {
|
|
161
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
162
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
163
|
+
return (await this.env.DB.prepare("SELECT COUNT(*) as count FROM _plugin_storage WHERE plugin_id = ? AND collection = ?").bind(pluginId, collection).first())?.count ?? 0;
|
|
164
|
+
}
|
|
165
|
+
async storageGetMany(collection, ids) {
|
|
166
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
167
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
168
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
169
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
170
|
+
const results = await this.env.DB.prepare(`SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id IN (${placeholders})`).bind(pluginId, collection, ...ids).all();
|
|
171
|
+
const map = /* @__PURE__ */ new Map();
|
|
172
|
+
for (const row of results.results ?? []) map.set(row.id, JSON.parse(row.data));
|
|
173
|
+
return map;
|
|
174
|
+
}
|
|
175
|
+
async storagePutMany(collection, items) {
|
|
176
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
177
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
178
|
+
if (items.length === 0) return;
|
|
179
|
+
for (const item of items) await this.env.DB.prepare("INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))").bind(pluginId, collection, item.id, JSON.stringify(item.data)).run();
|
|
180
|
+
}
|
|
181
|
+
async storageDeleteMany(collection, ids) {
|
|
182
|
+
const { pluginId, storageCollections } = this.ctx.props;
|
|
183
|
+
if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
|
|
184
|
+
if (ids.length === 0) return 0;
|
|
185
|
+
let deleted = 0;
|
|
186
|
+
for (const id of ids) {
|
|
187
|
+
const result = await this.env.DB.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(pluginId, collection, id).run();
|
|
188
|
+
deleted += result.meta?.changes ?? 0;
|
|
189
|
+
}
|
|
190
|
+
return deleted;
|
|
191
|
+
}
|
|
192
|
+
async contentGet(collection, id) {
|
|
193
|
+
const { capabilities } = this.ctx.props;
|
|
194
|
+
if (!capabilities.includes("read:content")) throw new Error("Missing capability: read:content");
|
|
195
|
+
if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
|
|
196
|
+
try {
|
|
197
|
+
const result = await this.env.DB.prepare(`SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`).bind(id).first();
|
|
198
|
+
if (!result) return null;
|
|
199
|
+
return rowToContentItem(collection, result);
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async contentList(collection, opts = {}) {
|
|
205
|
+
const { capabilities } = this.ctx.props;
|
|
206
|
+
if (!capabilities.includes("read:content")) throw new Error("Missing capability: read:content");
|
|
207
|
+
if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
|
|
208
|
+
const limit = Math.min(opts.limit ?? 50, 100);
|
|
209
|
+
try {
|
|
210
|
+
let sql = `SELECT * FROM ec_${collection} WHERE deleted_at IS NULL`;
|
|
211
|
+
const params = [];
|
|
212
|
+
if (opts.cursor) {
|
|
213
|
+
sql += " AND id < ?";
|
|
214
|
+
params.push(opts.cursor);
|
|
215
|
+
}
|
|
216
|
+
sql += " ORDER BY id DESC LIMIT ?";
|
|
217
|
+
params.push(limit + 1);
|
|
218
|
+
const rows = (await this.env.DB.prepare(sql).bind(...params).all()).results ?? [];
|
|
219
|
+
const items = rows.slice(0, limit).map((row) => rowToContentItem(collection, row));
|
|
220
|
+
const hasMore = rows.length > limit;
|
|
221
|
+
return {
|
|
222
|
+
items,
|
|
223
|
+
cursor: hasMore && items.length > 0 ? items.at(-1).id : void 0,
|
|
224
|
+
hasMore
|
|
225
|
+
};
|
|
226
|
+
} catch {
|
|
227
|
+
return {
|
|
228
|
+
items: [],
|
|
229
|
+
hasMore: false
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async contentCreate(collection, data) {
|
|
234
|
+
const { capabilities } = this.ctx.props;
|
|
235
|
+
if (!capabilities.includes("write:content")) throw new Error("Missing capability: write:content");
|
|
236
|
+
if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
|
|
237
|
+
const id = ulid();
|
|
238
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
239
|
+
const columns = [
|
|
240
|
+
"\"id\"",
|
|
241
|
+
"\"slug\"",
|
|
242
|
+
"\"status\"",
|
|
243
|
+
"\"author_id\"",
|
|
244
|
+
"\"created_at\"",
|
|
245
|
+
"\"updated_at\"",
|
|
246
|
+
"\"version\""
|
|
247
|
+
];
|
|
248
|
+
const values = [
|
|
249
|
+
id,
|
|
250
|
+
typeof data.slug === "string" ? data.slug : null,
|
|
251
|
+
typeof data.status === "string" ? data.status : "draft",
|
|
252
|
+
typeof data.author_id === "string" ? data.author_id : null,
|
|
253
|
+
now,
|
|
254
|
+
now,
|
|
255
|
+
1
|
|
256
|
+
];
|
|
257
|
+
for (const [key, value] of Object.entries(data)) if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_REGEX.test(key)) {
|
|
258
|
+
columns.push(`"${key}"`);
|
|
259
|
+
values.push(serializeValue(value));
|
|
260
|
+
}
|
|
261
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
262
|
+
const columnList = columns.join(", ");
|
|
263
|
+
await this.env.DB.prepare(`INSERT INTO ec_${collection} (${columnList}) VALUES (${placeholders})`).bind(...values).run();
|
|
264
|
+
const created = await this.env.DB.prepare(`SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`).bind(id).first();
|
|
265
|
+
if (!created) return {
|
|
266
|
+
id,
|
|
267
|
+
type: collection,
|
|
268
|
+
data: {},
|
|
269
|
+
createdAt: now,
|
|
270
|
+
updatedAt: now
|
|
271
|
+
};
|
|
272
|
+
return rowToContentItem(collection, created);
|
|
273
|
+
}
|
|
274
|
+
async contentUpdate(collection, id, data) {
|
|
275
|
+
const { capabilities } = this.ctx.props;
|
|
276
|
+
if (!capabilities.includes("write:content")) throw new Error("Missing capability: write:content");
|
|
277
|
+
if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
|
|
278
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
279
|
+
const setClauses = ["\"updated_at\" = ?", "\"version\" = \"version\" + 1"];
|
|
280
|
+
const values = [now];
|
|
281
|
+
if (typeof data.status === "string") {
|
|
282
|
+
setClauses.push("\"status\" = ?");
|
|
283
|
+
values.push(data.status);
|
|
284
|
+
}
|
|
285
|
+
if (data.slug !== void 0) {
|
|
286
|
+
setClauses.push("\"slug\" = ?");
|
|
287
|
+
values.push(typeof data.slug === "string" ? data.slug : null);
|
|
288
|
+
}
|
|
289
|
+
for (const [key, value] of Object.entries(data)) if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_REGEX.test(key)) {
|
|
290
|
+
setClauses.push(`"${key}" = ?`);
|
|
291
|
+
values.push(serializeValue(value));
|
|
292
|
+
}
|
|
293
|
+
values.push(id);
|
|
294
|
+
if (((await this.env.DB.prepare(`UPDATE ec_${collection} SET ${setClauses.join(", ")} WHERE "id" = ? AND "deleted_at" IS NULL`).bind(...values).run()).meta?.changes ?? 0) === 0) throw new Error(`Content not found or deleted: ${collection}/${id}`);
|
|
295
|
+
const updated = await this.env.DB.prepare(`SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`).bind(id).first();
|
|
296
|
+
if (!updated) throw new Error(`Content not found: ${collection}/${id}`);
|
|
297
|
+
return rowToContentItem(collection, updated);
|
|
298
|
+
}
|
|
299
|
+
async contentDelete(collection, id) {
|
|
300
|
+
const { capabilities } = this.ctx.props;
|
|
301
|
+
if (!capabilities.includes("write:content")) throw new Error("Missing capability: write:content");
|
|
302
|
+
if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
|
|
303
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
304
|
+
return ((await this.env.DB.prepare(`UPDATE ec_${collection} SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`).bind(now, now, id).run()).meta?.changes ?? 0) > 0;
|
|
305
|
+
}
|
|
306
|
+
async mediaGet(id) {
|
|
307
|
+
const { capabilities } = this.ctx.props;
|
|
308
|
+
if (!capabilities.includes("read:media")) throw new Error("Missing capability: read:media");
|
|
309
|
+
const result = await this.env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(id).first();
|
|
310
|
+
if (!result) return null;
|
|
311
|
+
return {
|
|
312
|
+
id: result.id,
|
|
313
|
+
filename: result.filename,
|
|
314
|
+
mimeType: result.mime_type,
|
|
315
|
+
size: result.size,
|
|
316
|
+
url: `/_emdash/api/media/file/${result.storage_key}`,
|
|
317
|
+
createdAt: result.created_at
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
async mediaList(opts = {}) {
|
|
321
|
+
const { capabilities } = this.ctx.props;
|
|
322
|
+
if (!capabilities.includes("read:media")) throw new Error("Missing capability: read:media");
|
|
323
|
+
const limit = Math.min(opts.limit ?? 50, 100);
|
|
324
|
+
let sql = "SELECT * FROM media WHERE status = 'ready'";
|
|
325
|
+
const params = [];
|
|
326
|
+
if (opts.mimeType) {
|
|
327
|
+
sql += " AND mime_type LIKE ?";
|
|
328
|
+
params.push(opts.mimeType + "%");
|
|
329
|
+
}
|
|
330
|
+
if (opts.cursor) {
|
|
331
|
+
sql += " AND id < ?";
|
|
332
|
+
params.push(opts.cursor);
|
|
333
|
+
}
|
|
334
|
+
sql += " ORDER BY id DESC LIMIT ?";
|
|
335
|
+
params.push(limit + 1);
|
|
336
|
+
const rows = (await this.env.DB.prepare(sql).bind(...params).all()).results ?? [];
|
|
337
|
+
const items = rows.slice(0, limit).map((row) => ({
|
|
338
|
+
id: row.id,
|
|
339
|
+
filename: row.filename,
|
|
340
|
+
mimeType: row.mime_type,
|
|
341
|
+
size: row.size,
|
|
342
|
+
url: `/_emdash/api/media/file/${row.storage_key}`,
|
|
343
|
+
createdAt: row.created_at
|
|
344
|
+
}));
|
|
345
|
+
const hasMore = rows.length > limit;
|
|
346
|
+
return {
|
|
347
|
+
items,
|
|
348
|
+
cursor: hasMore && items.length > 0 ? items.at(-1).id : void 0,
|
|
349
|
+
hasMore
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Create a pending media record and write bytes directly to R2.
|
|
354
|
+
*
|
|
355
|
+
* Unlike the admin UI flow (presigned URL → client PUT → confirm), sandboxed
|
|
356
|
+
* plugins are network-isolated and can't make external requests. The bridge
|
|
357
|
+
* accepts the file bytes directly and writes them to storage.
|
|
358
|
+
*
|
|
359
|
+
* Returns the media ID, storage key, and confirm URL. The plugin should
|
|
360
|
+
* call the confirm endpoint after this to finalize the record.
|
|
361
|
+
*/
|
|
362
|
+
async mediaUpload(filename, contentType, bytes) {
|
|
363
|
+
const { capabilities } = this.ctx.props;
|
|
364
|
+
if (!capabilities.includes("write:media")) throw new Error("Missing capability: write:media");
|
|
365
|
+
if (!this.env.MEDIA) throw new Error("Media storage (R2) not configured. Add MEDIA binding to wrangler config.");
|
|
366
|
+
if (![
|
|
367
|
+
"image/",
|
|
368
|
+
"video/",
|
|
369
|
+
"audio/",
|
|
370
|
+
"application/pdf"
|
|
371
|
+
].some((prefix) => contentType.startsWith(prefix))) throw new Error(`Unsupported content type: ${contentType}. Allowed: image/*, video/*, audio/*, application/pdf`);
|
|
372
|
+
const mediaId = ulid();
|
|
373
|
+
const basename = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
|
|
374
|
+
const rawExt = basename.includes(".") ? basename.slice(basename.lastIndexOf(".")) : "";
|
|
375
|
+
const storageKey = `${mediaId}${FILE_EXT_REGEX.test(rawExt) ? rawExt : ""}`;
|
|
376
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
377
|
+
await this.env.MEDIA.put(storageKey, bytes, { httpMetadata: { contentType } });
|
|
378
|
+
try {
|
|
379
|
+
await this.env.DB.prepare("INSERT INTO media (id, filename, mime_type, size, storage_key, status, created_at) VALUES (?, ?, ?, ?, ?, 'ready', ?)").bind(mediaId, filename, contentType, bytes.byteLength, storageKey, now).run();
|
|
380
|
+
} catch (error) {
|
|
381
|
+
try {
|
|
382
|
+
await this.env.MEDIA.delete(storageKey);
|
|
383
|
+
} catch {
|
|
384
|
+
console.warn(`[plugin-bridge] Failed to clean up orphaned R2 object: ${storageKey}`);
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
mediaId,
|
|
390
|
+
storageKey,
|
|
391
|
+
url: `/_emdash/api/media/file/${storageKey}`
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async mediaDelete(id) {
|
|
395
|
+
const { capabilities } = this.ctx.props;
|
|
396
|
+
if (!capabilities.includes("write:media")) throw new Error("Missing capability: write:media");
|
|
397
|
+
const media = await this.env.DB.prepare("SELECT storage_key FROM media WHERE id = ?").bind(id).first();
|
|
398
|
+
if (!media) return false;
|
|
399
|
+
const result = await this.env.DB.prepare("DELETE FROM media WHERE id = ?").bind(id).run();
|
|
400
|
+
if (this.env.MEDIA && media.storage_key) try {
|
|
401
|
+
await this.env.MEDIA.delete(media.storage_key);
|
|
402
|
+
} catch {
|
|
403
|
+
console.warn(`[plugin-bridge] Failed to delete R2 object: ${media.storage_key}`);
|
|
404
|
+
}
|
|
405
|
+
return (result.meta?.changes ?? 0) > 0;
|
|
406
|
+
}
|
|
407
|
+
async httpFetch(url, init) {
|
|
408
|
+
const { capabilities, allowedHosts } = this.ctx.props;
|
|
409
|
+
const hasUnrestricted = capabilities.includes("network:fetch:any");
|
|
410
|
+
if (!(capabilities.includes("network:fetch") || hasUnrestricted)) throw new Error("Missing capability: network:fetch");
|
|
411
|
+
if (!hasUnrestricted) {
|
|
412
|
+
const host = new URL(url).host;
|
|
413
|
+
if (allowedHosts.length === 0) throw new Error(`Plugin has no allowed hosts configured. Add hosts to allowedHosts to enable HTTP requests.`);
|
|
414
|
+
if (!allowedHosts.some((pattern) => {
|
|
415
|
+
if (pattern.startsWith("*.")) return host.endsWith(pattern.slice(1)) || host === pattern.slice(2);
|
|
416
|
+
return host === pattern;
|
|
417
|
+
})) throw new Error(`Host not allowed: ${host}. Allowed: ${allowedHosts.join(", ")}`);
|
|
418
|
+
}
|
|
419
|
+
const response = await fetch(url, init);
|
|
420
|
+
const headers = {};
|
|
421
|
+
response.headers.forEach((value, key) => {
|
|
422
|
+
headers[key] = value;
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
status: response.status,
|
|
426
|
+
headers,
|
|
427
|
+
text: await response.text()
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
async userGet(id) {
|
|
431
|
+
const { capabilities } = this.ctx.props;
|
|
432
|
+
if (!capabilities.includes("read:users")) throw new Error("Missing capability: read:users");
|
|
433
|
+
const result = await this.env.DB.prepare("SELECT id, email, name, role, created_at FROM users WHERE id = ?").bind(id).first();
|
|
434
|
+
if (!result) return null;
|
|
435
|
+
return {
|
|
436
|
+
id: result.id,
|
|
437
|
+
email: result.email,
|
|
438
|
+
name: result.name,
|
|
439
|
+
role: result.role,
|
|
440
|
+
createdAt: result.created_at
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
async userGetByEmail(email) {
|
|
444
|
+
const { capabilities } = this.ctx.props;
|
|
445
|
+
if (!capabilities.includes("read:users")) throw new Error("Missing capability: read:users");
|
|
446
|
+
const result = await this.env.DB.prepare("SELECT id, email, name, role, created_at FROM users WHERE email = ?").bind(email.toLowerCase()).first();
|
|
447
|
+
if (!result) return null;
|
|
448
|
+
return {
|
|
449
|
+
id: result.id,
|
|
450
|
+
email: result.email,
|
|
451
|
+
name: result.name,
|
|
452
|
+
role: result.role,
|
|
453
|
+
createdAt: result.created_at
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async userList(opts) {
|
|
457
|
+
const { capabilities } = this.ctx.props;
|
|
458
|
+
if (!capabilities.includes("read:users")) throw new Error("Missing capability: read:users");
|
|
459
|
+
const limit = Math.max(1, Math.min(opts?.limit ?? 50, 100));
|
|
460
|
+
let sql = "SELECT id, email, name, role, created_at FROM users";
|
|
461
|
+
const params = [];
|
|
462
|
+
const conditions = [];
|
|
463
|
+
if (opts?.role !== void 0) {
|
|
464
|
+
conditions.push("role = ?");
|
|
465
|
+
params.push(opts.role);
|
|
466
|
+
}
|
|
467
|
+
if (opts?.cursor) {
|
|
468
|
+
conditions.push("id < ?");
|
|
469
|
+
params.push(opts.cursor);
|
|
470
|
+
}
|
|
471
|
+
if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
|
|
472
|
+
sql += " ORDER BY id DESC LIMIT ?";
|
|
473
|
+
params.push(limit + 1);
|
|
474
|
+
const rows = (await this.env.DB.prepare(sql).bind(...params).all()).results ?? [];
|
|
475
|
+
const items = rows.slice(0, limit).map((row) => ({
|
|
476
|
+
id: row.id,
|
|
477
|
+
email: row.email,
|
|
478
|
+
name: row.name,
|
|
479
|
+
role: row.role,
|
|
480
|
+
createdAt: row.created_at
|
|
481
|
+
}));
|
|
482
|
+
return {
|
|
483
|
+
items,
|
|
484
|
+
nextCursor: rows.length > limit && items.length > 0 ? items.at(-1).id : void 0
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async emailSend(message) {
|
|
488
|
+
const { capabilities, pluginId } = this.ctx.props;
|
|
489
|
+
if (!capabilities.includes("email:send")) throw new Error("Missing capability: email:send");
|
|
490
|
+
if (!emailSendCallback) throw new Error("Email is not configured. No email provider is available.");
|
|
491
|
+
await emailSendCallback(message, pluginId);
|
|
492
|
+
}
|
|
493
|
+
log(level, msg, data) {
|
|
494
|
+
const { pluginId } = this.ctx.props;
|
|
495
|
+
console[level](`[plugin:${pluginId}]`, msg, data ?? "");
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/sandbox/wrapper.ts
|
|
501
|
+
const TRAILING_SLASH_RE = /\/$/;
|
|
502
|
+
const NEWLINE_RE = /[\n\r]/g;
|
|
503
|
+
const COMMENT_CLOSE_RE = /\*\//g;
|
|
504
|
+
function generatePluginWrapper(manifest, options) {
|
|
505
|
+
const storageCollections = Object.keys(manifest.storage || {});
|
|
506
|
+
const site = options?.site ?? {
|
|
507
|
+
name: "",
|
|
508
|
+
url: "",
|
|
509
|
+
locale: "en"
|
|
510
|
+
};
|
|
511
|
+
const hasReadUsers = manifest.capabilities.includes("read:users");
|
|
512
|
+
const hasEmailSend = manifest.capabilities.includes("email:send");
|
|
513
|
+
return `
|
|
514
|
+
// =============================================================================
|
|
515
|
+
// Sandboxed Plugin Wrapper
|
|
516
|
+
// Generated by @emdash-cms/cloudflare
|
|
517
|
+
// Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)}
|
|
518
|
+
// =============================================================================
|
|
519
|
+
|
|
520
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
521
|
+
|
|
522
|
+
// Plugin code lives in a separate module for scope isolation
|
|
523
|
+
import pluginModule from "sandbox-plugin.js";
|
|
524
|
+
|
|
525
|
+
// Extract hooks and routes from the plugin module
|
|
526
|
+
const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {};
|
|
527
|
+
const routes = pluginModule?.routes || pluginModule?.default?.routes || {};
|
|
528
|
+
|
|
529
|
+
// -----------------------------------------------------------------------------
|
|
530
|
+
// Context Factory - creates ctx that proxies to BRIDGE
|
|
531
|
+
// -----------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
function createContext(env) {
|
|
534
|
+
const bridge = env.BRIDGE;
|
|
535
|
+
const storageCollections = ${JSON.stringify(storageCollections)};
|
|
536
|
+
|
|
537
|
+
// KV - proxies to bridge.kvGet/Set/Delete/List
|
|
538
|
+
const kv = {
|
|
539
|
+
get: (key) => bridge.kvGet(key),
|
|
540
|
+
set: (key, value) => bridge.kvSet(key, value),
|
|
541
|
+
delete: (key) => bridge.kvDelete(key),
|
|
542
|
+
list: (prefix) => bridge.kvList(prefix)
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Storage collection factory
|
|
546
|
+
function createStorageCollection(collectionName) {
|
|
547
|
+
return {
|
|
548
|
+
get: (id) => bridge.storageGet(collectionName, id),
|
|
549
|
+
put: (id, data) => bridge.storagePut(collectionName, id, data),
|
|
550
|
+
delete: (id) => bridge.storageDelete(collectionName, id),
|
|
551
|
+
exists: async (id) => (await bridge.storageGet(collectionName, id)) !== null,
|
|
552
|
+
query: (opts) => bridge.storageQuery(collectionName, opts),
|
|
553
|
+
count: (where) => bridge.storageCount(collectionName, where),
|
|
554
|
+
getMany: (ids) => bridge.storageGetMany(collectionName, ids),
|
|
555
|
+
putMany: (items) => bridge.storagePutMany(collectionName, items),
|
|
556
|
+
deleteMany: (ids) => bridge.storageDeleteMany(collectionName, ids)
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Storage proxy that creates collections on access
|
|
561
|
+
const storage = new Proxy({}, {
|
|
562
|
+
get(_, collectionName) {
|
|
563
|
+
if (typeof collectionName !== "string") return undefined;
|
|
564
|
+
return createStorageCollection(collectionName);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// Content access - proxies to bridge (capability enforced by bridge)
|
|
569
|
+
const content = {
|
|
570
|
+
get: (collection, id) => bridge.contentGet(collection, id),
|
|
571
|
+
list: (collection, opts) => bridge.contentList(collection, opts),
|
|
572
|
+
create: (collection, data) => bridge.contentCreate(collection, data),
|
|
573
|
+
update: (collection, id, data) => bridge.contentUpdate(collection, id, data),
|
|
574
|
+
delete: (collection, id) => bridge.contentDelete(collection, id)
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Media access - proxies to bridge (capability enforced by bridge)
|
|
578
|
+
const media = {
|
|
579
|
+
get: (id) => bridge.mediaGet(id),
|
|
580
|
+
list: (opts) => bridge.mediaList(opts),
|
|
581
|
+
upload: (filename, contentType, bytes) => bridge.mediaUpload(filename, contentType, bytes),
|
|
582
|
+
getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload(filename, contentType, bytes) instead."); },
|
|
583
|
+
delete: (id) => bridge.mediaDelete(id)
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// HTTP access - proxies to bridge (capability + host enforced by bridge)
|
|
587
|
+
const http = {
|
|
588
|
+
fetch: async (url, init) => {
|
|
589
|
+
const result = await bridge.httpFetch(url, init);
|
|
590
|
+
// Bridge returns serialized response, reconstruct Response-like object
|
|
591
|
+
return {
|
|
592
|
+
status: result.status,
|
|
593
|
+
ok: result.status >= 200 && result.status < 300,
|
|
594
|
+
headers: new Headers(result.headers),
|
|
595
|
+
text: async () => result.text,
|
|
596
|
+
json: async () => JSON.parse(result.text)
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Logger - proxies to bridge
|
|
602
|
+
const log = {
|
|
603
|
+
debug: (msg, data) => bridge.log("debug", msg, data),
|
|
604
|
+
info: (msg, data) => bridge.log("info", msg, data),
|
|
605
|
+
warn: (msg, data) => bridge.log("warn", msg, data),
|
|
606
|
+
error: (msg, data) => bridge.log("error", msg, data)
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// Site info - injected at wrapper generation time, no RPC needed
|
|
610
|
+
const site = ${JSON.stringify(site)};
|
|
611
|
+
|
|
612
|
+
// URL helper - generates absolute URLs from paths
|
|
613
|
+
const siteBaseUrl = ${JSON.stringify(site.url.replace(TRAILING_SLASH_RE, ""))};
|
|
614
|
+
function url(path) {
|
|
615
|
+
if (!path.startsWith("/")) {
|
|
616
|
+
throw new Error('URL path must start with "/", got: "' + path + '"');
|
|
617
|
+
}
|
|
618
|
+
if (path.startsWith("//")) {
|
|
619
|
+
throw new Error('URL path must not be protocol-relative, got: "' + path + '"');
|
|
620
|
+
}
|
|
621
|
+
return siteBaseUrl + path;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// User access - proxies to bridge (capability enforced by bridge)
|
|
625
|
+
const users = ${hasReadUsers} ? {
|
|
626
|
+
get: (id) => bridge.userGet(id),
|
|
627
|
+
getByEmail: (email) => bridge.userGetByEmail(email),
|
|
628
|
+
list: (opts) => bridge.userList(opts)
|
|
629
|
+
} : undefined;
|
|
630
|
+
|
|
631
|
+
// Email access - proxies to bridge (capability enforced by bridge)
|
|
632
|
+
const email = ${hasEmailSend} ? {
|
|
633
|
+
send: (message) => bridge.emailSend(message)
|
|
634
|
+
} : undefined;
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
plugin: {
|
|
638
|
+
id: env.PLUGIN_ID,
|
|
639
|
+
version: env.PLUGIN_VERSION
|
|
640
|
+
},
|
|
641
|
+
storage,
|
|
642
|
+
kv,
|
|
643
|
+
content,
|
|
644
|
+
media,
|
|
645
|
+
http,
|
|
646
|
+
log,
|
|
647
|
+
site,
|
|
648
|
+
url,
|
|
649
|
+
users,
|
|
650
|
+
email
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// -----------------------------------------------------------------------------
|
|
655
|
+
// Worker Entrypoint (RPC interface)
|
|
656
|
+
// -----------------------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
export default class PluginEntrypoint extends WorkerEntrypoint {
|
|
659
|
+
async invokeHook(hookName, event) {
|
|
660
|
+
const ctx = createContext(this.env);
|
|
661
|
+
|
|
662
|
+
// Find the hook handler
|
|
663
|
+
const hookDef = hooks[hookName];
|
|
664
|
+
|
|
665
|
+
if (!hookDef) {
|
|
666
|
+
// No handler for this hook - that's ok, return undefined
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Get the handler (might be wrapped in config object)
|
|
671
|
+
const handler = typeof hookDef === "function" ? hookDef : hookDef.handler;
|
|
672
|
+
|
|
673
|
+
if (typeof handler !== "function") {
|
|
674
|
+
throw new Error(\`Hook \${hookName} handler is not a function\`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Execute the hook
|
|
678
|
+
return handler(event, ctx);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async invokeRoute(routeName, input, serializedRequest) {
|
|
682
|
+
const ctx = createContext(this.env);
|
|
683
|
+
|
|
684
|
+
// Find the route handler
|
|
685
|
+
const route = routes[routeName];
|
|
686
|
+
|
|
687
|
+
if (!route) {
|
|
688
|
+
throw new Error(\`Route not found: \${routeName}\`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Get handler (might be direct function or object with handler)
|
|
692
|
+
const handler = typeof route === "function" ? route : route.handler;
|
|
693
|
+
|
|
694
|
+
if (typeof handler !== "function") {
|
|
695
|
+
throw new Error(\`Route \${routeName} handler is not a function\`);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Execute the route handler with input, request metadata, and context
|
|
699
|
+
return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta }, ctx);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
`;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Sanitize a string for inclusion in a JavaScript comment.
|
|
706
|
+
* Prevents comment injection via manifest.id or manifest.version containing
|
|
707
|
+
* newlines or comment-closing sequences.
|
|
708
|
+
*/
|
|
709
|
+
function sanitizeComment(s) {
|
|
710
|
+
return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
//#endregion
|
|
714
|
+
//#region src/sandbox/runner.ts
|
|
715
|
+
/**
|
|
716
|
+
* Cloudflare Sandbox Runner
|
|
717
|
+
*
|
|
718
|
+
* Uses Worker Loader to run plugins in isolated V8 isolates.
|
|
719
|
+
* Plugins communicate with the host via a BRIDGE service binding
|
|
720
|
+
* that enforces capabilities and scopes operations.
|
|
721
|
+
*
|
|
722
|
+
* This module imports directly from cloudflare:workers to access
|
|
723
|
+
* the LOADER binding and PluginBridge export. It's only loaded
|
|
724
|
+
* when the user configures `sandboxRunner: "@emdash-cms/cloudflare/sandbox"`.
|
|
725
|
+
*
|
|
726
|
+
*/
|
|
727
|
+
/**
|
|
728
|
+
* Default resource limits for sandboxed plugins.
|
|
729
|
+
*
|
|
730
|
+
* cpuMs and subrequests are enforced by Worker Loader at the V8 isolate level.
|
|
731
|
+
* wallTimeMs is enforced by the runner via Promise.race.
|
|
732
|
+
* memoryMb is declared for API compatibility but NOT currently enforced —
|
|
733
|
+
* Worker Loader doesn't expose a memory limit option. V8 isolates have a
|
|
734
|
+
* platform-level memory ceiling (~128MB) but it's not configurable per-worker.
|
|
735
|
+
*/
|
|
736
|
+
const DEFAULT_LIMITS = {
|
|
737
|
+
cpuMs: 50,
|
|
738
|
+
memoryMb: 128,
|
|
739
|
+
subrequests: 10,
|
|
740
|
+
wallTimeMs: 3e4
|
|
741
|
+
};
|
|
742
|
+
/**
|
|
743
|
+
* Get the Worker Loader binding from env
|
|
744
|
+
*/
|
|
745
|
+
function getLoader() {
|
|
746
|
+
return env.LOADER;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Get the PluginBridge from exports (loopback binding)
|
|
750
|
+
*/
|
|
751
|
+
function getPluginBridge() {
|
|
752
|
+
return exports.PluginBridge;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Resolve resource limits by merging user-provided overrides with defaults.
|
|
756
|
+
*/
|
|
757
|
+
function resolveLimits(limits) {
|
|
758
|
+
return {
|
|
759
|
+
cpuMs: limits?.cpuMs ?? DEFAULT_LIMITS.cpuMs,
|
|
760
|
+
memoryMb: limits?.memoryMb ?? DEFAULT_LIMITS.memoryMb,
|
|
761
|
+
subrequests: limits?.subrequests ?? DEFAULT_LIMITS.subrequests,
|
|
762
|
+
wallTimeMs: limits?.wallTimeMs ?? DEFAULT_LIMITS.wallTimeMs
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Cloudflare sandbox runner using Worker Loader.
|
|
767
|
+
*/
|
|
768
|
+
var CloudflareSandboxRunner = class {
|
|
769
|
+
plugins = /* @__PURE__ */ new Map();
|
|
770
|
+
options;
|
|
771
|
+
resolvedLimits;
|
|
772
|
+
siteInfo;
|
|
773
|
+
constructor(options) {
|
|
774
|
+
this.options = options;
|
|
775
|
+
this.resolvedLimits = resolveLimits(options.limits);
|
|
776
|
+
this.siteInfo = options.siteInfo;
|
|
777
|
+
setEmailSendCallback(options.emailSend ?? null);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Set the email send callback for sandboxed plugins.
|
|
781
|
+
* Called after the EmailPipeline is created, since the pipeline
|
|
782
|
+
* doesn't exist when the sandbox runner is constructed.
|
|
783
|
+
*/
|
|
784
|
+
setEmailSend(callback) {
|
|
785
|
+
setEmailSendCallback(callback);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Check if Worker Loader is available.
|
|
789
|
+
*/
|
|
790
|
+
isAvailable() {
|
|
791
|
+
return !!getLoader() && !!getPluginBridge();
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Load a sandboxed plugin.
|
|
795
|
+
*
|
|
796
|
+
* @param manifest - Plugin manifest with capabilities and storage declarations
|
|
797
|
+
* @param code - The bundled plugin JavaScript code
|
|
798
|
+
*/
|
|
799
|
+
async load(manifest, code) {
|
|
800
|
+
const pluginId = `${manifest.id}:${manifest.version}`;
|
|
801
|
+
const existing = this.plugins.get(pluginId);
|
|
802
|
+
if (existing) return existing;
|
|
803
|
+
const loader = getLoader();
|
|
804
|
+
const pluginBridge = getPluginBridge();
|
|
805
|
+
if (!loader) throw new Error("Worker Loader not available. Add worker_loaders binding to wrangler config.");
|
|
806
|
+
if (!pluginBridge) throw new Error("PluginBridge not available. Export PluginBridge from your worker entrypoint.");
|
|
807
|
+
const plugin = new CloudflareSandboxedPlugin(manifest, code, loader, pluginBridge, this.resolvedLimits, this.siteInfo);
|
|
808
|
+
this.plugins.set(pluginId, plugin);
|
|
809
|
+
return plugin;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Terminate all loaded plugins.
|
|
813
|
+
*/
|
|
814
|
+
async terminateAll() {
|
|
815
|
+
for (const plugin of this.plugins.values()) await plugin.terminate();
|
|
816
|
+
this.plugins.clear();
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
/**
|
|
820
|
+
* A plugin running in a Worker Loader isolate.
|
|
821
|
+
*
|
|
822
|
+
* IMPORTANT: Worker stubs and bridge bindings are tied to request context.
|
|
823
|
+
* We must create fresh stubs for each invocation to avoid I/O isolation errors:
|
|
824
|
+
* "Cannot perform I/O on behalf of a different request"
|
|
825
|
+
*/
|
|
826
|
+
var CloudflareSandboxedPlugin = class {
|
|
827
|
+
id;
|
|
828
|
+
manifest;
|
|
829
|
+
loader;
|
|
830
|
+
createBridge;
|
|
831
|
+
code;
|
|
832
|
+
wrapperCode = null;
|
|
833
|
+
limits;
|
|
834
|
+
siteInfo;
|
|
835
|
+
constructor(manifest, code, loader, createBridge, limits, siteInfo) {
|
|
836
|
+
this.id = `${manifest.id}:${manifest.version}`;
|
|
837
|
+
this.manifest = manifest;
|
|
838
|
+
this.code = code;
|
|
839
|
+
this.loader = loader;
|
|
840
|
+
this.createBridge = createBridge;
|
|
841
|
+
this.limits = limits;
|
|
842
|
+
this.siteInfo = siteInfo;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Create a fresh worker stub for the current request.
|
|
846
|
+
*
|
|
847
|
+
* Worker Loader stubs contain bindings (like BRIDGE) that are tied to the
|
|
848
|
+
* request context in which they were created. Reusing stubs across requests
|
|
849
|
+
* causes "Cannot perform I/O on behalf of a different request" errors.
|
|
850
|
+
*
|
|
851
|
+
* The Worker Loader internally caches the V8 isolate, so we only pay the
|
|
852
|
+
* cost of creating the bridge binding and stub wrapper per request.
|
|
853
|
+
*/
|
|
854
|
+
createWorker() {
|
|
855
|
+
if (!this.wrapperCode) this.wrapperCode = generatePluginWrapper(this.manifest, { site: this.siteInfo });
|
|
856
|
+
const bridgeBinding = this.createBridge({ props: {
|
|
857
|
+
pluginId: this.manifest.id,
|
|
858
|
+
pluginVersion: this.manifest.version || "0.0.0",
|
|
859
|
+
capabilities: this.manifest.capabilities || [],
|
|
860
|
+
allowedHosts: this.manifest.allowedHosts || [],
|
|
861
|
+
storageCollections: Object.keys(this.manifest.storage || {})
|
|
862
|
+
} });
|
|
863
|
+
const loaderLimits = {
|
|
864
|
+
cpuMs: this.limits.cpuMs,
|
|
865
|
+
subRequests: this.limits.subrequests
|
|
866
|
+
};
|
|
867
|
+
return this.loader.get(this.id, () => ({
|
|
868
|
+
compatibilityDate: "2025-01-01",
|
|
869
|
+
mainModule: "plugin.js",
|
|
870
|
+
modules: {
|
|
871
|
+
"plugin.js": { js: this.wrapperCode },
|
|
872
|
+
"sandbox-plugin.js": { js: this.code }
|
|
873
|
+
},
|
|
874
|
+
globalOutbound: null,
|
|
875
|
+
limits: loaderLimits,
|
|
876
|
+
env: {
|
|
877
|
+
PLUGIN_ID: this.manifest.id,
|
|
878
|
+
PLUGIN_VERSION: this.manifest.version || "0.0.0",
|
|
879
|
+
BRIDGE: bridgeBinding
|
|
880
|
+
}
|
|
881
|
+
}));
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Run a function with wall-time enforcement.
|
|
885
|
+
*
|
|
886
|
+
* CPU limits and subrequest limits are enforced by the Worker Loader
|
|
887
|
+
* at the V8 isolate level. Wall-time is enforced here because Worker
|
|
888
|
+
* Loader doesn't expose a wall-time limit — a plugin could stall
|
|
889
|
+
* indefinitely waiting on network I/O.
|
|
890
|
+
*/
|
|
891
|
+
async withWallTimeLimit(operation, fn) {
|
|
892
|
+
const wallTimeMs = this.limits.wallTimeMs;
|
|
893
|
+
let timer;
|
|
894
|
+
const timeout = new Promise((_, reject) => {
|
|
895
|
+
timer = setTimeout(() => {
|
|
896
|
+
reject(/* @__PURE__ */ new Error(`Plugin ${this.manifest.id} exceeded wall-time limit of ${wallTimeMs}ms during ${operation}`));
|
|
897
|
+
}, wallTimeMs);
|
|
898
|
+
});
|
|
899
|
+
try {
|
|
900
|
+
return await Promise.race([fn(), timeout]);
|
|
901
|
+
} finally {
|
|
902
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Invoke a hook in the sandboxed plugin.
|
|
907
|
+
*
|
|
908
|
+
* CPU and subrequest limits are enforced by Worker Loader.
|
|
909
|
+
* Wall-time is enforced here.
|
|
910
|
+
*/
|
|
911
|
+
async invokeHook(hookName, event) {
|
|
912
|
+
return this.withWallTimeLimit(`hook:${hookName}`, () => {
|
|
913
|
+
return this.createWorker().getEntrypoint("default").invokeHook(hookName, event);
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Invoke an API route in the sandboxed plugin.
|
|
918
|
+
*
|
|
919
|
+
* CPU and subrequest limits are enforced by Worker Loader.
|
|
920
|
+
* Wall-time is enforced here.
|
|
921
|
+
*/
|
|
922
|
+
async invokeRoute(routeName, input, request) {
|
|
923
|
+
return this.withWallTimeLimit(`route:${routeName}`, () => {
|
|
924
|
+
return this.createWorker().getEntrypoint("default").invokeRoute(routeName, input, request);
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Terminate the sandboxed plugin.
|
|
929
|
+
*/
|
|
930
|
+
async terminate() {
|
|
931
|
+
this.wrapperCode = null;
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
/**
|
|
935
|
+
* Factory function for creating the Cloudflare sandbox runner.
|
|
936
|
+
*
|
|
937
|
+
* Matches the SandboxRunnerFactory signature. The LOADER and PluginBridge
|
|
938
|
+
* are obtained internally from cloudflare:workers imports.
|
|
939
|
+
*/
|
|
940
|
+
const createSandboxRunner = (options) => {
|
|
941
|
+
return new CloudflareSandboxRunner(options);
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
//#endregion
|
|
945
|
+
export { CloudflareSandboxRunner, PluginBridge, createSandboxRunner, generatePluginWrapper, setEmailSendCallback };
|