@ezghcloud/workflows 0.0.0 → 1.0.0

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 (51) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +418 -1
  3. package/dist/client.d.ts +209 -0
  4. package/dist/client.js +259 -0
  5. package/dist/env.d.ts +2 -0
  6. package/dist/env.js +32 -0
  7. package/dist/index.d.ts +15 -0
  8. package/dist/index.js +7 -0
  9. package/dist/protocol/bytes.d.ts +8 -0
  10. package/dist/protocol/bytes.js +49 -0
  11. package/dist/protocol/callback.d.ts +6 -0
  12. package/dist/protocol/callback.js +31 -0
  13. package/dist/protocol/codec.d.ts +6 -0
  14. package/dist/protocol/codec.js +38 -0
  15. package/dist/protocol/derived.d.ts +5 -0
  16. package/dist/protocol/derived.js +41 -0
  17. package/dist/protocol/index.d.ts +14 -0
  18. package/dist/protocol/index.js +21 -0
  19. package/dist/protocol/public.d.ts +2 -0
  20. package/dist/protocol/public.js +1 -0
  21. package/dist/protocol/signature.d.ts +17 -0
  22. package/dist/protocol/signature.js +101 -0
  23. package/dist/protocol/stepid.d.ts +4 -0
  24. package/dist/protocol/stepid.js +9 -0
  25. package/dist/protocol/tokens.d.ts +2 -0
  26. package/dist/protocol/tokens.js +38 -0
  27. package/dist/protocol/types.d.ts +185 -0
  28. package/dist/protocol/types.js +1 -0
  29. package/dist/protocol/validate.d.ts +15 -0
  30. package/dist/protocol/validate.js +279 -0
  31. package/dist/queues.d.ts +47 -0
  32. package/dist/queues.js +126 -0
  33. package/dist/runtime/context.d.ts +82 -0
  34. package/dist/runtime/context.js +1 -0
  35. package/dist/runtime/errors.d.ts +49 -0
  36. package/dist/runtime/errors.js +160 -0
  37. package/dist/runtime/execution.d.ts +17 -0
  38. package/dist/runtime/execution.js +608 -0
  39. package/dist/runtime/invocation.d.ts +5 -0
  40. package/dist/runtime/invocation.js +47 -0
  41. package/dist/runtime/live.d.ts +23 -0
  42. package/dist/runtime/live.js +103 -0
  43. package/dist/runtime/workflow.d.ts +26 -0
  44. package/dist/runtime/workflow.js +49 -0
  45. package/dist/serve.d.ts +12 -0
  46. package/dist/serve.js +149 -0
  47. package/dist/version.d.ts +2 -0
  48. package/dist/version.js +2 -0
  49. package/dist/worker.d.ts +43 -0
  50. package/dist/worker.js +364 -0
  51. package/package.json +48 -4
