@decocms/runtime 1.2.15 → 1.3.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/package.json +4 -2
- package/src/tools.ts +178 -118
- package/src/trigger-storage.ts +195 -0
- package/src/triggers.test.ts +411 -0
- package/src/triggers.ts +307 -0
- package/src/workflows.ts +50 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"check": "tsc --noEmit",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"./bindings": "./src/bindings/index.ts",
|
|
23
23
|
"./asset-server": "./src/asset-server/index.ts",
|
|
24
24
|
"./tools": "./src/tools.ts",
|
|
25
|
-
"./decopilot": "./src/decopilot.ts"
|
|
25
|
+
"./decopilot": "./src/decopilot.ts",
|
|
26
|
+
"./triggers": "./src/triggers.ts",
|
|
27
|
+
"./trigger-storage": "./src/trigger-storage.ts"
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
|
28
30
|
"ai": ">=6.0.0"
|
package/src/tools.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
9
9
|
import { WebStandardStreamableHTTPServerTransport as HttpServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
10
10
|
import type {
|
|
11
11
|
GetPromptResult,
|
|
12
|
+
Implementation,
|
|
12
13
|
ToolAnnotations,
|
|
13
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
15
|
import { z } from "zod";
|
|
@@ -485,6 +486,7 @@ export interface CreateMCPServerOptions<
|
|
|
485
486
|
State extends
|
|
486
487
|
TEnv["MESH_REQUEST_CONTEXT"]["state"] = TEnv["MESH_REQUEST_CONTEXT"]["state"],
|
|
487
488
|
> {
|
|
489
|
+
serverInfo?: Partial<Implementation> & { instructions?: string };
|
|
488
490
|
before?: (env: TEnv) => Promise<void> | void;
|
|
489
491
|
oauth?: OAuthConfig;
|
|
490
492
|
events?: {
|
|
@@ -711,14 +713,21 @@ const toolsFor = <TSchema extends ZodTypeAny = never>({
|
|
|
711
713
|
...(workflows?.length
|
|
712
714
|
? workflows.map((wf) => {
|
|
713
715
|
const id = wf.toolId ?? workflowToolId(wf.title);
|
|
716
|
+
const baseDescription = [
|
|
717
|
+
wf.description
|
|
718
|
+
? `Run workflow: ${wf.description}`
|
|
719
|
+
: `Start the "${wf.title}" workflow.`,
|
|
720
|
+
"Returns an execution_id immediately. Use COLLECTION_WORKFLOW_EXECUTION_GET to track progress.",
|
|
721
|
+
].join(" ");
|
|
714
722
|
return createTool({
|
|
715
723
|
id,
|
|
716
|
-
description:
|
|
717
|
-
wf.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
724
|
+
description: (() => {
|
|
725
|
+
if (!wf.inputSchema) return baseDescription;
|
|
726
|
+
const schemaStr = JSON.stringify(wf.inputSchema, null, 2);
|
|
727
|
+
return schemaStr.length <= 2048
|
|
728
|
+
? `${baseDescription}\n\nInput schema:\n${schemaStr}`
|
|
729
|
+
: `${baseDescription}\n\nThis workflow expects structured input. Use COLLECTION_WORKFLOW_GET to inspect the full input schema.`;
|
|
730
|
+
})(),
|
|
722
731
|
inputSchema: z.object({
|
|
723
732
|
input: z
|
|
724
733
|
.record(z.string(), z.unknown())
|
|
@@ -808,44 +817,118 @@ export const createMCPServer = <
|
|
|
808
817
|
>(
|
|
809
818
|
options: CreateMCPServerOptions<TEnv, TSchema, TBindings>,
|
|
810
819
|
): MCPServer<TEnv, TSchema, TBindings> => {
|
|
811
|
-
|
|
812
|
-
|
|
820
|
+
// Tool/prompt/resource definitions are resolved once on first request and
|
|
821
|
+
// cached for the lifetime of the process. Tool *execution* reads per-request
|
|
822
|
+
// context from State (AsyncLocalStorage), so reusing definitions is safe.
|
|
823
|
+
type Registrations = {
|
|
824
|
+
tools: CreatedTool[];
|
|
825
|
+
prompts: CreatedPrompt[];
|
|
826
|
+
resources: CreatedResource[];
|
|
827
|
+
workflows?: WorkflowDefinition[];
|
|
828
|
+
};
|
|
813
829
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
|
817
|
-
);
|
|
830
|
+
let cached: Registrations | null = null;
|
|
831
|
+
let inflightResolve: Promise<Registrations> | null = null;
|
|
818
832
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
return await options.tools(bindings);
|
|
825
|
-
}
|
|
826
|
-
return await Promise.all(
|
|
827
|
-
options.tools?.flatMap(async (tool) => {
|
|
828
|
-
const toolResult = tool(bindings);
|
|
829
|
-
const awaited = await toolResult;
|
|
830
|
-
if (Array.isArray(awaited)) {
|
|
831
|
-
return awaited;
|
|
832
|
-
}
|
|
833
|
-
return [awaited];
|
|
834
|
-
}) ?? [],
|
|
835
|
-
).then((t) => t.flat());
|
|
836
|
-
};
|
|
837
|
-
const tools = await toolsFn(bindings);
|
|
833
|
+
const resolveRegistrations = async (
|
|
834
|
+
bindings: TEnv,
|
|
835
|
+
): Promise<Registrations> => {
|
|
836
|
+
if (cached) return cached;
|
|
837
|
+
if (inflightResolve) return inflightResolve;
|
|
838
838
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
839
|
+
inflightResolve = (async (): Promise<Registrations> => {
|
|
840
|
+
try {
|
|
841
|
+
const toolsFn =
|
|
842
|
+
typeof options.tools === "function"
|
|
843
|
+
? options.tools
|
|
844
|
+
: async (bindings: TEnv) => {
|
|
845
|
+
if (typeof options.tools === "function") {
|
|
846
|
+
return await options.tools(bindings);
|
|
847
|
+
}
|
|
848
|
+
return await Promise.all(
|
|
849
|
+
options.tools?.flatMap(async (tool) => {
|
|
850
|
+
const toolResult = tool(bindings);
|
|
851
|
+
const awaited = await toolResult;
|
|
852
|
+
if (Array.isArray(awaited)) {
|
|
853
|
+
return awaited;
|
|
854
|
+
}
|
|
855
|
+
return [awaited];
|
|
856
|
+
}) ?? [],
|
|
857
|
+
).then((t) => t.flat());
|
|
858
|
+
};
|
|
859
|
+
const tools = await toolsFn(bindings);
|
|
860
|
+
|
|
861
|
+
const resolvedWorkflows =
|
|
862
|
+
typeof options.workflows === "function"
|
|
863
|
+
? await options.workflows(bindings)
|
|
864
|
+
: options.workflows;
|
|
865
|
+
|
|
866
|
+
tools.push(
|
|
867
|
+
...toolsFor<TSchema>({ ...options, workflows: resolvedWorkflows }),
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
const promptsFn =
|
|
871
|
+
typeof options.prompts === "function"
|
|
872
|
+
? options.prompts
|
|
873
|
+
: async (bindings: TEnv) => {
|
|
874
|
+
if (typeof options.prompts === "function") {
|
|
875
|
+
return await options.prompts(bindings);
|
|
876
|
+
}
|
|
877
|
+
return await Promise.all(
|
|
878
|
+
options.prompts?.flatMap(async (prompt) => {
|
|
879
|
+
const promptResult = prompt(bindings);
|
|
880
|
+
const awaited = await promptResult;
|
|
881
|
+
if (Array.isArray(awaited)) {
|
|
882
|
+
return awaited;
|
|
883
|
+
}
|
|
884
|
+
return [awaited];
|
|
885
|
+
}) ?? [],
|
|
886
|
+
).then((p) => p.flat());
|
|
887
|
+
};
|
|
888
|
+
const prompts = await promptsFn(bindings);
|
|
889
|
+
|
|
890
|
+
const resourcesFn =
|
|
891
|
+
typeof options.resources === "function"
|
|
892
|
+
? options.resources
|
|
893
|
+
: async (bindings: TEnv) => {
|
|
894
|
+
if (typeof options.resources === "function") {
|
|
895
|
+
return await options.resources(bindings);
|
|
896
|
+
}
|
|
897
|
+
return await Promise.all(
|
|
898
|
+
options.resources?.flatMap(async (resource) => {
|
|
899
|
+
const resourceResult = resource(bindings);
|
|
900
|
+
const awaited = await resourceResult;
|
|
901
|
+
if (Array.isArray(awaited)) {
|
|
902
|
+
return awaited;
|
|
903
|
+
}
|
|
904
|
+
return [awaited];
|
|
905
|
+
}) ?? [],
|
|
906
|
+
).then((r) => r.flat());
|
|
907
|
+
};
|
|
908
|
+
const resources = await resourcesFn(bindings);
|
|
909
|
+
|
|
910
|
+
const result = {
|
|
911
|
+
tools,
|
|
912
|
+
prompts,
|
|
913
|
+
resources,
|
|
914
|
+
workflows: resolvedWorkflows,
|
|
915
|
+
};
|
|
916
|
+
cached = result;
|
|
917
|
+
return result;
|
|
918
|
+
} catch (err) {
|
|
919
|
+
inflightResolve = null;
|
|
920
|
+
throw err;
|
|
921
|
+
}
|
|
922
|
+
})();
|
|
843
923
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
);
|
|
924
|
+
return inflightResolve;
|
|
925
|
+
};
|
|
847
926
|
|
|
848
|
-
|
|
927
|
+
const registerAll = (
|
|
928
|
+
server: McpServer,
|
|
929
|
+
registrations: Registrations,
|
|
930
|
+
) => {
|
|
931
|
+
for (const tool of registrations.tools) {
|
|
849
932
|
server.registerTool(
|
|
850
933
|
tool.id,
|
|
851
934
|
{
|
|
@@ -873,9 +956,8 @@ export const createMCPServer = <
|
|
|
873
956
|
runtimeContext: createRuntimeContext(),
|
|
874
957
|
});
|
|
875
958
|
|
|
876
|
-
// For streamable tools, the Response is handled at the transport layer
|
|
877
|
-
// Do NOT call result.bytes()
|
|
878
|
-
// causing massive memory leaks (2GB+ Uint8Array accumulation)
|
|
959
|
+
// For streamable tools, the Response is handled at the transport layer.
|
|
960
|
+
// Do NOT call result.bytes() — it buffers the entire body in memory.
|
|
879
961
|
if (isStreamableTool(tool) && result instanceof Response) {
|
|
880
962
|
return {
|
|
881
963
|
structuredContent: {
|
|
@@ -904,28 +986,7 @@ export const createMCPServer = <
|
|
|
904
986
|
);
|
|
905
987
|
}
|
|
906
988
|
|
|
907
|
-
|
|
908
|
-
const promptsFn =
|
|
909
|
-
typeof options.prompts === "function"
|
|
910
|
-
? options.prompts
|
|
911
|
-
: async (bindings: TEnv) => {
|
|
912
|
-
if (typeof options.prompts === "function") {
|
|
913
|
-
return await options.prompts(bindings);
|
|
914
|
-
}
|
|
915
|
-
return await Promise.all(
|
|
916
|
-
options.prompts?.flatMap(async (prompt) => {
|
|
917
|
-
const promptResult = prompt(bindings);
|
|
918
|
-
const awaited = await promptResult;
|
|
919
|
-
if (Array.isArray(awaited)) {
|
|
920
|
-
return awaited;
|
|
921
|
-
}
|
|
922
|
-
return [awaited];
|
|
923
|
-
}) ?? [],
|
|
924
|
-
).then((p) => p.flat());
|
|
925
|
-
};
|
|
926
|
-
const prompts = await promptsFn(bindings);
|
|
927
|
-
|
|
928
|
-
for (const prompt of prompts) {
|
|
989
|
+
for (const prompt of registrations.prompts) {
|
|
929
990
|
server.registerPrompt(
|
|
930
991
|
prompt.name,
|
|
931
992
|
{
|
|
@@ -944,28 +1005,7 @@ export const createMCPServer = <
|
|
|
944
1005
|
);
|
|
945
1006
|
}
|
|
946
1007
|
|
|
947
|
-
|
|
948
|
-
const resourcesFn =
|
|
949
|
-
typeof options.resources === "function"
|
|
950
|
-
? options.resources
|
|
951
|
-
: async (bindings: TEnv) => {
|
|
952
|
-
if (typeof options.resources === "function") {
|
|
953
|
-
return await options.resources(bindings);
|
|
954
|
-
}
|
|
955
|
-
return await Promise.all(
|
|
956
|
-
options.resources?.flatMap(async (resource) => {
|
|
957
|
-
const resourceResult = resource(bindings);
|
|
958
|
-
const awaited = await resourceResult;
|
|
959
|
-
if (Array.isArray(awaited)) {
|
|
960
|
-
return awaited;
|
|
961
|
-
}
|
|
962
|
-
return [awaited];
|
|
963
|
-
}) ?? [],
|
|
964
|
-
).then((r) => r.flat());
|
|
965
|
-
};
|
|
966
|
-
const resources = await resourcesFn(bindings);
|
|
967
|
-
|
|
968
|
-
for (const resource of resources) {
|
|
1008
|
+
for (const resource of registrations.resources) {
|
|
969
1009
|
server.resource(
|
|
970
1010
|
resource.name,
|
|
971
1011
|
resource.uri,
|
|
@@ -978,19 +1018,7 @@ export const createMCPServer = <
|
|
|
978
1018
|
uri,
|
|
979
1019
|
runtimeContext: createRuntimeContext(),
|
|
980
1020
|
});
|
|
981
|
-
// Build content object based on what's provided (text or blob, not both)
|
|
982
|
-
const content: {
|
|
983
|
-
uri: string;
|
|
984
|
-
mimeType?: string;
|
|
985
|
-
text?: string;
|
|
986
|
-
blob?: string;
|
|
987
|
-
} = { uri: result.uri };
|
|
988
|
-
|
|
989
|
-
if (result.mimeType) {
|
|
990
|
-
content.mimeType = result.mimeType;
|
|
991
|
-
}
|
|
992
1021
|
|
|
993
|
-
// MCP SDK expects either text or blob content, not both
|
|
994
1022
|
const meta =
|
|
995
1023
|
(result as { _meta?: Record<string, unknown> | null })._meta ??
|
|
996
1024
|
undefined;
|
|
@@ -1018,7 +1046,6 @@ export const createMCPServer = <
|
|
|
1018
1046
|
};
|
|
1019
1047
|
}
|
|
1020
1048
|
|
|
1021
|
-
// Fallback to empty text if neither provided
|
|
1022
1049
|
return {
|
|
1023
1050
|
contents: [
|
|
1024
1051
|
{ uri: result.uri, mimeType: result.mimeType, text: "" },
|
|
@@ -1027,8 +1054,28 @@ export const createMCPServer = <
|
|
|
1027
1054
|
},
|
|
1028
1055
|
);
|
|
1029
1056
|
}
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const createServer = async (bindings: TEnv) => {
|
|
1060
|
+
await options.before?.(bindings);
|
|
1061
|
+
|
|
1062
|
+
const { instructions, ...serverInfoOverrides } = options.serverInfo ?? {};
|
|
1063
|
+
const server = new McpServer(
|
|
1064
|
+
{
|
|
1065
|
+
...serverInfoOverrides,
|
|
1066
|
+
name: serverInfoOverrides.name ?? "@deco/mcp-api",
|
|
1067
|
+
version: serverInfoOverrides.version ?? "1.0.0",
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
capabilities: { tools: {}, prompts: {}, resources: {} },
|
|
1071
|
+
...(instructions && { instructions }),
|
|
1072
|
+
},
|
|
1073
|
+
);
|
|
1030
1074
|
|
|
1031
|
-
|
|
1075
|
+
const registrations = await resolveRegistrations(bindings);
|
|
1076
|
+
registerAll(server, registrations);
|
|
1077
|
+
|
|
1078
|
+
return { server, ...registrations };
|
|
1032
1079
|
};
|
|
1033
1080
|
|
|
1034
1081
|
const fetch = async (req: Request, env: TEnv) => {
|
|
@@ -1037,34 +1084,45 @@ export const createMCPServer = <
|
|
|
1037
1084
|
|
|
1038
1085
|
await server.connect(transport);
|
|
1039
1086
|
|
|
1087
|
+
const cleanup = () => {
|
|
1088
|
+
try {
|
|
1089
|
+
transport.close?.();
|
|
1090
|
+
} catch {
|
|
1091
|
+
/* ignore */
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
server.close?.();
|
|
1095
|
+
} catch {
|
|
1096
|
+
/* ignore */
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1040
1100
|
try {
|
|
1041
1101
|
const response = await transport.handleRequest(req);
|
|
1042
1102
|
|
|
1043
|
-
// Check if this is a streaming response (SSE or streamable tool)
|
|
1044
|
-
// SSE responses have text/event-stream content-type
|
|
1045
|
-
// Note: response.body is always non-null for all HTTP responses, so we can't use it to detect streaming
|
|
1046
1103
|
const contentType = response.headers.get("content-type");
|
|
1047
1104
|
const isStreaming =
|
|
1048
1105
|
contentType?.includes("text/event-stream") ||
|
|
1049
1106
|
contentType?.includes("application/json-rpc");
|
|
1050
1107
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
await transport.close?.();
|
|
1055
|
-
} catch {
|
|
1056
|
-
// Ignore close errors
|
|
1057
|
-
}
|
|
1108
|
+
if (!isStreaming || !response.body) {
|
|
1109
|
+
cleanup();
|
|
1110
|
+
return response;
|
|
1058
1111
|
}
|
|
1059
1112
|
|
|
1060
|
-
|
|
1113
|
+
// Pipe the SSE body through a passthrough so that when the stream
|
|
1114
|
+
// finishes (server sent the response) or the client disconnects
|
|
1115
|
+
// (cancel), the server and transport are always cleaned up.
|
|
1116
|
+
const { readable, writable } = new TransformStream();
|
|
1117
|
+
response.body.pipeTo(writable).catch(() => {}).finally(cleanup);
|
|
1118
|
+
|
|
1119
|
+
return new Response(readable, {
|
|
1120
|
+
status: response.status,
|
|
1121
|
+
statusText: response.statusText,
|
|
1122
|
+
headers: response.headers,
|
|
1123
|
+
});
|
|
1061
1124
|
} catch (error) {
|
|
1062
|
-
|
|
1063
|
-
try {
|
|
1064
|
-
await transport.close?.();
|
|
1065
|
-
} catch {
|
|
1066
|
-
// Ignore close errors
|
|
1067
|
-
}
|
|
1125
|
+
cleanup();
|
|
1068
1126
|
throw error;
|
|
1069
1127
|
}
|
|
1070
1128
|
};
|
|
@@ -1075,7 +1133,9 @@ export const createMCPServer = <
|
|
|
1075
1133
|
throw new Error("Missing state, did you forget to call State.bind?");
|
|
1076
1134
|
}
|
|
1077
1135
|
const env = currentState?.env;
|
|
1078
|
-
const { tools } = await
|
|
1136
|
+
const { tools } = await resolveRegistrations(
|
|
1137
|
+
env as TEnv & DefaultEnv<TSchema>,
|
|
1138
|
+
);
|
|
1079
1139
|
const tool = tools.find((t) => t.id === toolCallId);
|
|
1080
1140
|
const execute = tool?.execute;
|
|
1081
1141
|
if (!execute) {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in TriggerStorage implementations.
|
|
3
|
+
*
|
|
4
|
+
* - StudioKV: Persists to Mesh/Studio's KV API (recommended for production)
|
|
5
|
+
* - JsonFileStorage: Persists to a local JSON file (for dev/simple deployments)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TriggerStorage } from "./triggers.ts";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// StudioKV — backed by Mesh's /api/kv endpoint
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
interface StudioKVOptions {
|
|
15
|
+
/** Mesh/Studio base URL (e.g., "https://studio.example.com") */
|
|
16
|
+
url: string;
|
|
17
|
+
/** API key created in the Studio org */
|
|
18
|
+
apiKey: string;
|
|
19
|
+
/** Key prefix to namespace trigger data (default: "triggers") */
|
|
20
|
+
prefix?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* TriggerStorage backed by Mesh/Studio's org-scoped KV API.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { createTriggers } from "@decocms/runtime/triggers";
|
|
29
|
+
* import { StudioKV } from "@decocms/runtime/trigger-storage";
|
|
30
|
+
*
|
|
31
|
+
* const triggers = createTriggers({
|
|
32
|
+
* definitions: [...],
|
|
33
|
+
* storage: new StudioKV({
|
|
34
|
+
* url: process.env.MESH_URL!,
|
|
35
|
+
* apiKey: process.env.MESH_API_KEY!,
|
|
36
|
+
* }),
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export class StudioKV implements TriggerStorage {
|
|
41
|
+
private baseUrl: string;
|
|
42
|
+
private apiKey: string;
|
|
43
|
+
private prefix: string;
|
|
44
|
+
|
|
45
|
+
constructor(options: StudioKVOptions) {
|
|
46
|
+
this.baseUrl = options.url.replace(/\/$/, "");
|
|
47
|
+
this.apiKey = options.apiKey;
|
|
48
|
+
this.prefix = options.prefix ?? "triggers";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private key(connectionId: string): string {
|
|
52
|
+
return `${this.prefix}:${connectionId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async get(connectionId: string) {
|
|
56
|
+
const res = await fetch(
|
|
57
|
+
`${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
|
|
58
|
+
{
|
|
59
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (res.status === 404) return null;
|
|
64
|
+
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
console.error(`[StudioKV] GET failed: ${res.status} ${res.statusText}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const body = (await res.json()) as {
|
|
71
|
+
value?: {
|
|
72
|
+
credentials: { callbackUrl: string; callbackToken: string };
|
|
73
|
+
activeTriggerTypes: string[];
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
return body.value ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async set(
|
|
80
|
+
connectionId: string,
|
|
81
|
+
state: {
|
|
82
|
+
credentials: { callbackUrl: string; callbackToken: string };
|
|
83
|
+
activeTriggerTypes: string[];
|
|
84
|
+
},
|
|
85
|
+
) {
|
|
86
|
+
const res = await fetch(
|
|
87
|
+
`${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
|
|
88
|
+
{
|
|
89
|
+
method: "PUT",
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
92
|
+
"Content-Type": "application/json",
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify(state),
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
console.error(`[StudioKV] PUT failed: ${res.status} ${res.statusText}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async delete(connectionId: string) {
|
|
104
|
+
const res = await fetch(
|
|
105
|
+
`${this.baseUrl}/api/kv/${encodeURIComponent(this.key(connectionId))}`,
|
|
106
|
+
{
|
|
107
|
+
method: "DELETE",
|
|
108
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!res.ok && res.status !== 404) {
|
|
113
|
+
console.error(
|
|
114
|
+
`[StudioKV] DELETE failed: ${res.status} ${res.statusText}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// JsonFileStorage — backed by a local JSON file
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
interface JsonFileStorageOptions {
|
|
125
|
+
/** Path to the JSON file (will be created if it doesn't exist) */
|
|
126
|
+
path: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* TriggerStorage backed by a local JSON file.
|
|
131
|
+
* Suitable for development and single-instance deployments.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* import { createTriggers } from "@decocms/runtime/triggers";
|
|
136
|
+
* import { JsonFileStorage } from "@decocms/runtime/trigger-storage";
|
|
137
|
+
*
|
|
138
|
+
* const triggers = createTriggers({
|
|
139
|
+
* definitions: [...],
|
|
140
|
+
* storage: new JsonFileStorage({ path: "./trigger-state.json" }),
|
|
141
|
+
* });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export class JsonFileStorage implements TriggerStorage {
|
|
145
|
+
private path: string;
|
|
146
|
+
private cache: Map<string, unknown> | null = null;
|
|
147
|
+
|
|
148
|
+
constructor(options: JsonFileStorageOptions) {
|
|
149
|
+
this.path = options.path;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private async load(): Promise<Map<string, unknown>> {
|
|
153
|
+
if (this.cache) return this.cache;
|
|
154
|
+
try {
|
|
155
|
+
const fs = await import("node:fs/promises");
|
|
156
|
+
const raw = await fs.readFile(this.path, "utf-8");
|
|
157
|
+
const data = JSON.parse(raw) as Record<string, unknown>;
|
|
158
|
+
this.cache = new Map(Object.entries(data));
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
if (
|
|
161
|
+
err instanceof Error &&
|
|
162
|
+
"code" in err &&
|
|
163
|
+
(err as NodeJS.ErrnoException).code === "ENOENT"
|
|
164
|
+
) {
|
|
165
|
+
this.cache = new Map();
|
|
166
|
+
} else {
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return this.cache;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private async save(): Promise<void> {
|
|
174
|
+
const data = Object.fromEntries(this.cache ?? new Map());
|
|
175
|
+
const fs = await import("node:fs/promises");
|
|
176
|
+
await fs.writeFile(this.path, JSON.stringify(data, null, 2));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async get(connectionId: string) {
|
|
180
|
+
const map = await this.load();
|
|
181
|
+
return (map.get(connectionId) as any) ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async set(connectionId: string, state: unknown) {
|
|
185
|
+
const map = await this.load();
|
|
186
|
+
map.set(connectionId, state);
|
|
187
|
+
await this.save();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async delete(connectionId: string) {
|
|
191
|
+
const map = await this.load();
|
|
192
|
+
map.delete(connectionId);
|
|
193
|
+
await this.save();
|
|
194
|
+
}
|
|
195
|
+
}
|