slapflow 1.0.2
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/LICENSE +15 -0
- package/README.md +175 -0
- package/RUNTIME-FLOW.mmd +18 -0
- package/SPEC-RU.md +448 -0
- package/SPEC.md +448 -0
- package/dist/index.cjs +2560 -0
- package/dist/index.d.cts +379 -0
- package/dist/index.d.ts +379 -0
- package/dist/index.js +2524 -0
- package/package.json +65 -0
package/SPEC.md
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
# Slapflow Specification
|
|
2
|
+
|
|
3
|
+
`slapflow` is an npm package for declaratively executing synchronous and asynchronous actions in ordered chains with execution conditions, fallback branches, trace output, and safety limits.
|
|
4
|
+
|
|
5
|
+
The package is not coupled to a UI, server framework, scheduler, or domain model. An application registers actions and conditions, supplies context and input, and the runner returns the chain execution result.
|
|
6
|
+
|
|
7
|
+
## Runtime Flow
|
|
8
|
+
|
|
9
|
+
[View the runtime flow](RUNTIME-FLOW.mmd).
|
|
10
|
+
|
|
11
|
+
## Public API
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
createActionsRegistry,
|
|
16
|
+
createConditionsRegistry,
|
|
17
|
+
createMemoryTraceSink,
|
|
18
|
+
defineErrorReporter,
|
|
19
|
+
createPubSub,
|
|
20
|
+
PubSub,
|
|
21
|
+
createFlow,
|
|
22
|
+
createWS,
|
|
23
|
+
catchError,
|
|
24
|
+
} from 'slapflow'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const flow = createFlow<Context, Patch>(
|
|
29
|
+
{ config: { strategies: {} } },
|
|
30
|
+
{ context: () => ({} as Context) }
|
|
31
|
+
)
|
|
32
|
+
const runner = flow.runner
|
|
33
|
+
|
|
34
|
+
runner.registerAction('jobs.execute', executeJob)
|
|
35
|
+
runner.registerCondition('hasQueue', ({ context }) => context.queue.length > 0)
|
|
36
|
+
|
|
37
|
+
runner.loadConfig(config)
|
|
38
|
+
const result = await runner.run('worker.tick', context, input)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Core Types
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
type Config = {
|
|
45
|
+
version?: 1
|
|
46
|
+
strategies: Record<string, Strategy>
|
|
47
|
+
entrypoints?: Record<string, string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Strategy = {
|
|
51
|
+
fn: string
|
|
52
|
+
props?: Record<string, unknown>
|
|
53
|
+
when?: ConditionExpression
|
|
54
|
+
then?: Next[]
|
|
55
|
+
catch?: Next[]
|
|
56
|
+
mode?: 'sequence' | 'selector' | 'parallel'
|
|
57
|
+
terminal?: boolean
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Error Reporting
|
|
62
|
+
|
|
63
|
+
The runner works as a declarative try/catch pipeline: an action can return `runtime.fail(...)` or throw, a strategy can define `catch`, and an application can centrally report errors through `onError`.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const reportError = defineErrorReporter({
|
|
67
|
+
report: ({ error, context, input, data, patches, events, trace }) => {
|
|
68
|
+
Sentry.captureException(error.cause ?? error, {
|
|
69
|
+
tags: {
|
|
70
|
+
code: error.code,
|
|
71
|
+
phase: error.stage?.phase,
|
|
72
|
+
strategy: error.stage?.strategy,
|
|
73
|
+
fn: error.stage?.fn,
|
|
74
|
+
},
|
|
75
|
+
extra: { context, input, data, patches, events, trace },
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const flow = createFlow(
|
|
81
|
+
{ config: { strategies: {} } },
|
|
82
|
+
{ context: () => ({} as Context), trace: true, onError: reportError }
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`onError` receives `SlapErrorEvent`:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
type SlapErrorEvent<TContext, TPatch> = {
|
|
90
|
+
error: SlapError
|
|
91
|
+
context: TContext
|
|
92
|
+
input: Input
|
|
93
|
+
data: Record<string, unknown>
|
|
94
|
+
patches: TPatch[]
|
|
95
|
+
events: SlapEvent[]
|
|
96
|
+
trace?: TraceEntry[]
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`SlapError.stage` identifies the chain phase:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
type ErrorStage = {
|
|
104
|
+
phase: 'entrypoint' | 'condition' | 'action' | 'catch' | 'limit'
|
|
105
|
+
entrypoint?: string
|
|
106
|
+
strategy?: string
|
|
107
|
+
fn?: string
|
|
108
|
+
mode?: Mode
|
|
109
|
+
step?: number
|
|
110
|
+
depth?: number
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
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
|
+
|
|
116
|
+
## Registry Model
|
|
117
|
+
|
|
118
|
+
The runner uses runner-scoped registries:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
src/registry/
|
|
122
|
+
actions.ts
|
|
123
|
+
conditions.ts
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`createActionsRegistry()` creates a `Map` prepopulated with built-in actions.
|
|
127
|
+
|
|
128
|
+
`createConditionsRegistry()` creates a `Map` prepopulated with built-in conditions.
|
|
129
|
+
|
|
130
|
+
Each runner receives its own mutable registry copy. Applications can override any built-in action or condition:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
runner.registerAction('app.setData', customSetData)
|
|
134
|
+
runner.registerCondition('eq', customEq)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Built-ins are therefore default values, not a separate immutable layer.
|
|
138
|
+
|
|
139
|
+
Configuration validation accesses registries through the minimal `has(name)` contract.
|
|
140
|
+
|
|
141
|
+
## Built-In Actions
|
|
142
|
+
|
|
143
|
+
| Action | Props | Description |
|
|
144
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
145
|
+
| `core.noop` | — | Completes successfully without changing runtime state. |
|
|
146
|
+
| `core.stop` | `reason?` | Stops the run with an optional reason. |
|
|
147
|
+
| `core.fail` | `reason?`, `data?` | Fails the current strategy with an optional reason and error data. |
|
|
148
|
+
| `core.fetch` | **`url`**, `method?`, `headers?`, `body?`, `credentials?`, `response?`, `dataPath?`, `contextPath?`, `acceptStatuses?`, `retryStatuses?`, `retry?` | Fetches data with cancellation, response parsing, status control, and retry backoff. |
|
|
149
|
+
| `core.loop` | `duration?`, `max?`, `immediate?` | Repeats its `then` branch on an interval until aborted or the iteration limit is reached. |
|
|
150
|
+
| `core.sequence` | — | Executes `then` targets in order. |
|
|
151
|
+
| `core.selector` | — | Executes `then` targets until one succeeds or stops. |
|
|
152
|
+
| `core.parallel` | — | Executes `then` targets concurrently in isolated context and data branches. |
|
|
153
|
+
| `core.set` | **`path`**, `value?`, `data?` | Writes `value` to a nested context `path`; optional `data` is merged into runtime data. |
|
|
154
|
+
| `core.setData` | **`path`**, `value?`, `data?` | **Deprecated.** Writes `value` to runtime data; use `runtime.data.set(path, value)` inside an application action. |
|
|
155
|
+
| `core.emit` | **`type`**, `payload?` | Appends an event to the run result. |
|
|
156
|
+
| `core.patch` | **`patch`** | Appends a patch to the run result. |
|
|
157
|
+
| `core.delay` | `ms?` | Waits for the configured duration or until the run is aborted. |
|
|
158
|
+
|
|
159
|
+
Bold props are required; `?` marks optional props. All names in this column are fields of the strategy's `props` object.
|
|
160
|
+
|
|
161
|
+
`core.loop` executes its `then` branch every `props.duration` milliseconds until the run is aborted or `props.max` iterations complete. The default maximum is `999`, leaving one of the default `maxStepCount: 1000` steps for the loop action itself; `max: -1` disables the iteration limit, but not runner safety limits. Zero, values below `-1`, `NaN`, and infinity fall back to the default. When `props.immediate` is `true`, the first iteration executes immediately, counts toward `max`, and does not wait for the first interval. Overlapping iterations are skipped. A failed iteration executes `catch`; the loop continues when `catch` succeeds.
|
|
162
|
+
Nested `core.loop` strategies are invalid, including transitive references through `then` or `catch`. Sibling loops in separate branches are allowed.
|
|
163
|
+
|
|
164
|
+
Actions can execute their own configured branches through `runtime.executeThen()` and `runtime.executeCatch()`. `executeThen()` honors the strategy's `mode`, so control actions such as `core.loop` can compose with `sequence`, `selector`, and `parallel` execution without accessing runner internals.
|
|
165
|
+
|
|
166
|
+
`core.set` writes a nested context value through `runtime.set`. `core.setData` remains available for compatibility; new application actions should write temporary chain data through `runtime.data.set(path, value)`.
|
|
167
|
+
|
|
168
|
+
`core.fetch` uses native `fetch` with the run signal. Its `response` prop selects `json`, `text`, `blob`, `arrayBuffer`, or `none`; successful responses are normalized as `{ status, ok, headers, body }` and can be written to `dataPath` or `contextPath`. `acceptStatuses` overrides the default `Response.ok` success condition. `credentials` accepts `include`, `same-origin`, or `omit` and is forwarded to native `fetch`. CORS, preflight requests, SameSite cookie rules, and server cookie policy remain the responsibility of the browser and server. `retry` accepts `initialDelay`, `maxDelay`, `multiplier`, `jitter`, and `maxAttempts`; `retryStatuses` overrides the default retryable statuses. The default performs two retries for network failures and statuses `408`, `425`, `429`, and `5xx`. Response-body parsing failures do not retry. An aborted request or retry returns `skip`. Retries are intended for replayable request bodies.
|
|
169
|
+
|
|
170
|
+
## Built-In Conditions
|
|
171
|
+
|
|
172
|
+
| Condition | Description | Example |
|
|
173
|
+
| --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
174
|
+
| `and` | Matches when every nested condition matches. | `['and', ['typeIs', '$input.id', 'string'], ['notEmpty', '$input.id']]` |
|
|
175
|
+
| `or` | Matches when at least one nested condition matches. | `['or', ['eq', '$context.status', 'ready'], ['eq', '$context.status', 'idle']]` |
|
|
176
|
+
| `not` | Inverts a nested condition. | `['not', ['truthy', '$context.disabled']]` |
|
|
177
|
+
| `eq` | Compares two values with `Object.is`. | `['eq', '$context.status', 'ready']` |
|
|
178
|
+
| `neq` | Matches when `Object.is` does not consider the values equal. | `['neq', '$context.status', 'failed']` |
|
|
179
|
+
| `gt` | Compares values numerically with `>`. | `['gt', '$context.count', 0]` |
|
|
180
|
+
| `gte` | Compares values numerically with `>=`. | `['gte', '$context.count', 1]` |
|
|
181
|
+
| `lt` | Compares values numerically with `<`. | `['lt', '$context.count', 100]` |
|
|
182
|
+
| `lte` | Compares values numerically with `<=`. | `['lte', '$context.count', 99]` |
|
|
183
|
+
| `truthy` | Applies JavaScript truthiness. | `['truthy', '$context.enabled']` |
|
|
184
|
+
| `falsy` | Applies JavaScript falsiness. | `['falsy', '$context.disabled']` |
|
|
185
|
+
| `exists` | Matches values other than `null` and `undefined`. | `['exists', '$data.response']` |
|
|
186
|
+
| `missing` | Matches `null` or `undefined`. | `['missing', '$data.error']` |
|
|
187
|
+
| `empty` | Matches empty strings, arrays, maps, sets, objects, and nullish values. | `['empty', '$context.items']` |
|
|
188
|
+
| `notEmpty` | Matches supported values with a size greater than zero. | `['notEmpty', '$context.items']` |
|
|
189
|
+
| `includes` | Checks membership in strings, arrays, and sets. | `['includes', ['parts', 'food'], '$input.resource']` |
|
|
190
|
+
| `typeIs` | Matches `string`, `number`, `finite-number`, `boolean`, `array`, or `record`. | `['typeIs', '$input.amount', 'finite-number']` |
|
|
191
|
+
| `changed` | Matches when current and previous values differ by `Object.is`. | `['changed', '$context.current', '$context.previous']` |
|
|
192
|
+
| `cooldownReady` | Matches when no previous timestamp exists or the cooldown has elapsed. | `['cooldownReady', '$context.now', '$context.lastAt', 1000]` |
|
|
193
|
+
|
|
194
|
+
## Config Example
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
export const config = {
|
|
198
|
+
version: 1,
|
|
199
|
+
entrypoints: {
|
|
200
|
+
'worker.tick': 'worker.tick',
|
|
201
|
+
},
|
|
202
|
+
strategies: {
|
|
203
|
+
'worker.tick': {
|
|
204
|
+
fn: 'core.selector',
|
|
205
|
+
mode: 'selector',
|
|
206
|
+
then: ['worker.pickQueuedJob', 'worker.idle'],
|
|
207
|
+
},
|
|
208
|
+
'worker.pickQueuedJob': {
|
|
209
|
+
fn: 'jobs.findNext',
|
|
210
|
+
when: ['and', ['eq', '$context.worker.state', 'idle'], ['gt', '$context.worker.queueSize', 0]],
|
|
211
|
+
then: ['jobs.reserve', 'jobs.execute'],
|
|
212
|
+
},
|
|
213
|
+
'worker.idle': {
|
|
214
|
+
fn: 'core.noop',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Execution Modes
|
|
221
|
+
|
|
222
|
+
`sequence` executes `then` targets in order.
|
|
223
|
+
|
|
224
|
+
`selector` executes `then` targets until the first successful or stopped step. `skip` means “try the next option.”
|
|
225
|
+
|
|
226
|
+
`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
|
+
|
|
228
|
+
## Runtime Helpers
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
type Runtime = {
|
|
232
|
+
get(path: string): unknown
|
|
233
|
+
set(path: string, value: unknown): void
|
|
234
|
+
data: {
|
|
235
|
+
get(path: string): unknown
|
|
236
|
+
set(path: string, value: unknown): void
|
|
237
|
+
}
|
|
238
|
+
variables?: {
|
|
239
|
+
get(path: string): unknown
|
|
240
|
+
}
|
|
241
|
+
/** @deprecated Use runtime.data.get(path). */
|
|
242
|
+
getData(path: string): unknown
|
|
243
|
+
/** @deprecated Use runtime.data.set(path, value). */
|
|
244
|
+
setData(path: string, value: unknown): void
|
|
245
|
+
resolve(value: unknown): unknown
|
|
246
|
+
signal: AbortSignal
|
|
247
|
+
executeThen(): Promise<RuntimeBranchResult>
|
|
248
|
+
executeCatch(): Promise<RuntimeBranchResult | undefined>
|
|
249
|
+
emit(event: SlapEvent): void
|
|
250
|
+
patch(patch: unknown): void
|
|
251
|
+
stop(reason?: string): ActionStop<unknown>
|
|
252
|
+
fail(reason?: string, data?: Record<string, unknown>): ActionFail
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`runtime.get` and `runtime.set` read and write nested context values. `runtime.data.get` and `runtime.data.set` read and write temporary chain data.
|
|
257
|
+
|
|
258
|
+
`runtime.getData` and `runtime.setData` are deprecated compatibility aliases and emit a console warning when called.
|
|
259
|
+
|
|
260
|
+
`runtime.variables.get` reads immutable runtime variables. `runtime.resolve` resolves `$context.*`, `$data.*`, `$input.*`, and immutable `$variables.*` values. It also evaluates `$expression` and `$template` objects recursively, using the expression operators registered in runner options. In `$template`, `{{ path }}` reads runtime data for compatibility; `{{ data.path }}`, `{{ context.path }}`, and `{{ input.path }}` select their source explicitly.
|
|
261
|
+
|
|
262
|
+
Runtime path get/set is implemented directly through `objwalk`.
|
|
263
|
+
|
|
264
|
+
## Validation
|
|
265
|
+
|
|
266
|
+
`validateConfig` validates:
|
|
267
|
+
|
|
268
|
+
- unknown actions through `actionsRegistry.has(fn)`;
|
|
269
|
+
- unknown condition operators through `conditionsRegistry.has(operator)`;
|
|
270
|
+
- missing strategies in `then`, `catch`, and `entrypoints`;
|
|
271
|
+
- invalid modes;
|
|
272
|
+
- invalid path references;
|
|
273
|
+
- cycles without a terminal step.
|
|
274
|
+
|
|
275
|
+
## Trace
|
|
276
|
+
|
|
277
|
+
Trace entries contain:
|
|
278
|
+
|
|
279
|
+
- step/depth;
|
|
280
|
+
- strategy/fn/mode;
|
|
281
|
+
- status;
|
|
282
|
+
- input;
|
|
283
|
+
- props;
|
|
284
|
+
- dataBefore/dataAfter;
|
|
285
|
+
- durationMs;
|
|
286
|
+
- reason.
|
|
287
|
+
|
|
288
|
+
Trace does not store a complete context snapshot.
|
|
289
|
+
|
|
290
|
+
## Pub/Sub Bus
|
|
291
|
+
|
|
292
|
+
`PubSub` is a process-local singleton event bus. Use `createPubSub` for isolated runtimes.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
type AppEvents = {
|
|
296
|
+
'auth.signed-in': { userId: string }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const bus = createPubSub<AppEvents>()
|
|
300
|
+
const unsubscribe = bus.on('auth.signed-in', ({ parsed, serialized }) => {
|
|
301
|
+
console.log(parsed.userId)
|
|
302
|
+
socket.send(serialized)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
bus.emit('auth.signed-in', { userId: 'ada' }, { origin: 'api' })
|
|
306
|
+
unsubscribe()
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
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
|
|
316
|
+
emit<TEvent extends keyof TEvents>(
|
|
317
|
+
topic: TEvent,
|
|
318
|
+
payload: TEvents[TEvent],
|
|
319
|
+
options?: { origin?: string }
|
|
320
|
+
): BusEvent<TEvents[TEvent]>
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
type BusEvent<TPayload> = {
|
|
324
|
+
id: string
|
|
325
|
+
topic: string
|
|
326
|
+
occurredAt: number
|
|
327
|
+
origin?: string
|
|
328
|
+
parsed: TPayload
|
|
329
|
+
serialized: string
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`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
|
+
|
|
335
|
+
## Flow
|
|
336
|
+
|
|
337
|
+
`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.
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
type Events = {
|
|
341
|
+
'form.submit': { email: string }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const flow = createFlow<Context, Patch, Events>(
|
|
345
|
+
{
|
|
346
|
+
actions: { 'form.save': saveForm },
|
|
347
|
+
conditions: { allowed: isAllowed },
|
|
348
|
+
events: { '[bus] form.submit': { entrypoint: 'form.submit' } },
|
|
349
|
+
config,
|
|
350
|
+
},
|
|
351
|
+
{ bus, context: () => appStore.getState() }
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
const started = flow.start()
|
|
355
|
+
flow.stop()
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
A `[bus] <event-name>` binding starts an `entrypoint` from `config.entrypoints`. The event payload must be an object and is passed to the runner as `input`. Context is read for each event, so a context provider returns current state.
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
type StartResult = {
|
|
362
|
+
active: string[]
|
|
363
|
+
inactive: Array<{ binding: string; reason: 'unsupported-source' }>
|
|
364
|
+
validation: ValidationResult
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
`start()` registers actions and conditions, validates and loads configuration. Bindings are not installed after failed validation. Calling `start()` again replaces existing bindings. `stop()` releases only subscriptions owned by the current chain.
|
|
369
|
+
|
|
370
|
+
`onRunnerError` in `FlowOptions` is called only when final `RunResult.status === 'failed'`. The callback receives `error`, `result`, `binding`, `entrypoint`, `runId`, and optional `key`. An error recovered by a strategy through `catch` does not invoke `onRunnerError`.
|
|
371
|
+
|
|
372
|
+
### Concurrency
|
|
373
|
+
|
|
374
|
+
Each binding supports `parallel`, `latest`, `queue`, and `drop`. The default mode is `parallel`. Concurrency applies to one binding and lane; `key(payload)` creates independent lanes.
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
type ConcurrencyOptions<TPayload> = {
|
|
378
|
+
mode?: 'parallel' | 'latest' | 'queue' | 'drop'
|
|
379
|
+
key?: (payload: TPayload) => string
|
|
380
|
+
maxQueueSize?: number
|
|
381
|
+
overflow?: 'drop-oldest' | 'drop-newest'
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Options are set globally in `createFlow` and can be overridden by a binding. `queue` is limited by `maxQueueSize`, which defaults to `50`. On overflow, Slapflow publishes `slapflow.queue.overflow` and `slapflow.run.dropped`.
|
|
386
|
+
|
|
387
|
+
`ActionArgs` and `Runtime` contain `signal: AbortSignal`. `latest` aborts the previous run in the same lane. `flow.stop({ force: true })` aborts every active run; normal `stop()` removes bindings and does not cancel running actions. Abort is cooperative: an action uses the signal for fetches, timers, and its own asynchronous work.
|
|
388
|
+
|
|
389
|
+
Lifecycle diagnostics are published through the configured bus:
|
|
390
|
+
|
|
391
|
+
- `slapflow.run.started`;
|
|
392
|
+
- `slapflow.run.finished`;
|
|
393
|
+
- `slapflow.run.failed`;
|
|
394
|
+
- `slapflow.run.cancelled`;
|
|
395
|
+
- `slapflow.run.dropped`;
|
|
396
|
+
- `slapflow.queue.overflow`.
|
|
397
|
+
|
|
398
|
+
### DOM Bindings
|
|
399
|
+
|
|
400
|
+
A DOM binding key uses the `[dom] <css-selector>:<event>` format. Slapflow installs a delegated listener on `options.root` or `document`. In a runtime without DOM, the binding is added to `inactive` with reason `dom-unavailable`.
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
'[dom] .app-button[type="submit"]:click': {
|
|
404
|
+
entrypoint: 'form.submit',
|
|
405
|
+
options: {
|
|
406
|
+
preventDefault: true,
|
|
407
|
+
stopPropagation: false,
|
|
408
|
+
capture: false,
|
|
409
|
+
once: false,
|
|
410
|
+
concurrency: { mode: 'drop' },
|
|
411
|
+
input: ({ event, element, defaultInput }) => defaultInput,
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`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
|
+
|
|
418
|
+
### WebSocket Bridge
|
|
419
|
+
|
|
420
|
+
`createWS` connects a bus to a WebSocket-like transport. The bridge accepts `createSocket`, so it works with browser WebSocket and a server adapter alike.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
const ws = createWS({
|
|
424
|
+
bus,
|
|
425
|
+
createSocket: () => new WebSocket(url),
|
|
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 },
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
ws.start()
|
|
433
|
+
```
|
|
434
|
+
|
|
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`.
|
|
436
|
+
|
|
437
|
+
## Safety Limits
|
|
438
|
+
|
|
439
|
+
Defaults:
|
|
440
|
+
|
|
441
|
+
- `maxStepCount`: `1000`
|
|
442
|
+
- `maxDepth`: `32`
|
|
443
|
+
- `timeout`: `0`
|
|
444
|
+
- `trace`: `false`
|
|
445
|
+
|
|
446
|
+
Limit failures are returned as failed results with `MAX_STEPS`, `MAX_DEPTH`, and `TIMEOUT` codes.
|
|
447
|
+
|
|
448
|
+
Set `maxStepCount` or `maxDepth` to `-1` to disable that check. Validation emits a `LIMIT_DISABLED` warning because unbounded runs may execute indefinitely and unbounded nesting may exhaust the call stack.
|