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 +15 -0
- package/README.md +179 -0
- package/examples/job-worker.ts +33 -0
- package/examples/service-pipeline.ts +29 -0
- package/examples/todo-app/README.md +14 -0
- package/examples/todo-app/build.ts +31 -0
- package/examples/todo-app/package.json +15 -0
- package/examples/todo-app/server.ts +63 -0
- package/examples/todo-app/src/app.ts +174 -0
- package/examples/todo-app/src/public/index.html +26 -0
- package/examples/todo-app/src/public/styles.css +62 -0
- package/examples/ui-flow.ts +27 -0
- package/package.json +69 -0
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
|
+
}
|