@iworkflow/temporal 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -7,5 +7,6 @@ Temporal для `@iworkflow/core`. Peer: `@temporalio/{client,worker,workflow,ac
7
7
  - `@iworkflow/temporal/workflow` — только для входа песочницы модуля:
8
8
  `export const outreach = temporalFlow(flows.outreach, ["send-twice"])`.
9
9
 
10
- `opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`.
10
+ `opts` (`TemporalOptions`): `address`, `namespace`, `tls`, `apiKey` — источник токена, меняется каждые `apiKeyRenewMs`;
11
+ `plugins` — плагины SDK воркерам и клиенту; `temporalClient({ lazy: true })` — соединение при первом вызове.
11
12
  Без них — переменные `TEMPORAL_URL` (по умолчанию `127.0.0.1:17233`), `TEMPORAL_NAMESPACE`.
@@ -0,0 +1,23 @@
1
+ import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
2
+ import type { WorkerPlugin } from "@temporalio/worker";
3
+ /** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
4
+ export interface TemporalOptions {
5
+ address?: string;
6
+ namespace?: string;
7
+ /** TLSConfig SDK (serverNameOverride, свой CA, mTLS). С apiKey SDK включает TLS сам — без TLS только `false`. */
8
+ tls?: boolean | TLSConfig;
9
+ /** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
10
+ apiKey?: () => Promise<string>;
11
+ apiKeyRenewMs?: number;
12
+ /** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
13
+ plugins?: (ConnectionPlugin & WorkerPlugin)[];
14
+ }
15
+ export declare const address: (o: TemporalOptions) => string;
16
+ export declare const namespace: (o: TemporalOptions) => string;
17
+ /** Токен меняется на месте: иначе воркер однажды молча перестанет брать задачи. */
18
+ export declare function renewApiKey(o: TemporalOptions, set: (token: string) => unknown): () => void;
19
+ export declare const connectOptions: (o: TemporalOptions) => Promise<{
20
+ address: string;
21
+ tls?: boolean | TLSConfig | undefined;
22
+ apiKey?: string | undefined;
23
+ }>;
@@ -0,0 +1,7 @@
1
+ import { FlowError } from "@iworkflow/core";
2
+ /** Отказ шага, дочернего процесса или процесса целиком — FlowError; прочее (отмена) — как есть. */
3
+ export declare function fromTemporal(e: unknown): unknown;
4
+ /** Причина отказа процесса или шага — FlowError; undefined — причина не наша (отмена и т. п.). */
5
+ export declare function cause(c: Error | undefined, exhausted?: boolean): FlowError | undefined;
6
+ /** Отказ из кода порта — ApplicationFailure: обычная ошибка в песочнице не роняет процесс, а повторяет задачу. */
7
+ export declare function toTemporal(e: unknown, nonRetryable: boolean): unknown;
@@ -0,0 +1,14 @@
1
+ import { type EngineClient, type EngineRuntime } from "@iworkflow/core";
2
+ import { type TemporalOptions } from "./connection";
3
+ export type { TemporalOptions };
4
+ /**
5
+ * workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
6
+ * workflowBundlePath — готовый бандл того же входа (прод-сборка); с ним воркер не собирает код сам.
7
+ */
8
+ export declare function temporalRuntime(workflowsPath: string, opts?: TemporalOptions & {
9
+ workflowBundlePath?: string;
10
+ }): EngineRuntime;
11
+ /** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
12
+ export declare function temporalClient(opts?: TemporalOptions & {
13
+ lazy?: boolean;
14
+ }): Promise<EngineClient>;
package/dist/index.js ADDED
@@ -0,0 +1,277 @@
1
+ // src/index.ts
2
+ import { Client, Connection, WorkflowFailedError, WorkflowNotFoundError } from "@temporalio/client";
3
+ import { Context } from "@temporalio/activity";
4
+ import { NativeConnection, Worker } from "@temporalio/worker";
5
+ import {
6
+ bindStepRuntime,
7
+ checkPriority,
8
+ DEFAULT_PRIORITY,
9
+ exitSignal,
10
+ FlowError as FlowError2
11
+ } from "@iworkflow/core";
12
+
13
+ // src/connection.ts
14
+ var address = (o) => o.address ?? process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
15
+ var namespace = (o) => o.namespace ?? process.env.TEMPORAL_NAMESPACE ?? "default";
16
+ function renewApiKey(o, set) {
17
+ if (!o.apiKey)
18
+ return () => {};
19
+ const apiKey = o.apiKey;
20
+ let busy = false;
21
+ let stopped = false;
22
+ const timer = setInterval(() => {
23
+ if (busy)
24
+ return;
25
+ busy = true;
26
+ apiKey().then((t) => stopped ? undefined : set(t)).catch((e) => stopped || console.error("токен Temporal не обновлён:", e)).finally(() => busy = false);
27
+ }, o.apiKeyRenewMs ?? 60000);
28
+ timer.unref?.();
29
+ return () => {
30
+ stopped = true;
31
+ clearInterval(timer);
32
+ };
33
+ }
34
+ var connectOptions = async (o) => ({
35
+ address: address(o),
36
+ ...o.tls !== undefined ? { tls: o.tls } : {},
37
+ ...o.apiKey ? { apiKey: await o.apiKey() } : {}
38
+ });
39
+
40
+ // src/failure.ts
41
+ import {
42
+ ActivityFailure,
43
+ ApplicationFailure,
44
+ ChildWorkflowFailure,
45
+ TerminatedFailure,
46
+ TimeoutFailure,
47
+ WorkflowExecutionAlreadyStartedError
48
+ } from "@temporalio/common";
49
+ import { Failure, FlowError, isFlowError } from "@iworkflow/core";
50
+ function fromTemporal(e) {
51
+ if (e instanceof WorkflowExecutionAlreadyStartedError)
52
+ return new FlowError(`процесс ${e.workflowId} уже есть`, { reason: "already-started" });
53
+ if (e instanceof ActivityFailure) {
54
+ const exhausted = e.retryState === "MAXIMUM_ATTEMPTS_REACHED";
55
+ const f = cause(e.cause, exhausted);
56
+ if (f && e.retryState === "TIMEOUT")
57
+ return new FlowError(f.message, { reason: "timeout", type: f.type, details: f.details });
58
+ return f ?? e;
59
+ }
60
+ if (e instanceof ChildWorkflowFailure)
61
+ return cause(e.cause, false) ?? e;
62
+ return e;
63
+ }
64
+ function cause(c, exhausted = false) {
65
+ if (c instanceof TimeoutFailure)
66
+ return new FlowError(c.message || "срок вышел", { reason: "timeout", exhausted });
67
+ if (c instanceof TerminatedFailure)
68
+ return new FlowError(c.message || "процесс прерван", { reason: "terminated" });
69
+ if (c instanceof ApplicationFailure)
70
+ return new FlowError(c.message, { reason: "failed", type: c.type ?? undefined, details: c.details?.[0], exhausted });
71
+ return;
72
+ }
73
+ function toTemporal(e, nonRetryable) {
74
+ if (e instanceof Failure)
75
+ return ApplicationFailure.create({
76
+ message: e.message,
77
+ type: e.type,
78
+ details: [e.details],
79
+ nonRetryable: nonRetryable || e.nonRetryable,
80
+ ...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}
81
+ });
82
+ if (isFlowError(e))
83
+ return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
84
+ return e;
85
+ }
86
+
87
+ // src/index.ts
88
+ var SIGNAL = "iwf.signal";
89
+ var QUERY = "iwf.query";
90
+ var poolQueue = (app, pool) => `${app}@${pool}`;
91
+ var STATUS = {
92
+ RUNNING: "running",
93
+ COMPLETED: "completed",
94
+ FAILED: "failed",
95
+ CANCELLED: "terminated",
96
+ TERMINATED: "terminated",
97
+ TIMED_OUT: "timed-out",
98
+ CONTINUED_AS_NEW: "running"
99
+ };
100
+ var notFound = (id) => (e) => {
101
+ if (e instanceof WorkflowNotFoundError)
102
+ throw new FlowError2(`процесса ${id} нет`, { reason: "not-found" });
103
+ throw e;
104
+ };
105
+ var wrap = (h, ready = async () => {}) => ({
106
+ id: h.workflowId,
107
+ result: async () => {
108
+ await ready();
109
+ return h.result().catch((e) => {
110
+ throw e instanceof WorkflowFailedError && cause(e.cause) || e;
111
+ });
112
+ },
113
+ signal: async (topic, value) => {
114
+ await ready();
115
+ await h.signal(SIGNAL, { topic, value });
116
+ },
117
+ query: async (key) => {
118
+ await ready();
119
+ return await h.query(QUERY, key) ?? undefined;
120
+ },
121
+ async describe() {
122
+ await ready();
123
+ const d = await h.describe().catch(notFound(h.workflowId));
124
+ return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
125
+ },
126
+ async terminate(reason) {
127
+ await ready();
128
+ await h.terminate(reason).catch(notFound(h.workflowId));
129
+ }
130
+ });
131
+ var withFailures = (activities) => Object.fromEntries(Object.entries(activities).map(([name, fn]) => [
132
+ name,
133
+ async (...args) => {
134
+ try {
135
+ return await fn(...args);
136
+ } catch (e) {
137
+ throw toTemporal(e, false);
138
+ }
139
+ }
140
+ ]));
141
+ function temporalRuntime(workflowsPath, opts = {}) {
142
+ let workers = [];
143
+ let running = [];
144
+ let conn;
145
+ let starting = false;
146
+ let stopping = false;
147
+ const exit = exitSignal();
148
+ let stopRenew = () => {};
149
+ return {
150
+ async start(def) {
151
+ if (starting)
152
+ throw new Error("воркер уже запущен");
153
+ starting = true;
154
+ exit.arm();
155
+ stopping = false;
156
+ bindStepRuntime({
157
+ heartbeat: async (details) => Context.current().heartbeat(details),
158
+ lastHeartbeat: async () => Context.current().info.heartbeatDetails,
159
+ attempt: () => Context.current().info.attempt
160
+ });
161
+ let c;
162
+ try {
163
+ c = conn = await NativeConnection.connect(await connectOptions(opts));
164
+ } catch (e) {
165
+ starting = false;
166
+ exit.reject(e);
167
+ throw e;
168
+ }
169
+ stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
170
+ try {
171
+ const activities = withFailures(def.activities);
172
+ const base = { connection: c, namespace: namespace(opts), ...opts.plugins ? { plugins: opts.plugins } : {} };
173
+ const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
174
+ workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
175
+ for (const [pool, limit] of Object.entries(def.pools ?? {}))
176
+ workers.push(await Worker.create({
177
+ ...base,
178
+ taskQueue: poolQueue(def.app, pool),
179
+ activities,
180
+ maxConcurrentActivityTaskExecutions: limit
181
+ }));
182
+ } catch (e) {
183
+ await Promise.allSettled(workers.map(async (w) => {
184
+ const r = w.run();
185
+ w.shutdown();
186
+ await r;
187
+ }));
188
+ stopRenew();
189
+ await c.close().catch(() => {});
190
+ conn = undefined;
191
+ workers = [];
192
+ starting = false;
193
+ exit.reject(e);
194
+ throw e;
195
+ }
196
+ running = workers.map((w) => w.run());
197
+ Promise.all(running).then(() => stopping || exit.resolve(), () => {});
198
+ for (const r of running)
199
+ r.catch((e) => stopping || exit.reject(e));
200
+ },
201
+ done: () => exit.promise(),
202
+ async stop() {
203
+ stopping = true;
204
+ for (const w of workers)
205
+ if (w.getState() === "RUNNING")
206
+ w.shutdown();
207
+ const done = await Promise.allSettled(running);
208
+ stopRenew();
209
+ try {
210
+ await conn?.close();
211
+ } finally {
212
+ conn = undefined;
213
+ workers = [];
214
+ running = [];
215
+ starting = false;
216
+ exit.resolve();
217
+ }
218
+ const failed = done.find((r) => r.status === "rejected");
219
+ if (failed)
220
+ throw failed.reason;
221
+ }
222
+ };
223
+ }
224
+ async function temporalClient(opts = {}) {
225
+ let connection;
226
+ let stopRenew;
227
+ let ready = async () => {
228
+ return;
229
+ };
230
+ const plugins = opts.plugins ? { plugins: opts.plugins } : {};
231
+ if (opts.lazy) {
232
+ let token = "";
233
+ const apiKey = opts.apiKey;
234
+ let pending;
235
+ if (apiKey)
236
+ ready = async () => token || (token = await (pending ??= apiKey().finally(() => pending = undefined)));
237
+ connection = Connection.lazy({
238
+ ...await connectOptions({ ...opts, apiKey: undefined }),
239
+ ...apiKey ? { apiKey: () => token } : {},
240
+ ...plugins
241
+ });
242
+ stopRenew = renewApiKey(opts, (t) => token = t);
243
+ } else {
244
+ connection = await Connection.connect({ ...await connectOptions(opts), ...plugins });
245
+ stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
246
+ }
247
+ const client = new Client({ connection, namespace: namespace(opts) });
248
+ const startOptions = (target, args, o) => ({
249
+ taskQueue: target.app,
250
+ workflowId: o.id,
251
+ args,
252
+ ...o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {},
253
+ priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY }
254
+ });
255
+ return {
256
+ async start(target, args, o) {
257
+ await ready();
258
+ const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
259
+ return wrap(h, ready);
260
+ },
261
+ async signalWithStart(target, args, o, topic, value) {
262
+ await ready();
263
+ const h = await client.workflow.signalWithStart(target.flow, {
264
+ ...startOptions(target, args, o),
265
+ signal: SIGNAL,
266
+ signalArgs: [{ topic, value }]
267
+ });
268
+ return wrap(h, ready);
269
+ },
270
+ handle: (id) => wrap(client.workflow.getHandle(id), ready),
271
+ close: () => (stopRenew(), connection.close())
272
+ };
273
+ }
274
+ export {
275
+ temporalClient,
276
+ temporalRuntime
277
+ };
@@ -0,0 +1,7 @@
1
+ import { type Flow } from "@iworkflow/core";
2
+ export declare const SIGNAL = "iwf.signal";
3
+ export declare const QUERY = "iwf.query";
4
+ /** Очередь пула шагов модуля; воркер пула поднимает temporalRuntime. */
5
+ export declare const poolQueue: (app: string, pool: string) => string;
6
+ /** patches — как AppDefinition.patches: необъявленный id — ошибка, как у DBOS. */
7
+ export declare function temporalFlow(flow: Flow, patches?: string[]): (...args: unknown[]) => Promise<unknown>;
@@ -0,0 +1,165 @@
1
+ // src/workflow.ts
2
+ import {
3
+ condition,
4
+ defineQuery,
5
+ defineSignal,
6
+ deprecatePatch,
7
+ executeChild,
8
+ getExternalWorkflowHandle,
9
+ ParentClosePolicy,
10
+ patched,
11
+ proxyActivities,
12
+ setHandler,
13
+ sleep,
14
+ startChild,
15
+ TemporalFailure,
16
+ workflowInfo
17
+ } from "@temporalio/workflow";
18
+ import { checkPriority, DEFAULT_PRIORITY, FlowError as FlowError2 } from "@iworkflow/core";
19
+
20
+ // src/failure.ts
21
+ import {
22
+ ActivityFailure,
23
+ ApplicationFailure,
24
+ ChildWorkflowFailure,
25
+ TerminatedFailure,
26
+ TimeoutFailure,
27
+ WorkflowExecutionAlreadyStartedError
28
+ } from "@temporalio/common";
29
+ import { Failure, FlowError, isFlowError } from "@iworkflow/core";
30
+ function fromTemporal(e) {
31
+ if (e instanceof WorkflowExecutionAlreadyStartedError)
32
+ return new FlowError(`процесс ${e.workflowId} уже есть`, { reason: "already-started" });
33
+ if (e instanceof ActivityFailure) {
34
+ const exhausted = e.retryState === "MAXIMUM_ATTEMPTS_REACHED";
35
+ const f = cause(e.cause, exhausted);
36
+ if (f && e.retryState === "TIMEOUT")
37
+ return new FlowError(f.message, { reason: "timeout", type: f.type, details: f.details });
38
+ return f ?? e;
39
+ }
40
+ if (e instanceof ChildWorkflowFailure)
41
+ return cause(e.cause, false) ?? e;
42
+ return e;
43
+ }
44
+ function cause(c, exhausted = false) {
45
+ if (c instanceof TimeoutFailure)
46
+ return new FlowError(c.message || "срок вышел", { reason: "timeout", exhausted });
47
+ if (c instanceof TerminatedFailure)
48
+ return new FlowError(c.message || "процесс прерван", { reason: "terminated" });
49
+ if (c instanceof ApplicationFailure)
50
+ return new FlowError(c.message, { reason: "failed", type: c.type ?? undefined, details: c.details?.[0], exhausted });
51
+ return;
52
+ }
53
+ function toTemporal(e, nonRetryable) {
54
+ if (e instanceof Failure)
55
+ return ApplicationFailure.create({
56
+ message: e.message,
57
+ type: e.type,
58
+ details: [e.details],
59
+ nonRetryable: nonRetryable || e.nonRetryable,
60
+ ...e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}
61
+ });
62
+ if (isFlowError(e))
63
+ return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
64
+ return e;
65
+ }
66
+
67
+ // src/workflow.ts
68
+ var SIGNAL = "iwf.signal";
69
+ var QUERY = "iwf.query";
70
+ var signalDef = defineSignal(SIGNAL);
71
+ var queryDef = defineQuery(QUERY);
72
+ var poolQueue = (app, pool) => `${app}@${pool}`;
73
+ var proxies = new Map;
74
+ function activitiesWith(opts = {}) {
75
+ const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
76
+ const key = JSON.stringify({ ...opts, priority });
77
+ let p = proxies.get(key);
78
+ if (!p) {
79
+ p = proxyActivities({
80
+ ...opts.pool ? { taskQueue: poolQueue(workflowInfo().taskQueue, opts.pool) } : {},
81
+ startToCloseTimeout: opts.timeoutMs ?? 60000,
82
+ ...opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {},
83
+ ...opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {},
84
+ ...priority ? { priority: { priorityKey: priority } } : {},
85
+ retry: {
86
+ maximumAttempts: opts.attempts ?? 3,
87
+ initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
88
+ backoffCoefficient: opts.backoff ?? 1,
89
+ ...opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {},
90
+ ...opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}
91
+ }
92
+ });
93
+ proxies.set(key, p);
94
+ }
95
+ return p;
96
+ }
97
+ var childOptions = (app, args, opts) => ({
98
+ taskQueue: app,
99
+ workflowId: opts.id,
100
+ args,
101
+ ...opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {},
102
+ ...opts.onExisting === "reject" ? { workflowIdReusePolicy: "REJECT_DUPLICATE" } : {}
103
+ });
104
+ var rethrow = (e) => {
105
+ throw fromTemporal(e);
106
+ };
107
+ function temporalFlow(flow, patches = []) {
108
+ return async (...args) => {
109
+ const inbox = new Map;
110
+ const state = new Map;
111
+ setHandler(signalDef, ({ topic, value }) => {
112
+ const q = inbox.get(topic) ?? [];
113
+ q.push(value);
114
+ inbox.set(topic, q);
115
+ });
116
+ setHandler(queryDef, (key) => state.get(key));
117
+ const info = workflowInfo();
118
+ const ctx = {
119
+ workflowId: info.workflowId,
120
+ parentId: info.parent?.workflowId,
121
+ startedAt: info.startTime,
122
+ priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
123
+ now: async () => Date.now(),
124
+ step: (name, stepArgs, opts) => activitiesWith(opts)[name](...stepArgs).catch(rethrow),
125
+ child: (target, childArgs, opts) => executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow),
126
+ async startChild(target, childArgs, opts) {
127
+ const h = await startChild(target.flow, {
128
+ ...childOptions(target.app, childArgs, { ...opts, onExisting: "reject" }),
129
+ parentClosePolicy: ParentClosePolicy.ABANDON
130
+ }).catch(rethrow);
131
+ return { id: opts.id, result: () => h.result().catch(rethrow) };
132
+ },
133
+ sleep: (ms) => sleep(ms),
134
+ async recv(topic, timeoutMs) {
135
+ const ready = await condition(() => (inbox.get(topic)?.length ?? 0) > 0, timeoutMs);
136
+ return ready ? inbox.get(topic).shift() : null;
137
+ },
138
+ signal: (workflowId, topic, value) => getExternalWorkflowHandle(workflowId).signal(signalDef, { topic, value }).catch((e) => {
139
+ if (!(e instanceof TemporalFailure))
140
+ throw e;
141
+ throw new FlowError2(`процесса ${workflowId} нет или он завершён`, { reason: "not-found" });
142
+ }),
143
+ async setState(key, value) {
144
+ state.set(key, value);
145
+ },
146
+ patched: async (id) => {
147
+ if (!patches.includes(id))
148
+ throw new Error(`патч "${id}" не объявлен в patches процесса`);
149
+ return patched(id);
150
+ },
151
+ deprecatePatch: async (id) => deprecatePatch(id)
152
+ };
153
+ try {
154
+ return await flow(ctx, ...args);
155
+ } catch (e) {
156
+ throw toTemporal(e, true);
157
+ }
158
+ };
159
+ }
160
+ export {
161
+ QUERY,
162
+ SIGNAL,
163
+ poolQueue,
164
+ temporalFlow
165
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/temporal",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Temporal adapter for @iworkflow/core",
5
5
  "keywords": [
6
6
  "workflow",
@@ -13,23 +13,35 @@
13
13
  "directory": "packages/temporal"
14
14
  },
15
15
  "files": [
16
- "src"
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "dist"
17
19
  ],
18
20
  "type": "module",
19
21
  "sideEffects": false,
20
22
  "exports": {
21
- ".": "./src/index.ts",
22
- "./workflow": "./src/workflow.ts"
23
+ ".": {
24
+ "bun": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./workflow": {
29
+ "bun": "./src/workflow.ts",
30
+ "types": "./dist/workflow.d.ts",
31
+ "default": "./dist/workflow.js"
32
+ }
23
33
  },
24
34
  "publishConfig": {
25
35
  "access": "public"
26
36
  },
27
37
  "scripts": {
38
+ "build": "rm -rf dist && bun build src/index.ts src/workflow.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.build.json",
28
39
  "type-check": "tsc --noEmit -p tsconfig.json",
29
- "test": "bun test"
40
+ "test": "bun test",
41
+ "prepack": "bun run build"
30
42
  },
31
43
  "dependencies": {
32
- "@iworkflow/core": "^0.3.0"
44
+ "@iworkflow/core": "^0.5.0"
33
45
  },
34
46
  "peerDependencies": {
35
47
  "@temporalio/activity": "^1.24.0",
@@ -44,5 +56,8 @@
44
56
  "@temporalio/common": "^1.24.0",
45
57
  "@temporalio/worker": "^1.24.0",
46
58
  "@temporalio/workflow": "^1.24.0"
59
+ },
60
+ "engines": {
61
+ "node": ">=22"
47
62
  }
48
63
  }
package/src/connection.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Подключение к Temporal: адрес, TLS, токен с заменой на месте — общее для воркера и клиента.
2
- import type { TLSConfig } from "@temporalio/client";
2
+ import type { ConnectionPlugin, TLSConfig } from "@temporalio/client";
3
+ import type { WorkerPlugin } from "@temporalio/worker";
3
4
 
4
5
  /** Подключение к Temporal; не задано — TEMPORAL_URL и TEMPORAL_NAMESPACE из окружения. */
5
6
  export interface TemporalOptions {
@@ -10,6 +11,8 @@ export interface TemporalOptions {
10
11
  /** Источник токена; соединение берёт свежий каждые apiKeyRenewMs (по умолчанию 60 с). */
11
12
  apiKey?: () => Promise<string>;
12
13
  apiKeyRenewMs?: number;
14
+ /** Плагины SDK (OpenTelemetry и т. п.) — воркерам и клиенту. */
15
+ plugins?: (ConnectionPlugin & WorkerPlugin)[];
13
16
  }
14
17
 
15
18
  export const address = (o: TemporalOptions) => o.address ?? process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
package/src/failure.ts CHANGED
@@ -41,6 +41,8 @@ export function toTemporal(e: unknown, nonRetryable: boolean): unknown {
41
41
  type: e.type,
42
42
  details: [e.details],
43
43
  nonRetryable: nonRetryable || e.nonRetryable,
44
+ // 0 сервер считает «не задано» и берёт паузу политики; 1 мс — «сразу», как у DBOS.
45
+ ...(e.retryAfterMs !== undefined ? { nextRetryDelay: Math.max(e.retryAfterMs, 1) } : {}),
44
46
  });
45
47
  if (isFlowError(e))
46
48
  return ApplicationFailure.create({ message: e.message, type: e.type ?? e.reason, details: [e.details], nonRetryable });
package/src/index.ts CHANGED
@@ -4,13 +4,18 @@ import { Context } from "@temporalio/activity";
4
4
  import { NativeConnection, Worker } from "@temporalio/worker";
5
5
  import {
6
6
  bindStepRuntime,
7
+ checkPriority,
8
+ DEFAULT_PRIORITY,
9
+ exitSignal,
7
10
  FlowError,
8
11
  type Activity,
9
12
  type AppDefinition,
10
13
  type EngineClient,
11
14
  type EngineRuntime,
12
15
  type FlowHandle,
16
+ type FlowRef,
13
17
  type FlowStatus,
18
+ type StartOptions,
14
19
  } from "@iworkflow/core";
15
20
  import { address, connectOptions, namespace, renewApiKey, type TemporalOptions } from "./connection";
16
21
  import { cause, toTemporal } from "./failure";
@@ -37,19 +42,32 @@ const notFound = (id: string) => (e: unknown): never => {
37
42
  throw e;
38
43
  };
39
44
 
40
- const wrap = <T>(h: WorkflowHandle): FlowHandle<T> => ({
45
+ /** ready — токен ленивого клиента: без него SDK уйдёт с пустым ключом. */
46
+ const wrap = <T>(h: WorkflowHandle, ready: () => Promise<unknown> = async () => {}): FlowHandle<T> => ({
41
47
  id: h.workflowId,
42
- result: () =>
43
- h.result().catch((e) => {
48
+ result: async () => {
49
+ await ready();
50
+ return h.result().catch((e) => {
44
51
  throw (e instanceof WorkflowFailedError && cause(e.cause)) || e;
45
- }),
46
- signal: (topic, value) => h.signal(SIGNAL, { topic, value }),
47
- query: async <V>(key: string) => ((await h.query(QUERY, key)) ?? undefined) as V | undefined,
52
+ });
53
+ },
54
+ signal: async (topic, value) => {
55
+ await ready();
56
+ await h.signal(SIGNAL, { topic, value });
57
+ },
58
+ query: async <V>(key: string) => {
59
+ await ready();
60
+ return ((await h.query(QUERY, key)) ?? undefined) as V | undefined;
61
+ },
48
62
  async describe() {
63
+ await ready();
49
64
  const d = await h.describe().catch(notFound(h.workflowId));
50
65
  return { id: h.workflowId, status: STATUS[d.status.name] ?? "running", startedAt: d.startTime };
51
66
  },
52
- terminate: async (reason) => void (await h.terminate(reason).catch(notFound(h.workflowId))),
67
+ async terminate(reason) {
68
+ await ready();
69
+ await h.terminate(reason).catch(notFound(h.workflowId));
70
+ },
53
71
  });
54
72
 
55
73
  /** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
@@ -79,27 +97,33 @@ export function temporalRuntime(
79
97
  let running: Promise<void>[] = [];
80
98
  let conn: NativeConnection | undefined;
81
99
  let starting = false;
100
+ let stopping = false;
101
+ const exit = exitSignal();
82
102
  let stopRenew = () => {};
83
103
  return {
84
104
  async start(def: AppDefinition) {
85
105
  if (starting) throw new Error("воркер уже запущен");
86
106
  starting = true;
107
+ exit.arm();
108
+ stopping = false;
87
109
  bindStepRuntime({
88
110
  heartbeat: async (details) => Context.current().heartbeat(details),
89
111
  lastHeartbeat: async () => Context.current().info.heartbeatDetails,
112
+ attempt: () => Context.current().info.attempt,
90
113
  });
91
114
  let c: NativeConnection;
92
115
  try {
93
116
  c = conn = await NativeConnection.connect(await connectOptions(opts));
94
117
  } catch (e) {
95
118
  starting = false;
119
+ exit.reject(e);
96
120
  throw e;
97
121
  }
98
122
  stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
99
123
  // Воркер не создался (нет бандла, namespace) — соединение и таймер токена закрываются.
100
124
  try {
101
125
  const activities = withFailures(def.activities);
102
- const base = { connection: c, namespace: namespace(opts) };
126
+ const base = { connection: c, namespace: namespace(opts), ...(opts.plugins ? { plugins: opts.plugins } : {}) };
103
127
  const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
104
128
  workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
105
129
  // Пул — своя очередь с пределом одновременных шагов в этом процессе.
@@ -124,11 +148,19 @@ export function temporalRuntime(
124
148
  conn = undefined;
125
149
  workers = [];
126
150
  starting = false;
151
+ exit.reject(e);
127
152
  throw e;
128
153
  }
129
154
  running = workers.map((w) => w.run());
155
+ // Воркер упал сам — done() отклоняется сразу, не дожидаясь stop.
156
+ // Отказ run() во время stop (срок дренажа) — не падение: done() решает stop.
157
+ // Воркеры погасил сам Runtime (SIGTERM) без stop — тоже конец цикла.
158
+ void Promise.all(running).then(() => stopping || exit.resolve(), () => {});
159
+ for (const r of running) r.catch((e) => stopping || exit.reject(e));
130
160
  },
161
+ done: () => exit.promise(),
131
162
  async stop() {
163
+ stopping = true;
132
164
  for (const w of workers) if (w.getState() === "RUNNING") w.shutdown();
133
165
  // Упавший воркер не мешает закрыть соединение и таймер токена.
134
166
  const done = await Promise.allSettled(running);
@@ -140,6 +172,7 @@ export function temporalRuntime(
140
172
  workers = [];
141
173
  running = [];
142
174
  starting = false;
175
+ exit.resolve();
143
176
  }
144
177
  const failed = done.find((r) => r.status === "rejected");
145
178
  if (failed) throw (failed as PromiseRejectedResult).reason;
@@ -147,22 +180,59 @@ export function temporalRuntime(
147
180
  };
148
181
  }
149
182
 
150
- export async function temporalClient(opts: TemporalOptions = {}): Promise<EngineClient> {
151
- const connection = await Connection.connect(await connectOptions(opts));
152
- const stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
183
+ /** lazy — соединение при первом вызове: модуль поднимается и без Temporal. */
184
+ export async function temporalClient(opts: TemporalOptions & { lazy?: boolean } = {}): Promise<EngineClient> {
185
+ let connection: Connection;
186
+ let stopRenew: () => void;
187
+ let ready = async (): Promise<unknown> => undefined;
188
+ const plugins = opts.plugins ? { plugins: opts.plugins } : {};
189
+ if (opts.lazy) {
190
+ // Ни Temporal, ни источник токена не нужны для старта: ключ — функцией, берётся при первом вызове.
191
+ let token = "";
192
+ const apiKey = opts.apiKey;
193
+ // Неудача не залипает: следующий вызов спросит токен снова.
194
+ // Один запрос на всех ждущих; неудача не залипает: следующий вызов спросит снова.
195
+ let pending: Promise<string> | undefined;
196
+ if (apiKey)
197
+ ready = async () =>
198
+ token ||
199
+ (token = await (pending ??= apiKey().finally(() => (pending = undefined))));
200
+ connection = Connection.lazy({
201
+ ...(await connectOptions({ ...opts, apiKey: undefined })),
202
+ ...(apiKey ? { apiKey: () => token } : {}),
203
+ ...plugins,
204
+ });
205
+ stopRenew = renewApiKey(opts, (t) => (token = t));
206
+ } else {
207
+ connection = await Connection.connect({ ...(await connectOptions(opts)), ...plugins });
208
+ stopRenew = renewApiKey(opts, (t) => connection.setApiKey(t));
209
+ }
210
+ // Плагины — у соединения: оно само передаёт их клиенту.
153
211
  const client = new Client({ connection, namespace: namespace(opts) });
212
+ const startOptions = (target: FlowRef, args: unknown[], o: StartOptions) => ({
213
+ taskQueue: target.app,
214
+ workflowId: o.id,
215
+ args,
216
+ ...(o.timeoutMs ? { workflowExecutionTimeout: o.timeoutMs } : {}),
217
+ // Явно и по умолчанию: ctx.priority и очередь видят одно и то же, как у DBOS.
218
+ priority: { priorityKey: checkPriority(o.priority) ?? DEFAULT_PRIORITY },
219
+ });
154
220
  return {
155
- async start(target, args, opts) {
156
- const h = await client.workflow.start(target.flow, {
157
- taskQueue: target.app,
158
- workflowId: opts.id,
159
- args,
160
- workflowIdConflictPolicy: "USE_EXISTING",
161
- ...(opts.timeoutMs ? { workflowExecutionTimeout: opts.timeoutMs } : {}),
221
+ async start(target, args, o) {
222
+ await ready();
223
+ const h = await client.workflow.start(target.flow, { ...startOptions(target, args, o), workflowIdConflictPolicy: "USE_EXISTING" });
224
+ return wrap(h, ready);
225
+ },
226
+ async signalWithStart(target, args, o, topic, value) {
227
+ await ready();
228
+ const h = await client.workflow.signalWithStart(target.flow, {
229
+ ...startOptions(target, args, o),
230
+ signal: SIGNAL,
231
+ signalArgs: [{ topic, value }],
162
232
  });
163
- return wrap(h);
233
+ return wrap(h, ready);
164
234
  },
165
- handle: (id) => wrap(client.workflow.getHandle(id)),
235
+ handle: (id) => wrap(client.workflow.getHandle(id), ready),
166
236
  close: () => (stopRenew(), connection.close()),
167
237
  };
168
238
  }
package/src/workflow.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  workflowInfo,
19
19
  } from "@temporalio/workflow";
20
20
 
21
- import { FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
21
+ import { checkPriority, DEFAULT_PRIORITY, FlowError, type ChildOptions, type Flow, type FlowContext, type StepOptions } from "@iworkflow/core";
22
22
  import { fromTemporal, toTemporal } from "./failure";
23
23
 
24
24
  export const SIGNAL = "iwf.signal";
@@ -32,7 +32,9 @@ export const poolQueue = (app: string, pool: string) => `${app}@${pool}`;
32
32
  const proxies = new Map<string, Record<string, (...a: unknown[]) => Promise<unknown>>>();
33
33
 
34
34
  function activitiesWith(opts: StepOptions = {}) {
35
- const key = JSON.stringify(opts);
35
+ // Шаг без своего приоритета — с приоритетом процесса, как у Temporal по умолчанию.
36
+ const priority = checkPriority(opts.priority) ?? workflowInfo().priority?.priorityKey;
37
+ const key = JSON.stringify({ ...opts, priority });
36
38
  let p = proxies.get(key);
37
39
  if (!p) {
38
40
  p = proxyActivities({
@@ -40,9 +42,11 @@ function activitiesWith(opts: StepOptions = {}) {
40
42
  startToCloseTimeout: opts.timeoutMs ?? 60_000,
41
43
  ...(opts.totalTimeoutMs ? { scheduleToCloseTimeout: opts.totalTimeoutMs } : {}),
42
44
  ...(opts.heartbeatTimeoutMs ? { heartbeatTimeout: opts.heartbeatTimeoutMs } : {}),
45
+ ...(priority ? { priority: { priorityKey: priority } } : {}),
43
46
  retry: {
44
47
  maximumAttempts: opts.attempts ?? 3,
45
- initialInterval: opts.retryIntervalMs ?? 200,
48
+ // 0 Temporal не принимает (ValueError — вечный повтор задачи процесса): 1 мс — «сразу», как у DBOS.
49
+ initialInterval: Math.max(opts.retryIntervalMs ?? 200, 1),
46
50
  backoffCoefficient: opts.backoff ?? 1,
47
51
  ...(opts.maxRetryIntervalMs ? { maximumInterval: opts.maxRetryIntervalMs } : {}),
48
52
  ...(opts.nonRetryableTypes ? { nonRetryableErrorTypes: opts.nonRetryableTypes } : {}),
@@ -83,6 +87,9 @@ export function temporalFlow(flow: Flow, patches: string[] = []) {
83
87
  workflowId: info.workflowId,
84
88
  parentId: info.parent?.workflowId,
85
89
  startedAt: info.startTime,
90
+ priority: info.priority?.priorityKey || DEFAULT_PRIORITY,
91
+ // Date.now() в песочнице Temporal детерминирован: время задачи процесса.
92
+ now: async () => Date.now(),
86
93
  step: (name, stepArgs, opts) => activitiesWith(opts)[name]!(...stepArgs).catch(rethrow) as Promise<never>,
87
94
  child: (target, childArgs, opts) =>
88
95
  executeChild(target.flow, childOptions(target.app, childArgs, opts)).catch(rethrow) as Promise<never>,
@@ -1,34 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { connectOptions, renewApiKey } from "./connection";
3
-
4
- test("токен берётся при подключении и меняется по таймеру", async () => {
5
- let n = 0;
6
- const apiKey = async () => `t${++n}`;
7
- expect(await connectOptions({ address: "a:1", tls: true, apiKey })).toEqual({ address: "a:1", tls: true, apiKey: "t1" });
8
- const got: string[] = [];
9
- const stop = renewApiKey({ apiKey, apiKeyRenewMs: 10 }, (t) => got.push(t));
10
- while (got.length < 2) await Bun.sleep(5);
11
- stop();
12
- const seen = got.length;
13
- await Bun.sleep(25);
14
- expect(seen).toBeGreaterThanOrEqual(2);
15
- expect(got.length).toBe(seen);
16
- });
17
-
18
- test("без токена и TLS — только адрес", async () => {
19
- expect(await connectOptions({ address: "a:1" })).toEqual({ address: "a:1" });
20
- });
21
-
22
- test("медленный запрос не затирает свежий токен, после остановки токен не ставится", async () => {
23
- let release!: (t: string) => void;
24
- let calls = 0;
25
- const apiKey = () => (++calls === 1 ? new Promise<string>((r) => (release = r)) : Promise.resolve(`t${calls}`));
26
- const got: string[] = [];
27
- const stop = renewApiKey({ apiKey, apiKeyRenewMs: 5 }, (t) => got.push(t));
28
- await Bun.sleep(40);
29
- expect(calls).toBe(1);
30
- stop();
31
- release("old");
32
- await Bun.sleep(5);
33
- expect(got).toEqual([]);
34
- });