@decocms/runtime 1.0.0-alpha.32 → 1.0.0-alpha.34

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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/events.ts +469 -0
  3. package/src/tools.ts +85 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/runtime",
3
- "version": "1.0.0-alpha.32",
3
+ "version": "1.0.0-alpha.34",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit"
@@ -8,7 +8,7 @@
8
8
  "dependencies": {
9
9
  "@cloudflare/workers-types": "^4.20250617.0",
10
10
  "@deco/mcp": "npm:@jsr/deco__mcp@0.7.8",
11
- "@decocms/bindings": "1.0.1-alpha.17",
11
+ "@decocms/bindings": "1.0.1-alpha.19",
12
12
  "@modelcontextprotocol/sdk": "1.20.2",
13
13
  "@ai-sdk/provider": "^2.0.0",
14
14
  "hono": "^4.10.7",
package/src/events.ts ADDED
@@ -0,0 +1,469 @@
1
+ import type {
2
+ CloudEvent,
3
+ EventResult,
4
+ OnEventsOutput,
5
+ } from "@decocms/bindings";
6
+ import z from "zod";
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ export interface EventSubscription {
13
+ connectionId: string;
14
+ events: string[];
15
+ }
16
+
17
+ interface Binding {
18
+ __type: string;
19
+ value: string;
20
+ }
21
+
22
+ /**
23
+ * Per-event handler - handles events of a specific type
24
+ * Returns result for each event individually
25
+ */
26
+ export type PerEventHandler<TEnv> = (
27
+ context: { events: CloudEvent[] },
28
+ env: TEnv,
29
+ ) => EventResult | Promise<EventResult>;
30
+
31
+ /**
32
+ * Batch handler function - handles multiple events at once
33
+ * Can return batch result or per-event results
34
+ */
35
+ export type BatchHandlerFn<TEnv> = (
36
+ context: { events: CloudEvent[] },
37
+ env: TEnv,
38
+ ) => OnEventsOutput | Promise<OnEventsOutput>;
39
+
40
+ /**
41
+ * Batch handler with explicit event types for subscription
42
+ */
43
+ export interface BatchHandler<TEnv> {
44
+ /** Handler function */
45
+ handler: BatchHandlerFn<TEnv>;
46
+ /** Event types to subscribe to */
47
+ events: string[];
48
+ }
49
+
50
+ /**
51
+ * Binding-level handlers - either a batch handler with events or per-event handlers
52
+ *
53
+ * @example Per-event handlers (event types inferred from keys)
54
+ * ```ts
55
+ * { "order.created": handler, "order.updated": handler }
56
+ * ```
57
+ *
58
+ * @example Batch handler with explicit events
59
+ * ```ts
60
+ * { handler: fn, events: ["order.created", "order.updated"] }
61
+ * ```
62
+ */
63
+ export type BindingHandlers<TEnv> =
64
+ | BatchHandler<TEnv>
65
+ | Record<string, PerEventHandler<TEnv>>;
66
+
67
+ /**
68
+ * EventHandlers type supports three granularity levels:
69
+ *
70
+ * @example Global handler with explicit events
71
+ * ```ts
72
+ * { handler: (ctx, env) => result, events: ["order.created"] }
73
+ * ```
74
+ *
75
+ * @example Per-binding batch handler
76
+ * ```ts
77
+ * { DATABASE: { handler: fn, events: ["order.created"] } }
78
+ * ```
79
+ *
80
+ * @example Per-event handlers (events inferred from keys)
81
+ * ```ts
82
+ * { DATABASE: { "order.created": (ctx, env) => result } }
83
+ * ```
84
+ */
85
+ export type EventHandlers<TSchema extends z.ZodTypeAny = never> = [
86
+ TSchema,
87
+ ] extends [never]
88
+ ? Record<string, never>
89
+ :
90
+ | BatchHandler<z.infer<TSchema>> // Global handler with events
91
+ | {
92
+ [K in keyof z.infer<TSchema> as z.infer<TSchema>[K] extends {
93
+ __type: string;
94
+ value: string;
95
+ }
96
+ ? K
97
+ : never]?: BindingHandlers<z.infer<TSchema>>;
98
+ };
99
+
100
+ /**
101
+ * Extract only the keys from T where the value is a Binding shape.
102
+ * Filters out non-binding properties at the type level.
103
+ */
104
+ export type BindingKeysOf<T> = {
105
+ [K in keyof T]: T[K] extends { __type: string; value: string } ? K : never;
106
+ }[keyof T];
107
+
108
+ // ============================================================================
109
+ // Type Guards
110
+ // ============================================================================
111
+
112
+ const isBinding = (v: unknown): v is Binding => {
113
+ return (
114
+ typeof v === "object" &&
115
+ v !== null &&
116
+ "__type" in v &&
117
+ typeof v.__type === "string" &&
118
+ "value" in v &&
119
+ typeof v.value === "string"
120
+ );
121
+ };
122
+
123
+ /**
124
+ * Check if handlers is a global batch handler (has handler + events at top level)
125
+ */
126
+ const isGlobalHandler = <TEnv>(
127
+ handlers: EventHandlers<z.ZodTypeAny>,
128
+ ): handlers is BatchHandler<TEnv> => {
129
+ return (
130
+ typeof handlers === "object" &&
131
+ handlers !== null &&
132
+ "handler" in handlers &&
133
+ "events" in handlers &&
134
+ typeof handlers.handler === "function" &&
135
+ Array.isArray(handlers.events)
136
+ );
137
+ };
138
+
139
+ /**
140
+ * Check if a binding handler is a batch handler (has handler + events) vs per-event handlers (object of functions)
141
+ */
142
+ const isBatchHandler = <TEnv>(
143
+ handler: BindingHandlers<TEnv>,
144
+ ): handler is BatchHandler<TEnv> => {
145
+ return (
146
+ typeof handler === "object" &&
147
+ handler !== null &&
148
+ "handler" in handler &&
149
+ "events" in handler &&
150
+ typeof handler.handler === "function" &&
151
+ Array.isArray(handler.events)
152
+ );
153
+ };
154
+
155
+ // ============================================================================
156
+ // Helper Functions
157
+ // ============================================================================
158
+
159
+ /**
160
+ * Get binding keys from event handlers object
161
+ */
162
+ const getBindingKeys = <TSchema extends z.ZodTypeAny>(
163
+ handlers: EventHandlers<TSchema>,
164
+ ): string[] => {
165
+ if (isGlobalHandler(handlers)) {
166
+ return [];
167
+ }
168
+ return Object.keys(handlers);
169
+ };
170
+
171
+ /**
172
+ * Get event types for a binding from handlers
173
+ */
174
+ const getEventTypesForBinding = <TSchema extends z.ZodTypeAny>(
175
+ handlers: EventHandlers<TSchema>,
176
+ binding: string,
177
+ ): string[] => {
178
+ if (isGlobalHandler(handlers)) {
179
+ return handlers.events;
180
+ }
181
+ const bindingHandler = handlers[binding as keyof typeof handlers];
182
+ if (!bindingHandler) {
183
+ return [];
184
+ }
185
+ if (isBatchHandler(bindingHandler)) {
186
+ // Batch handler - return explicit events array
187
+ return bindingHandler.events;
188
+ }
189
+ // Per-event handlers - event types are the keys
190
+ return Object.keys(bindingHandler);
191
+ };
192
+
193
+ /**
194
+ * Get scopes from event handlers for subscription
195
+ */
196
+ const scopesFromEvents = <TSchema extends z.ZodTypeAny = never>(
197
+ handlers: EventHandlers<TSchema>,
198
+ ): string[] => {
199
+ if (isGlobalHandler(handlers)) {
200
+ // Global handler - scopes are based on explicit events array
201
+ // Note: "*" binding means all bindings
202
+ return handlers.events.map((event) => `*::event@${event}`);
203
+ }
204
+
205
+ const scopes: string[] = [];
206
+ for (const binding of getBindingKeys(handlers)) {
207
+ const eventTypes = getEventTypesForBinding(handlers, binding);
208
+ for (const eventType of eventTypes) {
209
+ scopes.push(`${binding}::event@${eventType}`);
210
+ }
211
+ }
212
+ return scopes;
213
+ };
214
+
215
+ /**
216
+ * Get subscriptions from event handlers and state
217
+ */
218
+ const eventsSubscriptions = <TSchema extends z.ZodTypeAny = never>(
219
+ handlers: EventHandlers<TSchema>,
220
+ state: z.infer<TSchema>,
221
+ ): EventSubscription[] => {
222
+ if (isGlobalHandler(handlers)) {
223
+ // Global handler - subscribe to all bindings with the explicit events
224
+ const subscriptions: EventSubscription[] = [];
225
+ for (const [, value] of Object.entries(state)) {
226
+ if (isBinding(value)) {
227
+ subscriptions.push({
228
+ connectionId: value.value,
229
+ events: handlers.events,
230
+ });
231
+ }
232
+ }
233
+ return subscriptions;
234
+ }
235
+
236
+ const subscriptions: EventSubscription[] = [];
237
+ for (const binding of getBindingKeys(handlers)) {
238
+ const bindingValue = state[binding as keyof typeof state];
239
+ if (!isBinding(bindingValue)) continue;
240
+
241
+ const eventTypes = getEventTypesForBinding(handlers, binding);
242
+ subscriptions.push({
243
+ connectionId: bindingValue.value,
244
+ events: eventTypes,
245
+ });
246
+ }
247
+ return subscriptions;
248
+ };
249
+
250
+ // ============================================================================
251
+ // Event Execution
252
+ // ============================================================================
253
+
254
+ /**
255
+ * Group events by source (connection ID)
256
+ */
257
+ const groupEventsBySource = (
258
+ events: CloudEvent[],
259
+ ): Map<string, CloudEvent[]> => {
260
+ const grouped = new Map<string, CloudEvent[]>();
261
+ for (const event of events) {
262
+ const source = event.source;
263
+ const existing = grouped.get(source) || [];
264
+ existing.push(event);
265
+ grouped.set(source, existing);
266
+ }
267
+ return grouped;
268
+ };
269
+
270
+ /**
271
+ * Group events by type
272
+ */
273
+ const groupEventsByType = (events: CloudEvent[]): Map<string, CloudEvent[]> => {
274
+ const grouped = new Map<string, CloudEvent[]>();
275
+ for (const event of events) {
276
+ const type = event.type;
277
+ const existing = grouped.get(type) || [];
278
+ existing.push(event);
279
+ grouped.set(type, existing);
280
+ }
281
+ return grouped;
282
+ };
283
+
284
+ /**
285
+ * Merge multiple OnEventsOutput results into a single result
286
+ */
287
+ const mergeResults = (results: OnEventsOutput[]): OnEventsOutput => {
288
+ const merged: OnEventsOutput = {};
289
+ const allResults: Record<string, EventResult> = {};
290
+
291
+ let hasAnyFailure = false;
292
+ let totalProcessed = 0;
293
+ const errors: string[] = [];
294
+
295
+ for (const result of results) {
296
+ // Merge per-event results
297
+ if (result.results) {
298
+ Object.assign(allResults, result.results);
299
+ }
300
+
301
+ // Track batch-level status
302
+ if (result.success === false) {
303
+ hasAnyFailure = true;
304
+ if (result.error) {
305
+ errors.push(result.error);
306
+ }
307
+ }
308
+
309
+ if (result.processedCount !== undefined) {
310
+ totalProcessed += result.processedCount;
311
+ }
312
+ }
313
+
314
+ // Build merged result
315
+ if (Object.keys(allResults).length > 0) {
316
+ merged.results = allResults;
317
+ }
318
+
319
+ // Set batch-level success based on all results
320
+ merged.success = !hasAnyFailure;
321
+
322
+ if (errors.length > 0) {
323
+ merged.error = errors.join("; ");
324
+ }
325
+
326
+ if (totalProcessed > 0) {
327
+ merged.processedCount = totalProcessed;
328
+ }
329
+
330
+ return merged;
331
+ };
332
+
333
+ /**
334
+ * Execute event handlers and return merged result
335
+ *
336
+ * Supports three handler formats:
337
+ * 1. Global: `(context, env) => result` - handles all events
338
+ * 2. Per-binding: `{ BINDING: (context, env) => result }` - handles all events from binding
339
+ * 3. Per-event: `{ BINDING: { "event.type": (context, env) => result } }` - handles specific events
340
+ */
341
+ const executeEventHandlers = async <TSchema extends z.ZodTypeAny>(
342
+ handlers: EventHandlers<TSchema>,
343
+ events: CloudEvent[],
344
+ env: z.infer<TSchema>,
345
+ state: z.infer<TSchema>,
346
+ ): Promise<OnEventsOutput> => {
347
+ // Case 1: Global handler
348
+ if (isGlobalHandler(handlers)) {
349
+ try {
350
+ return await handlers.handler({ events }, env);
351
+ } catch (error) {
352
+ return {
353
+ success: false,
354
+ error: error instanceof Error ? error.message : String(error),
355
+ };
356
+ }
357
+ }
358
+
359
+ // Build a map from connectionId -> binding key
360
+ const connectionToBinding = new Map<string, string>();
361
+ for (const binding of getBindingKeys(handlers)) {
362
+ const bindingValue = state[binding as keyof typeof state];
363
+ if (isBinding(bindingValue)) {
364
+ connectionToBinding.set(bindingValue.value, binding);
365
+ }
366
+ }
367
+
368
+ // Group events by source
369
+ const eventsBySource = groupEventsBySource(events);
370
+
371
+ // Process each binding's events in parallel
372
+ const promises: Promise<OnEventsOutput>[] = [];
373
+
374
+ for (const [source, sourceEvents] of eventsBySource) {
375
+ const binding = connectionToBinding.get(source);
376
+ if (!binding) {
377
+ // No handler for this source - mark as success (ignore)
378
+ continue;
379
+ }
380
+
381
+ const bindingHandler = handlers[binding as keyof typeof handlers];
382
+ if (!bindingHandler) continue;
383
+
384
+ // Case 2: Per-binding batch handler
385
+ if (isBatchHandler(bindingHandler)) {
386
+ promises.push(
387
+ (async () => {
388
+ try {
389
+ return await bindingHandler.handler({ events: sourceEvents }, env);
390
+ } catch (error) {
391
+ // Mark all events from this binding as failed
392
+ const results: Record<string, EventResult> = {};
393
+ for (const event of sourceEvents) {
394
+ results[event.id] = {
395
+ success: false,
396
+ error: error instanceof Error ? error.message : String(error),
397
+ };
398
+ }
399
+ return { results };
400
+ }
401
+ })(),
402
+ );
403
+ continue;
404
+ }
405
+
406
+ // Case 3: Per-event handlers
407
+ const perEventHandlers = bindingHandler as Record<
408
+ string,
409
+ PerEventHandler<z.infer<TSchema>>
410
+ >;
411
+ const eventsByType = groupEventsByType(sourceEvents);
412
+
413
+ for (const [eventType, typedEvents] of eventsByType) {
414
+ const eventHandler = perEventHandlers[eventType];
415
+ if (!eventHandler) {
416
+ // No handler for this event type - mark as success (ignore)
417
+ continue;
418
+ }
419
+
420
+ // Call handler for each event type (handler receives all events of that type)
421
+ promises.push(
422
+ (async () => {
423
+ try {
424
+ const result = await eventHandler({ events: typedEvents }, env);
425
+ // Convert per-event result to output with results for each event
426
+ const results: Record<string, EventResult> = {};
427
+ for (const event of typedEvents) {
428
+ results[event.id] = result;
429
+ }
430
+ return { results };
431
+ } catch (error) {
432
+ const results: Record<string, EventResult> = {};
433
+ for (const event of typedEvents) {
434
+ results[event.id] = {
435
+ success: false,
436
+ error: error instanceof Error ? error.message : String(error),
437
+ };
438
+ }
439
+ return { results };
440
+ }
441
+ })(),
442
+ );
443
+ }
444
+ }
445
+
446
+ // Wait for all handlers to complete
447
+ const results = await Promise.all(promises);
448
+
449
+ // If no handlers were called, return success
450
+ if (results.length === 0) {
451
+ return { success: true };
452
+ }
453
+
454
+ // Merge all results
455
+ return mergeResults(results);
456
+ };
457
+
458
+ // ============================================================================
459
+ // Exports
460
+ // ============================================================================
461
+
462
+ /**
463
+ * Event utilities for subscriptions and execution
464
+ */
465
+ export const Event = {
466
+ subscriptions: eventsSubscriptions,
467
+ scopes: scopesFromEvents,
468
+ execute: executeEventHandlers,
469
+ };
package/src/tools.ts CHANGED
@@ -1,13 +1,22 @@
1
1
  /* oxlint-disable no-explicit-any */
2
2
  /* oxlint-disable ban-types */
3
3
  import { HttpServerTransport } from "@deco/mcp/http";
4
+ import {
5
+ OnEventsInputSchema,
6
+ OnEventsOutputSchema,
7
+ type EventBusBindingClient,
8
+ } from "@decocms/bindings";
4
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
10
  import { z } from "zod";
6
11
  import { zodToJsonSchema } from "zod-to-json-schema";
12
+ import { Event, type EventHandlers } from "./events.ts";
7
13
  import type { DefaultEnv } from "./index.ts";
8
14
  import { State } from "./state.ts";
9
15
  import { Binding } from "./wrangler.ts";
10
16
 
17
+ // Re-export EventHandlers type for external use
18
+ export type { EventHandlers } from "./events.ts";
19
+
11
20
  export const createRuntimeContext = (prev?: AppContext) => {
12
21
  const store = State.getStore();
13
22
  if (!store) {
@@ -213,12 +222,23 @@ export interface OAuthConfig {
213
222
  };
214
223
  }
215
224
 
225
+ /**
226
+ * Constructs a type by picking all properties from T that are assignable to Value.
227
+ */
228
+ type PickByType<T, Value> = {
229
+ [P in keyof T as T[P] extends Value ? P : never]: T[P];
230
+ };
231
+
216
232
  export interface CreateMCPServerOptions<
217
233
  Env = unknown,
218
234
  TSchema extends z.ZodTypeAny = never,
219
235
  > {
220
236
  before?: (env: Env & DefaultEnv<TSchema>) => Promise<void> | void;
221
237
  oauth?: OAuthConfig;
238
+ events?: {
239
+ bus?: keyof PickByType<Env & DefaultEnv<TSchema>, EventBusBindingClient>;
240
+ handlers?: EventHandlers<TSchema>;
241
+ };
222
242
  configuration?: {
223
243
  onChange?: (
224
244
  env: Env & DefaultEnv<TSchema>,
@@ -255,17 +275,22 @@ export interface AppContext<TEnv extends DefaultEnv = DefaultEnv> {
255
275
  req?: Request;
256
276
  }
257
277
 
258
- const configurationToolsFor = <TSchema extends z.ZodTypeAny = never>({
259
- state: schema,
260
- scopes,
261
- onChange,
262
- }: CreateMCPServerOptions<
263
- any,
264
- TSchema
265
- >["configuration"] = {}): CreatedTool[] => {
278
+ const getEventBus = (
279
+ prop: string | number,
280
+ env: DefaultEnv,
281
+ ): EventBusBindingClient | undefined => {
282
+ const bus = env as unknown as { [prop]: EventBusBindingClient };
283
+ return typeof bus[prop] !== "undefined" ? bus[prop] : undefined;
284
+ };
285
+
286
+ const toolsFor = <TSchema extends z.ZodTypeAny = never>({
287
+ events,
288
+ configuration: { state: schema, scopes, onChange } = {},
289
+ }: CreateMCPServerOptions<any, TSchema> = {}): CreatedTool[] => {
266
290
  const jsonSchema = schema
267
291
  ? zodToJsonSchema(schema)
268
292
  : { type: "object", properties: {} };
293
+ const busProp = String(events?.bus ?? "EVENT_BUS");
269
294
  return [
270
295
  ...(onChange
271
296
  ? [
@@ -282,15 +307,60 @@ const configurationToolsFor = <TSchema extends z.ZodTypeAny = never>({
282
307
  }),
283
308
  outputSchema: z.object({}),
284
309
  execute: async (input) => {
310
+ const state = input.context.state as z.infer<TSchema>;
285
311
  await onChange(input.runtimeContext.env, {
286
- state: input.context.state,
312
+ state,
287
313
  scopes: input.context.scopes,
288
314
  });
315
+ const bus = getEventBus(busProp, input.runtimeContext.env);
316
+ if (events && state && bus) {
317
+ // subscribe to events
318
+ const subscriptions = Event.subscriptions(
319
+ events?.handlers ?? {},
320
+ state,
321
+ );
322
+
323
+ await Promise.all(
324
+ subscriptions.map(async (subscription) => {
325
+ return Promise.all(
326
+ subscription.events.map(async (event) => {
327
+ return bus.EVENT_SUBSCRIBE({
328
+ publisher: subscription.connectionId,
329
+ eventType: event,
330
+ });
331
+ }),
332
+ );
333
+ }),
334
+ );
335
+ }
289
336
  return Promise.resolve({});
290
337
  },
291
338
  }),
292
339
  ]
293
340
  : []),
341
+
342
+ ...(events?.handlers
343
+ ? [
344
+ createTool({
345
+ id: "ON_EVENTS",
346
+ description:
347
+ "Receive and process CloudEvents from the event bus. Returns per-event or batch results.",
348
+ inputSchema: OnEventsInputSchema,
349
+ outputSchema: OnEventsOutputSchema,
350
+ execute: async (input) => {
351
+ const env = input.runtimeContext.env;
352
+ // Get state from MESH_REQUEST_CONTEXT - this has the binding values
353
+ const state = env.MESH_REQUEST_CONTEXT?.state as z.infer<TSchema>;
354
+ return Event.execute(
355
+ events.handlers!,
356
+ input.context.events,
357
+ env,
358
+ state,
359
+ );
360
+ },
361
+ }),
362
+ ]
363
+ : []),
294
364
  createTool({
295
365
  id: "MCP_CONFIGURATION",
296
366
  description: "MCP Configuration",
@@ -302,7 +372,11 @@ const configurationToolsFor = <TSchema extends z.ZodTypeAny = never>({
302
372
  execute: () => {
303
373
  return Promise.resolve({
304
374
  stateSchema: jsonSchema,
305
- scopes,
375
+ scopes: [
376
+ ...(scopes ?? []),
377
+ ...Event.scopes(events?.handlers ?? {}),
378
+ ...(busProp ? [`${busProp}::EVENT_SUBSCRIBE`] : []),
379
+ ],
306
380
  });
307
381
  },
308
382
  }),
@@ -353,7 +427,7 @@ export const createMCPServer = <
353
427
  };
354
428
  const tools = await toolsFn(bindings);
355
429
 
356
- tools.push(...configurationToolsFor<TSchema>(options.configuration));
430
+ tools.push(...toolsFor<TSchema>(options));
357
431
 
358
432
  for (const tool of tools) {
359
433
  server.registerTool(