@daisy-workflow/plugin-sdk 0.1.2

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/README.md +116 -0
  2. package/index.js +237 -0
  3. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # daisy-plugin-sdk
2
+
3
+ Tiny helper for authoring Daisy external plugins. Implements
4
+ the four-endpoint HTTP contract (`/manifest`, `/healthz`, `/readyz`,
5
+ `/execute`) so plugin authors only write `execute(input, ctx)`.
6
+
7
+ Zero runtime dependencies — uses Node's built-in `http` server and
8
+ global `fetch`.
9
+
10
+ # Daisy
11
+
12
+ [Daisy wiki](https://github.com/vivekg13186/Daisy-workflow/wiki)
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ mkdir my-plugin && cd my-plugin
18
+ npm init -y
19
+ npm install daisy-plugin-sdk
20
+ ```
21
+
22
+ ```json
23
+ // manifest.json
24
+ {
25
+ "name": "my.plugin",
26
+ "version": "0.1.0",
27
+ "description": "Does the thing",
28
+ "primaryOutput": "result",
29
+ "inputSchema": { "type": "object", "required": ["query"], "properties": { "query": { "type": "string" } } },
30
+ "outputSchema": { "type": "object", "properties": { "result": { "type": "string" } } },
31
+ "configRefs": []
32
+ }
33
+ ```
34
+
35
+ ```js
36
+ // index.js
37
+ import { servePlugin } from "daisy-plugin-sdk";
38
+ import manifest from "./manifest.json" with { type: "json" };
39
+
40
+ servePlugin({
41
+ manifest,
42
+ async execute(input, ctx) {
43
+ // ctx = { executionId, workspaceId, nodeName, config, deadlineMs, signal }
44
+ return { result: `you said: ${input.query}` };
45
+ },
46
+ });
47
+ ```
48
+
49
+ Ship it as a container, expose port 8080, install with
50
+ `npm run install-plugin -- --endpoint http://my-plugin:8080`.
51
+
52
+ ## Contract details
53
+
54
+ ### `servePlugin(opts)`
55
+
56
+ | Option | Required | Description |
57
+ |----------|----------|-------------|
58
+ | `manifest` | yes | Object matching the plugin manifest schema. |
59
+ | `execute` | yes | `async (input, ctx) => output \| { output, usage }` |
60
+ | `readyz` | no | `async () => boolean` — return false to make `/readyz` respond 503. Default: always true. |
61
+ | `port` | no | Listen port. Default: `PORT` env or `8080`. |
62
+ | `host` | no | Bind host. Default: `0.0.0.0`. |
63
+ | `log` | no | `(level, msg, meta) => void`. Default: stdout/stderr JSON lines. |
64
+
65
+ ### `ctx` inside execute
66
+
67
+ ```js
68
+ {
69
+ executionId: "uuid", // the workflow execution that's running you
70
+ workspaceId: "uuid", // for scoping / audit
71
+ nodeName: "string", // the DSL node that resolved to this plugin
72
+ config: { }, // plaintext values for configs declared in configRefs
73
+ deadlineMs: 60000, // wall-clock budget set by the engine
74
+ signal: AbortSignal // pass to fetch / pg / etc. for cooperative cancellation
75
+ }
76
+ ```
77
+
78
+ When the engine times out OR the workflow user cancels, `signal`
79
+ aborts. Pass it to any outbound call that supports
80
+ `AbortController` and your plugin shuts down its work cleanly
81
+ instead of running detached for another N seconds.
82
+
83
+ ### Return value
84
+
85
+ Either:
86
+
87
+ ```js
88
+ return { result: "..." }; // whole object is the output
89
+ // or
90
+ return { output: { result: "..." }, usage: {} }; // explicit
91
+ ```
92
+
93
+ The engine validates against `outputSchema` from the manifest. A
94
+ return that doesn't match throws on the engine side.
95
+
96
+ ### Errors
97
+
98
+ Throwing inside `execute()` produces a 500 response with
99
+ `{ error: "<message>" }`. The engine surfaces these as node
100
+ failures and applies its retry / timeout / self-heal policies.
101
+
102
+ ## Example: real plugin
103
+
104
+ See `plugins-external/reddit/` in the Daisy repo — a real
105
+ external plugin in ~25 lines of code using this SDK.
106
+
107
+ ## What's NOT in this SDK (yet)
108
+
109
+ - **Streaming.** `/execute` is synchronous request/response in Phase
110
+ 1+2. Streaming hooks (`ctx.stream.text(...)`) come in Phase 3
111
+ via a server-sent-events callback URL.
112
+ - **Auto-registration.** Plugins still install via the engine's
113
+ CLI / admin UI. The SDK doesn't try to call back to the engine.
114
+ - **Schema validation.** The engine validates input/output against
115
+ the manifest schemas. The SDK trusts what comes in and what
116
+ `execute()` returns.
package/index.js ADDED
@@ -0,0 +1,237 @@
1
+ // daisy-plugin-sdk — minimal helper for authoring Daisy
2
+ // external plugins.
3
+ //
4
+ // Wires the four-endpoint HTTP contract (/manifest, /healthz,
5
+ // /readyz, /execute) so the only code the plugin author needs to
6
+ // write is the body of execute(input, ctx). Zero runtime deps —
7
+ // uses Node's built-in `http` server + global `fetch`.
8
+ //
9
+ // Usage:
10
+ //
11
+ // import { servePlugin } from "daisy-plugin-sdk";
12
+ // import manifest from "./manifest.json" with { type: "json" };
13
+ //
14
+ // servePlugin({
15
+ // manifest,
16
+ // async execute(input, ctx) {
17
+ // const r = await fetch("https://...", { signal: ctx.signal });
18
+ // return { posts: await r.json() };
19
+ // },
20
+ // // Optional: custom readiness check. Default returns true.
21
+ // // async readyz() { return await pingUpstream(); },
22
+ // });
23
+ //
24
+ // What ctx carries:
25
+ // {
26
+ // executionId, workspaceId, nodeName, // for log / trace correlation
27
+ // config, // plaintext values for configRefs
28
+ // deadlineMs, // wall-clock budget set by the engine
29
+ // signal, // AbortSignal — pass to fetch / pg / etc.
30
+ // }
31
+ //
32
+ // Return shape:
33
+ // • Recommended: return { output: { ... }, usage?: { ... } }
34
+ // • Also accepted: return { ... } ← the whole object IS the output
35
+ //
36
+ // Errors thrown inside execute() become 500 responses with
37
+ // `{ error: "<message>" }`. The engine surfaces these as node
38
+ // failures and applies its retry / fallback / self-heal policies.
39
+
40
+ import http from "node:http";
41
+
42
+ /**
43
+ * Boot the plugin's HTTP server.
44
+ *
45
+ * @param {object} opts
46
+ * - manifest: the plugin's manifest JSON (must export name +
47
+ * version + valid input/output schemas).
48
+ * - execute: async (input, ctx) => output | { output, usage }
49
+ * - readyz?: async () => boolean — return false to make /readyz
50
+ * respond 503. Default: always true.
51
+ * - port?: PORT env var or 8080 by default.
52
+ * - host?: default "0.0.0.0" (works in-container).
53
+ * - log?: function(level, msg, meta) — default console.log
54
+ * with a `[{name}]` prefix. Pass a no-op to silence.
55
+ *
56
+ * Returns the http.Server instance (for tests / advanced shutdown).
57
+ */
58
+ export function servePlugin({
59
+ manifest,
60
+ execute,
61
+ readyz = async () => true,
62
+ port = parseInt(process.env.PORT || "8080", 10),
63
+ host = process.env.HOST || "0.0.0.0",
64
+ log = defaultLog(manifest?.name || "plugin"),
65
+ } = {}) {
66
+ validateManifest(manifest);
67
+ if (typeof execute !== "function") {
68
+ throw new Error("servePlugin: `execute` must be an async function");
69
+ }
70
+
71
+ const server = http.createServer(async (req, res) => {
72
+ const t0 = Date.now();
73
+ try {
74
+ if (req.method === "GET" && req.url === "/manifest") {
75
+ return reply(res, 200, manifest);
76
+ }
77
+ if (req.method === "GET" && req.url === "/healthz") {
78
+ return reply(res, 200, { ok: true });
79
+ }
80
+ if (req.method === "GET" && req.url === "/readyz") {
81
+ try {
82
+ const ok = await readyz();
83
+ return reply(res, ok ? 200 : 503, { ok: !!ok });
84
+ } catch (e) {
85
+ return reply(res, 503, { ok: false, error: shortErr(e) });
86
+ }
87
+ }
88
+ if (req.method === "POST" && req.url === "/execute") {
89
+ return handleExecute(req, res, execute, log, t0);
90
+ }
91
+ return reply(res, 404, { error: "not found" });
92
+ } catch (e) {
93
+ // Last-resort guard: never let an unhandled error tear down
94
+ // the server.
95
+ reply(res, 500, { error: shortErr(e) });
96
+ }
97
+ });
98
+
99
+ server.listen(port, host, () => {
100
+ log("info", "plugin listening", { port, host, name: manifest.name, version: manifest.version });
101
+ });
102
+
103
+ // Graceful shutdown on SIGTERM / SIGINT so docker stop / k8s
104
+ // doesn't kill in-flight requests.
105
+ const close = () => server.close(() => process.exit(0));
106
+ process.on("SIGTERM", close);
107
+ process.on("SIGINT", close);
108
+
109
+ return server;
110
+ }
111
+
112
+ // ────────────────────────────────────────────────────────────────────
113
+ // /execute handler
114
+ // ────────────────────────────────────────────────────────────────────
115
+
116
+ async function handleExecute(req, res, execute, log, t0) {
117
+ let body;
118
+ try { body = await readJson(req); }
119
+ catch (e) { return reply(res, 400, { error: e.message }); }
120
+
121
+ const input = body?.input || {};
122
+ const ctxBase = {
123
+ executionId: body?.executionId || null,
124
+ workspaceId: body?.workspaceId || null,
125
+ nodeName: body?.nodeName || null,
126
+ config: body?.config || {},
127
+ deadlineMs: Number.isFinite(body?.deadlineMs) ? body.deadlineMs : null,
128
+ };
129
+
130
+ // Wire an AbortSignal that fires when:
131
+ // • The client (engine) drops the connection.
132
+ // • The deadline expires.
133
+ const ac = new AbortController();
134
+ let deadlineTimer = null;
135
+ const onClose = () => ac.abort(new Error("client disconnected"));
136
+ req.on("aborted", onClose);
137
+ req.on("close", onClose);
138
+ if (ctxBase.deadlineMs && ctxBase.deadlineMs > 0) {
139
+ deadlineTimer = setTimeout(
140
+ () => ac.abort(new Error(`plugin deadline ${ctxBase.deadlineMs}ms exceeded`)),
141
+ ctxBase.deadlineMs,
142
+ );
143
+ if (typeof deadlineTimer.unref === "function") deadlineTimer.unref();
144
+ }
145
+ const ctx = { ...ctxBase, signal: ac.signal };
146
+
147
+ try {
148
+ const result = await execute(input, ctx);
149
+ const payload = (result && typeof result === "object" && "output" in result)
150
+ ? result // {output, usage?}
151
+ : { output: result }; // back-compat: whole return IS output
152
+ log("info", "execute ok", {
153
+ executionId: ctxBase.executionId,
154
+ nodeName: ctxBase.nodeName,
155
+ ms: Date.now() - t0,
156
+ });
157
+ reply(res, 200, payload);
158
+ } catch (e) {
159
+ log("warn", "execute failed", {
160
+ executionId: ctxBase.executionId,
161
+ nodeName: ctxBase.nodeName,
162
+ ms: Date.now() - t0,
163
+ error: shortErr(e),
164
+ });
165
+ reply(res, 500, { error: shortErr(e) });
166
+ } finally {
167
+ if (deadlineTimer) clearTimeout(deadlineTimer);
168
+ req.off("aborted", onClose);
169
+ req.off("close", onClose);
170
+ }
171
+ }
172
+
173
+ // ────────────────────────────────────────────────────────────────────
174
+ // Manifest validation
175
+ // ────────────────────────────────────────────────────────────────────
176
+
177
+ const NAME_RE = /^[a-z][a-z0-9_.-]*$/;
178
+ const SEMVER_RE = /^\d+\.\d+\.\d+/;
179
+
180
+ export function validateManifest(m) {
181
+ if (!m || typeof m !== "object") {
182
+ throw new Error("manifest must be an object");
183
+ }
184
+ if (typeof m.name !== "string" || !NAME_RE.test(m.name)) {
185
+ throw new Error(`manifest.name must match ${NAME_RE}; got "${m.name}"`);
186
+ }
187
+ if (typeof m.version !== "string" || !SEMVER_RE.test(m.version)) {
188
+ throw new Error(`manifest.version must be semver; got "${m.version}"`);
189
+ }
190
+ if (m.inputSchema && typeof m.inputSchema !== "object") throw new Error("manifest.inputSchema must be an object");
191
+ if (m.outputSchema && typeof m.outputSchema !== "object") throw new Error("manifest.outputSchema must be an object");
192
+ if (m.configRefs && !Array.isArray(m.configRefs)) throw new Error("manifest.configRefs must be an array");
193
+ }
194
+
195
+ // ────────────────────────────────────────────────────────────────────
196
+ // Helpers
197
+ // ────────────────────────────────────────────────────────────────────
198
+
199
+ function reply(res, code, body) {
200
+ if (res.writableEnded) return;
201
+ res.writeHead(code, { "content-type": "application/json" });
202
+ res.end(JSON.stringify(body));
203
+ }
204
+
205
+ function readJson(req, maxBytes = 1_000_000) {
206
+ return new Promise((resolve, reject) => {
207
+ let s = "";
208
+ let bytes = 0;
209
+ req.on("data", (c) => {
210
+ bytes += c.length;
211
+ if (bytes > maxBytes) {
212
+ req.destroy();
213
+ return reject(new Error("request body too large"));
214
+ }
215
+ s += c;
216
+ });
217
+ req.on("end", () => {
218
+ if (!s) return resolve({});
219
+ try { resolve(JSON.parse(s)); }
220
+ catch (e) { reject(new Error("invalid JSON body: " + e.message)); }
221
+ });
222
+ req.on("error", reject);
223
+ });
224
+ }
225
+
226
+ function shortErr(e) {
227
+ const m = (e && (e.message || String(e))) || "unknown error";
228
+ return m.length > 800 ? m.slice(0, 800) + "…" : m;
229
+ }
230
+
231
+ function defaultLog(name) {
232
+ return (level, msg, meta) => {
233
+ const line = { t: new Date().toISOString(), level, name, msg, ...(meta || {}) };
234
+ const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
235
+ stream.write(JSON.stringify(line) + "\n");
236
+ };
237
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@daisy-workflow/plugin-sdk",
3
+ "version": "0.1.2",
4
+ "description": "SDK for authoring Daisy external plugins. Wires the four-endpoint HTTP contract so plugin authors only write execute().",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/vivekg13186/daisy-plugin-sdk"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/vivekg13186/daisy-plugin-sdk/issues"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "homepage": "https://github.com/vivekg13186/daisy-plugin-sdk#readme",
29
+ "dependencies": {
30
+ "daisy-plugin-sdk": "file:daisy-plugin-sdk-0.1.0.tgz"
31
+ }
32
+ }