chain-functions-behavior 1.0.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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Sergey Khalilov
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
15
+ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # CFB - Chain Functions Behavior
2
+
3
+ CFB is a TypeScript framework for turning application scenarios into executable behavior chains. It connects typed events to reusable synchronous and asynchronous functions through declarative strategies, conditions, concurrency rules, and error branches.
4
+
5
+ The same behavior model can run in a browser, API service, worker, or WebSocket-connected process. Application code owns the domain state and side effects; CFB owns orchestration.
6
+
7
+ ## Manifest
8
+
9
+ A user story, enterprise architecture diagram, or mind map should translate naturally into a chain of functions. CFB keeps that chain explicit so product behavior does not disappear into UI handlers, transport callbacks, and service-specific control flow.
10
+
11
+ The framework separates **what should happen** from **where and how each function runs**:
12
+
13
+ - events select an entrypoint;
14
+ - strategies describe execution order and conditions;
15
+ - actions implement domain behavior;
16
+ - bindings connect browser and server event sources;
17
+ - the runner returns data, patches, events, trace, and a normalized result.
18
+
19
+ ## Runtime Flow
20
+
21
+ ```mermaid
22
+ flowchart LR
23
+ DOM["DOM event"] --> CFB["Chain behavior binding"]
24
+ SYNTHETIC["Synthetic event: API, timer, worker"] --> BUS["PubSubBehavior"]
25
+ TRANSPORT["WebSocket transport"] --> WS["WebSocket bridge"]
26
+ WS --> BUS
27
+ BUS --> WS
28
+ WS --> TRANSPORT
29
+ BUS --> CFB
30
+
31
+ CFB --> CONCURRENCY["Concurrency lane"]
32
+ CONCURRENCY --> RUNNER["Behavior runner"]
33
+ RUNNER --> CONDITIONS["Conditions"]
34
+ CONDITIONS --> ACTIONS["Actions"]
35
+ ACTIONS --> RESULT["Run result"]
36
+
37
+ RUNNER --> DIAGNOSTICS["cfb.* diagnostics"]
38
+ DIAGNOSTICS --> BUS
39
+ RESULT --> DIAGNOSTICS
40
+ ```
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ npm install chain-functions-behavior
46
+ ```
47
+
48
+ ## Chain Behavior
49
+
50
+ `createChainBehavior` is the application-facing runtime. It combines:
51
+
52
+ - a behavior config with strategies and entrypoints;
53
+ - application actions and conditions;
54
+ - a context value or context provider;
55
+ - bus and DOM event bindings;
56
+ - concurrency and lifecycle options;
57
+ - a behavior runner created for this definition.
58
+
59
+ `start()` validates the config and installs available bindings. `stop()` removes those bindings. DOM bindings become inactive in runtimes without a DOM, while bus bindings continue to work on both client and server.
60
+
61
+ ```ts
62
+ import { createChainBehavior, createPubSubBehavior } from 'chain-functions-behavior'
63
+
64
+ type Context = {
65
+ orders: Map<string, { id: string; status: 'draft' | 'submitted' }>
66
+ }
67
+
68
+ type Events = {
69
+ 'order.submit': { orderId: string }
70
+ }
71
+
72
+ const context: Context = {
73
+ orders: new Map([['order-1', { id: 'order-1', status: 'draft' }]]),
74
+ }
75
+
76
+ const bus = createPubSubBehavior<Events>()
77
+
78
+ const behavior = createChainBehavior<Context, unknown, Events>(
79
+ {
80
+ events: {
81
+ '[bus] order.submit': {
82
+ entrypoint: 'order.submit',
83
+ options: {
84
+ concurrency: {
85
+ mode: 'latest',
86
+ key: ({ orderId }) => orderId,
87
+ },
88
+ },
89
+ },
90
+ },
91
+ actions: {
92
+ 'order.submit': ({ context, input }) => {
93
+ const order = context.orders.get(input.orderId as string)
94
+
95
+ if (order) {
96
+ order.status = 'submitted'
97
+ }
98
+ },
99
+ },
100
+ config: {
101
+ entrypoints: {
102
+ 'order.submit': 'order.submit',
103
+ },
104
+ strategies: {
105
+ 'order.submit': {
106
+ fn: 'order.submit',
107
+ },
108
+ },
109
+ },
110
+ },
111
+ {
112
+ bus,
113
+ context,
114
+ onRunnerError: ({ error, entrypoint, runId }) => {
115
+ console.error('Behavior failed', { error, entrypoint, runId })
116
+ },
117
+ }
118
+ )
119
+
120
+ const started = behavior.start()
121
+
122
+ bus.emit('order.submit', { orderId: 'order-1' }, { origin: 'api' })
123
+
124
+ behavior.stop()
125
+ ```
126
+
127
+ `start()` returns active and inactive bindings together with the config validation result. Calling it again replaces bindings installed by the previous start.
128
+
129
+ ## Event Sources
130
+
131
+ Chain behavior accepts multiple event sources without coupling actions to a transport:
132
+
133
+ - `[bus] <topic>` consumes typed `PubSubBehavior` events;
134
+ - `[dom] <selector>:<event>` delegates browser events from `document` or a configured root;
135
+ - application code produces synthetic events with `bus.emit()` from API callbacks, timers, workers, or tests;
136
+ - `createBehaviorWs` forwards selected bus envelopes through a WebSocket-like transport and dispatches allowed inbound envelopes back to the bus.
137
+
138
+ DOM input is normalized to `{ type, value?, dataset, form? }`. For submit events, `preventDefault` defaults to `true`. On the server, DOM bindings are reported as inactive instead of failing behavior startup.
139
+
140
+ ## Concurrency And Lifecycle
141
+
142
+ Each binding supports `parallel`, `latest`, `queue`, and `drop` modes. A binding can derive a lane key from its payload, allowing unrelated entities to execute independently.
143
+
144
+ Actions receive an `AbortSignal`. `latest` aborts the previous run in the same lane, and `behavior.stop({ force: true })` aborts all active runs. Cancellation is cooperative: actions must pass the signal to fetches, timers, or other asynchronous work.
145
+
146
+ CFB publishes lifecycle diagnostics through the configured bus, including `cfb.run.started`, `cfb.run.finished`, `cfb.run.failed`, `cfb.run.cancelled`, `cfb.run.dropped`, and `cfb.queue.overflow`.
147
+
148
+ ## Runnable Example
149
+
150
+ [`examples/todo-app`](examples/todo-app) is a Bun microapp with DOM bindings, synthetic bus events, and live WebSocket synchronization between browser tabs. Its state, actions, configuration, bindings, and transport setup are intentionally kept together in one `src/app.ts` file.
151
+
152
+ ```bash
153
+ npm run build
154
+ cd examples/todo-app
155
+ bun install
156
+ bun run dev
157
+ ```
158
+
159
+ Open `http://localhost:4173` in two browser tabs to observe event envelopes propagating through the WebSocket bridge.
160
+
161
+ ## Specification
162
+
163
+ [SPEC.md](SPEC.md) defines the complete technical contract, including:
164
+
165
+ - runner and registry APIs;
166
+ - built-in actions and conditions;
167
+ - execution modes and condition expressions;
168
+ - runtime helpers, validation, trace, and safety limits;
169
+ - PubSub envelopes and error behavior;
170
+ - chain behavior bindings and concurrency;
171
+ - DOM normalization and WebSocket transport behavior.
172
+
173
+ ## Development
174
+
175
+ ```bash
176
+ npm test
177
+ npm run build
178
+ npm run pack:check
179
+ ```
@@ -0,0 +1,33 @@
1
+ import { createBehaviorRunner, defineBehaviorConfig } from '~/index'
2
+
3
+ interface JobWorkerContext {
4
+ worker: { id: string; state: 'idle' | 'busy'; queueSize: number }
5
+ system: { limits: { maxQueueSize: number } }
6
+ now: number
7
+ }
8
+
9
+ export const jobWorkerBehavior = defineBehaviorConfig({
10
+ version: 1,
11
+ entrypoints: { 'worker.tick': 'worker.tick' },
12
+ strategies: {
13
+ 'worker.tick': {
14
+ fn: 'core.selector',
15
+ mode: 'selector',
16
+ then: ['worker.pickQueuedJob', 'worker.idle'],
17
+ },
18
+ 'worker.pickQueuedJob': {
19
+ fn: 'jobs.findNext',
20
+ when: ['and', ['eq', '$context.worker.state', 'idle'], ['gt', '$context.worker.queueSize', 0]],
21
+ then: ['jobs.reserve', 'jobs.execute'],
22
+ },
23
+ 'worker.idle': { fn: 'worker.idle' },
24
+ 'jobs.reserve': { fn: 'jobs.reserve' },
25
+ 'jobs.execute': { fn: 'jobs.execute' },
26
+ },
27
+ })
28
+
29
+ export const createJobWorkerRunner = () => {
30
+ const runner = createBehaviorRunner<JobWorkerContext>()
31
+ runner.loadConfig(jobWorkerBehavior)
32
+ return runner
33
+ }
@@ -0,0 +1,29 @@
1
+ import { createBehaviorRunner, defineBehaviorConfig } from '~/index'
2
+
3
+ interface ServicePipelineContext {
4
+ service: { id: string; status: 'ready' | 'paused'; pendingTasks: number }
5
+ now: number
6
+ }
7
+
8
+ export const servicePipelineBehavior = defineBehaviorConfig({
9
+ version: 1,
10
+ entrypoints: { 'service.tick': 'service.tick' },
11
+ strategies: {
12
+ 'service.tick': {
13
+ fn: 'core.sequence',
14
+ mode: 'sequence',
15
+ then: ['service.processPending', 'service.emitStatus'],
16
+ },
17
+ 'service.processPending': { fn: 'service.processPending' },
18
+ 'service.emitStatus': {
19
+ fn: 'core.emit',
20
+ props: { type: 'service.status', payload: { serviceId: '$context.service.id' } },
21
+ },
22
+ },
23
+ })
24
+
25
+ export const createServicePipelineRunner = () => {
26
+ const runner = createBehaviorRunner<ServicePipelineContext>()
27
+ runner.loadConfig(servicePipelineBehavior)
28
+ return runner
29
+ }
@@ -0,0 +1,14 @@
1
+ # CFB Todo App
2
+
3
+ Runnable browser example for CFB DOM bindings, synthetic bus events, and the WebSocket bridge.
4
+
5
+ ```sh
6
+ npm run build
7
+ cd examples/todo-app
8
+ bun install
9
+ bun run dev
10
+ ```
11
+
12
+ Open two browser tabs at `http://localhost:4173`: tasks created, toggled, or removed in one tab are propagated to the other through CFB event envelopes.
13
+
14
+ `src/app.ts` deliberately keeps state, actions, behavior config, bindings, and transport setup together so the full flow is visible in one file.
@@ -0,0 +1,31 @@
1
+ import Bun, { type BuildOutput } from 'bun'
2
+ import { FileMoverPlugin } from 'bun-file-mover-plugin'
3
+
4
+ export const build = async (): Promise<BuildOutput> => {
5
+ const result = await Bun.build({
6
+ entrypoints: ['./src/app.ts'],
7
+ outdir: './build',
8
+ target: 'browser',
9
+ format: 'esm',
10
+ splitting: true,
11
+ minify: false,
12
+ publicPath: '/',
13
+ sourcemap: 'external',
14
+ plugins: [
15
+ FileMoverPlugin({
16
+ from: './src/public',
17
+ to: './build',
18
+ }),
19
+ ],
20
+ })
21
+
22
+ if (!result.success) {
23
+ throw new Error('Todo app build failed')
24
+ }
25
+
26
+ return result
27
+ }
28
+
29
+ if (import.meta.main) {
30
+ await build()
31
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "cfb-todo-app-example",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "bun run build.ts",
7
+ "dev": "bun run server.ts",
8
+ "start": "bun run server.ts"
9
+ },
10
+ "dependencies": {
11
+ "bun-file-mover-plugin": "^1.0.0",
12
+ "chain-functions-behavior": "file:../..",
13
+ "serve-static-bun": "^0.5.3"
14
+ }
15
+ }
@@ -0,0 +1,63 @@
1
+ import Bun, { type Server } from 'bun'
2
+ import { join } from 'node:path'
3
+ import ServeStatic from 'serve-static-bun'
4
+ import { build } from './build'
5
+
6
+ type SocketData = {
7
+ id: string
8
+ }
9
+
10
+ const serve = async (): Promise<Server<SocketData>> => {
11
+ const port = Number(process.env.PORT ?? 4173)
12
+ const directory = './build'
13
+ const root = await Bun.file(join(directory, 'index.html')).text()
14
+ const staticHandler = ServeStatic(directory, {
15
+ headers: {
16
+ 'Access-Control-Allow-Origin': '*',
17
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
18
+ 'Access-Control-Allow-Headers': 'Content-Type',
19
+ },
20
+ })
21
+
22
+ const server = Bun.serve<SocketData>({
23
+ port,
24
+ async fetch(request, server): Promise<Response | undefined> {
25
+ const url = new URL(request.url)
26
+
27
+ if (url.pathname === '/ws') {
28
+ const upgraded = server.upgrade(request, { data: { id: crypto.randomUUID() } })
29
+
30
+ if (upgraded) {
31
+ return undefined
32
+ }
33
+ return new Response('WebSocket upgrade failed', { status: 400 })
34
+ }
35
+
36
+ try {
37
+ const response = await staticHandler(request)
38
+
39
+ if (response.status === 404 && root) {
40
+ return new Response(root, { headers: { 'Content-Type': 'text/html' } })
41
+ }
42
+ return response
43
+ } catch {
44
+ return new Response('Internal Server Error', { status: 500 })
45
+ }
46
+ },
47
+ websocket: {
48
+ open(socket): void {
49
+ socket.subscribe('todo-events')
50
+ },
51
+ message(socket, message): void {
52
+ server.publish('todo-events', message)
53
+ },
54
+ },
55
+ })
56
+
57
+ console.log(`Todo app is available at http://localhost:${server.port}`)
58
+
59
+ return server
60
+ }
61
+
62
+ await build()
63
+ await serve()
@@ -0,0 +1,174 @@
1
+ import {
2
+ createBehaviorWs,
3
+ createChainBehavior,
4
+ createPubSubBehavior,
5
+ type BehaviorDomForm,
6
+ } from 'chain-functions-behavior'
7
+ import { pick } from 'objwalk'
8
+
9
+ type Todo = {
10
+ id: string
11
+ title: string
12
+ completed: boolean
13
+ }
14
+
15
+ type TodoEvents = {
16
+ 'todo.created': Todo
17
+ 'todo.toggled': Pick<Todo, 'id' | 'completed'>
18
+ 'todo.removed': Pick<Todo, 'id'>
19
+ }
20
+
21
+ type TodoState = {
22
+ todos: Map<string, Todo>
23
+ }
24
+
25
+ const state: TodoState = { todos: new Map() }
26
+ const bus = createPubSubBehavior<TodoEvents>()
27
+
28
+ const getFormValue = (form: BehaviorDomForm | undefined, name: string): string => {
29
+ const value = form?.[name]
30
+
31
+ return typeof value === 'string' ? value.trim() : ''
32
+ }
33
+
34
+ const render = (): void => {
35
+ const list = document.querySelector<HTMLUListElement>('[data-todo-list]')
36
+ const status = document.querySelector<HTMLElement>('[data-todo-status]')
37
+
38
+ if (list) {
39
+ const items = [...state.todos.values()].map((todo) => {
40
+ const item = document.createElement('li')
41
+ const toggle = document.createElement('input')
42
+ const title = document.createElement('span')
43
+ const remove = document.createElement('button')
44
+
45
+ item.className = todo.completed ? 'todo todo-completed' : 'todo'
46
+ toggle.type = 'checkbox'
47
+ toggle.checked = todo.completed
48
+ toggle.dataset.todoToggle = todo.id
49
+ title.textContent = todo.title
50
+ remove.type = 'button'
51
+ remove.dataset.todoRemove = todo.id
52
+ remove.textContent = 'Remove'
53
+
54
+ item.append(toggle, title, remove)
55
+ return item
56
+ })
57
+
58
+ list.replaceChildren(...items)
59
+ }
60
+ if (status) {
61
+ const completed = [...state.todos.values()].filter((todo) => todo.completed).length
62
+
63
+ status.textContent = `${state.todos.size} tasks, ${completed} completed`
64
+ }
65
+ }
66
+
67
+ const behavior = createChainBehavior<TodoState, unknown, TodoEvents>(
68
+ {
69
+ actions: {
70
+ 'todo.requestCreate': ({ input }) => {
71
+ const form = input.form as BehaviorDomForm | undefined
72
+ const title = getFormValue(form, 'title')
73
+
74
+ if (title) {
75
+ bus.emit('todo.created', { id: crypto.randomUUID(), title, completed: false }, { origin: 'ui' })
76
+ }
77
+ },
78
+ 'todo.requestToggle': ({ input }) => {
79
+ const id = pick(input, 'dataset.todoToggle')
80
+ const todo = typeof id === 'string' ? state.todos.get(id) : undefined
81
+
82
+ if (todo) {
83
+ bus.emit('todo.toggled', { id: todo.id, completed: !todo.completed }, { origin: 'ui' })
84
+ }
85
+ },
86
+ 'todo.requestRemove': ({ input }) => {
87
+ const id = pick(input, 'dataset.todoRemove')
88
+
89
+ if (typeof id === 'string') {
90
+ bus.emit('todo.removed', { id }, { origin: 'ui' })
91
+ }
92
+ },
93
+ 'todo.addSynthetic': () => {
94
+ bus.emit(
95
+ 'todo.created',
96
+ { id: crypto.randomUUID(), title: 'Synthetic event from an API callback', completed: false },
97
+ { origin: 'api' }
98
+ )
99
+ },
100
+ 'todo.applyCreated': ({ input }) => {
101
+ const todo = input as Todo
102
+
103
+ state.todos.set(todo.id, todo)
104
+ render()
105
+ },
106
+ 'todo.applyToggled': ({ input }) => {
107
+ const update = input as TodoEvents['todo.toggled']
108
+ const todo = state.todos.get(update.id)
109
+
110
+ if (todo) {
111
+ state.todos.set(update.id, { ...todo, completed: update.completed })
112
+ render()
113
+ }
114
+ },
115
+ 'todo.applyRemoved': ({ input }) => {
116
+ const update = input as TodoEvents['todo.removed']
117
+
118
+ state.todos.delete(update.id)
119
+ render()
120
+ },
121
+ },
122
+ events: {
123
+ '[dom] [data-todo-form]:submit': { entrypoint: 'todo.requestCreate' },
124
+ '[dom] [data-todo-toggle]:click': { entrypoint: 'todo.requestToggle' },
125
+ '[dom] [data-todo-remove]:click': { entrypoint: 'todo.requestRemove' },
126
+ '[dom] [data-todo-synthetic]:click': { entrypoint: 'todo.addSynthetic' },
127
+ '[bus] todo.created': { entrypoint: 'todo.applyCreated' },
128
+ '[bus] todo.toggled': { entrypoint: 'todo.applyToggled' },
129
+ '[bus] todo.removed': { entrypoint: 'todo.applyRemoved' },
130
+ },
131
+ config: {
132
+ entrypoints: {
133
+ 'todo.requestCreate': 'todo.requestCreate',
134
+ 'todo.requestToggle': 'todo.requestToggle',
135
+ 'todo.requestRemove': 'todo.requestRemove',
136
+ 'todo.addSynthetic': 'todo.addSynthetic',
137
+ 'todo.applyCreated': 'todo.applyCreated',
138
+ 'todo.applyToggled': 'todo.applyToggled',
139
+ 'todo.applyRemoved': 'todo.applyRemoved',
140
+ },
141
+ strategies: {
142
+ 'todo.requestCreate': { fn: 'todo.requestCreate' },
143
+ 'todo.requestToggle': { fn: 'todo.requestToggle' },
144
+ 'todo.requestRemove': { fn: 'todo.requestRemove' },
145
+ 'todo.addSynthetic': { fn: 'todo.addSynthetic' },
146
+ 'todo.applyCreated': { fn: 'todo.applyCreated' },
147
+ 'todo.applyToggled': { fn: 'todo.applyToggled' },
148
+ 'todo.applyRemoved': { fn: 'todo.applyRemoved' },
149
+ },
150
+ },
151
+ },
152
+ { bus, context: state }
153
+ )
154
+
155
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
156
+ const ws = createBehaviorWs({
157
+ bus,
158
+ createSocket: () => new WebSocket(`${protocol}//${window.location.host}/ws`),
159
+ inboundTopics: ['todo.created', 'todo.toggled', 'todo.removed'],
160
+ outboundTopics: ['todo.created', 'todo.toggled', 'todo.removed'],
161
+ origin: 'ws',
162
+ retry: { initialDelay: 250, maxDelay: 5_000 },
163
+ })
164
+
165
+ behavior.start()
166
+ ws.start()
167
+
168
+ queueMicrotask(() => {
169
+ bus.emit(
170
+ 'todo.created',
171
+ { id: crypto.randomUUID(), title: 'Synthetic task created after startup', completed: false },
172
+ { origin: 'api' }
173
+ )
174
+ })
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>CFB Todo App</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <header>
12
+ <p>CFB Todo App</p>
13
+ <span data-todo-status>0 tasks, 0 completed</span>
14
+ </header>
15
+
16
+ <form data-todo-form>
17
+ <input name="title" placeholder="What needs doing?" required />
18
+ <button type="submit">Add task</button>
19
+ </form>
20
+
21
+ <button type="button" data-todo-synthetic>Add synthetic task</button>
22
+ <ul data-todo-list></ul>
23
+ </main>
24
+ <script type="module" src="/app.js"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,62 @@
1
+ :root {
2
+ color: #1c242b;
3
+ background: #f5f7f8;
4
+ font-family: Arial, sans-serif;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ }
10
+
11
+ main {
12
+ width: min(640px, calc(100% - 32px));
13
+ margin: 48px auto;
14
+ }
15
+
16
+ header,
17
+ form,
18
+ .todo {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 12px;
22
+ }
23
+
24
+ header {
25
+ justify-content: space-between;
26
+ border-bottom: 1px solid #ccd4d9;
27
+ margin-bottom: 20px;
28
+ }
29
+
30
+ header p {
31
+ font-size: 24px;
32
+ font-weight: 700;
33
+ }
34
+
35
+ input[name='title'] {
36
+ flex: 1;
37
+ }
38
+
39
+ button,
40
+ input {
41
+ font: inherit;
42
+ padding: 8px 10px;
43
+ }
44
+
45
+ ul {
46
+ list-style: none;
47
+ padding: 0;
48
+ }
49
+
50
+ .todo {
51
+ border-bottom: 1px solid #dbe1e4;
52
+ padding: 10px 0;
53
+ }
54
+
55
+ .todo span {
56
+ flex: 1;
57
+ }
58
+
59
+ .todo-completed span {
60
+ color: #65727a;
61
+ text-decoration: line-through;
62
+ }
@@ -0,0 +1,27 @@
1
+ import { createBehaviorRunner, defineBehaviorConfig } from '~/index'
2
+
3
+ interface UiContext {
4
+ modal: 'buyOre' | null
5
+ }
6
+
7
+ export const uiBehavior = defineBehaviorConfig({
8
+ version: 1,
9
+ entrypoints: { 'settings.save.click': 'settings.save.click' },
10
+ strategies: {
11
+ 'settings.save.click': {
12
+ fn: 'ui.validateSettings',
13
+ then: ['settings.persist', 'ui.closeModal', 'ui.showMessage'],
14
+ catch: ['ui.showError'],
15
+ },
16
+ 'settings.persist': { fn: 'settings.persist' },
17
+ 'ui.closeModal': { fn: 'core.patch', props: { patch: { type: 'ui.modal.closed' } } },
18
+ 'ui.showMessage': { fn: 'core.emit', props: { type: 'ui.message', payload: { text: 'Settings saved' } } },
19
+ 'ui.showError': { fn: 'core.emit', props: { type: 'ui.error', payload: { text: 'Could not save settings' } } },
20
+ },
21
+ })
22
+
23
+ export const createUiRunner = () => {
24
+ const runner = createBehaviorRunner<UiContext>()
25
+ runner.loadConfig(uiBehavior)
26
+ return runner
27
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "chain-functions-behavior",
3
+ "version": "1.0.0",
4
+ "description": "Chain functions behaviour",
5
+ "license": "ISC",
6
+ "author": "Sergey Khalilov",
7
+ "keywords": [
8
+ "behavior",
9
+ "workflow",
10
+ "action-runner",
11
+ "rules-engine",
12
+ "typescript"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/khalilov/chain-functions-behavior.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/khalilov/chain-functions-behavior/issues"
20
+ },
21
+ "homepage": "https://github.com/khalilov/chain-functions-behavior#readme",
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "require": "./dist/index.cjs"
31
+ }
32
+ },
33
+ "main": "./dist/index.cjs",
34
+ "types": "./dist/index.d.ts",
35
+ "module": "./dist/index.js",
36
+ "files": [
37
+ "dist",
38
+ "examples/*.ts",
39
+ "examples/todo-app/README.md",
40
+ "examples/todo-app/build.ts",
41
+ "examples/todo-app/bun.lockb",
42
+ "examples/todo-app/package.json",
43
+ "examples/todo-app/server.ts",
44
+ "examples/todo-app/src",
45
+ "!dist/**/*.js.map",
46
+ "!dist/**/*.d.ts.map"
47
+ ],
48
+ "directories": {
49
+ "example": "examples",
50
+ "test": "test"
51
+ },
52
+ "scripts": {
53
+ "test": "vitest run",
54
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
55
+ "pack:check": "npm pack --dry-run"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^26.1.0",
59
+ "prettier": "^3.0.0",
60
+ "tsup": "^8.5.1",
61
+ "typescript": "^5.9.3",
62
+ "vitest": "^4.1.10"
63
+ },
64
+ "sideEffects": false,
65
+ "dependencies": {
66
+ "nanoid": "^5.1.16",
67
+ "objwalk": "^0.2.0"
68
+ }
69
+ }