slapflow 1.0.2 → 1.1.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
@@ -14,6 +14,12 @@ Slapflow takes care of orchestration, concurrency, cancellation, and diagnostics
14
14
  - **Make async behavior deliberate.** Choose `parallel`, `latest`, `queue`, or `drop` for each event source. Actions receive an `AbortSignal` when cancellation matters.
15
15
  - **Use the same flow in more than one place.** The chain can start from a typed bus, DOM event, API callback, timer, worker, or WebSocket message.
16
16
 
17
+ ### When to use Slapflow
18
+
19
+ Slapflow orchestrates a control flow that lives and finishes inside a single process. Reach for it when a scenario spans several steps, branches, and failure paths and you want it in one place rather than spread across handlers and callbacks.
20
+
21
+ **What it is not:** a web framework (no routing, no middleware stack — pair it with Express, Hono, or Next.js routes) or a job queue (no built-in persistence, no distributed workers). It is also not a durable workflow engine: a chain runs inside a live process and stops with it — a `core.loop` can spin indefinitely while the process runs, but unlike Temporal there is no state that survives a crash or restart unless your application prevails it. It is closest to a lightweight, in-process state machine: decisions, ordering, concurrency, and cancellation live in a chain, while your application keeps the domain state and side effects.
22
+
17
23
  ## Installation
18
24
 
19
25
  ```bash
@@ -163,6 +169,7 @@ const config = {
163
169
 
164
170
  ## Where to go next
165
171
 
172
+ - See [slapflow-studio](https://github.com/khalilov/slapflow-studio), a working application built entirely on Slapflow.
166
173
  - Read the complete [technical specification](SPEC.md) for the runner API, built-in actions and conditions, expressions, validation, safety limits, transport behavior, and lifecycle semantics.
167
174
  - Russian documentation: [README-RU.md](README-RU.md) and [SPEC-RU.md](SPEC-RU.md).
168
175
 
package/SPEC-RU.md CHANGED
@@ -45,8 +45,14 @@ type Config = {
45
45
  version?: 1
46
46
  strategies: Record<string, Strategy>
47
47
  entrypoints?: Record<string, string>
48
+ guards?: Record<string, ConditionExpression>
48
49
  }
49
50
 
51
+ type ConditionExpression =
52
+ | boolean
53
+ | [operator: string, ...args: unknown[]]
54
+ | ['guard', name: string]
55
+
50
56
  type Strategy = {
51
57
  fn: string
52
58
  props?: Record<string, unknown>
@@ -113,6 +119,21 @@ type ErrorStage = {
113
119
 
114
120
  Если ошибка обработана через `catch`, `onError` всё равно вызывается для исходного сбоя, а итоговый `run` может завершиться со статусом `success`.
115
121
 
122
+ ## Нормализация возврата действия
123
+
124
+ Возвращаемое значение действия нормализуется в один итог. Соответствие:
125
+
126
+ | Возврат | Итог |
127
+ | ------------------------------------------------- | ------------------------------------------------------------ |
128
+ | `undefined` / `null` | `success` |
129
+ | `false` | `skipped` |
130
+ | `{ type: 'skip', reason?, data? }` | `skipped` (селектор пробует следующую ветку) |
131
+ | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (цепочка останавливается без ошибки) |
132
+ | `{ type: 'fail', reason?, data?, error? }` | `failed` (запускается `catch`, затем `onError`) |
133
+ | `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` прерывает оставшиеся `then`-цели |
134
+
135
+ Брошенное исключение трактуется как `fail`. Возврат `false` и `{ type: 'skip' }` эквивалентны.
136
+
116
137
  ## Модель реестров
117
138
 
118
139
  Исполнитель использует собственные реестры:
@@ -225,6 +246,18 @@ export const config = {
225
246
 
226
247
  `parallel` запускает цели `then` независимо. Простые объекты и массивы контекста и runtime data копируются для каждой ветки; инфраструктурные значения вроде функций, DOM-узлов и экземпляров классов остаются ссылками. Safety limits, включая `maxStepCount`, остаются общими для всего запуска. Полученные патчи и события возвращаются вызывающей стороне; исполнитель их не применяет.
227
248
 
249
+ ### Прерывание цепочки
250
+
251
+ Не-`success` итог шага меняет дальнейшее поведение в зависимости от режима:
252
+
253
+ | Итог | `sequence` | `selector` |
254
+ | -------- | ------------------------- | -------------------------- |
255
+ | `skipped`| **прерывает остаток** | пробует следующую ветку |
256
+
257
+ `sequence` — режим по умолчанию, и он прерывает оставшиеся `then`-цели на *любом* не-`success` (`skipped`, `stopped`, `failed`) — не только на сбое. Условный шаг внутри последовательности — это, таким образом, скрытый ранний выход для всего остатка. Если пропуск шага не должен рвать цепочку, заверните его в селектор с запасным `core.noop`.
258
+
259
+ `terminal: true` останавливает `then`-цепочку после этой стратегии даже при `success`; `continue: false` в `ActionSuccess` даёт тот же эффект.
260
+
228
261
  ## Вспомогательные средства среды выполнения
229
262
 
230
263
  ```ts
