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