package/dist/worker.js ADDED
@@ -0,0 +1,364 @@
1
+ import { PROTOCOL_VERSIONS } from "./protocol/index.js";
2
+ import { cleanDeployment, detectDeployment } from "./env.js";
3
+ import { CancelledError } from "./runtime/errors.js";
4
+ import { execute } from "./runtime/execution.js";
5
+ import { parseInvocation } from "./runtime/invocation.js";
6
+ import { InvocationSuperseded, httpLive } from "./runtime/live.js";
7
+ import { definitionOf } from "./runtime/workflow.js";
8
+ import { SDK_NAME, SDK_VERSION } from "./version.js";
9
+ // workflows-live refused the API key (401) or its permissions (403).
10
+ export class WorkerAuthError extends Error {
11
+ status;
12
+ name = "WorkerAuthError";
13
+ constructor(status, message) {
14
+ super(message);
15
+ this.status = status;
16
+ }
17
+ }
18
+ // workflows-live refused the poll for another reason: an unknown app or queue, a bad request.
19
+ export class WorkerConfigError extends Error {
20
+ status;
21
+ code;
22
+ name = "WorkerConfigError";
23
+ constructor(status, code, message) {
24
+ super(message);
25
+ this.status = status;
26
+ this.code = code;
27
+ }
28
+ }
29
+ class Retryable extends Error {
30
+ retryAfterMs;
31
+ constructor(message, retryAfterMs) {
32
+ super(message);
33
+ this.retryAfterMs = retryAfterMs;
34
+ }
35
+ }
36
+ const DEFAULT_LIVE = "https://workflows-live.ezghcloud.com";
37
+ // internal/protocol PollRequest.Validate: queue names and worker IDs.
38
+ const QUEUE = /^[a-z0-9][a-z0-9-]{0,62}$/;
39
+ const WORKER_ID = /^[A-Za-z0-9._:-]{1,128}$/;
40
+ const POLL_TIMEOUT_MS = 70_000; // the gateway holds a poll at most 55 s
41
+ const MAX_HEARTBEAT_INTERVAL_MS = 30_000;
42
+ const DEFAULT_GRACE_MS = 30_000;
43
+ // After the grace period, running steps are cancelled and get this long to finish.
44
+ const CANCEL_WAIT_MS = 5000;
45
+ const MAX_WORKFLOWS = 1000;
46
+ function sleep(ms, signal) {
47
+ return new Promise((resolve) => {
48
+ if (signal?.aborted)
49
+ return resolve();
50
+ const t = setTimeout(resolve, ms);
51
+ signal?.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true });
52
+ });
53
+ }
54
+ function anySignal(...signals) {
55
+ const c = new AbortController();
56
+ for (const s of signals) {
57
+ if (s.aborted) {
58
+ c.abort(s.reason);
59
+ break;
60
+ }
61
+ s.addEventListener("abort", () => c.abort(s.reason), { once: true });
62
+ }
63
+ return c.signal;
64
+ }
65
+ function env(name) {
66
+ try {
67
+ return globalThis.process?.env?.[name];
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ function hostName() {
74
+ const h = env("HOSTNAME") ?? env("COMPUTERNAME") ?? "worker";
75
+ return h.toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 40) || "worker";
76
+ }
77
+ function retryAfterMs(res) {
78
+ const v = res.headers.get("Retry-After");
79
+ if (!v)
80
+ return undefined;
81
+ const n = Number(v);
82
+ if (Number.isFinite(n) && n >= 0)
83
+ return n * 1000;
84
+ const at = Date.parse(v);
85
+ return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
86
+ }
87
+ function errorCode(text) {
88
+ try {
89
+ const c = JSON.parse(text).error?.code;
90
+ return typeof c === "string" ? c : undefined;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ // devQueue: dev-<user> in lowercase letters, digits and - (the user given, else $USER, $USERNAME
97
+ // or $LOGNAME, else "dev").
98
+ export function devQueue(user) {
99
+ const raw = user || env("USER") || env("USERNAME") || env("LOGNAME") || "dev";
100
+ const clean = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 58).replace(/^-+|-+$/g, "");
101
+ return `dev-${clean || "dev"}`;
102
+ }
103
+ export function worker(opts) {
104
+ for (const k of ["apiKey", "organizationId", "projectId", "app"])
105
+ if (typeof opts[k] !== "string" || opts[k] === "")
106
+ throw new TypeError(`worker() needs ${k}`);
107
+ const byName = new Map();
108
+ const announce = [];
109
+ for (const wf of opts.workflows ?? []) {
110
+ const d = definitionOf(wf);
111
+ if (byName.has(d.name))
112
+ throw new TypeError(`Two workflows are named "${d.name}"`);
113
+ byName.set(d.name, wf);
114
+ announce.push({ name: d.name, ...(d.opts.retry ? { retry: d.opts.retry } : {}), ...(d.opts.timeoutMs ? { timeoutMs: d.opts.timeoutMs } : {}) });
115
+ }
116
+ if (byName.size === 0)
117
+ throw new TypeError("worker() needs at least one workflow");
118
+ // What an announcement may carry (internal/protocol MaxWorkflows): checked here, not at the first poll.
119
+ if (byName.size > MAX_WORKFLOWS)
120
+ throw new TypeError(`A worker serves at most ${MAX_WORKFLOWS} workflows; got ${byName.size}`);
121
+ const queue = opts.dev ? devQueue(typeof opts.dev === "object" ? opts.dev.user : undefined) : (opts.queue ?? "default");
122
+ if (!QUEUE.test(queue))
123
+ throw new TypeError(`A queue name matches ^[a-z0-9][a-z0-9-]{0,62}$; got "${queue}"`);
124
+ const maxTasks = opts.maxTasks ?? 4;
125
+ if (!Number.isInteger(maxTasks) || maxTasks < 1 || maxTasks > 10)
126
+ throw new TypeError("maxTasks is a whole number from 1 to 10");
127
+ const concurrency = opts.concurrency ?? 10;
128
+ if (!Number.isInteger(concurrency) || concurrency < 1)
129
+ throw new TypeError("concurrency is a whole number ≥ 1");
130
+ const heartbeatMs = opts.heartbeatIntervalMs ?? 20_000;
131
+ // Well inside the engine's 60 s heartbeat timeout, so one lost heartbeat never costs the invocation.
132
+ if (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0 || heartbeatMs > MAX_HEARTBEAT_INTERVAL_MS)
133
+ throw new TypeError(`heartbeatIntervalMs is a number from 1 to ${MAX_HEARTBEAT_INTERVAL_MS} (the engine's heartbeat timeout is 60 s)`);
134
+ const backoffMax = opts.backoffMaxMs ?? 30_000;
135
+ if (!Number.isFinite(backoffMax) || backoffMax < 0)
136
+ throw new TypeError("backoffMaxMs is a number ≥ 0");
137
+ if (opts.maxStepsPerInvocation !== undefined && (!Number.isInteger(opts.maxStepsPerInvocation) || opts.maxStepsPerInvocation < 1))
138
+ throw new TypeError("maxStepsPerInvocation is a whole number ≥ 1");
139
+ const live = (opts.liveUrl ?? DEFAULT_LIVE).replace(/\/+$/, "");
140
+ const pollUrl = `${live}/v1/organizations/${encodeURIComponent(opts.organizationId)}/projects/${encodeURIComponent(opts.projectId)}/workers/poll`;
141
+ const workerId = opts.workerId ?? `${hostName()}-${crypto.randomUUID().slice(0, 8)}`;
142
+ if (!WORKER_ID.test(workerId))
143
+ throw new TypeError(`A worker ID matches ^[A-Za-z0-9._:-]{1,128}$; got "${workerId}"`);
144
+ const fetchImpl = opts.fetch ?? ((u, i) => fetch(u, i));
145
+ const sdk = { name: SDK_NAME, version: SDK_VERSION };
146
+ const deployment = opts.deployment !== undefined ? cleanDeployment(opts.deployment) : detectDeployment();
147
+ const onError = opts.onError ?? (() => { });
148
+ const active = new Set();
149
+ const cancels = new Set();
150
+ const stopped = new AbortController();
151
+ let pollAbort;
152
+ // The poll in flight and the handing of its invocations to handle(): stop() waits for it, so a
153
+ // poll answer arriving as stop begins is run (within the grace period), never dropped.
154
+ let cycle;
155
+ let isRunning = false;
156
+ let release = () => { };
157
+ const released = new Promise((r) => (release = r));
158
+ async function poll(want) {
159
+ pollAbort = new AbortController();
160
+ const res = await fetchImpl(pollUrl, {
161
+ method: "POST",
162
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json", Accept: "application/json" },
163
+ body: JSON.stringify({ app: opts.app, queue, maxTasks: want, workerId, protocolVersions: [...PROTOCOL_VERSIONS], sdk, workflows: announce }),
164
+ signal: anySignal(pollAbort.signal, AbortSignal.timeout(POLL_TIMEOUT_MS)), redirect: "manual",
165
+ });
166
+ // The answer has begun: the gateway may have started tasks for it, so stop() no longer aborts it.
167
+ pollAbort = undefined;
168
+ const receivedAt = Date.now();
169
+ if (res.status === 204) {
170
+ await res.body?.cancel().catch(() => { });
171
+ return { invocations: [], receivedAt };
172
+ }
173
+ const text = await res.text();
174
+ if (res.ok) {
175
+ let body;
176
+ try {
177
+ body = JSON.parse(text);
178
+ }
179
+ catch {
180
+ throw new Retryable("workflows-live's poll answer isn't JSON");
181
+ }
182
+ const date = Date.parse(res.headers.get("Date") ?? "");
183
+ return { invocations: Array.isArray(body.invocations) ? body.invocations : [], receivedAt, ...(Number.isNaN(date) ? {} : { serverDate: date }) };
184
+ }
185
+ const code = errorCode(text);
186
+ if (res.status === 401 || res.status === 403)
187
+ throw new WorkerAuthError(res.status, `workflows-live refused the poll (${res.status}${code ? ` ${code}` : ""}): check the API key and its workflows.workers.poll permission`);
188
+ if (res.status === 429 || res.status >= 500)
189
+ throw new Retryable(`workflows-live answered ${res.status}${code ? ` ${code}` : ""}`, retryAfterMs(res));
190
+ throw new WorkerConfigError(res.status, code ?? "unknown", `workflows-live refused the poll: ${res.status}${code ? ` ${code}` : ""}` +
191
+ (opts.dev && res.status === 400 ? ` (dev mode polls ${queue}: app must be your own pull app with that queue; Client.devApp() creates it)` : ""));
192
+ }
193
+ async function heartbeat(inv, cancel, superseded) {
194
+ try {
195
+ const res = await fetchImpl(`${inv.endpoints.live.replace(/\/+$/, "")}/v1/invocations/heartbeat`, {
196
+ method: "POST", headers: { Authorization: `Bearer ${inv.token}`, "Content-Type": "application/json" }, body: "{}", signal: AbortSignal.timeout(10_000), redirect: "manual",
197
+ });
198
+ if (res.status === 409) {
199
+ await res.body?.cancel().catch(() => { });
200
+ superseded();
201
+ if (!cancel.signal.aborted)
202
+ cancel.abort(new CancelledError("This invocation was superseded"));
203
+ return;
204
+ }
205
+ if (!res.ok) {
206
+ // 401/403: the invocation token was refused; 5xx: the engine is unreachable. The step keeps
207
+ // running; without heartbeats the engine re-dispatches after its timeout.
208
+ await res.body?.cancel().catch(() => { });
209
+ onError(new Error(`workflows-live answered the heartbeat with ${res.status}`), "heartbeat");
210
+ return;
211
+ }
212
+ const body = (await res.json());
213
+ if (body?.cancel && !cancel.signal.aborted)
214
+ cancel.abort(new CancelledError("The run is cancelling"));
215
+ }
216
+ catch (e) {
217
+ onError(e, "heartbeat");
218
+ }
219
+ }
220
+ async function complete(inv, result, deadline) {
221
+ const url = `${inv.endpoints.live.replace(/\/+$/, "")}/v1/invocations/complete`;
222
+ for (let attempt = 0;; attempt++) {
223
+ const left = deadline + 30_000 - Date.now();
224
+ if (left <= 0)
225
+ throw new Error("No time left to complete the invocation; the engine re-dispatches it");
226
+ let res;
227
+ try {
228
+ res = await fetchImpl(url, { method: "POST", headers: { Authorization: `Bearer ${inv.token}`, "Content-Type": "application/json" }, body: JSON.stringify(result), signal: AbortSignal.timeout(Math.min(30_000, left)), redirect: "manual" });
229
+ }
230
+ catch { /* network error: retried below */ }
231
+ if (res) {
232
+ await res.body?.cancel().catch(() => { });
233
+ if (res.ok || res.status === 409)
234
+ return; // 409: superseded; the engine has moved on
235
+ if (res.status !== 429 && res.status < 500)
236
+ throw new Error(`workflows-live refused the result: ${res.status}`);
237
+ }
238
+ await sleep(Math.max(res ? (retryAfterMs(res) ?? 0) : 0, Math.min(5000, 200 * 2 ** attempt)));
239
+ }
240
+ }
241
+ async function handle(raw, receivedAt, serverDate) {
242
+ let inv;
243
+ try {
244
+ inv = parseInvocation(JSON.stringify(raw));
245
+ }
246
+ catch (e) {
247
+ onError(e, "invalid invocation");
248
+ return;
249
+ }
250
+ if (inv.kind === "sync")
251
+ return;
252
+ const sent = Date.parse(inv.deadline);
253
+ const deadline = serverDate === undefined ? sent : Math.min(sent, receivedAt + (sent - serverDate));
254
+ const reserveMs = Math.max(5000, Math.max(0, deadline - receivedAt) * 0.1);
255
+ const cancel = new AbortController();
256
+ cancels.add(cancel);
257
+ let superseded = false;
258
+ const beat = setInterval(() => { void heartbeat(inv, cancel, () => { superseded = true; }); }, heartbeatMs);
259
+ try {
260
+ const wf = byName.get(inv.run.workflow);
261
+ const commands = wf
262
+ ? await execute(inv, wf, httpLive(inv.endpoints.live, inv.token, fetchImpl, { deadline, reserveMs }), {
263
+ deadline, cancelSignal: cancel.signal,
264
+ ...(opts.apiBaseUrl ? { apiBaseUrl: opts.apiBaseUrl } : {}),
265
+ ...(opts.maxStepsPerInvocation ? { maxStepsPerInvocation: opts.maxStepsPerInvocation } : {}),
266
+ })
267
+ : [{ type: "workflowNotFound", workflow: inv.run.workflow }];
268
+ if (superseded)
269
+ return;
270
+ await complete(inv, { commands, sdk, ...(deployment ? { deployment } : {}) }, deadline);
271
+ }
272
+ catch (e) {
273
+ // CheckpointFailed or an SDK error: the engine's heartbeat and deadline timers re-dispatch.
274
+ if (!(e instanceof InvocationSuperseded))
275
+ onError(e, `invocation ${inv.invocationId}`);
276
+ }
277
+ finally {
278
+ clearInterval(beat);
279
+ cancels.delete(cancel);
280
+ }
281
+ }
282
+ // drain waits up to graceMs for running invocations, then cancels their steps (each step's signal
283
+ // aborts, and no new step starts) and waits at most 5 s more. What's still running is abandoned:
284
+ // the engine's heartbeat and deadline timers re-dispatch it.
285
+ // Its timers are cleared once the invocations settle, so a stopped worker keeps no process alive.
286
+ async function drain(graceMs) {
287
+ if (active.size === 0)
288
+ return;
289
+ await settledWithin(graceMs);
290
+ if (active.size === 0)
291
+ return;
292
+ for (const c of cancels)
293
+ if (!c.signal.aborted)
294
+ c.abort(new CancelledError("The worker is stopping"));
295
+ await settledWithin(CANCEL_WAIT_MS);
296
+ }
297
+ async function settledWithin(ms) {
298
+ const timer = new AbortController();
299
+ try {
300
+ await Promise.race([Promise.allSettled([...active]), sleep(ms, timer.signal)]);
301
+ }
302
+ finally {
303
+ timer.abort();
304
+ }
305
+ }
306
+ return {
307
+ async run() {
308
+ if (isRunning)
309
+ throw new Error("This worker is already running");
310
+ isRunning = true;
311
+ let failures = 0;
312
+ try {
313
+ while (!stopped.signal.aborted) {
314
+ while (active.size >= concurrency && !stopped.signal.aborted)
315
+ await Promise.race([...active, released]);
316
+ if (stopped.signal.aborted)
317
+ break;
318
+ let done;
319
+ cycle = new Promise((r) => (done = r));
320
+ try {
321
+ let got;
322
+ try {
323
+ got = await poll(Math.min(maxTasks, concurrency - active.size));
324
+ }
325
+ catch (e) {
326
+ if (e instanceof WorkerAuthError || e instanceof WorkerConfigError) {
327
+ // Fatal: stop polling, let running invocations finish (stop()'s grace), then reject.
328
+ stopped.abort();
329
+ await drain(DEFAULT_GRACE_MS);
330
+ throw e;
331
+ }
332
+ if (stopped.signal.aborted)
333
+ break;
334
+ onError(e, "poll");
335
+ const backoff = Math.min(backoffMax, 500 * 2 ** Math.min(failures++, 16)) * (0.5 + Math.random() / 2);
336
+ await sleep(Math.max(e instanceof Retryable ? (e.retryAfterMs ?? 0) : 0, backoff), stopped.signal);
337
+ continue;
338
+ }
339
+ failures = 0;
340
+ // Even when stop() has begun: these tasks are started, and run within its grace period.
341
+ for (const raw of got.invocations) {
342
+ const p = handle(raw, got.receivedAt, got.serverDate).finally(() => active.delete(p));
343
+ active.add(p);
344
+ }
345
+ }
346
+ finally {
347
+ done();
348
+ }
349
+ }
350
+ await Promise.race([Promise.allSettled([...active]), released]);
351
+ }
352
+ finally {
353
+ isRunning = false;
354
+ }
355
+ },
356
+ async stop({ graceMs = DEFAULT_GRACE_MS } = {}) {
357
+ stopped.abort();
358
+ pollAbort?.abort(); // a poll still waiting for its answer
359
+ await cycle?.catch(() => { });
360
+ await drain(graceMs);
361
+ release();
362
+ },
363
+ };
364
+ }
package/package.json CHANGED
@@ -1,7 +1,51 @@
1
1
  {
2
2
  "name": "@ezghcloud/workflows",
3
- "version": "0.0.0",
4
- "description": "Placeholder: releases are published from https://github.com/ezgamehost/workflows-sdk-ts",
5
- "repository": { "type": "git", "url": "git+https://github.com/ezgamehost/workflows-sdk-ts.git" },
6
- "license": "UNLICENSED"
3
+ "version": "1.0.0",
4
+ "description": "Durable workflows as code on EZGH Cloud",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ezgamehost/workflows-sdk-ts.git"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./protocol": {
19
+ "types": "./dist/protocol/public.d.ts",
20
+ "import": "./dist/protocol/public.js",
21
+ "default": "./dist/protocol/public.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "types": "./dist/index.d.ts",
26
+ "files": [
27
+ "dist",
28
+ "LICENSE",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
39
+ "typecheck": "tsc --noEmit -p tsconfig.json",
40
+ "test": "bun test",
41
+ "smoke": "node scripts/smoke.mjs",
42
+ "check": "bun run typecheck && bun test && bun run build && bun run smoke && npm pack --dry-run",
43
+ "prepack": "npm run build",
44
+ "types-compat": "scripts/types-compat.sh"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "1.4.2",
48
+ "@types/node": "24.19.2",
49
+ "typescript": "6.0.3"
50
+ }
7
51
  }