@nanobpm/urban 0.10.0 → 0.11.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/README.md +32 -1
- package/dist/runtime/core/manifest.d.ts +19 -0
- package/dist/runtime/core/modules/cron.d.ts +27 -0
- package/dist/runtime/core/modules/cron.js +138 -0
- package/dist/runtime/core/modules/triggers.d.ts +43 -9
- package/dist/runtime/core/modules/triggers.js +181 -22
- package/dist/runtime/core/runtime.js +2 -1
- package/dist/runtime/engine/nanosdk.d.ts +6 -1
- package/dist/runtime/engine/nanosdk.js +13 -4
- package/package.json +1 -1
- package/src/runtime/core/manifest.ts +23 -1
- package/src/runtime/core/modules/cron.test.ts +105 -0
- package/src/runtime/core/modules/cron.ts +150 -0
- package/src/runtime/core/modules/triggers.test.ts +269 -0
- package/src/runtime/core/modules/triggers.ts +207 -28
- package/src/runtime/core/runtime.ts +2 -1
- package/src/runtime/engine/nanosdk.test.ts +32 -2
- package/src/runtime/engine/nanosdk.ts +18 -5
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ console.log(app.inspect()); // { app, name, httpPort, ... }
|
|
|
78
78
|
|
|
79
79
|
`runFromEnv` reads the engine address and transport from the environment, starts
|
|
80
80
|
the app (validate → deploy → provision datasources → start workers → serve
|
|
81
|
-
surfaces and webhook triggers), and installs SIGINT/SIGTERM handlers for a
|
|
81
|
+
surfaces and webhook + cron triggers), and installs SIGINT/SIGTERM handlers for a
|
|
82
82
|
graceful shutdown. For full control, assemble the pieces yourself:
|
|
83
83
|
|
|
84
84
|
```ts
|
|
@@ -141,6 +141,37 @@ wiring; `WorkflowClient` deploys and starts, `Worker` hosts your `run` steps.
|
|
|
141
141
|
inspectable in a modeller/Operate — `@nanobpm/urban` bundles `bpmn-auto-layout`
|
|
142
142
|
so this works out of the box.
|
|
143
143
|
|
|
144
|
+
### Triggers — the inbound I/O edge
|
|
145
|
+
|
|
146
|
+
Declare `triggers[]` in the app manifest to turn outside events into engine
|
|
147
|
+
calls (start a process or publish a message). Two source kinds are built in:
|
|
148
|
+
|
|
149
|
+
- **`webhook`** — mounts an HTTP `POST` route (`/hooks/<id>` by default), with
|
|
150
|
+
optional `hmac:<connection>` signature verification and delivery-id
|
|
151
|
+
idempotency.
|
|
152
|
+
- **`cron`** — arms a background timer from a 5-field crontab `spec` (evaluated
|
|
153
|
+
in **UTC**), firing its `action` on schedule and rescheduling itself.
|
|
154
|
+
|
|
155
|
+
```jsonc
|
|
156
|
+
{
|
|
157
|
+
"triggers": [
|
|
158
|
+
{ "id": "nightly", "type": "cron", "spec": "0 6 * * *",
|
|
159
|
+
"action": { "start": "daily-report" } },
|
|
160
|
+
{ "id": "gh", "type": "webhook", "auth": "hmac:github",
|
|
161
|
+
"action": { "message": "pr-opened", "correlationKey": "= body.number" } }
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Cron scheduling is **app-side**: per-replica, in-memory, and it stops when the
|
|
167
|
+
process stops — glue for invoking handlers on a clock, not a durable clustered
|
|
168
|
+
scheduler. It therefore only honours `onMissed: "skip"` (the default); a declared
|
|
169
|
+
`"once"`/`"all"` catch-up needs a persisted last-fire the runtime does not keep,
|
|
170
|
+
so it warns and degrades to `skip`. For **durable, clustered** scheduling that
|
|
171
|
+
survives restarts, model a timer **start**/**intermediate** event instead with
|
|
172
|
+
`w.startOn(...)` / `w.timer(...)` from [`@nanobpm/workflow`](../workflow) — the
|
|
173
|
+
engine owns those.
|
|
174
|
+
|
|
144
175
|
## Related packages
|
|
145
176
|
|
|
146
177
|
- [`@nanobpm/workflow`](../workflow) — the code-first process surface (`defineFlow`).
|
|
@@ -46,9 +46,28 @@ export interface TriggerDecl {
|
|
|
46
46
|
type: string;
|
|
47
47
|
path?: string;
|
|
48
48
|
auth?: string;
|
|
49
|
+
/** cron: the 5-field crontab spec (UTC), e.g. "0 6 * * *". */
|
|
50
|
+
spec?: string;
|
|
51
|
+
/** cron catch-up policy for fires missed while the app was down. Default "skip". */
|
|
52
|
+
onMissed?: "skip" | "once" | "all";
|
|
53
|
+
/** Source-kind-specific settings (e.g. file `{ pollMs }`). */
|
|
54
|
+
config?: Record<string, unknown>;
|
|
55
|
+
/** Name of a connections[] entry supplying this source's credentials. */
|
|
56
|
+
connection?: string;
|
|
57
|
+
/** Maps the event to one engine call: start a process OR publish a message (ADR 0025 §1). */
|
|
49
58
|
action?: {
|
|
59
|
+
/** Process id/name to start. */
|
|
60
|
+
start?: string;
|
|
61
|
+
/** messageName to publish as a CorrelateMessage. */
|
|
50
62
|
message?: string;
|
|
63
|
+
/** correlationKey (literal, or `= body.path`) for a message action. */
|
|
51
64
|
correlationKey?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Variables for the started instance / published message. Per the manifest schema
|
|
67
|
+
* (`#/$defs/triggerAction.variables`) this is a FEEL string over the event body
|
|
68
|
+
* (e.g. `= body.payload`); an inline object literal is also accepted for convenience.
|
|
69
|
+
*/
|
|
70
|
+
variables?: string | Record<string, unknown>;
|
|
52
71
|
};
|
|
53
72
|
}
|
|
54
73
|
export interface SurfaceDecl {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A parsed schedule: the allowed value set per field, plus whether dom/dow are wildcards. */
|
|
2
|
+
export interface CronSchedule {
|
|
3
|
+
minutes: Set<number>;
|
|
4
|
+
hours: Set<number>;
|
|
5
|
+
daysOfMonth: Set<number>;
|
|
6
|
+
months: Set<number>;
|
|
7
|
+
daysOfWeek: Set<number>;
|
|
8
|
+
/** dom / dow were `*` (unrestricted) — controls the OR-vs-AND day rule. */
|
|
9
|
+
domWildcard: boolean;
|
|
10
|
+
dowWildcard: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Parse a 5-field crontab spec into a `CronSchedule`. Throws on a malformed spec. */
|
|
13
|
+
export declare function parseCron(spec: string): CronSchedule;
|
|
14
|
+
/** Does `date` (read in UTC) match the schedule? */
|
|
15
|
+
export declare function cronMatches(s: CronSchedule, date: Date): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The first fire strictly after `after`. Scans minute-by-minute (UTC) up to a bounded
|
|
18
|
+
* horizon; returns `undefined` if nothing matches within ~4 years (an impossible spec,
|
|
19
|
+
* e.g. Feb 30). The bound keeps a pathological spec from spinning forever.
|
|
20
|
+
*/
|
|
21
|
+
export declare function nextCronFire(s: CronSchedule, after: Date): Date | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The fire instants strictly between `after` and `until` (both exclusive on `after`,
|
|
24
|
+
* inclusive on `until`). Used by the `onMissed` catch-up policy to enumerate fires that
|
|
25
|
+
* were due while the app was down. Bounded to avoid unbounded output on a huge gap.
|
|
26
|
+
*/
|
|
27
|
+
export declare function cronFiresBetween(s: CronSchedule, after: Date, until: Date, max?: number): Date[];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// A small, dependency-free 5-field crontab evaluator (ADR 0025 §2) — the schedule engine
|
|
2
|
+
// behind `cron` triggers. Specs are evaluated in **UTC**. Fields, in order:
|
|
3
|
+
//
|
|
4
|
+
// minute (0-59) hour (0-23) day-of-month (1-31) month (1-12) day-of-week (0-6, 0=Sun)
|
|
5
|
+
//
|
|
6
|
+
// Each field supports `*`, a number, `a-b` ranges, `*/n` and `a-b/n` steps, and comma lists
|
|
7
|
+
// of any of those. Month and day-of-week also accept three-letter names (jan…dec, sun…sat).
|
|
8
|
+
// Day-of-month and day-of-week follow the standard cron rule: when **both** are restricted
|
|
9
|
+
// (neither is `*`), a day matches if it satisfies **either** field.
|
|
10
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
11
|
+
const DOWS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
12
|
+
function nameToNumber(token, names, offset) {
|
|
13
|
+
const i = names.indexOf(token.toLowerCase());
|
|
14
|
+
return i >= 0 ? String(i + offset) : token;
|
|
15
|
+
}
|
|
16
|
+
/** Parse one field into the set of matching integers in [min,max]. */
|
|
17
|
+
function parseField(field, min, max, names) {
|
|
18
|
+
const out = new Set();
|
|
19
|
+
for (const partRaw of field.split(",")) {
|
|
20
|
+
const part = partRaw.trim();
|
|
21
|
+
if (part === "")
|
|
22
|
+
throw new Error(`cron: empty term in "${field}"`);
|
|
23
|
+
let step = 1;
|
|
24
|
+
let range = part;
|
|
25
|
+
const slash = part.indexOf("/");
|
|
26
|
+
if (slash >= 0) {
|
|
27
|
+
range = part.slice(0, slash);
|
|
28
|
+
if (range === "")
|
|
29
|
+
throw new Error(`cron: missing range before "/" in "${part}"`);
|
|
30
|
+
const stepText = part.slice(slash + 1);
|
|
31
|
+
step = Number(stepText);
|
|
32
|
+
if (stepText === "" || !Number.isInteger(step) || step <= 0) {
|
|
33
|
+
throw new Error(`cron: bad step in "${part}"`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
let lo;
|
|
37
|
+
let hi;
|
|
38
|
+
if (range === "*") {
|
|
39
|
+
lo = min;
|
|
40
|
+
hi = max;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const dash = range.indexOf("-", range.startsWith("-") ? 1 : 0);
|
|
44
|
+
if (dash > 0) {
|
|
45
|
+
lo = Number(names ? nameToNumber(range.slice(0, dash), names, min) : range.slice(0, dash));
|
|
46
|
+
hi = Number(names ? nameToNumber(range.slice(dash + 1), names, min) : range.slice(dash + 1));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
lo = hi = Number(names ? nameToNumber(range, names, min) : range);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) {
|
|
53
|
+
throw new Error(`cron: value out of range [${min},${max}] in "${part}"`);
|
|
54
|
+
}
|
|
55
|
+
for (let v = lo; v <= hi; v += step)
|
|
56
|
+
out.add(v);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Parse a 5-field crontab spec into a `CronSchedule`. Throws on a malformed spec. */
|
|
61
|
+
export function parseCron(spec) {
|
|
62
|
+
const fields = spec.trim().split(/\s+/);
|
|
63
|
+
if (fields.length !== 5) {
|
|
64
|
+
throw new Error(`cron: expected 5 fields, got ${fields.length} in "${spec}"`);
|
|
65
|
+
}
|
|
66
|
+
const [min, hr, dom, mon, dow] = fields;
|
|
67
|
+
// Normalise day-of-week 7 → 0 (both are Sunday in common cron dialects).
|
|
68
|
+
const daysOfWeek = parseField(dow, 0, 7, DOWS);
|
|
69
|
+
if (daysOfWeek.delete(7))
|
|
70
|
+
daysOfWeek.add(0);
|
|
71
|
+
return {
|
|
72
|
+
minutes: parseField(min, 0, 59),
|
|
73
|
+
hours: parseField(hr, 0, 23),
|
|
74
|
+
daysOfMonth: parseField(dom, 1, 31),
|
|
75
|
+
months: parseField(mon, 1, 12, MONTHS),
|
|
76
|
+
daysOfWeek,
|
|
77
|
+
// The OR-vs-AND day rule keys off whether dom/dow are "star" fields. Following Vixie
|
|
78
|
+
// cron, a field counts as a wildcard when it *starts* with `*` (so `*` and `*/n` both
|
|
79
|
+
// qualify); an explicit full range like `1-31` is a restricted field, not a wildcard.
|
|
80
|
+
domWildcard: dom.trim().startsWith("*"),
|
|
81
|
+
dowWildcard: dow.trim().startsWith("*"),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Does `date` (read in UTC) match the schedule? */
|
|
85
|
+
export function cronMatches(s, date) {
|
|
86
|
+
if (!s.minutes.has(date.getUTCMinutes()))
|
|
87
|
+
return false;
|
|
88
|
+
if (!s.hours.has(date.getUTCHours()))
|
|
89
|
+
return false;
|
|
90
|
+
if (!s.months.has(date.getUTCMonth() + 1))
|
|
91
|
+
return false;
|
|
92
|
+
const domHit = s.daysOfMonth.has(date.getUTCDate());
|
|
93
|
+
const dowHit = s.daysOfWeek.has(date.getUTCDay());
|
|
94
|
+
// Standard cron day rule: both restricted → OR; otherwise the restricted one(s) must hit.
|
|
95
|
+
if (!s.domWildcard && !s.dowWildcard)
|
|
96
|
+
return domHit || dowHit;
|
|
97
|
+
if (!s.domWildcard)
|
|
98
|
+
return domHit;
|
|
99
|
+
if (!s.dowWildcard)
|
|
100
|
+
return dowHit;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** Ceiling to the start of the next minute (UTC), so scheduling lands on minute boundaries. */
|
|
104
|
+
function nextMinute(after) {
|
|
105
|
+
const d = new Date(after.getTime());
|
|
106
|
+
d.setUTCSeconds(0, 0);
|
|
107
|
+
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
|
108
|
+
return d;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The first fire strictly after `after`. Scans minute-by-minute (UTC) up to a bounded
|
|
112
|
+
* horizon; returns `undefined` if nothing matches within ~4 years (an impossible spec,
|
|
113
|
+
* e.g. Feb 30). The bound keeps a pathological spec from spinning forever.
|
|
114
|
+
*/
|
|
115
|
+
export function nextCronFire(s, after) {
|
|
116
|
+
let cur = nextMinute(after);
|
|
117
|
+
const limit = 4 * 366 * 24 * 60; // minutes in ~4 years
|
|
118
|
+
for (let i = 0; i < limit; i++) {
|
|
119
|
+
if (cronMatches(s, cur))
|
|
120
|
+
return cur;
|
|
121
|
+
cur = new Date(cur.getTime() + 60_000);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The fire instants strictly between `after` and `until` (both exclusive on `after`,
|
|
127
|
+
* inclusive on `until`). Used by the `onMissed` catch-up policy to enumerate fires that
|
|
128
|
+
* were due while the app was down. Bounded to avoid unbounded output on a huge gap.
|
|
129
|
+
*/
|
|
130
|
+
export function cronFiresBetween(s, after, until, max = 10_000) {
|
|
131
|
+
const fires = [];
|
|
132
|
+
let next = nextCronFire(s, after);
|
|
133
|
+
while (next && next.getTime() <= until.getTime() && fires.length < max) {
|
|
134
|
+
fires.push(next);
|
|
135
|
+
next = nextCronFire(s, next);
|
|
136
|
+
}
|
|
137
|
+
return fires;
|
|
138
|
+
}
|
|
@@ -1,15 +1,49 @@
|
|
|
1
|
-
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
1
|
+
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
2
2
|
import { type Route } from "../router.ts";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import type { TriggerDecl } from "../manifest.ts";
|
|
4
|
+
export interface TriggersHandle extends Mounted {
|
|
5
5
|
routes: Route[];
|
|
6
|
-
describe(): Record<string, unknown>;
|
|
7
6
|
}
|
|
8
|
-
/**
|
|
9
|
-
export
|
|
7
|
+
/** Injectable timer + clock seam so cron scheduling is deterministic under test. */
|
|
8
|
+
export interface SchedulerDeps {
|
|
9
|
+
setTimer: (fn: () => void, delayMs: number) => unknown;
|
|
10
|
+
clearTimer: (handle: unknown) => void;
|
|
11
|
+
/** Current wall-clock time in ms since epoch. */
|
|
12
|
+
now: () => number;
|
|
13
|
+
}
|
|
14
|
+
type Scope = {
|
|
10
15
|
body: unknown;
|
|
11
16
|
headers: Record<string, string>;
|
|
12
17
|
query: Record<string, string>;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
|
|
21
|
+
* returns the raw value at that path (or `undefined` if any segment is missing); a literal
|
|
22
|
+
* (non-`=`) string is returned verbatim.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveExpr(expr: string, scope: Scope): unknown;
|
|
25
|
+
/** Evaluate a correlation expression to a string key: `= body.a.b`, or a literal string. */
|
|
26
|
+
export declare function evalCorrelation(expr: string | undefined, scope: Scope): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `action.variables` to the record handed to the engine (schema: a FEEL string over
|
|
29
|
+
* the event body — `#/$defs/triggerAction.variables`). A `= body.<path>` expression must
|
|
30
|
+
* resolve to an object; a plain object literal is accepted for convenience; anything else
|
|
31
|
+
* (missing, or a non-object result) falls back to the raw event body when that is an object,
|
|
32
|
+
* else `{}`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveActionVariables(expr: unknown, scope: Scope): Record<string, unknown>;
|
|
35
|
+
/**
|
|
36
|
+
* Fire a trigger's action against the engine: start a process or publish a message
|
|
37
|
+
* (ADR 0025 §1). `scope` supplies the event body/headers/query for FEEL-ish resolution
|
|
38
|
+
* of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
|
|
39
|
+
* was done.
|
|
40
|
+
*/
|
|
41
|
+
export declare function runTriggerAction(app: AppApi, action: TriggerDecl["action"], scope: Scope): Promise<{
|
|
42
|
+
kind: "start" | "message" | "none";
|
|
43
|
+
target?: string;
|
|
44
|
+
correlationKey?: string;
|
|
45
|
+
}>;
|
|
46
|
+
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
47
|
+
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
48
|
+
export declare function mountTriggers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): TriggersHandle;
|
|
49
|
+
export {};
|
|
@@ -3,24 +3,54 @@
|
|
|
3
3
|
// message to the engine for correlation. HMAC verification uses Web Crypto (global in both
|
|
4
4
|
// Node 18+ and Deno), so this stays runtime-agnostic.
|
|
5
5
|
import { json, normalizeRoutePath } from "../router.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
import { nextCronFire, parseCron } from "./cron.js";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
|
|
9
|
+
* returns the raw value at that path (or `undefined` if any segment is missing); a literal
|
|
10
|
+
* (non-`=`) string is returned verbatim.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveExpr(expr, scope) {
|
|
10
13
|
const trimmed = expr.trim();
|
|
11
14
|
if (!trimmed.startsWith("="))
|
|
12
15
|
return trimmed; // literal
|
|
13
16
|
const pathExpr = trimmed.slice(1).trim(); // e.g. "body.taskId"
|
|
14
17
|
let cur = scope;
|
|
15
18
|
for (const seg of pathExpr.split(".")) {
|
|
16
|
-
if (cur && typeof cur === "object" && seg
|
|
19
|
+
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
|
|
17
20
|
cur = cur[seg];
|
|
18
21
|
}
|
|
19
22
|
else {
|
|
20
23
|
return undefined;
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
|
-
return cur
|
|
26
|
+
return cur;
|
|
27
|
+
}
|
|
28
|
+
/** Evaluate a correlation expression to a string key: `= body.a.b`, or a literal string. */
|
|
29
|
+
export function evalCorrelation(expr, scope) {
|
|
30
|
+
if (!expr)
|
|
31
|
+
return undefined;
|
|
32
|
+
const cur = resolveExpr(expr, scope);
|
|
33
|
+
// A correlation key must be scalar; a non-scalar (object/array) is not a usable key —
|
|
34
|
+
// return undefined rather than forcing a "[object Object]" string.
|
|
35
|
+
if (typeof cur === "string" || typeof cur === "number" || typeof cur === "boolean") {
|
|
36
|
+
return String(cur);
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve `action.variables` to the record handed to the engine (schema: a FEEL string over
|
|
42
|
+
* the event body — `#/$defs/triggerAction.variables`). A `= body.<path>` expression must
|
|
43
|
+
* resolve to an object; a plain object literal is accepted for convenience; anything else
|
|
44
|
+
* (missing, or a non-object result) falls back to the raw event body when that is an object,
|
|
45
|
+
* else `{}`.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveActionVariables(expr, scope) {
|
|
48
|
+
const asRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
49
|
+
if (typeof expr === "string") {
|
|
50
|
+
return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
|
|
51
|
+
}
|
|
52
|
+
// Back-compat: an inline object literal used directly as the variables.
|
|
53
|
+
return asRecord(expr) ?? asRecord(scope.body) ?? {};
|
|
24
54
|
}
|
|
25
55
|
async function verifyHmac(secret, body, signature) {
|
|
26
56
|
const enc = new TextEncoder();
|
|
@@ -45,12 +75,136 @@ function headerRecord(req) {
|
|
|
45
75
|
req.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
|
|
46
76
|
return out;
|
|
47
77
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Fire a trigger's action against the engine: start a process or publish a message
|
|
80
|
+
* (ADR 0025 §1). `scope` supplies the event body/headers/query for FEEL-ish resolution
|
|
81
|
+
* of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
|
|
82
|
+
* was done.
|
|
83
|
+
*/
|
|
84
|
+
export async function runTriggerAction(app, action, scope) {
|
|
85
|
+
if (!action)
|
|
86
|
+
return { kind: "none" };
|
|
87
|
+
const variables = resolveActionVariables(action.variables, scope);
|
|
88
|
+
if (action.start) {
|
|
89
|
+
await app.engine.createInstance({ processDefinitionId: action.start, variables });
|
|
90
|
+
return { kind: "start", target: action.start };
|
|
91
|
+
}
|
|
92
|
+
if (action.message) {
|
|
93
|
+
const correlationKey = evalCorrelation(action.correlationKey, scope);
|
|
94
|
+
await app.engine.publishMessage({ name: action.message, correlationKey, variables });
|
|
95
|
+
return { kind: "message", target: action.message, correlationKey };
|
|
96
|
+
}
|
|
97
|
+
return { kind: "none" };
|
|
98
|
+
}
|
|
99
|
+
const emptyScope = { body: {}, headers: {}, query: {} };
|
|
100
|
+
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
101
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
102
|
+
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
103
|
+
function defaultScheduler() {
|
|
104
|
+
return {
|
|
105
|
+
setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
|
|
106
|
+
clearTimer: (h) => globalThis.clearTimeout(h),
|
|
107
|
+
now: () => Date.now(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
|
|
111
|
+
* background timers that fire their action on schedule. `stop()` clears every timer. */
|
|
112
|
+
export function mountTriggers(ctx, app, sched = defaultScheduler()) {
|
|
50
113
|
const routes = [];
|
|
51
114
|
const mounted = [];
|
|
115
|
+
const scheduled = [];
|
|
52
116
|
const seenDeliveries = new Set();
|
|
117
|
+
const timers = new Set();
|
|
118
|
+
let stopped = false;
|
|
119
|
+
// Arm a cron trigger: compute the next UTC fire, sleep until it, fire the action, repeat.
|
|
120
|
+
const armCron = (trig) => {
|
|
121
|
+
const spec = trig.spec;
|
|
122
|
+
if (!spec) {
|
|
123
|
+
ctx.host.log("warn", `trigger "${trig.id}": cron trigger has no spec, skipped`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
let schedule;
|
|
127
|
+
try {
|
|
128
|
+
schedule = parseCron(spec);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
app.log("error", `trigger "${trig.id}": invalid cron spec "${spec}": ${String(e)}`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!trig.action?.start && !trig.action?.message) {
|
|
135
|
+
// A timer that can never do anything is pure waste — don't arm it (unlike a webhook,
|
|
136
|
+
// which is request-driven and costs nothing while idle).
|
|
137
|
+
app.log("warn", `trigger "${trig.id}": cron trigger has no action.start/message; not scheduled`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (trig.onMissed && trig.onMissed !== "skip") {
|
|
141
|
+
// once/all catch-up needs a durable last-fire timestamp the runtime does not persist;
|
|
142
|
+
// without it, honour the safe default (skip) and say so rather than silently dropping.
|
|
143
|
+
app.log("warn", `trigger "${trig.id}": onMissed="${trig.onMissed}" not supported without ` +
|
|
144
|
+
`durable trigger state; scheduling forward as "skip"`);
|
|
145
|
+
}
|
|
146
|
+
// Probe the first fire before recording the trigger as scheduled, so an impossible spec
|
|
147
|
+
// (e.g. Feb 30) is reported honestly and never shows up in describe()/startup logs.
|
|
148
|
+
if (!nextCronFire(schedule, new Date(sched.now()))) {
|
|
149
|
+
app.log("warn", `trigger "${trig.id}": cron spec "${spec}" never fires; not scheduled`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
scheduled.push(`${trig.id}@${spec}`);
|
|
153
|
+
const arm = () => {
|
|
154
|
+
if (stopped)
|
|
155
|
+
return;
|
|
156
|
+
// Capture the clock once so `next` and `delay` are derived from the same instant.
|
|
157
|
+
const nowMs = sched.now();
|
|
158
|
+
const next = nextCronFire(schedule, new Date(nowMs));
|
|
159
|
+
if (!next)
|
|
160
|
+
return; // spec became unsatisfiable (defensive; the probe above already vetted it)
|
|
161
|
+
const remaining = Math.max(0, next.getTime() - nowMs);
|
|
162
|
+
// setTimeout delays are clamped to a 32-bit signed int (~24.8 days); a larger delay
|
|
163
|
+
// overflows and fires immediately. For far-future fires (monthly/annual specs), sleep in
|
|
164
|
+
// bounded chunks and re-arm — without firing — until the real deadline is within range.
|
|
165
|
+
if (remaining > MAX_TIMER_DELAY_MS) {
|
|
166
|
+
let chunk;
|
|
167
|
+
chunk = sched.setTimer(() => {
|
|
168
|
+
timers.delete(chunk);
|
|
169
|
+
if (stopped)
|
|
170
|
+
return;
|
|
171
|
+
arm();
|
|
172
|
+
}, MAX_TIMER_DELAY_MS);
|
|
173
|
+
timers.add(chunk);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Declare `handle` before arming so the callback can reference it even if a scheduler
|
|
177
|
+
// double invokes it synchronously (avoids a TDZ ReferenceError on `timers.delete`).
|
|
178
|
+
let handle;
|
|
179
|
+
handle = sched.setTimer(() => {
|
|
180
|
+
timers.delete(handle);
|
|
181
|
+
if (stopped)
|
|
182
|
+
return;
|
|
183
|
+
void (async () => {
|
|
184
|
+
try {
|
|
185
|
+
const res = await runTriggerAction(app, trig.action, {
|
|
186
|
+
...emptyScope,
|
|
187
|
+
body: { firedAt: next.toISOString() },
|
|
188
|
+
});
|
|
189
|
+
app.log("info", `trigger "${trig.id}" fired`, { firedAt: next.toISOString(), ...res });
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
app.log("error", `trigger "${trig.id}": action failed`, { error: String(err) });
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
arm(); // reschedule regardless of success (at-least-once, idempotent handlers)
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
}, remaining);
|
|
199
|
+
timers.add(handle);
|
|
200
|
+
};
|
|
201
|
+
arm();
|
|
202
|
+
};
|
|
53
203
|
for (const trig of (ctx.manifest.triggers ?? [])) {
|
|
204
|
+
if (trig.type === "cron") {
|
|
205
|
+
armCron(trig);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
54
208
|
if (trig.type !== "webhook") {
|
|
55
209
|
ctx.host.log("warn", `trigger "${trig.id}": type "${trig.type}" not implemented, skipped`);
|
|
56
210
|
continue;
|
|
@@ -94,26 +248,31 @@ export function mountTriggers(ctx, app) {
|
|
|
94
248
|
catch {
|
|
95
249
|
return json({ error: "body must be JSON" }, 400);
|
|
96
250
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return json({ ok: true, published: false });
|
|
251
|
+
if (!trig.action?.message && !trig.action?.start) {
|
|
252
|
+
app.log("warn", `trigger "${trig.id}": no action.message/start; nothing to do`);
|
|
253
|
+
return json({ ok: true, fired: false });
|
|
101
254
|
}
|
|
102
|
-
const
|
|
255
|
+
const res = await runTriggerAction(app, trig.action, {
|
|
103
256
|
body,
|
|
104
257
|
headers,
|
|
105
258
|
query: Object.fromEntries(req.query),
|
|
106
259
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
variables: (body && typeof body === "object" ? body : {}),
|
|
111
|
-
});
|
|
112
|
-
app.log("info", `trigger "${trig.id}" published`, { message, correlationKey });
|
|
113
|
-
return json({ ok: true, published: true, message, correlationKey });
|
|
260
|
+
// `res.kind` is the honest verb (start | message); don't hard-code "published".
|
|
261
|
+
app.log("info", `trigger "${trig.id}" fired`, { ...res });
|
|
262
|
+
return json({ ok: true, fired: true, ...res });
|
|
114
263
|
},
|
|
115
264
|
});
|
|
116
265
|
}
|
|
117
|
-
ctx.host.log("info", "triggers mounted", { mounted });
|
|
118
|
-
return {
|
|
266
|
+
ctx.host.log("info", "triggers mounted", { mounted, scheduled });
|
|
267
|
+
return {
|
|
268
|
+
name: "triggers",
|
|
269
|
+
routes,
|
|
270
|
+
describe: () => ({ mounted, scheduled }),
|
|
271
|
+
async stop() {
|
|
272
|
+
stopped = true;
|
|
273
|
+
for (const h of timers)
|
|
274
|
+
sched.clearTimer(h);
|
|
275
|
+
timers.clear();
|
|
276
|
+
},
|
|
277
|
+
};
|
|
119
278
|
}
|
|
@@ -132,7 +132,8 @@ export async function createUrbanApp(opts) {
|
|
|
132
132
|
if (flags.triggers) {
|
|
133
133
|
const t = mountTriggers(ctx, api);
|
|
134
134
|
routes.push(...t.routes);
|
|
135
|
-
|
|
135
|
+
mounted.push(t);
|
|
136
|
+
describe.triggers = t.describe?.();
|
|
136
137
|
}
|
|
137
138
|
if (security)
|
|
138
139
|
describe.security = security.describe();
|
|
@@ -26,7 +26,8 @@ export interface NanoSdkJobWorkerConfig {
|
|
|
26
26
|
jobTimeoutMs?: number;
|
|
27
27
|
pollTimeoutMs?: number;
|
|
28
28
|
fetchVariables?: string[];
|
|
29
|
-
/** Start polling immediately.
|
|
29
|
+
/** Start polling immediately (the SDK default). The adapter leaves this unset so the
|
|
30
|
+
* SDK owns the start lifecycle after its async Nano/Falcon transport detection. */
|
|
30
31
|
autoStart?: boolean;
|
|
31
32
|
}
|
|
32
33
|
/** The handle returned by `createJobWorker`. */
|
|
@@ -69,6 +70,10 @@ export interface NanoSdkClient {
|
|
|
69
70
|
variables?: Record<string, unknown>;
|
|
70
71
|
}, options?: unknown): Promise<unknown>;
|
|
71
72
|
createJobWorker(cfg: NanoSdkJobWorkerConfig): NanoSdkJobWorker;
|
|
73
|
+
/** Stop every worker created on this client. Used on teardown to also drain a
|
|
74
|
+
* REST-fallback worker, whose handle the `createJobWorker` proxy starts internally
|
|
75
|
+
* and does not hand back. Optional: not every injected client implements it. */
|
|
76
|
+
stopAllWorkers?(): void | Promise<void>;
|
|
72
77
|
close?(): void | Promise<void>;
|
|
73
78
|
}
|
|
74
79
|
type Log = (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
|
|
@@ -103,9 +103,6 @@ export class SdkEngineClient {
|
|
|
103
103
|
workerName: options?.workerName ?? `urban:${jobType}`,
|
|
104
104
|
maxParallelJobs: options?.maxParallelJobs ?? 8,
|
|
105
105
|
fetchVariables: options?.fetchVariables,
|
|
106
|
-
// Own the poll lifecycle here rather than leaving it to the SDK's auto-start
|
|
107
|
-
// default, so nothing polls until `start()` is called below.
|
|
108
|
-
autoStart: false,
|
|
109
106
|
jobHandler: async (job) => {
|
|
110
107
|
const rawKey = job.jobKey;
|
|
111
108
|
if (rawKey == null || rawKey === "") {
|
|
@@ -138,7 +135,11 @@ export class SdkEngineClient {
|
|
|
138
135
|
}
|
|
139
136
|
},
|
|
140
137
|
});
|
|
141
|
-
|
|
138
|
+
// The nano-sdk job worker owns its own start lifecycle: `createJobWorker` runs
|
|
139
|
+
// async Nano/Falcon detection and, once the transport is bound, starts polling
|
|
140
|
+
// itself (Falcon) or hands off to an auto-started REST worker. Calling `start()`
|
|
141
|
+
// here would race that detection and dereference a still-null transport, so we
|
|
142
|
+
// don't — the worker autostarts.
|
|
142
143
|
this.workers.add(worker);
|
|
143
144
|
return {
|
|
144
145
|
jobType,
|
|
@@ -162,6 +163,14 @@ export class SdkEngineClient {
|
|
|
162
163
|
for (const w of this.workers)
|
|
163
164
|
await this.stopWorker(w);
|
|
164
165
|
this.workers.clear();
|
|
166
|
+
try {
|
|
167
|
+
// Also drain any REST-fallback worker the SDK started internally (its handle is
|
|
168
|
+
// not the one we hold), which `stopWorker` above cannot reach.
|
|
169
|
+
await this.client.stopAllWorkers?.();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* no workers running / already stopped */
|
|
173
|
+
}
|
|
165
174
|
try {
|
|
166
175
|
await this.client.close?.();
|
|
167
176
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -55,7 +55,29 @@ export interface TriggerDecl {
|
|
|
55
55
|
type: string;
|
|
56
56
|
path?: string;
|
|
57
57
|
auth?: string;
|
|
58
|
-
|
|
58
|
+
/** cron: the 5-field crontab spec (UTC), e.g. "0 6 * * *". */
|
|
59
|
+
spec?: string;
|
|
60
|
+
/** cron catch-up policy for fires missed while the app was down. Default "skip". */
|
|
61
|
+
onMissed?: "skip" | "once" | "all";
|
|
62
|
+
/** Source-kind-specific settings (e.g. file `{ pollMs }`). */
|
|
63
|
+
config?: Record<string, unknown>;
|
|
64
|
+
/** Name of a connections[] entry supplying this source's credentials. */
|
|
65
|
+
connection?: string;
|
|
66
|
+
/** Maps the event to one engine call: start a process OR publish a message (ADR 0025 §1). */
|
|
67
|
+
action?: {
|
|
68
|
+
/** Process id/name to start. */
|
|
69
|
+
start?: string;
|
|
70
|
+
/** messageName to publish as a CorrelateMessage. */
|
|
71
|
+
message?: string;
|
|
72
|
+
/** correlationKey (literal, or `= body.path`) for a message action. */
|
|
73
|
+
correlationKey?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Variables for the started instance / published message. Per the manifest schema
|
|
76
|
+
* (`#/$defs/triggerAction.variables`) this is a FEEL string over the event body
|
|
77
|
+
* (e.g. `= body.payload`); an inline object literal is also accepted for convenience.
|
|
78
|
+
*/
|
|
79
|
+
variables?: string | Record<string, unknown>;
|
|
80
|
+
};
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
export interface SurfaceDecl {
|