@bpmnkit/proxy 0.0.17 → 0.0.21

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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Timer trigger — scans deployed processes for BPMN timer start events and
3
+ * fires `POST /v2/process-instances` at the appropriate times.
4
+ *
5
+ * Supports:
6
+ * timeDuration ISO 8601 duration (PT1H → repeat every 1 hour)
7
+ * timeDate ISO 8601 datetime (2026-01-01T00:00:00Z → fire once)
8
+ * timeCycle ISO 8601 repeating (R/PT30M or R3/PT1H → repeat N or ∞ times)
9
+ *
10
+ * Last-fired timestamps are persisted to `~/.bpmnkit/timer-state.json` to
11
+ * survive proxy restarts.
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { getActiveProfile, getAuthHeader } from "@bpmnkit/profiles";
17
+ // ── Persistence ───────────────────────────────────────────────────────────────
18
+ const STATE_PATH = join(homedir(), ".bpmnkit", "timer-state.json");
19
+ function loadState() {
20
+ try {
21
+ if (existsSync(STATE_PATH)) {
22
+ return JSON.parse(readFileSync(STATE_PATH, "utf8"));
23
+ }
24
+ }
25
+ catch {
26
+ // corrupt file — start fresh
27
+ }
28
+ return { lastFired: {} };
29
+ }
30
+ function saveState(state) {
31
+ try {
32
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf8");
33
+ }
34
+ catch {
35
+ // best-effort
36
+ }
37
+ }
38
+ // ── ISO 8601 duration parser ──────────────────────────────────────────────────
39
+ const DURATION_RE = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
40
+ function parseDurationMs(iso) {
41
+ const m = DURATION_RE.exec(iso);
42
+ if (!m)
43
+ return null;
44
+ const years = Number(m[1] ?? 0);
45
+ const months = Number(m[2] ?? 0);
46
+ const days = Number(m[3] ?? 0);
47
+ const hours = Number(m[4] ?? 0);
48
+ const minutes = Number(m[5] ?? 0);
49
+ const seconds = Number(m[6] ?? 0);
50
+ // Approximate: 1 year = 365 days, 1 month = 30 days
51
+ return ((years * 365 * 24 * 60 * 60 +
52
+ months * 30 * 24 * 60 * 60 +
53
+ days * 24 * 60 * 60 +
54
+ hours * 60 * 60 +
55
+ minutes * 60 +
56
+ seconds) *
57
+ 1000);
58
+ }
59
+ /**
60
+ * Parse an ISO 8601 repeating interval: `R<n>/<duration>` or `R/<duration>`.
61
+ * Returns { durationMs, remaining } where remaining=undefined means infinite.
62
+ */
63
+ function parseCycle(value) {
64
+ const m = /^R(\d*)\/(.+)$/.exec(value);
65
+ if (!m)
66
+ return null;
67
+ const durationMs = parseDurationMs(m[2] ?? "");
68
+ if (!durationMs)
69
+ return null;
70
+ const n = m[1] ? Number(m[1]) : undefined;
71
+ return { durationMs, remaining: n };
72
+ }
73
+ async function fetchDeployedProcesses(baseUrl, authHeader) {
74
+ const res = await fetch(`${baseUrl}/v2/process-definitions/search`, {
75
+ method: "POST",
76
+ headers: { authorization: authHeader, "content-type": "application/json" },
77
+ body: JSON.stringify({ pageSize: 100 }),
78
+ });
79
+ if (!res.ok)
80
+ return [];
81
+ const data = (await res.json());
82
+ return data.items ?? [];
83
+ }
84
+ async function fetchProcessXml(baseUrl, authHeader, processId) {
85
+ const res = await fetch(`${baseUrl}/v2/process-definitions/${encodeURIComponent(processId)}/xml`, {
86
+ headers: { authorization: authHeader },
87
+ });
88
+ if (!res.ok)
89
+ return null;
90
+ const data = (await res.json());
91
+ return data.bpmnXml ?? null;
92
+ }
93
+ /** Extract timer start event definitions from BPMN XML (simple regex approach). */
94
+ function extractTimerDefs(processId, xml) {
95
+ const defs = [];
96
+ // Match <timerEventDefinition> children: timeDuration, timeDate, timeCycle
97
+ const timerRe = /<timerEventDefinition[^>]*>[\s\S]*?<(timeDuration|timeDate|timeCycle)[^>]*>\s*([^<]+)\s*<\/\1>/g;
98
+ let m;
99
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex loop
100
+ while ((m = timerRe.exec(xml)) !== null) {
101
+ const kind = m[1];
102
+ const raw = m[2]?.trim() ?? "";
103
+ // Strip FEEL expression wrapper if present (e.g. = "PT1H" or = PT1H)
104
+ const value = raw
105
+ .replace(/^=\s*["']?/, "")
106
+ .replace(/["']$/, "")
107
+ .trim();
108
+ if (!value)
109
+ continue;
110
+ if (kind === "timeDuration") {
111
+ defs.push({ processId, type: "duration", value });
112
+ }
113
+ else if (kind === "timeDate") {
114
+ defs.push({ processId, type: "date", value });
115
+ }
116
+ else {
117
+ const cycle = parseCycle(value);
118
+ if (cycle) {
119
+ defs.push({ processId, type: "cycle", value, remaining: cycle.remaining });
120
+ }
121
+ }
122
+ }
123
+ return defs;
124
+ }
125
+ // ── Firing ────────────────────────────────────────────────────────────────────
126
+ async function fireProcess(baseUrl, authHeader, processId) {
127
+ const res = await fetch(`${baseUrl}/v2/process-instances`, {
128
+ method: "POST",
129
+ headers: { authorization: authHeader, "content-type": "application/json" },
130
+ body: JSON.stringify({ processDefinitionId: processId, variables: {} }),
131
+ });
132
+ if (res.ok) {
133
+ const data = (await res.json());
134
+ console.log(`[trigger:timer] fired ${processId} → instance ${data.processInstanceKey ?? "?"}`);
135
+ }
136
+ else {
137
+ console.error(`[trigger:timer] failed to start ${processId}: ${res.status}`);
138
+ }
139
+ }
140
+ // ── Scheduler ─────────────────────────────────────────────────────────────────
141
+ let timerDefs = [];
142
+ const timerState = loadState();
143
+ async function scanAndSchedule() {
144
+ const profile = getActiveProfile();
145
+ if (!profile?.config.baseUrl)
146
+ return;
147
+ const baseUrl = profile.config.baseUrl.replace(/\/$/, "");
148
+ if (!baseUrl.startsWith("http"))
149
+ return;
150
+ let authHeader = "";
151
+ try {
152
+ authHeader = await getAuthHeader(profile.config);
153
+ }
154
+ catch {
155
+ // proceed unauthenticated
156
+ }
157
+ let processes;
158
+ try {
159
+ processes = await fetchDeployedProcesses(baseUrl, authHeader);
160
+ }
161
+ catch {
162
+ return;
163
+ }
164
+ const defs = [];
165
+ for (const proc of processes) {
166
+ const xml = await fetchProcessXml(baseUrl, authHeader, proc.processDefinitionId);
167
+ if (xml) {
168
+ defs.push(...extractTimerDefs(proc.processDefinitionId, xml));
169
+ }
170
+ }
171
+ timerDefs = defs;
172
+ if (defs.length > 0) {
173
+ console.log(`[trigger:timer] tracking ${defs.length} timer(s)`);
174
+ }
175
+ }
176
+ async function tickTimers() {
177
+ const profile = getActiveProfile();
178
+ if (!profile?.config.baseUrl)
179
+ return;
180
+ const baseUrl = profile.config.baseUrl.replace(/\/$/, "");
181
+ if (!baseUrl.startsWith("http"))
182
+ return;
183
+ let authHeader = "";
184
+ try {
185
+ authHeader = await getAuthHeader(profile.config);
186
+ }
187
+ catch {
188
+ // proceed unauthenticated
189
+ }
190
+ const now = Date.now();
191
+ for (const def of timerDefs) {
192
+ const lastFired = timerState.lastFired[def.processId] ?? 0;
193
+ if (def.type === "date") {
194
+ const target = new Date(def.value).getTime();
195
+ if (Number.isNaN(target))
196
+ continue;
197
+ if (now >= target && lastFired < target) {
198
+ await fireProcess(baseUrl, authHeader, def.processId);
199
+ timerState.lastFired[def.processId] = now;
200
+ saveState(timerState);
201
+ }
202
+ continue;
203
+ }
204
+ if (def.type === "duration" || def.type === "cycle") {
205
+ const durationMs = def.type === "duration"
206
+ ? parseDurationMs(def.value)
207
+ : (parseCycle(def.value)?.durationMs ?? null);
208
+ if (!durationMs || durationMs <= 0)
209
+ continue;
210
+ // Skip if this process has exhausted its repeat count
211
+ if (def.remaining !== undefined && def.remaining <= 0)
212
+ continue;
213
+ const due = lastFired + durationMs;
214
+ if (now >= due) {
215
+ await fireProcess(baseUrl, authHeader, def.processId);
216
+ timerState.lastFired[def.processId] = now;
217
+ if (def.remaining !== undefined)
218
+ def.remaining--;
219
+ saveState(timerState);
220
+ }
221
+ }
222
+ }
223
+ }
224
+ // ── Public API ────────────────────────────────────────────────────────────────
225
+ const SCAN_INTERVAL_MS = 60_000;
226
+ const TICK_INTERVAL_MS = 5_000;
227
+ export function startTimerTrigger() {
228
+ // Initial scan
229
+ void scanAndSchedule();
230
+ // Re-scan every 60 s for newly deployed processes
231
+ setInterval(() => {
232
+ void scanAndSchedule();
233
+ }, SCAN_INTERVAL_MS);
234
+ // Tick every 5 s to check for due timers
235
+ setInterval(() => {
236
+ void tickTimers();
237
+ }, TICK_INTERVAL_MS);
238
+ }
239
+ //# sourceMappingURL=timer.js.map
@@ -0,0 +1,76 @@
1
+ import { getActiveProfile, getAuthHeader } from "@bpmnkit/profiles";
2
+ // ── Request matcher ───────────────────────────────────────────────────────────
3
+ /** Returns the processId if the request matches `POST /webhooks/:processId`. */
4
+ export function matchWebhookRoute(req) {
5
+ if (req.method !== "POST")
6
+ return null;
7
+ const m = /^\/webhooks\/([^/?]+)/.exec(req.url ?? "");
8
+ if (!m || !m[1])
9
+ return null;
10
+ return { processId: m[1] };
11
+ }
12
+ // ── Handler ───────────────────────────────────────────────────────────────────
13
+ function readBody(req) {
14
+ return new Promise((resolve, reject) => {
15
+ const chunks = [];
16
+ req.on("data", (chunk) => chunks.push(chunk));
17
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
18
+ req.on("error", reject);
19
+ });
20
+ }
21
+ function sendJson(res, status, body) {
22
+ const payload = JSON.stringify(body);
23
+ res.writeHead(status, { "content-type": "application/json" });
24
+ res.end(payload);
25
+ }
26
+ export async function handleWebhook(req, res, processId) {
27
+ // Token guard (optional)
28
+ const token = process.env.WEBHOOK_TOKEN;
29
+ if (token) {
30
+ const auth = req.headers.authorization ?? "";
31
+ if (auth !== `Bearer ${token}`) {
32
+ sendJson(res, 401, { error: "Unauthorized" });
33
+ return;
34
+ }
35
+ }
36
+ // Parse body
37
+ let variables = {};
38
+ try {
39
+ const raw = await readBody(req);
40
+ if (raw.trim()) {
41
+ variables = JSON.parse(raw);
42
+ }
43
+ }
44
+ catch {
45
+ sendJson(res, 400, { error: "Invalid JSON body" });
46
+ return;
47
+ }
48
+ // Start process instance via reebe
49
+ const profile = getActiveProfile();
50
+ if (!profile?.config.baseUrl) {
51
+ sendJson(res, 503, { error: "No active reebe profile" });
52
+ return;
53
+ }
54
+ const baseUrl = profile.config.baseUrl.replace(/\/$/, "");
55
+ let authHeader = "";
56
+ try {
57
+ authHeader = await getAuthHeader(profile.config);
58
+ }
59
+ catch {
60
+ // proceed without auth
61
+ }
62
+ const startRes = await fetch(`${baseUrl}/v2/process-instances`, {
63
+ method: "POST",
64
+ headers: { authorization: authHeader, "content-type": "application/json" },
65
+ body: JSON.stringify({ processDefinitionId: processId, variables }),
66
+ });
67
+ if (!startRes.ok) {
68
+ const text = await startRes.text();
69
+ sendJson(res, startRes.status, { error: text });
70
+ return;
71
+ }
72
+ const data = (await startRes.json());
73
+ console.log(`[trigger:webhook] started ${processId} → instance ${data.processInstanceKey ?? "?"}`);
74
+ sendJson(res, 200, { processInstanceKey: data.processInstanceKey });
75
+ }
76
+ //# sourceMappingURL=webhook.js.map