@decocms/runtime 1.0.0-alpha.2 → 1.0.0-alpha.20

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.
@@ -25,94 +25,3 @@ export function streamToResponse(
25
25
  },
26
26
  });
27
27
  }
28
-
29
- export function responseToStream(
30
- response: Response,
31
- ): ReadableStream<LanguageModelV2StreamPart> {
32
- if (!response.body) {
33
- throw new Error("Response body is null");
34
- }
35
-
36
- return response.body.pipeThrough(new TextDecoderStream()).pipeThrough(
37
- new TransformStream<string, LanguageModelV2StreamPart>({
38
- transform(chunk, controller) {
39
- // Split by newlines and parse each line
40
- const lines = chunk.split("\n");
41
-
42
- for (const line of lines) {
43
- if (line.trim()) {
44
- try {
45
- const parsed = JSON.parse(line) as LanguageModelV2StreamPart;
46
- controller.enqueue(parsed);
47
- } catch (error) {
48
- console.error("Failed to parse stream chunk:", error);
49
- }
50
- }
51
- }
52
- },
53
- }),
54
- );
55
- }
56
-
57
- /**
58
- * Lazy promise wrapper that defers execution until the promise is awaited.
59
- * The factory function is only called when .then() is invoked for the first time.
60
- */
61
- class Lazy<T> implements PromiseLike<T> {
62
- private promise: Promise<T> | null = null;
63
-
64
- constructor(private factory: () => Promise<T>) {}
65
-
66
- private getOrCreatePromise(): Promise<T> {
67
- if (!this.promise) {
68
- this.promise = this.factory();
69
- }
70
- return this.promise;
71
- }
72
-
73
- // eslint-disable-next-line no-thenable
74
- then<TResult1 = T, TResult2 = never>(
75
- onfulfilled?:
76
- | ((value: T) => TResult1 | PromiseLike<TResult1>)
77
- | null
78
- | undefined,
79
- onrejected?:
80
- | ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
81
- | null
82
- | undefined,
83
- ): PromiseLike<TResult1 | TResult2> {
84
- return this.getOrCreatePromise().then(onfulfilled, onrejected);
85
- }
86
-
87
- catch<TResult = never>(
88
- onrejected?:
89
- | ((reason: unknown) => TResult | PromiseLike<TResult>)
90
- | null
91
- | undefined,
92
- ): Promise<T | TResult> {
93
- return this.getOrCreatePromise().catch(onrejected);
94
- }
95
-
96
- finally(onfinally?: (() => void) | null | undefined): Promise<T> {
97
- return this.getOrCreatePromise().finally(onfinally);
98
- }
99
- }
100
-
101
- /**
102
- * Creates a lazy promise that only executes when awaited.
103
- *
104
- * @param factory - A function that returns a Promise<T>
105
- * @returns A Promise-like object that defers execution until .then() is called
106
- *
107
- * @example
108
- * ```ts
109
- * const lazyData = lazy(() => fetchExpensiveData());
110
- * // fetchExpensiveData() is NOT called yet
111
- *
112
- * const result = await lazyData;
113
- * // fetchExpensiveData() is called NOW
114
- * ```
115
- */
116
- export function lazy<T>(factory: () => Promise<T>): Promise<T> {
117
- return new Lazy(factory) as unknown as Promise<T>;
118
- }
package/src/bindings.ts CHANGED
@@ -4,89 +4,22 @@ import { MCPClient } from "./mcp.ts";
4
4
  import type {
5
5
  BindingBase,
6
6
  ContractBinding,
7
+ MCPAppBinding,
7
8
  MCPBinding,
8
- MCPIntegrationNameBinding,
9
9
  } from "./wrangler.ts";
10
10
 
