@iworkflow/temporal 0.2.0 → 0.3.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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iworkflow/temporal",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Temporal adapter for @iworkflow/core",
5
5
  "keywords": [
6
6
  "workflow",
@@ -29,7 +29,7 @@
29
29
  "test": "bun test"
30
30
  },
31
31
  "dependencies": {
32
- "@iworkflow/core": "^0.2.0"
32
+ "@iworkflow/core": "^0.3.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@temporalio/activity": "^1.24.0",
@@ -0,0 +1,34 @@
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
+ });
@@ -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
@@ -12,8 +12,11 @@ import {
12
12
  type FlowHandle,
13
13
  type FlowStatus,
14
14
  } from "@iworkflow/core";
15
+ import { address, connectOptions, namespace, renewApiKey, type TemporalOptions } from "./connection";
15
16
  import { cause, toTemporal } from "./failure";
16
17
 
18
+ export type { TemporalOptions };
19
+
17
20
  // Те же имена, что в workflow.ts: его нельзя импортировать вне песочницы.
18
21
  const SIGNAL = "iwf.signal";
19
22
  const QUERY = "iwf.query";
@@ -49,9 +52,6 @@ const wrap = <T>(h: WorkflowHandle): FlowHandle<T> => ({
49
52
  terminate: async (reason) => void (await h.terminate(reason).catch(notFound(h.workflowId))),
50
53
  });
51
54
 
52
- const address = () => process.env.TEMPORAL_URL ?? "127.0.0.1:17233";
53
- const namespace = () => process.env.TEMPORAL_NAMESPACE ?? "default";
54
-
55
55
  /** Шаги отдают Failure как ApplicationFailure: тип, детали и nonRetryable доезжают до процесса. */
56
56
  const withFailures = (activities: Record<string, Activity>) =>
57
57
  Object.fromEntries(
@@ -67,44 +67,90 @@ const withFailures = (activities: Record<string, Activity>) =>
67
67
  ]),
68
68
  );
69
69
 
70
- /** workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса. */
71
- export function temporalRuntime(workflowsPath: string): EngineRuntime {
70
+ /**
71
+ * workflowsPath — вход песочницы модуля: temporalFlow(...) на каждый процесс, имя экспорта = имя процесса.
72
+ * workflowBundlePath — готовый бандл того же входа (прод-сборка); с ним воркер не собирает код сам.
73
+ */
74
+ export function temporalRuntime(
75
+ workflowsPath: string,
76
+ opts: TemporalOptions & { workflowBundlePath?: string } = {},
77
+ ): EngineRuntime {
72
78
  let workers: Worker[] = [];
73
79
  let running: Promise<void>[] = [];
74
80
  let conn: NativeConnection | undefined;
81
+ let starting = false;
82
+ let stopRenew = () => {};
75
83
  return {
76
84
  async start(def: AppDefinition) {
85
+ if (starting) throw new Error("воркер уже запущен");
86
+ starting = true;
77
87
  bindStepRuntime({
78
88
  heartbeat: async (details) => Context.current().heartbeat(details),
79
89
  lastHeartbeat: async () => Context.current().info.heartbeatDetails,
80
90
  });
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
- );
91
+ let c: NativeConnection;
92
+ try {
93
+ c = conn = await NativeConnection.connect(await connectOptions(opts));
94
+ } catch (e) {
95
+ starting = false;
96
+ throw e;
97
+ }
98
+ stopRenew = renewApiKey(opts, (t) => c.setApiKey(t));
99
+ // Воркер не создался (нет бандла, namespace) — соединение и таймер токена закрываются.
100
+ try {
101
+ const activities = withFailures(def.activities);
102
+ const base = { connection: c, namespace: namespace(opts) };
103
+ const code = opts.workflowBundlePath ? { workflowBundle: { codePath: opts.workflowBundlePath } } : { workflowsPath };
104
+ workers = [await Worker.create({ ...base, taskQueue: def.app, ...code, activities })];
105
+ // Пул — своя очередь с пределом одновременных шагов в этом процессе.
106
+ for (const [pool, limit] of Object.entries(def.pools ?? {}))
107
+ workers.push(
108
+ await Worker.create({
109
+ ...base,
110
+ taskQueue: poolQueue(def.app, pool),
111
+ activities,
112
+ maxConcurrentActivityTaskExecutions: limit,
113
+ }),
114
+ );
115
+ } catch (e) {
116
+ // Созданный, но не запущенный воркер держит соединение: запустить и сразу погасить.
117
+ await Promise.allSettled(workers.map(async (w) => {
118
+ const r = w.run();
119
+ w.shutdown();
120
+ await r;
121
+ }));
122
+ stopRenew();
123
+ await c.close().catch(() => {});
124
+ conn = undefined;
125
+ workers = [];
126
+ starting = false;
127
+ throw e;
128
+ }
95
129
  running = workers.map((w) => w.run());
96
130
  },
97
131
  async stop() {
98
- for (const w of workers) w.shutdown();
99
- await Promise.all(running);
100
- await conn?.close();
132
+ for (const w of workers) if (w.getState() === "RUNNING") w.shutdown();
133
+ // Упавший воркер не мешает закрыть соединение и таймер токена.
134
+ const done = await Promise.allSettled(running);
135
+ stopRenew();
136
+ try {
137
+ await conn?.close();
138
+ } finally {
139
+ conn = undefined;
140
+ workers = [];
141
+ running = [];
142
+ starting = false;
143
+ }
144
+ const failed = done.find((r) => r.status === "rejected");
145
+ if (failed) throw (failed as PromiseRejectedResult).reason;
101
146
  },
102
147
  };
103
148
  }
104
149
 
105
- export async function temporalClient(): Promise<EngineClient> {
106
- const connection = await Connection.connect({ address: address() });
107
- const client = new Client({ connection, namespace: namespace() });
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));
153
+ const client = new Client({ connection, namespace: namespace(opts) });
108
154
  return {
109
155
  async start(target, args, opts) {
110
156
  const h = await client.workflow.start(target.flow, {
@@ -117,6 +163,6 @@ export async function temporalClient(): Promise<EngineClient> {
117
163
  return wrap(h);
118
164
  },
119
165
  handle: (id) => wrap(client.workflow.getHandle(id)),
120
- close: () => connection.close(),
166
+ close: () => (stopRenew(), connection.close()),
121
167
  };
122
168
  }