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 +7 -0
- package/SPEC-RU.md +81 -7
- package/SPEC.md +81 -7
- package/dist/index.cjs +142 -13
- package/dist/index.d.cts +14 -6
- package/dist/index.d.ts +14 -6
- package/dist/index.js +142 -13
- package/package.json +9 -1
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
|
|
312
|
-
event: TEvent,
|
|
313
|
-
handler: (event: BusEvent<
|
|
314
|
-
|
|
315
|
-
off
|
|
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
|
|
312
|
-
event: TEvent,
|
|
313
|
-
handler: (event: BusEvent<
|
|
314
|
-
|
|
315
|
-
off
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
124
|
-
const
|
|
125
|
-
handlers.add(
|
|
126
|
-
|
|
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 =
|
|
156
|
+
const handlers = registry.get(event);
|
|
132
157
|
if (handlers) {
|
|
133
158
|
handlers.delete(handler);
|
|
134
159
|
if (handlers.size === 0) {
|
|
135
|
-
|
|
160
|
+
registry.delete(event);
|
|
136
161
|
}
|
|
137
162
|
}
|
|
138
163
|
} else {
|
|
139
|
-
|
|
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 &&
|
|
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
|
|
108
|
-
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
89
|
-
const
|
|
90
|
-
handlers.add(
|
|
91
|
-
|
|
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 =
|
|
121
|
+
const handlers = registry.get(event);
|
|
97
122
|
if (handlers) {
|
|
98
123
|
handlers.delete(handler);
|
|
99
124
|
if (handlers.size === 0) {
|
|
100
|
-
|
|
125
|
+
registry.delete(event);
|
|
101
126
|
}
|
|
102
127
|
}
|
|
103
128
|
} else {
|
|
104
|
-
|
|
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 &&
|
|
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
|
|
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"
|