11
- interface IntegrationContext {
12
- integrationId: string;
13
- workspace: string;
14
- branch?: string;
15
- decoCmsApiUrl?: string;
16
- }
17
-
18
- const normalizeWorkspace = (workspace: string) => {
19
- if (workspace.startsWith("/users")) {
20
- return workspace;
21
- }
22
- if (workspace.startsWith("/shared")) {
23
- return workspace;
24
- }
25
- if (workspace.includes("/")) {
26
- return workspace;
27
- }
28
- return `/shared/${workspace}`;
29
- };
30
-
31
- /**
32
- * Url: /apps/mcp?appName=$appName
33
- */
34
- const createAppsUrl = ({
35
- appName,
36
- decoChatApiUrl,
37
- }: {
38
- appName: string;
39
- decoChatApiUrl?: string;
40
- }) =>
41
- new URL(
42
- `/apps/mcp?appName=${appName}`,
43
- decoChatApiUrl ?? "https://api.decocms.com",
44
- ).href;
45
- /**
46
- * Url: /:workspace.root/:workspace.slug/:integrationId/mcp
47
- */
48
- const createIntegrationsUrl = ({
49
- integrationId,
50
- workspace,
51
- decoCmsApiUrl,
52
- branch,
53
- }: IntegrationContext) => {
54
- const base = `${normalizeWorkspace(workspace)}/${integrationId}/mcp`;
55
- const url = new URL(base, decoCmsApiUrl ?? "https://api.decocms.com");
56
- branch && url.searchParams.set("branch", branch);
57
- return url.href;
58
- };
59
-
60
- type WorkspaceClientContext = Omit<
11
+ type ClientContext = Omit<
61
12
  RequestContext,
62
13
  "ensureAuthenticated" | "state" | "fetchIntegrationMetadata"
63
14
  >;
64
- export const workspaceClient = (
65
- ctx: WorkspaceClientContext,
66
- decocmsApiUrl?: string,
67
- ): ReturnType<(typeof MCPClient)["forWorkspace"]> => {
68
- return MCPClient.forWorkspace(ctx.workspace, ctx.token, decocmsApiUrl);
69
- };
70
-
71
- const mcpClientForAppName = (appName: string, decoChatApiUrl?: string) => {
72
- const mcpConnection: MCPConnection = {
73
- type: "HTTP",
74
- url: createAppsUrl({
75
- appName,
76
- decoChatApiUrl,
77
- }),
78
- };
79
-
80
- return MCPClient.forConnection(mcpConnection, decoChatApiUrl);
81
- };
82
15
 
