-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathwrapper.ts
More file actions
286 lines (248 loc) · 9.49 KB
/
Copy pathwrapper.ts
File metadata and controls
286 lines (248 loc) · 9.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/**
* Plugin Wrapper Generator
*
* Generates the code that wraps a plugin to run in a Worker Loader isolate.
* The wrapper:
* - Imports plugin hooks and routes from a separate module ("sandbox-plugin.js")
* - Creates plugin context that proxies to BRIDGE service binding
* - Exposes hooks and routes via RPC through WorkerEntrypoint
*
* Plugin code runs in its own module scope, isolated from the wrapper template.
*
*/
import { normalizeCapabilities, type PluginManifest } from "emdash";
const TRAILING_SLASH_RE = /\/$/;
const NEWLINE_RE = /[\n\r]/g;
const COMMENT_CLOSE_RE = /\*\//g;
/**
* Options for wrapper generation
*
* **Known limitation:** `site` info is baked into the generated wrapper code
* at load time. If site settings change (e.g., admin updates site name/URL),
* sandboxed plugins will see stale values until the worker restarts.
* Trusted-mode plugins always read fresh values from the database.
*/
export interface WrapperOptions {
/** Site info to inject into the context (no RPC needed) */
site?: {
name: string;
url: string;
locale: string;
trailingSlash?: "always" | "never" | "ignore";
};
}
export function generatePluginWrapper(manifest: PluginManifest, options?: WrapperOptions): string {
const storageCollections = Object.keys(manifest.storage || {});
const site = options?.site ?? { name: "", url: "", locale: "en" };
// Normalize so manifests that still declare legacy names (`read:users`)
// expose the same APIs as canonical names (`users:read`).
const capabilities = normalizeCapabilities(manifest.capabilities ?? []);
const hasReadUsers = capabilities.includes("users:read");
const hasEmailSend = capabilities.includes("email:send");
return `
// =============================================================================
// Sandboxed Plugin Wrapper
// Generated by @emdash-cms/cloudflare
// Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)}
// =============================================================================
import { WorkerEntrypoint } from "cloudflare:workers";
// Plugin code lives in a separate module for scope isolation
import pluginModule from "sandbox-plugin.js";
// Extract hooks and routes from the plugin module
const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {};
const routes = pluginModule?.routes || pluginModule?.default?.routes || {};
function sandboxRouteErrorDetails(value) {
if (!value || typeof value !== "object") return null;
const code =
value.code === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" ||
value.code === "MEDIA_USAGE_ACTIVATION_CHECK_FAILED"
? value.code
: value.name === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS" ||
value.name === "MEDIA_USAGE_ACTIVATION_CHECK_FAILED"
? value.name
: null;
if (!code || (value.status !== undefined && value.status !== 503)) return null;
return {
code,
message:
code === "MEDIA_USAGE_ACTIVATION_IN_PROGRESS"
? "Media usage activation is in progress"
: "Unable to verify media usage activation state",
status: 503,
};
}
// -----------------------------------------------------------------------------
// Context Factory - creates ctx that proxies to BRIDGE
// -----------------------------------------------------------------------------
function createContext(env) {
const bridge = env.BRIDGE;
const storageCollections = ${JSON.stringify(storageCollections)};
// KV - proxies to bridge.kvGet/Set/Delete/List
const kv = {
get: (key) => bridge.kvGet(key),
set: (key, value) => bridge.kvSet(key, value),
delete: (key) => bridge.kvDelete(key),
list: (prefix) => bridge.kvList(prefix)
};
// Storage collection factory
function createStorageCollection(collectionName) {
return {
get: (id) => bridge.storageGet(collectionName, id),
put: (id, data) => bridge.storagePut(collectionName, id, data),
delete: (id) => bridge.storageDelete(collectionName, id),
exists: async (id) => (await bridge.storageGet(collectionName, id)) !== null,
query: (opts) => bridge.storageQuery(collectionName, opts),
count: (where) => bridge.storageCount(collectionName, where),
getMany: (ids) => bridge.storageGetMany(collectionName, ids),
putMany: (items) => bridge.storagePutMany(collectionName, items),
deleteMany: (ids) => bridge.storageDeleteMany(collectionName, ids)
};
}
// Storage proxy that creates collections on access
const storage = new Proxy({}, {
get(_, collectionName) {
if (typeof collectionName !== "string") return undefined;
return createStorageCollection(collectionName);
}
});
// Content access - proxies to bridge (capability enforced by bridge)
const content = {
get: (collection, id) => bridge.contentGet(collection, id),
list: (collection, opts) => bridge.contentList(collection, opts),
create: (collection, data) => bridge.contentCreate(collection, data),
update: (collection, id, data) => bridge.contentUpdate(collection, id, data),
delete: (collection, id) => bridge.contentDelete(collection, id)
};
// Taxonomy access (read-only) - proxies to bridge (capability enforced by bridge)
const taxonomies = {
getAll: (opts) => bridge.taxonomyList(opts),
getTerms: (taxonomy, opts) => bridge.taxonomyTerms(taxonomy, opts),
getEntryTerms: (collection, entryId, opts) => bridge.taxonomyEntryTerms(collection, entryId, opts)
};
// Media access - proxies to bridge (capability enforced by bridge)
const media = {
get: (id) => bridge.mediaGet(id),
list: (opts) => bridge.mediaList(opts),
upload: (filename, contentType, bytes) => bridge.mediaUpload(filename, contentType, bytes),
getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload(filename, contentType, bytes) instead."); },
delete: (id) => bridge.mediaDelete(id)
};
// HTTP access - proxies to bridge (capability + host enforced by bridge)
const http = {
fetch: async (url, init) => {
const result = await bridge.httpFetch(url, init);
// Bridge returns serialized response, reconstruct Response-like object
return {
status: result.status,
ok: result.status >= 200 && result.status < 300,
headers: new Headers(result.headers),
text: async () => result.text,
json: async () => JSON.parse(result.text)
};
}
};
// Logger - proxies to bridge
const log = {
debug: (msg, data) => bridge.log("debug", msg, data),
info: (msg, data) => bridge.log("info", msg, data),
warn: (msg, data) => bridge.log("warn", msg, data),
error: (msg, data) => bridge.log("error", msg, data)
};
// Site info - injected at wrapper generation time, no RPC needed
const site = ${JSON.stringify(site)};
// URL helper - generates absolute URLs from paths
const siteBaseUrl = ${JSON.stringify(site.url.replace(TRAILING_SLASH_RE, ""))};
function url(path) {
if (!path.startsWith("/")) {
throw new Error('URL path must start with "/", got: "' + path + '"');
}
if (path.startsWith("//")) {
throw new Error('URL path must not be protocol-relative, got: "' + path + '"');
}
return siteBaseUrl + path;
}
// User access - proxies to bridge (capability enforced by bridge)
const users = ${hasReadUsers} ? {
get: (id) => bridge.userGet(id),
getByEmail: (email) => bridge.userGetByEmail(email),
list: (opts) => bridge.userList(opts)
} : undefined;
// Email access - proxies to bridge (capability enforced by bridge)
const email = ${hasEmailSend} ? {
send: (message) => bridge.emailSend(message)
} : undefined;
return {
plugin: {
id: env.PLUGIN_ID,
version: env.PLUGIN_VERSION
},
storage,
kv,
content,
taxonomies,
media,
http,
log,
site,
url,
users,
email
};
}
// -----------------------------------------------------------------------------
// Worker Entrypoint (RPC interface)
// -----------------------------------------------------------------------------
export default class PluginEntrypoint extends WorkerEntrypoint {
async invokeHook(hookName, event) {
const ctx = createContext(this.env);
// Find the hook handler
const hookDef = hooks[hookName];
if (!hookDef) {
// No handler for this hook - that's ok, return undefined
return undefined;
}
// Get the handler (might be wrapped in config object)
const handler = typeof hookDef === "function" ? hookDef : hookDef.handler;
if (typeof handler !== "function") {
throw new Error(\`Hook \${hookName} handler is not a function\`);
}
// Execute the hook
return handler(event, ctx);
}
async invokeRoute(routeName, input, serializedRequest) {
const ctx = createContext(this.env);
// Find the route handler
const route = routes[routeName];
if (!route) {
throw new Error(\`Route not found: \${routeName}\`);
}
// Get handler (might be direct function or object with handler)
const handler = typeof route === "function" ? route : route.handler;
if (typeof handler !== "function") {
throw new Error(\`Route \${routeName} handler is not a function\`);
}
// Execute the route handler with input, request metadata, and context
try {
return await handler(
{ input, request: serializedRequest, requestMeta: serializedRequest.meta },
ctx,
);
} catch (error) {
const details = sandboxRouteErrorDetails(error);
if (details) {
return { __emdashSandboxRouteError: true, error: details };
}
throw error;
}
}
}
`;
}
/**
* Sanitize a string for inclusion in a JavaScript comment.
* Prevents comment injection via manifest.id or manifest.version containing
* newlines or comment-closing sequences.
*/
function sanitizeComment(s: string): string {
return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /");
}