slapflow 1.0.2 → 1.2.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 +8 -1
- package/SPEC-RU.md +104 -36
- package/SPEC.md +104 -36
- package/dist/index.cjs +314 -63
- package/dist/index.d.cts +38 -8
- package/dist/index.d.ts +38 -8
- package/dist/index.js +309 -61
- package/package.json +12 -2
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
|
|
@@ -156,13 +162,14 @@ const config = {
|
|
|
156
162
|
- A broad set of [built-in conditions](SPEC.md#built-in-conditions) for comparisons, type checks, collections, and compound logic.
|
|
157
163
|
- Typed PubSub bindings and delegated DOM bindings.
|
|
158
164
|
- `parallel`, `latest`, `queue`, and `drop` concurrency modes with per-entity lanes.
|
|
159
|
-
- A WebSocket
|
|
165
|
+
- A native WebSocket client that proxies socket events into the bus.
|
|
160
166
|
- `core.fetch` with response parsing, cancellation, and retry backoff.
|
|
161
167
|
- Normalized results, execution trace, validation, and lifecycle diagnostics such as `slapflow.run.started` and `slapflow.run.failed`.
|
|
162
168
|
- Runtime variables for configuration values, templates, and expressions.
|
|
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
|
@@ -12,23 +12,20 @@
|
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
BUILTIN_ACTIONS,
|
|
16
|
+
BUILTIN_CONDITIONS,
|
|
17
17
|
createMemoryTraceSink,
|
|
18
18
|
defineErrorReporter,
|
|
19
19
|
createPubSub,
|
|
20
20
|
PubSub,
|
|
21
21
|
createFlow,
|
|
22
|
-
|
|
22
|
+
createWebSocket,
|
|
23
23
|
catchError,
|
|
24
24
|
} from 'slapflow'
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
const flow = createFlow<Context, Patch>(
|
|
29
|
-
{ config: { strategies: {} } },
|
|
30
|
-
{ context: () => ({} as Context) }
|
|
31
|
-
)
|
|
28
|
+
const flow = createFlow<Context, Patch>({ config: { strategies: {} } }, { context: () => ({}) as Context })
|
|
32
29
|
const runner = flow.runner
|
|
33
30
|
|
|
34
31
|
runner.registerAction('jobs.execute', executeJob)
|
|
@@ -45,8 +42,11 @@ type Config = {
|
|
|
45
42
|
version?: 1
|
|
46
43
|
strategies: Record<string, Strategy>
|
|
47
44
|
entrypoints?: Record<string, string>
|
|
45
|
+
guards?: Record<string, ConditionExpression>
|
|
48
46
|
}
|
|
49
47
|
|
|
48
|
+
type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string]
|
|
49
|
+
|
|
50
50
|
type Strategy = {
|
|
51
51
|
fn: string
|
|
52
52
|
props?: Record<string, unknown>
|
|
@@ -79,7 +79,7 @@ const reportError = defineErrorReporter({
|
|
|
79
79
|
|
|
80
80
|
const flow = createFlow(
|
|
81
81
|
{ config: { strategies: {} } },
|
|
82
|
-
{ context: () => ({} as Context
|
|
82
|
+
{ context: () => ({}) as Context, trace: true, onError: reportError }
|
|
83
83
|
)
|
|
84
84
|
```
|
|
85
85
|
|
|
@@ -113,29 +113,38 @@ type ErrorStage = {
|
|
|
113
113
|
|
|
114
114
|
Если ошибка обработана через `catch`, `onError` всё равно вызывается для исходного сбоя, а итоговый `run` может завершиться со статусом `success`.
|
|
115
115
|
|
|
116
|
+
## Нормализация возврата действия
|
|
117
|
+
|
|
118
|
+
Возвращаемое значение действия нормализуется в один итог. Соответствие:
|
|
119
|
+
|
|
120
|
+
| Возврат | Итог |
|
|
121
|
+
| ------------------------------------------------- | ------------------------------------------------------------- |
|
|
122
|
+
| `undefined` / `null` | `success` |
|
|
123
|
+
| `false` | `skipped` |
|
|
124
|
+
| `{ type: 'skip', reason?, data? }` | `skipped` (селектор пробует следующую ветку) |
|
|
125
|
+
| `{ type: 'stop', reason?, patch?, events? }` | `stopped` (цепочка останавливается без ошибки) |
|
|
126
|
+
| `{ type: 'fail', reason?, data?, error? }` | `failed` (запускается `catch`, затем `onError`) |
|
|
127
|
+
| `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` прерывает оставшиеся `then`-цели |
|
|
128
|
+
|
|
129
|
+
Брошенное исключение трактуется как `fail`. Возврат `false` и `{ type: 'skip' }` эквивалентны.
|
|
130
|
+
|
|
116
131
|
## Модель реестров
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
Встроенные элементы живут в двух общих константах — по одной на вид:
|
|
119
134
|
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
actions.ts
|
|
123
|
-
conditions.ts
|
|
135
|
+
```ts
|
|
136
|
+
import { BUILTIN_ACTIONS, BUILTIN_CONDITIONS } from 'slapflow'
|
|
124
137
|
```
|
|
125
138
|
|
|
126
|
-
`
|
|
139
|
+
`BUILTIN_ACTIONS` — это readonly-список `[name, action][]`, предварительно заполненный встроенными действиями; `BUILTIN_CONDITIONS` содержит встроенные условия. Каждый исполнитель получает собственный `new Map(BUILTIN_ACTIONS)` / `new Map(BUILTIN_CONDITIONS)`, поэтому регистрации остаются изолированными по исполнителям.
|
|
127
140
|
|
|
128
|
-
`
|
|
129
|
-
|
|
130
|
-
Каждый исполнитель получает собственную изменяемую копию реестра. Приложения могут переопределить любое встроенное действие или условие:
|
|
141
|
+
Встроенные элементы неизменяемы: `registerAction` и `registerCondition` отклоняют попытку переопределить встроенное имя.
|
|
131
142
|
|
|
132
143
|
```ts
|
|
133
144
|
runner.registerAction('app.setData', customSetData)
|
|
134
|
-
runner.registerCondition('
|
|
145
|
+
runner.registerCondition('hasQueue', hasItems)
|
|
135
146
|
```
|
|
136
147
|
|
|
137
|
-
Таким образом, встроенные элементы являются значениями по умолчанию, а не отдельным неизменяемым слоем.
|
|
138
|
-
|
|
139
148
|
Проверка конфигурации обращается к реестрам через минимальный контракт `has(name)`.
|
|
140
149
|
|
|
141
150
|
## Встроенные действия
|
|
@@ -225,6 +234,18 @@ export const config = {
|
|
|
225
234
|
|
|
226
235
|
`parallel` запускает цели `then` независимо. Простые объекты и массивы контекста и runtime data копируются для каждой ветки; инфраструктурные значения вроде функций, DOM-узлов и экземпляров классов остаются ссылками. Safety limits, включая `maxStepCount`, остаются общими для всего запуска. Полученные патчи и события возвращаются вызывающей стороне; исполнитель их не применяет.
|
|
227
236
|
|
|
237
|
+
### Прерывание цепочки
|
|
238
|
+
|
|
239
|
+
Не-`success` итог шага меняет дальнейшее поведение в зависимости от режима:
|
|
240
|
+
|
|
241
|
+
| Итог | `sequence` | `selector` |
|
|
242
|
+
| --------- | --------------------- | ----------------------- |
|
|
243
|
+
| `skipped` | **прерывает остаток** | пробует следующую ветку |
|
|
244
|
+
|
|
245
|
+
`sequence` — режим по умолчанию, и он прерывает оставшиеся `then`-цели на _любом_ не-`success` (`skipped`, `stopped`, `failed`) — не только на сбое. Условный шаг внутри последовательности — это, таким образом, скрытый ранний выход для всего остатка. Если пропуск шага не должен рвать цепочку, заверните его в селектор с запасным `core.noop`.
|
|
246
|
+
|
|
247
|
+
`terminal: true` останавливает `then`-цепочку после этой стратегии даже при `success`; `continue: false` в `ActionSuccess` даёт тот же эффект.
|
|
248
|
+
|
|
228
249
|
## Вспомогательные средства среды выполнения
|
|
229
250
|
|
|
230
251
|
```ts
|
|
@@ -261,6 +282,29 @@ type Runtime = {
|
|
|
261
282
|
|
|
262
283
|
Чтение и запись путей во время выполнения реализованы непосредственно через `objwalk`.
|
|
263
284
|
|
|
285
|
+
## Guards
|
|
286
|
+
|
|
287
|
+
Переиспользуемые выражения `when` живут в карте `guards` на `Config` и подключаются к `when` стратегии (или шага `then`/`catch`) узлом `['guard', имя]`:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const config = {
|
|
291
|
+
guards: {
|
|
292
|
+
'has-colony': ['truthy', '$data.colonyId'],
|
|
293
|
+
'same-colony': ['eq', '$input.colonyId', '$context.colonyId'],
|
|
294
|
+
},
|
|
295
|
+
strategies: {
|
|
296
|
+
'colony.join': {
|
|
297
|
+
fn: 'colony.join',
|
|
298
|
+
when: ['and', ['guard', 'has-colony'], ['not', ['guard', 'same-colony']]],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Guard — это обычное `ConditionExpression`, и сам может ссылаться на другие guards. Ссылки раскрываются один раз при загрузке конфигурации (`loadConfig`), до того как рантайм что-либо вычисляет, поэтому рантайм никогда не видит узел `['guard', ...]`. Guards раскрываются рекурсивно через `and`/`or`/`not`; ссылка на несуществующий guard даёт ошибку валидации `GUARD_NOT_FOUND`, а взаимные ссылки — `GUARD_CYCLE`. Значение guard должно быть выражением-условием, а не строкой `$path`.
|
|
305
|
+
|
|
306
|
+
Guards существуют, чтобы критерий истинности жил в одном месте, а не дублировался по стратегиям; это вычисляемые данные, а не зарегистрированный код (в отличие от `registerCondition`, который регистрирует функцию-оператор).
|
|
307
|
+
|
|
264
308
|
## Проверка конфигурации
|
|
265
309
|
|
|
266
310
|
`validateConfig` проверяет:
|
|
@@ -270,7 +314,8 @@ type Runtime = {
|
|
|
270
314
|
- отсутствующие стратегии в `then`, `catch` и `entrypoints`;
|
|
271
315
|
- недопустимые режимы;
|
|
272
316
|
- недопустимые ссылки на пути;
|
|
273
|
-
- циклы без завершающего
|
|
317
|
+
- циклы без завершающего шага;
|
|
318
|
+
- ссылки на guards (`GUARD_NOT_FOUND`, `GUARD_CYCLE`, `GUARD_INVALID`).
|
|
274
319
|
|
|
275
320
|
## Трассировка
|
|
276
321
|
|
|
@@ -308,11 +353,14 @@ unsubscribe()
|
|
|
308
353
|
|
|
309
354
|
```ts
|
|
310
355
|
type Bus<TEvents extends object = Record<string, unknown>> = {
|
|
311
|
-
on
|
|
312
|
-
event: TEvent,
|
|
313
|
-
handler: (event: BusEvent<
|
|
314
|
-
|
|
315
|
-
off
|
|
356
|
+
on: {
|
|
357
|
+
<TEvent extends keyof TEvents>(event: TEvent, handler: (event: BusEvent<TEvents[TEvent]>) => void): () => void
|
|
358
|
+
(event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void
|
|
359
|
+
}
|
|
360
|
+
off: {
|
|
361
|
+
<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
|
|
362
|
+
(event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void
|
|
363
|
+
}
|
|
316
364
|
emit<TEvent extends keyof TEvents>(
|
|
317
365
|
topic: TEvent,
|
|
318
366
|
payload: TEvents[TEvent],
|
|
@@ -320,6 +368,8 @@ type Bus<TEvents extends object = Record<string, unknown>> = {
|
|
|
320
368
|
): BusEvent<TEvents[TEvent]>
|
|
321
369
|
}
|
|
322
370
|
|
|
371
|
+
type EventPattern = `${string}*${string}`
|
|
372
|
+
|
|
323
373
|
type BusEvent<TPayload> = {
|
|
324
374
|
id: string
|
|
325
375
|
topic: string
|
|
@@ -332,6 +382,18 @@ type BusEvent<TPayload> = {
|
|
|
332
382
|
|
|
333
383
|
`emit` создаёт конверт и сериализует полезную нагрузку один раз до запуска подписчиков. Идентификаторы событий — непрозрачные 12-символьные буквенно-цифровые runtime-ID для корреляции и подавления эха. Они не криптографически стойкие: не используйте их для access token, подписей, публичных ссылок или иных security-sensitive задач. `on` возвращает функцию отписки. `off(event, handler)` удаляет один обработчик, а `off(event)` очищает канал. Ошибка одного подписчика не блокирует остальных; `createPubSub({ onError })` получает ошибку и исходное событие. При ошибке сериализации шина передаёт `{ error }` в качестве `parsed` и тело ошибки в качестве `serialized`, после чего вызывает `onError` с исходной причиной.
|
|
334
384
|
|
|
385
|
+
### Подписка по шаблону
|
|
386
|
+
|
|
387
|
+
На тему можно подписаться по шаблону, где `*` соответствует ровно одному сегменту, разделённому точкой. Шаблонный символ не пересекает границу `.`.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
|
|
391
|
+
bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
|
|
392
|
+
bus.on('hub.*.export', ({ parsed }) => {}) // НЕ hub.user.audit.export (один сегмент)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Подписки с точным именем остаются O(1); шаблоны обрабатываются отдельно, поэтому регистрации без `*` не несут накладных затрат на сравнение. Обработчик шаблона получает `parsed` как `unknown` — сужайте тип перед использованием. Шаблоны работают в `bus.on`/`bus.off`.
|
|
396
|
+
|
|
335
397
|
## Поток
|
|
336
398
|
|
|
337
399
|
`createFlow` объединяет конфигурацию, действия, условия, поставщик контекста и привязки событий. Функция создаёт исполнитель (доступный через `flow.runner`) и поддерживает жизненный цикл `start`/`stop`.
|
|
@@ -415,24 +477,30 @@ type ConcurrencyOptions<TPayload> = {
|
|
|
415
477
|
|
|
416
478
|
`defaultInput` имеет тип `{ type, value?, dataset, form? }`. `dataset` содержит все атрибуты `data-*` совпавшего элемента в виде ключей camelCase. `form` строится по ближайшему элементу `<form>`; повторяющиеся поля формы превращаются в массивы, а `File` остаётся `File`. Для `submit` значение `preventDefault` по умолчанию равно `true`; для остальных событий оно и `stopPropagation` по умолчанию равны `false`.
|
|
417
479
|
|
|
418
|
-
### WebSocket
|
|
480
|
+
### WebSocket-клиент
|
|
419
481
|
|
|
420
|
-
`
|
|
482
|
+
`createWebSocket` открывает нативный `WebSocket` по `url` и проксирует каждое событие сокета в шину. Формат провода не предполагается — каждое событие отправляется с фиксированной темой и конвертом, где `parsed` содержит сырую полезную нагрузку.
|
|
421
483
|
|
|
422
484
|
```ts
|
|
423
|
-
const
|
|
485
|
+
const socket = createWebSocket({
|
|
486
|
+
url,
|
|
424
487
|
bus,
|
|
425
|
-
|
|
426
|
-
inboundTopics: ['order.created'],
|
|
427
|
-
outboundTopics: ['slapflow.run.finished'],
|
|
428
|
-
origin: 'worker',
|
|
429
|
-
retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
|
|
488
|
+
origin: 'client',
|
|
430
489
|
})
|
|
431
490
|
|
|
432
|
-
|
|
491
|
+
socket.start()
|
|
433
492
|
```
|
|
434
493
|
|
|
435
|
-
|
|
494
|
+
```ts
|
|
495
|
+
bus.on('message', ({ parsed }) => {}) // parsed = сырое сообщение (JSON-декодированное, когда возможно)
|
|
496
|
+
bus.on('open', ({ parsed }) => {}) // { url }
|
|
497
|
+
bus.on('close', ({ parsed }) => {}) // { code, reason }
|
|
498
|
+
bus.on('error', ({ parsed }) => {}) // { error }
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
События сокета приходят с темой `open`, `message`, `close` или `error`. Полезная нагрузка `message` JSON-декодируется в `parsed`, если валидна, иначе остаётся сырой строкой. Фильтрация тем — забота потребителя: внутри клиента ничего не разрешается и не отклоняется. `reconnect` принимает `initialDelay`, `maxDelay`, `multiplier`, `jitter` и `maxAttempts`; без `maxAttempts` повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом; статус — одно из `idle`, `connecting`, `connected`, `reconnecting` или `stopped`.
|
|
502
|
+
|
|
503
|
+
`createWS` помечен как deprecated и будет удалён; мигрируйте на `createWebSocket`.
|
|
436
504
|
|
|
437
505
|
## Ограничения безопасности
|
|
438
506
|
|
package/SPEC.md
CHANGED
|
@@ -12,23 +12,20 @@ The package is not coupled to a UI, server framework, scheduler, or domain model
|
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
14
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
BUILTIN_ACTIONS,
|
|
16
|
+
BUILTIN_CONDITIONS,
|
|
17
17
|
createMemoryTraceSink,
|
|
18
18
|
defineErrorReporter,
|
|
19
19
|
createPubSub,
|
|
20
20
|
PubSub,
|
|
21
21
|
createFlow,
|
|
22
|
-
|
|
22
|
+
createWebSocket,
|
|
23
23
|
catchError,
|
|
24
24
|
} from 'slapflow'
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
const flow = createFlow<Context, Patch>(
|
|
29
|
-
{ config: { strategies: {} } },
|
|
30
|
-
{ context: () => ({} as Context) }
|
|
31
|
-
)
|
|
28
|
+
const flow = createFlow<Context, Patch>({ config: { strategies: {} } }, { context: () => ({}) as Context })
|
|
32
29
|
const runner = flow.runner
|
|
33
30
|
|
|
34
31
|
runner.registerAction('jobs.execute', executeJob)
|
|
@@ -45,8 +42,11 @@ type Config = {
|
|
|
45
42
|
version?: 1
|
|
46
43
|
strategies: Record<string, Strategy>
|
|
47
44
|
entrypoints?: Record<string, string>
|
|
45
|
+
guards?: Record<string, ConditionExpression>
|
|
48
46
|
}
|
|
49
47
|
|
|
48
|
+
type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string]
|
|
49
|
+
|
|
50
50
|
type Strategy = {
|
|
51
51
|
fn: string
|
|
52
52
|
props?: Record<string, unknown>
|
|
@@ -79,7 +79,7 @@ const reportError = defineErrorReporter({
|
|
|
79
79
|
|
|
80
80
|
const flow = createFlow(
|
|
81
81
|
{ config: { strategies: {} } },
|
|
82
|
-
{ context: () => ({} as Context
|
|
82
|
+
{ context: () => ({}) as Context, trace: true, onError: reportError }
|
|
83
83
|
)
|
|
84
84
|
```
|
|
85
85
|
|
|
@@ -113,29 +113,38 @@ type ErrorStage = {
|
|
|
113
113
|
|
|
114
114
|
If an error is recovered through `catch`, `onError` is still invoked for the original failure and the final `run` may finish with `success`.
|
|
115
115
|
|
|
116
|
+
## Action Return Normalization
|
|
117
|
+
|
|
118
|
+
An action's return value is normalized into one outcome. The mapping:
|
|
119
|
+
|
|
120
|
+
| Return | Outcome |
|
|
121
|
+
| ------------------------------------------------- | --------------------------------------------------------------- |
|
|
122
|
+
| `undefined` / `null` | `success` |
|
|
123
|
+
| `false` | `skipped` |
|
|
124
|
+
| `{ type: 'skip', reason?, data? }` | `skipped` (a selector tries the next branch) |
|
|
125
|
+
| `{ type: 'stop', reason?, patch?, events? }` | `stopped` (the chain halts without error) |
|
|
126
|
+
| `{ type: 'fail', reason?, data?, error? }` | `failed` (`catch` runs, then `onError`) |
|
|
127
|
+
| `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` halts the remaining `then` targets |
|
|
128
|
+
|
|
129
|
+
A thrown exception is treated as `fail`. Returning `false` and `{ type: 'skip' }` are equivalent.
|
|
130
|
+
|
|
116
131
|
## Registry Model
|
|
117
132
|
|
|
118
|
-
|
|
133
|
+
Built-ins live in two shared constants, one per kind:
|
|
119
134
|
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
actions.ts
|
|
123
|
-
conditions.ts
|
|
135
|
+
```ts
|
|
136
|
+
import { BUILTIN_ACTIONS, BUILTIN_CONDITIONS } from 'slapflow'
|
|
124
137
|
```
|
|
125
138
|
|
|
126
|
-
`
|
|
139
|
+
`BUILTIN_ACTIONS` is a readonly `[name, action][]` list prepopulated with built-in actions; `BUILTIN_CONDITIONS` holds built-in conditions. Each runner receives its own `new Map(BUILTIN_ACTIONS)` / `new Map(BUILTIN_CONDITIONS)` seed, so registrations stay isolated per runner.
|
|
127
140
|
|
|
128
|
-
`
|
|
129
|
-
|
|
130
|
-
Each runner receives its own mutable registry copy. Applications can override any built-in action or condition:
|
|
141
|
+
Built-ins are immutable defaults: `registerAction` and `registerCondition` reject an attempt to override a built-in name.
|
|
131
142
|
|
|
132
143
|
```ts
|
|
133
144
|
runner.registerAction('app.setData', customSetData)
|
|
134
|
-
runner.registerCondition('
|
|
145
|
+
runner.registerCondition('hasQueue', hasItems)
|
|
135
146
|
```
|
|
136
147
|
|
|
137
|
-
Built-ins are therefore default values, not a separate immutable layer.
|
|
138
|
-
|
|
139
148
|
Configuration validation accesses registries through the minimal `has(name)` contract.
|
|
140
149
|
|
|
141
150
|
## Built-In Actions
|
|
@@ -225,6 +234,18 @@ export const config = {
|
|
|
225
234
|
|
|
226
235
|
`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
236
|
|
|
237
|
+
### Chain interruption
|
|
238
|
+
|
|
239
|
+
A non-`success` outcome at a step changes what happens next, depending on the mode:
|
|
240
|
+
|
|
241
|
+
| Outcome | `sequence` | `selector` |
|
|
242
|
+
| --------- | ----------------------- | --------------------- |
|
|
243
|
+
| `skipped` | **interrupts the rest** | tries the next branch |
|
|
244
|
+
|
|
245
|
+
`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.
|
|
246
|
+
|
|
247
|
+
`terminal: true` stops the `then` chain after that strategy even on `success`; `continue: false` in `ActionSuccess` has the same effect.
|
|
248
|
+
|
|
228
249
|
## Runtime Helpers
|
|
229
250
|
|
|
230
251
|
```ts
|
|
@@ -261,6 +282,29 @@ type Runtime = {
|
|
|
261
282
|
|
|
262
283
|
Runtime path get/set is implemented directly through `objwalk`.
|
|
263
284
|
|
|
285
|
+
## Guards
|
|
286
|
+
|
|
287
|
+
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:
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const config = {
|
|
291
|
+
guards: {
|
|
292
|
+
'has-colony': ['truthy', '$data.colonyId'],
|
|
293
|
+
'same-colony': ['eq', '$input.colonyId', '$context.colonyId'],
|
|
294
|
+
},
|
|
295
|
+
strategies: {
|
|
296
|
+
'colony.join': {
|
|
297
|
+
fn: 'colony.join',
|
|
298
|
+
when: ['and', ['guard', 'has-colony'], ['not', ['guard', 'same-colony']]],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
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.
|
|
305
|
+
|
|
306
|
+
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).
|
|
307
|
+
|
|
264
308
|
## Validation
|
|
265
309
|
|
|
266
310
|
`validateConfig` validates:
|
|
@@ -270,7 +314,8 @@ Runtime path get/set is implemented directly through `objwalk`.
|
|
|
270
314
|
- missing strategies in `then`, `catch`, and `entrypoints`;
|
|
271
315
|
- invalid modes;
|
|
272
316
|
- invalid path references;
|
|
273
|
-
- cycles without a terminal step
|
|
317
|
+
- cycles without a terminal step;
|
|
318
|
+
- guard references (`GUARD_NOT_FOUND`, `GUARD_CYCLE`, `GUARD_INVALID`).
|
|
274
319
|
|
|
275
320
|
## Trace
|
|
276
321
|
|
|
@@ -308,11 +353,14 @@ unsubscribe()
|
|
|
308
353
|
|
|
309
354
|
```ts
|
|
310
355
|
type Bus<TEvents extends object = Record<string, unknown>> = {
|
|
311
|
-
on
|
|
312
|
-
event: TEvent,
|
|
313
|
-
handler: (event: BusEvent<
|
|
314
|
-
|
|
315
|
-
off
|
|
356
|
+
on: {
|
|
357
|
+
<TEvent extends keyof TEvents>(event: TEvent, handler: (event: BusEvent<TEvents[TEvent]>) => void): () => void
|
|
358
|
+
(event: EventPattern, handler: (event: BusEvent<unknown>) => void): () => void
|
|
359
|
+
}
|
|
360
|
+
off: {
|
|
361
|
+
<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
|
|
362
|
+
(event: EventPattern, handler?: (event: BusEvent<unknown>) => void): void
|
|
363
|
+
}
|
|
316
364
|
emit<TEvent extends keyof TEvents>(
|
|
317
365
|
topic: TEvent,
|
|
318
366
|
payload: TEvents[TEvent],
|
|
@@ -320,6 +368,8 @@ type Bus<TEvents extends object = Record<string, unknown>> = {
|
|
|
320
368
|
): BusEvent<TEvents[TEvent]>
|
|
321
369
|
}
|
|
322
370
|
|
|
371
|
+
type EventPattern = `${string}*${string}`
|
|
372
|
+
|
|
323
373
|
type BusEvent<TPayload> = {
|
|
324
374
|
id: string
|
|
325
375
|
topic: string
|
|
@@ -332,6 +382,18 @@ type BusEvent<TPayload> = {
|
|
|
332
382
|
|
|
333
383
|
`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
384
|
|
|
385
|
+
### Wildcard subscriptions
|
|
386
|
+
|
|
387
|
+
A topic may be subscribed by pattern, using `*` to match exactly one dot-delimited segment. A wildcard does not cross a `.` boundary.
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
|
|
391
|
+
bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
|
|
392
|
+
bus.on('hub.*.export', ({ parsed }) => {}) // NOT hub.user.audit.export (wildcard spans one segment)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
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`.
|
|
396
|
+
|
|
335
397
|
## Flow
|
|
336
398
|
|
|
337
399
|
`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.
|
|
@@ -415,24 +477,30 @@ A DOM binding key uses the `[dom] <css-selector>:<event>` format. Slapflow insta
|
|
|
415
477
|
|
|
416
478
|
`defaultInput` has type `{ type, value?, dataset, form? }`. `dataset` contains all `data-*` attributes from the matching element as camelCase keys. `form` is built from the nearest `<form>`; repeated form entries become arrays and `File` remains `File`. For `submit`, `preventDefault` defaults to `true`; for other events, it and `stopPropagation` default to `false`.
|
|
417
479
|
|
|
418
|
-
### WebSocket
|
|
480
|
+
### WebSocket Client
|
|
419
481
|
|
|
420
|
-
`
|
|
482
|
+
`createWebSocket` opens a native `WebSocket` to `url` and proxies every socket event into the bus. No wire format is assumed — each event is dispatched with a fixed topic and an envelope whose `parsed` holds the raw payload.
|
|
421
483
|
|
|
422
484
|
```ts
|
|
423
|
-
const
|
|
485
|
+
const socket = createWebSocket({
|
|
486
|
+
url,
|
|
424
487
|
bus,
|
|
425
|
-
|
|
426
|
-
inboundTopics: ['order.created'],
|
|
427
|
-
outboundTopics: ['slapflow.run.finished'],
|
|
428
|
-
origin: 'worker',
|
|
429
|
-
retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
|
|
488
|
+
origin: 'client',
|
|
430
489
|
})
|
|
431
490
|
|
|
432
|
-
|
|
491
|
+
socket.start()
|
|
433
492
|
```
|
|
434
493
|
|
|
435
|
-
|
|
494
|
+
```ts
|
|
495
|
+
bus.on('message', ({ parsed }) => {}) // parsed = the raw message (JSON-decoded when possible)
|
|
496
|
+
bus.on('open', ({ parsed }) => {}) // { url }
|
|
497
|
+
bus.on('close', ({ parsed }) => {}) // { code, reason }
|
|
498
|
+
bus.on('error', ({ parsed }) => {}) // { error }
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Socket events are forwarded with the topic `open`, `message`, `close`, or `error`. `message` payloads are JSON-decoded into `parsed` when valid, otherwise kept as the raw string. Filtering topics is the consumer's responsibility — nothing is allow-listed or rejected inside the client. `reconnect` accepts `initialDelay`, `maxDelay`, `multiplier`, `jitter`, and `maxAttempts`; omitting `maxAttempts` retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the lifecycle; status is one of `idle`, `connecting`, `connected`, `reconnecting`, or `stopped`.
|
|
502
|
+
|
|
503
|
+
`createWS` is deprecated and will be removed; migrate to `createWebSocket`.
|
|
436
504
|
|
|
437
505
|
## Safety Limits
|
|
438
506
|
|