83
16
  export const proxyConnectionForId = (
84
- integrationId: string,
85
- ctx: Omit<WorkspaceClientContext, "token"> & {
17
+ connectionId: string,
18
+ ctx: Omit<ClientContext, "token"> & {
86
19
  token?: string;
87
20
  cookie?: string;
21
+ meshUrl: string;
88
22
  },
89
- decocmsApiUrl?: string,
90
23
  appName?: string,
91
24
  ): MCPConnection => {
92
25
  let headers: Record<string, string> | undefined = appName
@@ -98,55 +31,39 @@ export const proxyConnectionForId = (
98
31
  }
99
32
  return {
100
33
  type: "HTTP",
101
- url: createIntegrationsUrl({
102
- integrationId,
103
- workspace: ctx.workspace,
104
- decoCmsApiUrl: decocmsApiUrl,
105
- branch: ctx.branch,
106
- }),
34
+ url: new URL(`/mcp/${connectionId}`, ctx.meshUrl).href,
107
35
  token: ctx.token,
108
36
  headers,
109
37
  };
110
38
  };
111
- const mcpClientForIntegrationId = (
112
- integrationId: string,
113
- ctx: WorkspaceClientContext,
114
- decocmsApiUrl?: string,
39
+ const mcpClientForConnectionId = (
40
+ connectionId: string,
41
+ ctx: ClientContext,
115
42
  appName?: string,
116
43
  ) => {
117
- const mcpConnection = proxyConnectionForId(
118
- integrationId,
119
- ctx,
120
- decocmsApiUrl,
121
- appName,
122
- );
44
+ const mcpConnection = proxyConnectionForId(connectionId, ctx, appName);
123
45
 
124
46
  // TODO(@igorbrasileiro): Switch this proxy to be a proxy that call MCP Client.toolCall from @modelcontextprotocol
125
- return MCPClient.forConnection(mcpConnection, decocmsApiUrl);
47
+ return MCPClient.forConnection(mcpConnection);
126
48
  };
127
49
 
128
50
  function mcpClientFromState(
129
- binding: BindingBase | MCPIntegrationNameBinding,
51
+ binding: BindingBase | MCPAppBinding,
130
52
  env: DefaultEnv,
131
53
  ) {
132
- const ctx = env.DECO_REQUEST_CONTEXT;
54
+ const ctx = env.MESH_REQUEST_CONTEXT;
133
55
  const bindingFromState = ctx?.state?.[binding.name];
134
- const integrationId =
56
+ const connectionId =
135
57
  bindingFromState &&
136
58
  typeof bindingFromState === "object" &&
137
59
  "value" in bindingFromState
138
60
  ? bindingFromState.value
139
61
  : undefined;
140
- if (typeof integrationId !== "string" && "integration_name" in binding) {
62
+ if (typeof connectionId !== "string" && "app_name" in binding) {
141
63
  // in case of a binding to an app name, we need to use the new apps/mcp endpoint which will proxy the request to the app but without any token
142
- return mcpClientForAppName(binding.integration_name, env.DECO_API_URL);
64
+ return undefined;
143
65
  }
144
- return mcpClientForIntegrationId(
145
- integrationId,
146
- ctx,
147
- env.DECO_API_URL,
148
- env.DECO_APP_NAME,
149
- );
66
+ return mcpClientForConnectionId(connectionId, ctx);
150
67
  }
151
68
 
152
69
  export const createContractBinding = (
@@ -160,20 +77,24 @@ export const createIntegrationBinding = (
160
77
  binding: MCPBinding,
161
78
  env: DefaultEnv,
162
79
  ) => {
163
- const integrationId =
164
- "integration_id" in binding ? binding.integration_id : undefined;
165
- if (!integrationId) {
80
+ const connectionId =
81
+ "connection_id" in binding ? binding.connection_id : undefined;
82
+ if (!connectionId) {
166
83
  return mcpClientFromState(binding, env);
167
84
  }
85
+ if (!env.MESH_RUNTIME_TOKEN) {
86
+ throw new Error("MESH_RUNTIME_TOKEN is required");
87
+ }
88
+ if (!env.MESH_URL) {
89
+ throw new Error("MESH_URL is required");
90
+ }
168
91
  // bindings pointed to an specific integration id are binded using the app deployment workspace
169
- return mcpClientForIntegrationId(
170
- integrationId,
92
+ return mcpClientForConnectionId(
93
+ connectionId,
171
94
  {
172
- workspace: env.DECO_WORKSPACE,
173
- token: env.DECO_API_TOKEN,
174
- branch: env.DECO_REQUEST_CONTEXT?.branch,
95
+ token: env.MESH_RUNTIME_TOKEN,
96
+ meshUrl: env.MESH_URL,
175
97
  },
176
- env.DECO_API_URL,
177
- env.DECO_APP_NAME,
98
+ env.MESH_APP_NAME,
178
99
  );
179
100
  };
package/src/client.ts CHANGED
@@ -1,35 +1,3 @@
1
- import { toAsyncIterator } from "./bindings/deconfig/helpers.ts";
2
- // Extract resource name from DECO_RESOURCE_${NAME}_READ pattern
3
- type ExtractResourceName<K> = K extends `DECO_RESOURCE_${infer Name}_READ`
4
- ? Name
5
- : never;
6
-
7
- // Generate SUBSCRIBE method name from resource name
8
- type SubscribeMethodName<Name extends string> =
9
- `DECO_RESOURCE_${Name}_SUBSCRIBE`;
10
-
11
- // Extract data type from READ method return type
12
- type ExtractReadData<T> = T extends Promise<{ data: infer D }>
13
- ? D
14
- : T extends { data: infer D }
15
- ? D
16
- : never;
17
-
18
- // Generate all SUBSCRIBE method names for a given type
19
- type SubscribeMethods<T> = {
20
- [K in keyof T as K extends `DECO_RESOURCE_${string}_READ`
21
- ? SubscribeMethodName<ExtractResourceName<K>>
22
- : never]: K extends `DECO_RESOURCE_${string}_READ`
23
- ? // oxlint-disable-next-line no-explicit-any
24
- T[K] extends (...args: any) => any
25
- ? (args: { id: string } | { uri: string }) => AsyncIterableIterator<{
26
- uri: string;
27
- data: ExtractReadData<Awaited<ReturnType<T[K]>>>;
28
- }>
29
- : never
30
- : never;
31
- };
32
-
33
1
  export type MCPClient<T> = {
34
2
  // oxlint-disable-next-line no-explicit-any
35
3
  [K in keyof T]: T[K] extends (...args: any) => any
@@ -38,7 +6,7 @@ export type MCPClient<T> = {
38
6
  init?: CustomInit,
39
7
  ) => Promise<Awaited<ReturnType<T[K]>>>
40
8
  : never;
41
- } & SubscribeMethods<T>;
9
+ };
42
10
 
43
11
  export type CustomInit = RequestInit & {
44
12
  handleResponse?: (response: Response) => Promise<unknown>;
@@ -53,123 +21,11 @@ export const DEFAULT_INIT: CustomInit = {
53
21
  },
54
22
  };
55
23
 
56
- /**
57
- * Helper function to call an MCP tool via fetch
58
- */
59
- async function callMCPTool<T = unknown>(
60
- methodName: string,
61
- args: unknown,
62
- init?: CustomInit,
63
- ): Promise<T> {
64
- const mergedInit: CustomInit = {
65
- ...init,
66
- headers: {
67
- ...DEFAULT_INIT.headers,
68
- ...init?.headers,
69
- },
70
- };
71
-
72
- const response = await fetch(`/mcp/call-tool/${methodName}`, {
73
- method: "POST",
74
- body: JSON.stringify(args),
75
- credentials: "include",
76
- ...mergedInit,
77
- });
78
-
79
- if (!response.ok) {
80
- throw new Error(`Failed to call ${methodName}: ${response.statusText}`);
81
- }
82
-
83
- return response.json() as Promise<T>;
84
- }
85
-
86
- /**
87
- * Creates a subscribe method for a resource that returns an async iterator
88
- * yielding {uri, data} objects as resources are updated.
89
- */
90
- function createSubscribeMethod(
91
- resourceName: string,
92
- init?: CustomInit,
93
- ): (args: { id: string }) => AsyncIterableIterator<{
94
- uri: string;
95
- data: unknown;
96
- }> {
97
- return async function* (args: { id: string } | { uri: string }) {
98
- // Step 1: Call DESCRIBE to get watch endpoint configuration and URI template
99
- const describeMethodName = `DECO_RESOURCE_${resourceName}_DESCRIBE`;
100
- const readMethodName = `DECO_RESOURCE_${resourceName}_READ`;
101
-
102
- // Get describe information
103
- const describeData = await callMCPTool<{
104
- uriTemplate?: string;
105
- features?: {
106
- watch?: {
107
- pathname?: string;
108
- };
109
- };
110
- }>(describeMethodName, {}, init);
111
-
112
- const watchPathname = describeData?.features?.watch?.pathname;
113
- const uriTemplate = describeData?.uriTemplate;
114
-
115
- if (!watchPathname) {
116
- throw new Error(
117
- `Resource ${resourceName} does not support watch functionality`,
118
- );
119
- }
120
-
121
- if (!uriTemplate) {
122
- throw new Error(`Resource ${resourceName} does not provide uriTemplate`);
123
- }
124
-
125
- // Step 2: Construct URI from template by replacing * with id
126
- const resourceUri =
127
- "uri" in args ? args.uri : uriTemplate.replace("*", args.id);
128
-
129
- // Step 3: Construct watch URL and create EventSource
130
- const watchUrl = new URL(watchPathname, globalThis.location.origin);
131
- watchUrl.searchParams.set("uri", resourceUri);
132
-
133
- const eventSource = new EventSource(watchUrl.href);
134
-
135
- // Step 4: Use toAsyncIterator to consume SSE events and enrich with READ data
136
- const eventStream = toAsyncIterator<{ uri: string }>(
137
- eventSource,
138
- "message",
139
- );
140
-
141
- // Iterate over SSE events and enrich with full data
142
- for await (const event of eventStream) {
143
- const uri = event.uri;
144
-
145
- if (uri) {
146
- // Call READ to get full resource data
147
- const readData = await callMCPTool<{ data: unknown }>(
148
- readMethodName,
149
- { uri },
150
- init,
151
- );
152
-
153
- yield { uri, data: readData.data };
154
- }
155
- }
156
- };
157
- }
158
-
159
24
  export const createClient = <T>(init?: CustomInit): MCPClient<T> => {
160
25
  return new Proxy(
161
26
  {},
162
27
  {
163
28
  get: (_, prop) => {
164
- const propStr = String(prop);
165
-
166
- // Check if this is a SUBSCRIBE method call
167
- const subscribeMatch = propStr.match(/^DECO_RESOURCE_(.+)_SUBSCRIBE$/);
168
- if (subscribeMatch) {
169
- const resourceName = subscribeMatch[1];
170
- return createSubscribeMethod(resourceName, init);
171
- }
172
-
173
29
  // Regular method call
174
30
  return async (args: unknown, innerInit?: CustomInit) => {
175
31
  const mergedInit: CustomInit = {
package/src/cors.ts ADDED
@@ -0,0 +1,140 @@
1
+ export type CORSOrigin =
2
+ | string
3
+ | string[]
4
+ | ((origin: string, req: Request) => string | null | undefined);
5
+
6
+ export interface CORSOptions {
7
+ /**
8
+ * The value of "Access-Control-Allow-Origin" CORS header.
9
+ * Can be a string, array of strings, or a function that returns the allowed origin.
10
+ * @default '*'
11
+ */
12
+ origin?: CORSOrigin;
13
+ /**
14
+ * The value of "Access-Control-Allow-Methods" CORS header.
15
+ * @default ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']
16
+ */
17
+ allowMethods?: string[];
18
+ /**
19
+ * The value of "Access-Control-Allow-Headers" CORS header.
20
+ * @default []
21
+ */
22
+ allowHeaders?: string[];
23
+ /**
24
+ * The value of "Access-Control-Max-Age" CORS header (in seconds).
25
+ */
26
+ maxAge?: number;
27
+ /**
28
+ * The value of "Access-Control-Allow-Credentials" CORS header.
29
+ */
30
+ credentials?: boolean;
31
+ /**
32
+ * The value of "Access-Control-Expose-Headers" CORS header.
33
+ * @default []
34
+ */
35
+ exposeHeaders?: string[];
36
+ }
37
+
38
+ const DEFAULT_ALLOW_METHODS = ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"];
39
+
40
+ const resolveOrigin = (
41
+ origin: CORSOrigin | undefined,
42
+ requestOrigin: string | null,
43
+ req: Request,
44
+ ): string | null => {
45
+ if (!requestOrigin) return null;
46
+
47
+ if (origin === undefined || origin === "*") {
48
+ return "*";
49
+ }
50
+
51
+ if (typeof origin === "string") {
52
+ return origin === requestOrigin ? origin : null;
53
+ }
54
+
55
+ if (Array.isArray(origin)) {
56
+ return origin.includes(requestOrigin) ? requestOrigin : null;
57
+ }
58
+
59
+ if (typeof origin === "function") {
60
+ return origin(requestOrigin, req) ?? null;
61
+ }
62
+
63
+ return null;
64
+ };
65
+
66
+ const setCORSHeaders = (
67
+ headers: Headers,
68
+ req: Request,
69
+ options: CORSOptions,
70
+ ): void => {
71
+ const requestOrigin = req.headers.get("Origin");
72
+ const allowedOrigin = resolveOrigin(options.origin, requestOrigin, req);
73
+
74
+ if (allowedOrigin) {
75
+ headers.set("Access-Control-Allow-Origin", allowedOrigin);
76
+ }
77
+
78
+ if (options.credentials) {
79
+ headers.set("Access-Control-Allow-Credentials", "true");
80
+ }
81
+
82
+ if (options.exposeHeaders?.length) {
83
+ headers.set(
84
+ "Access-Control-Expose-Headers",
85
+ options.exposeHeaders.join(", "),
86
+ );
87
+ }
88
+ };
89
+
90
+ export const handlePreflight = (
91
+ req: Request,
92
+ options: CORSOptions,
93
+ ): Response => {
94
+ const headers = new Headers();
95
+ const requestOrigin = req.headers.get("Origin");
96
+ const allowedOrigin = resolveOrigin(options.origin, requestOrigin, req);
97
+
98
+ if (allowedOrigin) {
99
+ headers.set("Access-Control-Allow-Origin", allowedOrigin);
100
+ }
101
+
102
+ if (options.credentials) {
103
+ headers.set("Access-Control-Allow-Credentials", "true");
104
+ }
105
+
106
+ const allowMethods = options.allowMethods ?? DEFAULT_ALLOW_METHODS;
107
+ headers.set("Access-Control-Allow-Methods", allowMethods.join(", "));
108
+
109
+ const requestHeaders = req.headers.get("Access-Control-Request-Headers");
110
+ if (options.allowHeaders?.length) {
111
+ headers.set(
112
+ "Access-Control-Allow-Headers",
113
+ options.allowHeaders.join(", "),
114
+ );
115
+ } else if (requestHeaders) {
116
+ // Mirror the requested headers if no explicit allowHeaders configured
117
+ headers.set("Access-Control-Allow-Headers", requestHeaders);
118
+ }
119
+
120
+ if (options.maxAge !== undefined) {
121
+ headers.set("Access-Control-Max-Age", options.maxAge.toString());
122
+ }
123
+
124
+ return new Response(null, { status: 204, headers });
125
+ };
126
+
127
+ export const withCORS = (
128
+ response: Response,
129
+ req: Request,
130
+ options: CORSOptions,
131
+ ): Response => {
132
+ const newHeaders = new Headers(response.headers);
133
+ setCORSHeaders(newHeaders, req, options);
134
+
135
+ return new Response(response.body, {
136
+ status: response.status,
137
+ statusText: response.statusText,
138
+ headers: newHeaders,
139
+ });
140
+ };