@@ -261,6 +294,29 @@ type Runtime = {
261
294
 
262
295
  Чтение и запись путей во время выполнения реализованы непосредственно через `objwalk`.
263
296
 
297
+ ## Guards
298
+
299
+ Переиспользуемые выражения `when` живут в карте `guards` на `Config` и подключаются к `when` стратегии (или шага `then`/`catch`) узлом `['guard', имя]`:
300
+
301
+ ```ts
302
+ const config = {
303
+ guards: {
304
+ 'has-colony': ['truthy', '$data.colonyId'],
305
+ 'same-colony': ['eq', '$input.colonyId', '$context.colonyId'],
306
+ },
307
+ strategies: {
308
+ 'colony.join': {
309
+ fn: 'colony.join',
310
+ when: ['and', ['guard', 'has-colony'], ['not', ['guard', 'same-colony']]],
311
+ },
312
+ },
313
+ }
314
+ ```
315
+
316
+ Guard — это обычное `ConditionExpression`, и сам может ссылаться на другие guards. Ссылки раскрываются один раз при загрузке конфигурации (`loadConfig`), до того как рантайм что-либо вычисляет, поэтому рантайм никогда не видит узел `['guard', ...]`. Guards раскрываются рекурсивно через `and`/`or`/`not`; ссылка на несуществующий guard даёт ошибку валидации `GUARD_NOT_FOUND`, а взаимные ссылки — `GUARD_CYCLE`. Значение guard должно быть выражением-условием, а не строкой `$path`.
317
+
318
+ Guards существуют, чтобы критерий истинности жил в одном месте, а не дублировался по стратегиям; это вычисляемые данные, а не зарегистрированный код (в отличие от `registerCondition`, который регистрирует функцию-оператор).
319
+
264
320
  ## Проверка конфигурации
265
321
 
266
322
  `validateConfig` проверяет:
@@ -270,7 +326,8 @@ type Runtime = {
270
326
  - отсутствующие стратегии в `then`, `catch` и `entrypoints`;
271
327
  - недопустимые режимы;
272
328
  - недопустимые ссылки на пути;
273
- - циклы без завершающего шага.
329
+ - циклы без завершающего шага;
330
+ - ссылки на guards (`GUARD_NOT_FOUND`, `GUARD_CYCLE`, `GUARD_INVALID`).
274
331
 
275
332
  ## Трассировка
276
333
 
@@ -308,11 +365,14 @@ unsubscribe()
308
365
 
309
366
  ```ts
310
367
  type Bus<TEvents extends object = Record<string, unknown>> = {
311
- on<TEvent extends keyof TEvents>(
312
- event: TEvent,
313
- handler: (event: BusEvent<TEvents[TEvent]>) => void
314
- ): () => void
315
- off<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
368
+ on: {
369
+ <TEvent extends keyof TEvents>(event: TEvent, handler: (event: BusEvent<TEvents[TEvent]>) => void): () => void
370
+ (event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void
371
+ }
372
+ off: {
373
+ <TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
374
+ (event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void
375
+ }
316
376
  emit<TEvent extends keyof TEvents>(
317
377
  topic: TEvent,
318
378
  payload: TEvents[TEvent],
@@ -320,6 +380,8 @@ type Bus<TEvents extends object = Record<string, unknown>> = {
320
380
  ): BusEvent<TEvents[TEvent]>
321
381
  }
322
382
 
383
+ type EventPattern = `${string}*${string}`
384
+
323
385
  type BusEvent<TPayload> = {
324
386
  id: string
325
387
  topic: string
@@ -332,6 +394,18 @@ type BusEvent<TPayload> = {
332
394
 
333
395
  `emit` создаёт конверт и сериализует полезную нагрузку один раз до запуска подписчиков. Идентификаторы событий — непрозрачные 12-символьные буквенно-цифровые runtime-ID для корреляции и подавления эха. Они не криптографически стойкие: не используйте их для access token, подписей, публичных ссылок или иных security-sensitive задач. `on` возвращает функцию отписки. `off(event, handler)` удаляет один обработчик, а `off(event)` очищает канал. Ошибка одного подписчика не блокирует остальных; `createPubSub({ onError })` получает ошибку и исходное событие. При ошибке сериализации шина передаёт `{ error }` в качестве `parsed` и тело ошибки в качестве `serialized`, после чего вызывает `onError` с исходной причиной.
334
396
 
397
+ ### Подписка по шаблону
398
+
399
+ На тему можно подписаться по шаблону, где `*` соответствует ровно одному сегменту, разделённому точкой. Шаблонный символ не пересекает границу `.`.
400
+
401
+ ```ts
402
+ bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
403
+ bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
404
+ bus.on('hub.*.export', ({ parsed }) => {}) // НЕ hub.user.audit.export (один сегмент)
405
+ ```
406
+
407
+ Подписки с точным именем остаются O(1); шаблоны обрабатываются отдельно, поэтому регистрации без `*` не несут накладных затрат на сравнение. Обработчик шаблона получает `parsed` как `unknown` — сужайте тип перед использованием. Шаблоны работают в `bus.on`/`bus.off`, а также в `inboundTopics`/`outboundTopics` у `createWS`.
408
+
335
409
  ## Поток
336
410
 
337
411
  `createFlow` объединяет конфигурацию, действия, условия, поставщик контекста и привязки событий. Функция создаёт исполнитель (доступный через `flow.runner`) и поддерживает жизненный цикл `start`/`stop`.
@@ -432,7 +506,7 @@ const ws = createWS({
432
506
  ws.start()
433
507
  ```
434
508
 
435
- Входящие темы проходят через явный список разрешений. Мост разбирает JSON-конверт и вызывает `bus.dispatch(event)`, сохраняя `id`, `occurredAt`, `origin`, `parsed` и `serialized`; принятые входящие идентификаторы запоминаются и не отправляются обратно наружу. Исходящие темы отправляют полный JSON-конверт события, поэтому удалённый мост может передать его в шину, не создавая новый идентификатор. `maxAttempts` ограничивает reconnect; без него повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом транспорта. Диагностические события: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying` и `slapflow.ws.message.rejected`.
509
+ Входящие темы проходят через явный список разрешений. Каждая запись может быть точной темой или шаблоном, где `*` соответствует одному сегменту, разделённому точкой. Мост разбирает JSON-конверт и вызывает `bus.dispatch(event)`, сохраняя `id`, `occurredAt`, `origin`, `parsed` и `serialized`; принятые входящие идентификаторы запоминаются и не отправляются обратно наружу. Исходящие темы отправляют полный JSON-конверт события, поэтому удалённый мост может передать его в шину, не создавая новый идентификатор. `maxAttempts` ограничивает reconnect; без него повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом транспорта. Диагностические события: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying` и `slapflow.ws.message.rejected`.
436
510
 
437
511
  ## Ограничения безопасности
438
512
 
package/SPEC.md CHANGED
@@ -45,8 +45,14 @@ type Config = {
45
45
  version?: 1
46
46
  strategies: Record<string, Strategy>
47
47
  entrypoints?: Record<string, string>
48
+ guards?: Record<string, ConditionExpression>
48
49
  }
49
50
 
51
+ type ConditionExpression =
52
+ | boolean
53
+ | [operator: string, ...args: unknown[]]
54
+ | ['guard', name: string]
55
+
50
56
  type Strategy = {
51
57
  fn: string
52
58
  props?: Record<string, unknown>
@@ -113,6 +119,21 @@ type ErrorStage = {
113
119
 
114
120
  If an error is recovered through `catch`, `onError` is still invoked for the original failure and the final `run` may finish with `success`.
115
121
 
122
+ ## Action Return Normalization
123
+
124
+ An action's return value is normalized into one outcome. The mapping:
125
+
126
+ | Return | Outcome |
127
+ | ------------------------------------------------- | -------------------------------------------------------------- |
128
+ | `undefined` / `null` | `success` |
129
+ | `false` | `skipped` |
130
+ | `{ type: 'skip', reason?, data? }` | `skipped` (a selector tries the next branch) |
131
+ | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (the chain halts without error) |
132
+ | `{ type: 'fail', reason?, data?, error? }` | `failed` (`catch` runs, then `onError`) |
133
+ | `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` halts the remaining `then` targets |
134
+
135
+ A thrown exception is treated as `fail`. Returning `false` and `{ type: 'skip' }` are equivalent.
136
+
116
137
  ## Registry Model
117
138
 
118
139
  The runner uses runner-scoped registries:
@@ -225,6 +246,18 @@ export const config = {
225
246
 
226
247
  `parallel` runs `then` targets independently. Plain objects and arrays in runtime context and data are cloned for each branch; infrastructure values such as functions, DOM nodes, and class instances remain references. Safety limits, including `maxStepCount`, remain shared by the whole run. Resulting patches and events are returned to the caller; the runner does not apply them.
227
248
 
249
+ ### Chain interruption
250
+
251
+ A non-`success` outcome at a step changes what happens next, depending on the mode:
252
+
253
+ | Outcome | `sequence` | `selector` |
254
+ | --------- | ---------------------- | ------------------------ |
255
+ | `skipped` | **interrupts the rest** | tries the next branch |
256
+
257
+ `sequence` is the default mode and interrupts the remaining `then` targets on *any* non-`success` (`skipped`, `stopped`, `failed`) — not only on failure. A conditional step inside a sequence is therefore a hidden early exit for the whole remainder. When skipping a step must not break the chain, wrap it in a selector with a `core.noop` fallback.
258
+
259
+ `terminal: true` stops the `then` chain after that strategy even on `success`; `continue: false` in `ActionSuccess` has the same effect.
260
+
228
261
  ## Runtime Helpers
229
262
 
230
263
  ```ts
@@ -261,6 +294,29 @@ type Runtime = {
261
294
 
262
295
  Runtime path get/set is implemented directly through `objwalk`.
263
296
 
297
+ ## Guards
298
+
299
+ Reusable `when` expressions live in the `guards` map on `Config` and are referenced from a strategy's `when` (or a `then`/`catch` step's `when`) with the `['guard', name]` node:
300
+
301
+ ```ts
302
+ const config = {
303
+ guards: {
304
+ 'has-colony': ['truthy', '$data.colonyId'],
305
+ 'same-colony': ['eq', '$input.colonyId', '$context.colonyId'],
306
+ },
307
+ strategies: {
308
+ 'colony.join': {
309
+ fn: 'colony.join',
310
+ when: ['and', ['guard', 'has-colony'], ['not', ['guard', 'same-colony']]],
311
+ },
312
+ },
313
+ }
314
+ ```
315
+
316
+ A guard is a plain `ConditionExpression` and may itself reference other guards. References are expanded once when the config is loaded (`loadConfig`), before the runtime evaluates anything, so the runtime never sees a `['guard', ...]` node. Guards are resolved recursively through `and`/`or`/`not`; a reference to an undefined guard is a `GUARD_NOT_FOUND` validation error, and mutually referencing guards produce `GUARD_CYCLE`. A guard value must be a condition expression, not a `$path` string.
317
+
318
+ Guards exist so a truth criterion can live in one place instead of being duplicated across strategies; they are evaluated data, not registered code (unlike `registerCondition`, which registers an operator function).
319
+
264
320
  ## Validation
265
321
 
266
322
  `validateConfig` validates:
@@ -270,7 +326,8 @@ Runtime path get/set is implemented directly through `objwalk`.
270
326
  - missing strategies in `then`, `catch`, and `entrypoints`;
271
327
  - invalid modes;
272
328
  - invalid path references;
273
- - cycles without a terminal step.
329
+ - cycles without a terminal step;
330
+ - guard references (`GUARD_NOT_FOUND`, `GUARD_CYCLE`, `GUARD_INVALID`).
274
331
 
275
332
  ## Trace
276
333
 
@@ -308,11 +365,14 @@ unsubscribe()
308
365
 
309
366
  ```ts
310
367
  type Bus<TEvents extends object = Record<string, unknown>> = {
311
- on<TEvent extends keyof TEvents>(
312
- event: TEvent,
313
- handler: (event: BusEvent<TEvents[TEvent]>) => void
314
- ): () => void
315
- off<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
368
+ on: {
369
+ <TEvent extends keyof TEvents>(event: TEvent, handler: (event: BusEvent<TEvents[TEvent]>) => void): () => void
370
+ (event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void
371
+ }
372
+ off: {
373
+ <TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
374
+ (event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void
375
+ }
316
376
  emit<TEvent extends keyof TEvents>(
317
377
  topic: TEvent,
318
378
  payload: TEvents[TEvent],
@@ -320,6 +380,8 @@ type Bus<TEvents extends object = Record<string, unknown>> = {
320
380
  ): BusEvent<TEvents[TEvent]>
321
381
  }
322
382
 
383
+ type EventPattern = `${string}*${string}`
384
+
323
385
  type BusEvent<TPayload> = {
324
386
  id: string
325
387
  topic: string
@@ -332,6 +394,18 @@ type BusEvent<TPayload> = {
332
394
 
333
395
  `emit` creates an envelope and serializes the payload once before subscribers run. Event identifiers are opaque 12-character alphanumeric runtime IDs for correlation and echo suppression. They are not cryptographically secure and must not be used for access tokens, signatures, public links, or any security-sensitive purpose. `on` returns an unsubscribe function. `off(event, handler)` removes one handler, while `off(event)` clears the channel. An error in one subscriber does not block the others; `createPubSub({ onError })` receives the error and original event. On serialization failure, the bus delivers `{ error }` as `parsed` and the error body as `serialized`, then calls `onError` with the original cause.
334
396
 
397
+ ### Wildcard subscriptions
398
+
399
+ A topic may be subscribed by pattern, using `*` to match exactly one dot-delimited segment. A wildcard does not cross a `.` boundary.
400
+
401
+ ```ts
402
+ bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
403
+ bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
404
+ bus.on('hub.*.export', ({ parsed }) => {}) // NOT hub.user.audit.export (wildcard spans one segment)
405
+ ```
406
+
407
+ Exact-name subscriptions stay O(1); wildcard patterns are matched separately, so registrations without `*` pay no matching cost. A wildcard handler receives `parsed` as `unknown` — narrow it before use. Wildcards work in `bus.on`/`bus.off` and in `inboundTopics`/`outboundTopics` of `createWS`.
408
+
335
409
  ## Flow
336
410
 
337
411
  `createFlow` combines configuration, actions, conditions, a context provider, and event bindings. It creates a runner (accessible via `flow.runner`) and supports the `start`/`stop` lifecycle.
@@ -432,7 +506,7 @@ const ws = createWS({
432
506
  ws.start()
433
507
  ```
434
508
 
435
- Inbound topics pass an explicit allowlist. The bridge parses a JSON envelope and calls `bus.dispatch(event)`, preserving `id`, `occurredAt`, `origin`, `parsed`, and `serialized`; it remembers accepted inbound IDs and does not send them back outbound. Outbound topics send the complete event envelope as JSON, so the remote bridge can dispatch it without recreating its identity. `maxAttempts` limits reconnects; omitting it retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the transport lifecycle. Diagnostics: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying`, and `slapflow.ws.message.rejected`.
509
+ Inbound topics pass an explicit allowlist. Each entry may be an exact topic or a wildcard pattern, where `*` matches one dot-delimited segment. The bridge parses a JSON envelope and calls `bus.dispatch(event)`, preserving `id`, `occurredAt`, `origin`, `parsed`, and `serialized`; it remembers accepted inbound IDs and does not send them back outbound. Outbound topics send the complete event envelope as JSON, so the remote bridge can dispatch it without recreating its identity. `maxAttempts` limits reconnects; omitting it retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the transport lifecycle. Diagnostics: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying`, and `slapflow.ws.message.rejected`.
436
510
 
437
511
  ## Safety Limits
438
512
 
package/dist/index.cjs CHANGED
@@ -87,6 +87,19 @@ var isBusEvent = (event) => {
87
87
  return typeof candidate.id === "string" && typeof candidate.topic === "string" && typeof candidate.occurredAt === "number" && typeof candidate.serialized === "string" && "parsed" in candidate && (candidate.origin === void 0 || typeof candidate.origin === "string");
88
88
  };
89
89
 
90
+ // src/helpers/pubSub/matchesTopic.ts
91
+ var matchesTopic = (topic, pattern) => {
92
+ if (!pattern.includes("*")) {
93
+ return topic === pattern;
94
+ }
95
+ const topicParts = topic.split(".");
96
+ const patternParts = pattern.split(".");
97
+ if (topicParts.length !== patternParts.length) {
98
+ return false;
99
+ }
100
+ return patternParts.every((part, index) => part === "*" || part === topicParts[index]);
101
+ };
102
+
90
103
  // src/helpers/pubSub/serializeError.ts
91
104
  var serializeError = (error) => ({
92
105
  error: error instanceof Error ? error.message : String(error)
@@ -95,6 +108,14 @@ var serializeError = (error) => ({
95
108
  // src/pubSub.ts
96
109
  var createPubSub = (options = {}) => {
97
110
  const subscribers = /* @__PURE__ */ new Map();
111
+ const wildcardSubscribers = /* @__PURE__ */ new Map();
112
+ const runHandler = (event, handler) => {
113
+ try {
114
+ handler(event);
115
+ } catch (error) {
116
+ options.onError?.({ type: "subscriber", event, error });
117
+ }
118
+ };
98
119
  const dispatch = (event) => {
99
120
  let dispatchedEvent;
100
121
  if (!isBusEvent(event)) {
@@ -108,10 +129,13 @@ var createPubSub = (options = {}) => {
108
129
  const handlers = subscribers.get(event.topic);
109
130
  if (handlers) {
110
131
  for (const handler of [...handlers]) {
111
- try {
112
- handler(event);
113
- } catch (error) {
114
- options.onError?.({ type: "subscriber", event, error });
132
+ runHandler(event, handler);
133
+ }
134
+ }
135
+ for (const [pattern, wildcardHandlers] of wildcardSubscribers) {
136
+ if (matchesTopic(event.topic, pattern)) {
137
+ for (const handler of [...wildcardHandlers]) {
138
+ runHandler(event, handler);
115
139
  }
116
140
  }
117
141
  }
@@ -120,23 +144,24 @@ var createPubSub = (options = {}) => {
120
144
  return dispatchedEvent;
121
145
  };
122
146
  const on = (event, handler) => {
123
- const handlers = subscribers.get(event) ?? /* @__PURE__ */ new Set();
124
- const listener = handler;
125
- handlers.add(listener);
126
- subscribers.set(event, handlers);
147
+ const registry = event.includes("*") ? wildcardSubscribers : subscribers;
148
+ const handlers = registry.get(event) ?? /* @__PURE__ */ new Set();
149
+ handlers.add(handler);
150
+ registry.set(event, handlers);
127
151
  return () => off(event, handler);
128
152
  };
129
153
  const off = (event, handler) => {
154
+ const registry = event.includes("*") ? wildcardSubscribers : subscribers;
130
155
  if (handler) {
131
- const handlers = subscribers.get(event);
156
+ const handlers = registry.get(event);
132
157
  if (handlers) {
133
158
  handlers.delete(handler);
134
159
  if (handlers.size === 0) {
135
- subscribers.delete(event);
160
+ registry.delete(event);
136
161
  }
137
162
  }
138
163
  } else {
139
- subscribers.delete(event);
164
+ registry.delete(event);
140
165
  }
141
166
  };
142
167
  const emit = (topic, payload, emitOptions = {}) => {
@@ -1833,6 +1858,12 @@ var validateCondition = (expression, strategy, path, conditionsRegistry, errors)
1833
1858
  return;
1834
1859
  }
1835
1860
  const [operator, ...args] = expression;
1861
+ if (operator === "guard") {
1862
+ if (args.length !== 1 || typeof args[0] !== "string") {
1863
+ errors.push({ code: "CONDITION_INVALID", message: "Guard reference must be a single string name", strategy, path });
1864
+ }
1865
+ return;
1866
+ }
1836
1867
  if (operator === "and" || operator === "or") {
1837
1868
  args.forEach((arg, index) => validateCondition(arg, strategy, `${path}.${index + 1}`, conditionsRegistry, errors));
1838
1869
  return;
@@ -1878,6 +1909,80 @@ var validateNextList = (config, list, path, strategy, conditionsRegistry, errors
1878
1909
  });
1879
1910
  };
1880
1911
 
1912
+ // src/helpers/validation/resolveGuards.ts
1913
+ var isGuardRef = (expression) => Array.isArray(expression) && expression.length === 2 && expression[0] === "guard" && typeof expression[1] === "string";
1914
+ var resolveRef = (config, name, visiting, path) => {
1915
+ if (visiting.includes(name)) {
1916
+ return { issue: { code: "GUARD_CYCLE", message: `Guard "${name}" is part of a reference cycle`, guard: name, path } };
1917
+ }
1918
+ const guard = config.guards?.[name];
1919
+ if (guard === void 0) {
1920
+ return { issue: { code: "GUARD_NOT_FOUND", message: `Guard "${name}" is not defined`, guard: name, path } };
1921
+ }
1922
+ return resolveExpression(config, guard, [...visiting, name], path);
1923
+ };
1924
+ var resolveExpression = (config, expression, visiting, path) => {
1925
+ if (isGuardRef(expression)) {
1926
+ return resolveRef(config, expression[1], visiting, path);
1927
+ }
1928
+ if (!Array.isArray(expression) || typeof expression[0] !== "string") {
1929
+ return { expression };
1930
+ }
1931
+ const [operator, ...args] = expression;
1932
+ if (operator === "and" || operator === "or") {
1933
+ const resolved = [];
1934
+ for (let index = 0; index < args.length; index += 1) {
1935
+ const result = resolveExpression(config, args[index], visiting, `${path}.${index + 1}`);
1936
+ if (result.issue) {
1937
+ return result;
1938
+ }
1939
+ resolved.push(result.expression);
1940
+ }
1941
+ return { expression: [operator, ...resolved] };
1942
+ }
1943
+ if (operator === "not") {
1944
+ const result = resolveExpression(config, args[0], visiting, `${path}.1`);
1945
+ if (result.issue) {
1946
+ return result;
1947
+ }
1948
+ return { expression: ["not", result.expression] };
1949
+ }
1950
+ return { expression };
1951
+ };
1952
+ var resolveGuards = (config) => {
1953
+ const issues = [];
1954
+ const resolveWhen = (when, path) => {
1955
+ if (when === void 0) {
1956
+ return void 0;
1957
+ }
1958
+ const result = resolveExpression(config, when, [], path);
1959
+ if (result.issue) {
1960
+ issues.push(result.issue);
1961
+ return when;
1962
+ }
1963
+ return result.expression;
1964
+ };
1965
+ const resolveNext = (next, prefix) => next.map((item, index) => {
1966
+ if (typeof item === "string" || !item || typeof item !== "object" || item.when === void 0) {
1967
+ return item;
1968
+ }
1969
+ const target = item;
1970
+ const when = resolveWhen(target.when, `${prefix}.${index}.when`);
1971
+ return when === void 0 ? item : { ...target, when };
1972
+ });
1973
+ const strategies = {};
1974
+ for (const [id, strategy] of Object.entries(config.strategies)) {
1975
+ const when = strategy.when === void 0 ? void 0 : resolveWhen(strategy.when, `${id}.when`);
1976
+ strategies[id] = {
1977
+ ...strategy,
1978
+ ...when !== void 0 ? { when } : {},
1979
+ ...strategy.then ? { then: resolveNext(strategy.then, `${id}.then`) } : {},
1980
+ ...strategy.catch ? { catch: resolveNext(strategy.catch, `${id}.catch`) } : {}
1981
+ };
1982
+ }
1983
+ return { config: { ...config, strategies }, issues };
1984
+ };
1985
+
1881
1986
  // src/helpers/validation/validateConfig.ts
1882
1987
  var validateConfig = (config, actionsRegistry, conditionsRegistry) => {
1883
1988
  const errors = [];
@@ -1896,6 +2001,22 @@ var validateConfig = (config, actionsRegistry, conditionsRegistry) => {
1896
2001
  warnings
1897
2002
  };
1898
2003
  }
2004
+ if (config.guards !== void 0) {
2005
+ if (typeof config.guards !== "object" || Array.isArray(config.guards)) {
2006
+ errors.push({ code: "GUARD_INVALID", message: "Config guards must be an object", path: "guards" });
2007
+ } else {
2008
+ for (const [name, expression] of Object.entries(config.guards)) {
2009
+ validateCondition(expression, name, `guards.${name}`, conditionsRegistry, errors);
2010
+ }
2011
+ }
2012
+ }
2013
+ for (const issue of resolveGuards(config).issues) {
2014
+ errors.push({
2015
+ code: issue.code,
2016
+ message: issue.message,
2017
+ ...issue.path ? { path: issue.path } : {}
2018
+ });
2019
+ }
1899
2020
  for (const [id, strategy] of Object.entries(config.strategies ?? {})) {
1900
2021
  if (!strategy || typeof strategy !== "object") {
1901
2022
  errors.push({ code: "STRATEGY_INVALID", message: "Strategy must be an object", strategy: id });
@@ -2051,7 +2172,7 @@ var createRunner = (options = {}) => {
2051
2172
  return { ...result, warnings: [...result.warnings, ...runnerLimitWarnings(runnerOptions)] };
2052
2173
  };
2053
2174
  const loadConfig = (nextConfig) => {
2054
- configRef.current = nextConfig;
2175
+ configRef.current = resolveGuards(nextConfig).config;
2055
2176
  return validateConfig2(nextConfig);
2056
2177
  };
2057
2178
  const runInternal = (entrypoint, context, input, sync, runOptions) => {
@@ -2393,6 +2514,14 @@ var createWS = (options) => {
2393
2514
  let started = false;
2394
2515
  let currentStatus = "idle";
2395
2516
  const diagnosticsBus = options.bus;
2517
+ const isInboundTopic = (topic) => {
2518
+ for (const pattern of inboundTopics) {
2519
+ if (matchesTopic(topic, pattern)) {
2520
+ return true;
2521
+ }
2522
+ }
2523
+ return false;
2524
+ };
2396
2525
  const emitDiagnostic = (topic, payload) => {
2397
2526
  diagnosticsBus.emit(topic, payload, {
2398
2527
  ...options.origin ? { origin: options.origin } : {}
@@ -2470,7 +2599,7 @@ var createWS = (options) => {
2470
2599
  if (typeof data === "string") {
2471
2600
  try {
2472
2601
  const busEvent = JSON.parse(data);
2473
- if (busEvent.topic && inboundTopics.has(busEvent.topic)) {
2602
+ if (busEvent.topic && isInboundTopic(busEvent.topic)) {
2474
2603
  if (busEvent.id) {
2475
2604
  rememberEvent(busEvent.id);
2476
2605
  }
package/dist/index.d.cts CHANGED
@@ -4,6 +4,7 @@ type Config = {
4
4
  version?: 1;
5
5
  strategies: Record<string, Strategy>;
6
6
  entrypoints?: Record<string, string>;
7
+ guards?: Record<string, ConditionExpression>;
7
8
  };
8
9
  type Strategy = {
9
10
  fn: string;
@@ -23,7 +24,7 @@ type Next = string | {
23
24
  props?: Props;
24
25
  when?: ConditionExpression;
25
26
  };
26
- type ConditionExpression = boolean | [operator: string, ...args: unknown[]];
27
+ type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string];
27
28
  type Action<TContext, TPatch = unknown> = (args: ActionArgs<TContext>) => ActionResult<TContext, TPatch> | Promise<ActionResult<TContext, TPatch>>;
28
29
  type ActionArgs<TContext> = {
29
30
  context: TContext;
@@ -77,6 +78,7 @@ type SlapEvent = {
77
78
  };
78
79
  type EventMap = Record<string, unknown>;
79
80
  type EventName<TEvents extends object> = Extract<keyof TEvents, string>;
81
+ type EventPattern = `${string}*${string}`;
80
82
  type BusEvent<TPayload = unknown> = {
81
83
  id: string;
82
84
  topic: string;
@@ -104,8 +106,14 @@ type BusOptions<TEvents extends object> = {
104
106
  onError?: (event: BusErrorEvent<TEvents>) => void;
105
107
  };
106
108
  type Bus<TEvents extends object = EventMap> = {
107
- on<TEvent extends EventName<TEvents>>(event: TEvent, handler: EventHandler<TEvents, TEvent>): () => void;
108
- off<TEvent extends EventName<TEvents>>(event: TEvent, handler?: EventHandler<TEvents, TEvent>): void;
109
+ on: {
110
+ <TEvent extends EventName<TEvents>>(event: TEvent, handler: EventHandler<TEvents, TEvent>): () => void;
111
+ (event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void;
112
+ };
113
+ off: {
114
+ <TEvent extends EventName<TEvents>>(event: TEvent, handler?: EventHandler<TEvents, TEvent>): void;
115
+ (event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void;
116
+ };
109
117
  emit<TEvent extends EventName<TEvents>>(topic: TEvent, payload: TEvents[TEvent], options?: BusEmitOptions): BusEvent<TEvents[TEvent]>;
110
118
  dispatch(event: unknown): BusEvent | undefined;
111
119
  };
@@ -127,8 +135,8 @@ type WSRetryOptions = RetryOptions;
127
135
  type WSOptions<TEvents extends object = EventMap> = {
128
136
  bus: Bus<TEvents>;
129
137
  createSocket: () => WSSocket;
130
- inboundTopics?: EventName<TEvents>[];
131
- outboundTopics?: EventName<TEvents>[];
138
+ inboundTopics?: (EventName<TEvents> | EventPattern)[];
139
+ outboundTopics?: (EventName<TEvents> | EventPattern)[];
132
140
  origin?: string;
133
141
  retry?: WSRetryOptions;
134
142
  };
@@ -376,4 +384,4 @@ declare const createActionsRegistry: <TContext, TPatch>() => ActionsRegistry<TCo
376
384
  type ConditionsRegistry<TContext> = Map<string, ConditionFn<TContext>>;
377
385
  declare const createConditionsRegistry: <TContext>() => ConditionsRegistry<TContext>;
378
386
 
379
- export { type Action, type ActionArgs, type ActionFail, type ActionResult, type ActionSkip, type ActionStop, type ActionSuccess, type ActionsRegistry, type BindingEventMap, type Bus, type BusBinding, type BusBindingKey, type BusBindings, type BusEmitOptions, type BusErrorEvent, type BusEvent, type BusOptions, type ConcurrencyMode, type ConcurrencyOptions, type ConditionExpression, type ConditionFn, type ConditionsRegistry, type Config, type DomBinding, type DomBindingKey, type DomBindings, type DomForm, type DomInput, type ErrorReporter, type ErrorReporterHandlers, type ErrorStage, type EventHandler, type EventMap, type EventName, type ExpressionOperator, type FetchResponseType, type Flow, type FlowDefinition, type FlowOptions, type InactiveBinding, type Input, type Mode, type Next, type Props, PubSub, type QueueOverflow, type RetryOptions, type RunOptions, type RunResult, type Runner, type RunnerErrorEvent, type RunnerOptions, type Runtime, type SlapError, type SlapErrorEvent, type SlapEvent, type StartResult, type Strategy, type TraceEntry, type TraceSink, type ValidationIssue, type ValidationResult, type VariableValue, type Variables, type WS, type WSOptions, type WSRetryOptions, type WSSocket, type WSStatus, catchError, createActionsRegistry, createConditionsRegistry, createFlow, createMemoryTraceSink, createPubSub, createWS, defineConfig, defineErrorReporter };
387
+ export { type Action, type ActionArgs, type ActionFail, type ActionResult, type ActionSkip, type ActionStop, type ActionSuccess, type ActionsRegistry, type BindingEventMap, type Bus, type BusBinding, type BusBindingKey, type BusBindings, type BusEmitOptions, type BusErrorEvent, type BusEvent, type BusOptions, type ConcurrencyMode, type ConcurrencyOptions, type ConditionExpression, type ConditionFn, type ConditionsRegistry, type Config, type DomBinding, type DomBindingKey, type DomBindings, type DomForm, type DomInput, type ErrorReporter, type ErrorReporterHandlers, type ErrorStage, type EventHandler, type EventMap, type EventName, type EventPattern, type ExpressionOperator, type FetchResponseType, type Flow, type FlowDefinition, type FlowOptions, type InactiveBinding, type Input, type Mode, type Next, type Props, PubSub, type QueueOverflow, type RetryOptions, type RunOptions, type RunResult, type Runner, type RunnerErrorEvent, type RunnerOptions, type Runtime, type SlapError, type SlapErrorEvent, type SlapEvent, type StartResult, type Strategy, type TraceEntry, type TraceSink, type ValidationIssue, type ValidationResult, type VariableValue, type Variables, type WS, type WSOptions, type WSRetryOptions, type WSSocket, type WSStatus, catchError, createActionsRegistry, createConditionsRegistry, createFlow, createMemoryTraceSink, createPubSub, createWS, defineConfig, defineErrorReporter };
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ type Config = {
4
4
  version?: 1;
5
5
  strategies: Record<string, Strategy>;
6
6
  entrypoints?: Record<string, string>;
7
+ guards?: Record<string, ConditionExpression>;
7
8
  };
8
9
  type Strategy = {
9
10
  fn: string;
@@ -23,7 +24,7 @@ type Next = string | {
23
24
  props?: Props;
24
25
  when?: ConditionExpression;
25
26
  };
26
- type ConditionExpression = boolean | [operator: string, ...args: unknown[]];
27
+ type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string];
27
28
  type Action<TContext, TPatch = unknown> = (args: ActionArgs<TContext>) => ActionResult<TContext, TPatch> | Promise<ActionResult<TContext, TPatch>>;
28
29
  type ActionArgs<TContext> = {
29
30
  context: TContext;
@@ -77,6 +78,7 @@ type SlapEvent = {
77
78
  };
78
79
  type EventMap = Record<string, unknown>;
79
80
  type EventName<TEvents extends object> = Extract<keyof TEvents, string>;
81
+ type EventPattern = `${string}*${string}`;
80
82
  type BusEvent<TPayload = unknown> = {
81
83
  id: string;
82
84
  topic: string;
@@ -104,8 +106,14 @@ type BusOptions<TEvents extends object> = {
104
106
  onError?: (event: BusErrorEvent<TEvents>) => void;
105
107
  };
106
108
  type Bus<TEvents extends object = EventMap> = {
107
- on<TEvent extends EventName<TEvents>>(event: TEvent, handler: EventHandler<TEvents, TEvent>): () => void;
108
- off<TEvent extends EventName<TEvents>>(event: TEvent, handler?: EventHandler<TEvents, TEvent>): void;
109
+ on: {
110
+ <TEvent extends EventName<TEvents>>(event: TEvent, handler: EventHandler<TEvents, TEvent>): () => void;
111
+ (event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void;
112
+ };
113
+ off: {
114
+ <TEvent extends EventName<TEvents>>(event: TEvent, handler?: EventHandler<TEvents, TEvent>): void;
115
+ (event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void;
116
+ };
109
117
  emit<TEvent extends EventName<TEvents>>(topic: TEvent, payload: TEvents[TEvent], options?: BusEmitOptions): BusEvent<TEvents[TEvent]>;
110
118
  dispatch(event: unknown): BusEvent | undefined;
111
119
  };
@@ -127,8 +135,8 @@ type WSRetryOptions = RetryOptions;
127
135
  type WSOptions<TEvents extends object = EventMap> = {
128
136
  bus: Bus<TEvents>;
129
137
  createSocket: () => WSSocket;
130
- inboundTopics?: EventName<TEvents>[];
131
- outboundTopics?: EventName<TEvents>[];
138
+ inboundTopics?: (EventName<TEvents> | EventPattern)[];
139
+ outboundTopics?: (EventName<TEvents> | EventPattern)[];
132
140
  origin?: string;
133
141
  retry?: WSRetryOptions;
134
142
  };
@@ -376,4 +384,4 @@ declare const createActionsRegistry: <TContext, TPatch>() => ActionsRegistry<TCo
376
384
  type ConditionsRegistry<TContext> = Map<string, ConditionFn<TContext>>;
377
385
  declare const createConditionsRegistry: <TContext>() => ConditionsRegistry<TContext>;
378
386
 
379
- export { type Action, type ActionArgs, type ActionFail, type ActionResult, type ActionSkip, type ActionStop, type ActionSuccess, type ActionsRegistry, type BindingEventMap, type Bus, type BusBinding, type BusBindingKey, type BusBindings, type BusEmitOptions, type BusErrorEvent, type BusEvent, type BusOptions, type ConcurrencyMode, type ConcurrencyOptions, type ConditionExpression, type ConditionFn, type ConditionsRegistry, type Config, type DomBinding, type DomBindingKey, type DomBindings, type DomForm, type DomInput, type ErrorReporter, type ErrorReporterHandlers, type ErrorStage, type EventHandler, type EventMap, type EventName, type ExpressionOperator, type FetchResponseType, type Flow, type FlowDefinition, type FlowOptions, type InactiveBinding, type Input, type Mode, type Next, type Props, PubSub, type QueueOverflow, type RetryOptions, type RunOptions, type RunResult, type Runner, type RunnerErrorEvent, type RunnerOptions, type Runtime, type SlapError, type SlapErrorEvent, type SlapEvent, type StartResult, type Strategy, type TraceEntry, type TraceSink, type ValidationIssue, type ValidationResult, type VariableValue, type Variables, type WS, type WSOptions, type WSRetryOptions, type WSSocket, type WSStatus, catchError, createActionsRegistry, createConditionsRegistry, createFlow, createMemoryTraceSink, createPubSub, createWS, defineConfig, defineErrorReporter };
387
+ export { type Action, type ActionArgs, type ActionFail, type ActionResult, type ActionSkip, type ActionStop, type ActionSuccess, type ActionsRegistry, type BindingEventMap, type Bus, type BusBinding, type BusBindingKey, type BusBindings, type BusEmitOptions, type BusErrorEvent, type BusEvent, type BusOptions, type ConcurrencyMode, type ConcurrencyOptions, type ConditionExpression, type ConditionFn, type ConditionsRegistry, type Config, type DomBinding, type DomBindingKey, type DomBindings, type DomForm, type DomInput, type ErrorReporter, type ErrorReporterHandlers, type ErrorStage, type EventHandler, type EventMap, type EventName, type EventPattern, type ExpressionOperator, type FetchResponseType, type Flow, type FlowDefinition, type FlowOptions, type InactiveBinding, type Input, type Mode, type Next, type Props, PubSub, type QueueOverflow, type RetryOptions, type RunOptions, type RunResult, type Runner, type RunnerErrorEvent, type RunnerOptions, type Runtime, type SlapError, type SlapErrorEvent, type SlapEvent, type StartResult, type Strategy, type TraceEntry, type TraceSink, type ValidationIssue, type ValidationResult, type VariableValue, type Variables, type WS, type WSOptions, type WSRetryOptions, type WSSocket, type WSStatus, catchError, createActionsRegistry, createConditionsRegistry, createFlow, createMemoryTraceSink, createPubSub, createWS, defineConfig, defineErrorReporter };
package/dist/index.js CHANGED
@@ -52,6 +52,19 @@ var isBusEvent = (event) => {
52
52
  return typeof candidate.id === "string" && typeof candidate.topic === "string" && typeof candidate.occurredAt === "number" && typeof candidate.serialized === "string" && "parsed" in candidate && (candidate.origin === void 0 || typeof candidate.origin === "string");
53
53
  };
54
54
 
55
+ // src/helpers/pubSub/matchesTopic.ts
56
+ var matchesTopic = (topic, pattern) => {
57
+ if (!pattern.includes("*")) {
58
+ return topic === pattern;
59
+ }
60
+ const topicParts = topic.split(".");
61
+ const patternParts = pattern.split(".");
62
+ if (topicParts.length !== patternParts.length) {
63
+ return false;
64
+ }
65
+ return patternParts.every((part, index) => part === "*" || part === topicParts[index]);
66
+ };
67
+
55
68
  // src/helpers/pubSub/serializeError.ts
56
69
  var serializeError = (error) => ({
57
70
  error: error instanceof Error ? error.message : String(error)
@@ -60,6 +73,14 @@ var serializeError = (error) => ({
60
73
  // src/pubSub.ts
61
74
  var createPubSub = (options = {}) => {
62
75
  const subscribers = /* @__PURE__ */ new Map();
76
+ const wildcardSubscribers = /* @__PURE__ */ new Map();
77
+ const runHandler = (event, handler) => {
78
+ try {
79
+ handler(event);
80
+ } catch (error) {
81
+ options.onError?.({ type: "subscriber", event, error });
82
+ }
83
+ };
63
84
  const dispatch = (event) => {
64
85
  let dispatchedEvent;
65
86
  if (!isBusEvent(event)) {
@@ -73,10 +94,13 @@ var createPubSub = (options = {}) => {
73
94
  const handlers = subscribers.get(event.topic);
74
95
  if (handlers) {
75
96
  for (const handler of [...handlers]) {
76
- try {
77
- handler(event);
78
- } catch (error) {
79
- options.onError?.({ type: "subscriber", event, error });
97
+ runHandler(event, handler);
98
+ }
99
+ }
100
+ for (const [pattern, wildcardHandlers] of wildcardSubscribers) {
101
+ if (matchesTopic(event.topic, pattern)) {
102
+ for (const handler of [...wildcardHandlers]) {
103
+ runHandler(event, handler);
80
104
  }
81
105
  }
82
106
  }
@@ -85,23 +109,24 @@ var createPubSub = (options = {}) => {
85
109
  return dispatchedEvent;
86
110
  };
87
111
  const on = (event, handler) => {
88
- const handlers = subscribers.get(event) ?? /* @__PURE__ */ new Set();
89
- const listener = handler;
90
- handlers.add(listener);
91
- subscribers.set(event, handlers);
112
+ const registry = event.includes("*") ? wildcardSubscribers : subscribers;
113
+ const handlers = registry.get(event) ?? /* @__PURE__ */ new Set();
114
+ handlers.add(handler);
115
+ registry.set(event, handlers);
92
116
  return () => off(event, handler);
93
117
  };
94
118
  const off = (event, handler) => {
119
+ const registry = event.includes("*") ? wildcardSubscribers : subscribers;
95
120
  if (handler) {
96
- const handlers = subscribers.get(event);
121
+ const handlers = registry.get(event);
97
122
  if (handlers) {
98
123
  handlers.delete(handler);
99
124
  if (handlers.size === 0) {
100
- subscribers.delete(event);
125
+ registry.delete(event);
101
126
  }
102
127
  }
103
128
  } else {
104
- subscribers.delete(event);
129
+ registry.delete(event);
105
130
  }
106
131
  };
107
132
  const emit = (topic, payload, emitOptions = {}) => {
@@ -1798,6 +1823,12 @@ var validateCondition = (expression, strategy, path, conditionsRegistry, errors)
1798
1823
  return;
1799
1824
  }
1800
1825
  const [operator, ...args] = expression;
1826
+ if (operator === "guard") {
1827
+ if (args.length !== 1 || typeof args[0] !== "string") {
1828
+ errors.push({ code: "CONDITION_INVALID", message: "Guard reference must be a single string name", strategy, path });
1829
+ }
1830
+ return;
1831
+ }
1801
1832
  if (operator === "and" || operator === "or") {
1802
1833
  args.forEach((arg, index) => validateCondition(arg, strategy, `${path}.${index + 1}`, conditionsRegistry, errors));
1803
1834
  return;
@@ -1843,6 +1874,80 @@ var validateNextList = (config, list, path, strategy, conditionsRegistry, errors
1843
1874
  });
1844
1875
  };
1845
1876
 
1877
+ // src/helpers/validation/resolveGuards.ts
1878
+ var isGuardRef = (expression) => Array.isArray(expression) && expression.length === 2 && expression[0] === "guard" && typeof expression[1] === "string";
1879
+ var resolveRef = (config, name, visiting, path) => {
1880
+ if (visiting.includes(name)) {
1881
+ return { issue: { code: "GUARD_CYCLE", message: `Guard "${name}" is part of a reference cycle`, guard: name, path } };
1882
+ }
1883
+ const guard = config.guards?.[name];
1884
+ if (guard === void 0) {
1885
+ return { issue: { code: "GUARD_NOT_FOUND", message: `Guard "${name}" is not defined`, guard: name, path } };
1886
+ }
1887
+ return resolveExpression(config, guard, [...visiting, name], path);
1888
+ };
1889
+ var resolveExpression = (config, expression, visiting, path) => {
1890
+ if (isGuardRef(expression)) {
1891
+ return resolveRef(config, expression[1], visiting, path);
1892
+ }
1893
+ if (!Array.isArray(expression) || typeof expression[0] !== "string") {
1894
+ return { expression };
1895
+ }
1896
+ const [operator, ...args] = expression;
1897
+ if (operator === "and" || operator === "or") {
1898
+ const resolved = [];
1899
+ for (let index = 0; index < args.length; index += 1) {
1900
+ const result = resolveExpression(config, args[index], visiting, `${path}.${index + 1}`);
1901
+ if (result.issue) {
1902
+ return result;
1903
+ }
1904
+ resolved.push(result.expression);
1905
+ }
1906
+ return { expression: [operator, ...resolved] };
1907
+ }
1908
+ if (operator === "not") {
1909
+ const result = resolveExpression(config, args[0], visiting, `${path}.1`);
1910
+ if (result.issue) {
1911
+ return result;
1912
+ }
1913
+ return { expression: ["not", result.expression] };
1914
+ }
1915
+ return { expression };
1916
+ };
1917
+ var resolveGuards = (config) => {
1918
+ const issues = [];
1919
+ const resolveWhen = (when, path) => {
1920
+ if (when === void 0) {
1921
+ return void 0;
1922
+ }
1923
+ const result = resolveExpression(config, when, [], path);
1924
+ if (result.issue) {
1925
+ issues.push(result.issue);
1926
+ return when;
1927
+ }
1928
+ return result.expression;
1929
+ };
1930
+ const resolveNext = (next, prefix) => next.map((item, index) => {
1931
+ if (typeof item === "string" || !item || typeof item !== "object" || item.when === void 0) {
1932
+ return item;
1933
+ }
1934
+ const target = item;
1935
+ const when = resolveWhen(target.when, `${prefix}.${index}.when`);
1936
+ return when === void 0 ? item : { ...target, when };
1937
+ });
1938
+ const strategies = {};
1939
+ for (const [id, strategy] of Object.entries(config.strategies)) {
1940
+ const when = strategy.when === void 0 ? void 0 : resolveWhen(strategy.when, `${id}.when`);
1941
+ strategies[id] = {
1942
+ ...strategy,
1943
+ ...when !== void 0 ? { when } : {},
1944
+ ...strategy.then ? { then: resolveNext(strategy.then, `${id}.then`) } : {},
1945
+ ...strategy.catch ? { catch: resolveNext(strategy.catch, `${id}.catch`) } : {}
1946
+ };
1947
+ }
1948
+ return { config: { ...config, strategies }, issues };
1949
+ };
1950
+
1846
1951
  // src/helpers/validation/validateConfig.ts
1847
1952
  var validateConfig = (config, actionsRegistry, conditionsRegistry) => {
1848
1953
  const errors = [];
@@ -1861,6 +1966,22 @@ var validateConfig = (config, actionsRegistry, conditionsRegistry) => {
1861
1966
  warnings
1862
1967
  };
1863
1968
  }
1969
+ if (config.guards !== void 0) {
1970
+ if (typeof config.guards !== "object" || Array.isArray(config.guards)) {
1971
+ errors.push({ code: "GUARD_INVALID", message: "Config guards must be an object", path: "guards" });
1972
+ } else {
1973
+ for (const [name, expression] of Object.entries(config.guards)) {
1974
+ validateCondition(expression, name, `guards.${name}`, conditionsRegistry, errors);
1975
+ }
1976
+ }
1977
+ }
1978
+ for (const issue of resolveGuards(config).issues) {
1979
+ errors.push({
1980
+ code: issue.code,
1981
+ message: issue.message,
1982
+ ...issue.path ? { path: issue.path } : {}
1983
+ });
1984
+ }
1864
1985
  for (const [id, strategy] of Object.entries(config.strategies ?? {})) {
1865
1986
  if (!strategy || typeof strategy !== "object") {
1866
1987
  errors.push({ code: "STRATEGY_INVALID", message: "Strategy must be an object", strategy: id });
@@ -2016,7 +2137,7 @@ var createRunner = (options = {}) => {
2016
2137
  return { ...result, warnings: [...result.warnings, ...runnerLimitWarnings(runnerOptions)] };
2017
2138
  };
2018
2139
  const loadConfig = (nextConfig) => {
2019
- configRef.current = nextConfig;
2140
+ configRef.current = resolveGuards(nextConfig).config;
2020
2141
  return validateConfig2(nextConfig);
2021
2142
  };
2022
2143
  const runInternal = (entrypoint, context, input, sync, runOptions) => {
@@ -2358,6 +2479,14 @@ var createWS = (options) => {
2358
2479
  let started = false;
2359
2480
  let currentStatus = "idle";
2360
2481
  const diagnosticsBus = options.bus;
2482
+ const isInboundTopic = (topic) => {
2483
+ for (const pattern of inboundTopics) {
2484
+ if (matchesTopic(topic, pattern)) {
2485
+ return true;
2486
+ }
2487
+ }
2488
+ return false;
2489
+ };
2361
2490
  const emitDiagnostic = (topic, payload) => {
2362
2491
  diagnosticsBus.emit(topic, payload, {
2363
2492
  ...options.origin ? { origin: options.origin } : {}
@@ -2435,7 +2564,7 @@ var createWS = (options) => {
2435
2564
  if (typeof data === "string") {
2436
2565
  try {
2437
2566
  const busEvent = JSON.parse(data);
2438
- if (busEvent.topic && inboundTopics.has(busEvent.topic)) {
2567
+ if (busEvent.topic && isInboundTopic(busEvent.topic)) {
2439
2568
  if (busEvent.id) {
2440
2569
  rememberEvent(busEvent.id);
2441
2570
  }
package/package.json CHANGED
@@ -1,12 +1,20 @@
1
1
  {
2
2
  "name": "slapflow",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Chain actions behavior runtime",
5
5
  "license": "ISC",
6
6
  "author": "Sergey Khalilov",
7
7
  "keywords": [
8
8
  "behavior",
9
9
  "workflow",
10
+ "orchestration",
11
+ "state-machine",
12
+ "event-driven",
13
+ "concurrency",
14
+ "cancellation",
15
+ "pubsub",
16
+ "fetch",
17
+ "websocket",
10
18
  "action-runner",
11
19
  "rules-engine",
12
20
  "typescript"