@gravito/flux 1.0.0-beta.1 → 1.0.0-beta.3
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.zh-TW.md +409 -1
- package/assets/flux-branching.svg +84 -0
- package/bin/flux.js +96 -0
- package/dev/viewer/app.js +131 -0
- package/dev/viewer/index.html +52 -0
- package/dev/viewer/styles.css +163 -0
- package/dist/bun.cjs +7 -0
- package/dist/bun.cjs.map +1 -0
- package/dist/{storage/BunSQLiteStorage.d.ts → bun.d.cts} +8 -5
- package/dist/bun.d.ts +72 -5
- package/dist/bun.js +2 -2
- package/dist/bun.js.map +1 -0
- package/dist/chunk-J37UUMLM.js +858 -0
- package/dist/chunk-J37UUMLM.js.map +1 -0
- package/dist/chunk-RPECIW7O.cjs +858 -0
- package/dist/chunk-RPECIW7O.cjs.map +1 -0
- package/dist/chunk-SJSPR4ZU.cjs +173 -0
- package/dist/chunk-SJSPR4ZU.cjs.map +1 -0
- package/dist/{chunk-qjdtqchy.js → chunk-ZAMVC732.js} +35 -7
- package/dist/chunk-ZAMVC732.js.map +1 -0
- package/dist/index.cjs +121 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +43 -0
- package/dist/index.d.ts +40 -35
- package/dist/index.js +102 -460
- package/dist/index.js.map +1 -0
- package/dist/index.node.cjs +28 -0
- package/dist/index.node.cjs.map +1 -0
- package/dist/index.node.d.cts +499 -0
- package/dist/index.node.d.ts +494 -13
- package/dist/index.node.js +28 -0
- package/dist/index.node.js.map +1 -0
- package/dist/{types.d.ts → types-DvVHBmP6.d.cts} +59 -18
- package/dist/types-DvVHBmP6.d.ts +235 -0
- package/package.json +32 -21
- package/dist/builder/WorkflowBuilder.d.ts +0 -96
- package/dist/builder/WorkflowBuilder.d.ts.map +0 -1
- package/dist/builder/index.d.ts +0 -2
- package/dist/builder/index.d.ts.map +0 -1
- package/dist/bun.d.ts.map +0 -1
- package/dist/core/ContextManager.d.ts +0 -40
- package/dist/core/ContextManager.d.ts.map +0 -1
- package/dist/core/StateMachine.d.ts +0 -43
- package/dist/core/StateMachine.d.ts.map +0 -1
- package/dist/core/StepExecutor.d.ts +0 -34
- package/dist/core/StepExecutor.d.ts.map +0 -1
- package/dist/core/index.d.ts +0 -4
- package/dist/core/index.d.ts.map +0 -1
- package/dist/engine/FluxEngine.d.ts +0 -66
- package/dist/engine/FluxEngine.d.ts.map +0 -1
- package/dist/engine/index.d.ts +0 -2
- package/dist/engine/index.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.node.d.ts.map +0 -1
- package/dist/logger/FluxLogger.d.ts +0 -40
- package/dist/logger/FluxLogger.d.ts.map +0 -1
- package/dist/logger/index.d.ts +0 -2
- package/dist/logger/index.d.ts.map +0 -1
- package/dist/node/index.mjs +0 -619
- package/dist/orbit/OrbitFlux.d.ts +0 -107
- package/dist/orbit/OrbitFlux.d.ts.map +0 -1
- package/dist/orbit/index.d.ts +0 -2
- package/dist/orbit/index.d.ts.map +0 -1
- package/dist/storage/BunSQLiteStorage.d.ts.map +0 -1
- package/dist/storage/MemoryStorage.d.ts +0 -28
- package/dist/storage/MemoryStorage.d.ts.map +0 -1
- package/dist/storage/index.d.ts +0 -3
- package/dist/storage/index.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/README.zh-TW.md
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
# @gravito/flux
|
|
2
2
|
|
|
3
|
-
> Gravito
|
|
3
|
+
> Gravito 的高效能工作流程引擎,跨平台、型別安全的狀態機,強調可追蹤、可重播與可靠重試。
|
|
4
|
+
|
|
5
|
+
## 定位與價值
|
|
6
|
+
|
|
7
|
+
- **工作流標準化**:在框架內用同一套模型描述流程,而不是每個服務自行手寫流程。
|
|
8
|
+
- **可靠性與可觀測**:狀態機 + 重試 + trace sink,讓流程可追蹤、可審計、可恢復。
|
|
9
|
+
- **可抽離與可複用**:Flux 可獨立於框架使用,讓 workflow 本身成為可移植的 workflow as code。
|
|
10
|
+
|
|
11
|
+
## API 語意(input / step / commit)
|
|
12
|
+
|
|
13
|
+
- `input<T>()`:定義輸入資料型別,提供型別推斷與編輯器提示。
|
|
14
|
+
- `step(name, handler, options)`:一般步驟,會依序執行,可設定重試/逾時/條件。
|
|
15
|
+
- `commit(name, handler, options)`:具副作用的步驟(寫庫、扣款、通知),在重播或重跑時語意更明確。
|
|
16
|
+
|
|
17
|
+
## 最佳實務與注意事項
|
|
18
|
+
|
|
19
|
+
- **儲存介面**:Memory 僅適合開發與測試,正式環境請使用持久化 storage。
|
|
20
|
+
- **版本變更**:workflow 定義改動時,既有流程的重跑需評估相容性。
|
|
21
|
+
- **重試策略**:外部依賴錯誤才重試,業務邏輯錯誤建議直接 fail。
|
|
22
|
+
- **Step vs Commit**:有副作用的操作應放在 commit,確保重播一致性。
|
|
23
|
+
|
|
24
|
+
## 核心特色
|
|
25
|
+
|
|
26
|
+
- **純狀態機模型** - 以明確狀態描述流程,讓每一步清晰可控
|
|
27
|
+
- **鏈式 Builder API** - 型別安全的流程定義方式
|
|
28
|
+
- **儲存介面** - Memory、Bun SQLite,其他儲存可自訂
|
|
29
|
+
- **重試與逾時** - Step 可設定 retries/timeout/when,面對不穩定依賴也能自動恢復
|
|
30
|
+
- **同步/非同步** - 同時支援同步流程與非同步長流程
|
|
31
|
+
- **事件 Hooks** - 監聽流程與步驟生命週期
|
|
32
|
+
- **事件 Hooks** - 監聽流程與步驟生命周期
|
|
33
|
+
- **雙平台支援** - Bun 與 Node.js 均可使用
|
|
4
34
|
|
|
5
35
|
## 安裝
|
|
6
36
|
|
|
7
37
|
```bash
|
|
8
38
|
bun add @gravito/flux
|
|
39
|
+
|
|
40
|
+
# npm
|
|
41
|
+
npm install @gravito/flux
|
|
9
42
|
```
|
|
10
43
|
|
|
11
44
|
## 快速開始
|
|
@@ -28,3 +61,378 @@ const orderFlow = createWorkflow('order-process')
|
|
|
28
61
|
const engine = new FluxEngine()
|
|
29
62
|
const result = await engine.execute(orderFlow, { orderId: '123' })
|
|
30
63
|
```
|
|
64
|
+
|
|
65
|
+
## 範例
|
|
66
|
+
|
|
67
|
+
### 訂單履約
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const orderWorkflow = createWorkflow('order-fulfillment')
|
|
71
|
+
.input<{ orderId: string; items: Item[] }>()
|
|
72
|
+
.step('validate', async (ctx) => {
|
|
73
|
+
for (const item of ctx.input.items) {
|
|
74
|
+
const stock = await db.products.getStock(item.productId)
|
|
75
|
+
if (stock < item.qty) throw new Error(`Out of stock: ${item.productId}`)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.step(
|
|
79
|
+
'payment',
|
|
80
|
+
async (ctx) => {
|
|
81
|
+
ctx.data.payment = await payment.charge(ctx.input.orderId)
|
|
82
|
+
},
|
|
83
|
+
{ retries: 3, timeout: 30_000 }
|
|
84
|
+
)
|
|
85
|
+
.commit('deduct', async (ctx) => {
|
|
86
|
+
await inventory.deduct(ctx.data.reservationIds)
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 影像處理
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const uploadWorkflow = createWorkflow('image-processing')
|
|
94
|
+
.input<{ fileBuffer: Buffer; fileName: string; userId: string }>()
|
|
95
|
+
.step('validate', async (ctx) => {
|
|
96
|
+
if (ctx.input.fileBuffer.length > 10 * 1024 * 1024) {
|
|
97
|
+
throw new Error('File size exceeds 10MB')
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.step('resize', async (ctx) => {
|
|
101
|
+
ctx.data.thumbnail = await sharp(ctx.input.fileBuffer).resize(200).toBuffer()
|
|
102
|
+
})
|
|
103
|
+
.commit('upload', async (ctx) => {
|
|
104
|
+
ctx.data.url = await s3.upload(ctx.input.fileName, ctx.data.thumbnail)
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 報表生成
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
const reportWorkflow = createWorkflow('generate-report')
|
|
112
|
+
.input<{ reportType: string; dateRange: DateRange; requestedBy: string }>()
|
|
113
|
+
.step('fetch-data', async (ctx) => {
|
|
114
|
+
ctx.data.sales = await db.orders.aggregate(ctx.input.dateRange)
|
|
115
|
+
}, { timeout: 60_000 })
|
|
116
|
+
.step('calculate', async (ctx) => {
|
|
117
|
+
ctx.data.metrics = { revenue: 0, orders: 0 }
|
|
118
|
+
})
|
|
119
|
+
.commit('upload', async (ctx) => {
|
|
120
|
+
ctx.data.url = await s3.upload(`reports/${ctx.id}.pdf`, ctx.data.pdf)
|
|
121
|
+
})
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## API
|
|
125
|
+
|
|
126
|
+
### `createWorkflow(name)`
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const flow = createWorkflow('my-workflow')
|
|
130
|
+
.input<{ value: number }>()
|
|
131
|
+
.step('step1', handler)
|
|
132
|
+
.step('step2', handler, { retries: 3 })
|
|
133
|
+
.commit('save', handler)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `FluxEngine`
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
const engine = new FluxEngine({
|
|
140
|
+
storage: new MemoryStorage(),
|
|
141
|
+
defaultRetries: 3,
|
|
142
|
+
defaultTimeout: 30_000,
|
|
143
|
+
logger: new FluxConsoleLogger(),
|
|
144
|
+
on: {
|
|
145
|
+
stepStart: (step, ctx) => {},
|
|
146
|
+
stepComplete: (step, ctx, result) => {},
|
|
147
|
+
stepError: (step, ctx, error) => {},
|
|
148
|
+
workflowComplete: (ctx) => {},
|
|
149
|
+
workflowError: (ctx, error) => {},
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Step Options
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
.step('name', handler, {
|
|
158
|
+
retries: 5,
|
|
159
|
+
timeout: 60_000,
|
|
160
|
+
when: (ctx) => ctx.data.x > 0,
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## 重跑指定步驟
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const first = await engine.execute(flow, { orderId: 'ORD-001' })
|
|
168
|
+
await engine.retryStep(flow, first.id, 'charge')
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 與隊列搭配的應用
|
|
172
|
+
|
|
173
|
+
隊列消費者只負責觸發 Flux,Flux 會把任務拆成多步驟 workflow。當某一步失敗時,只重試該步,不會重跑已完成的步驟。
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
queue.on('message', async (job) => {
|
|
177
|
+
await flux.execute(orderFlow, job.data)
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
搭配持久化 storage,已完成的步驟會被保留,避免重複執行。
|
|
182
|
+
|
|
183
|
+
### Kafka 完整案例(事件 → 消費者 → Flux)
|
|
184
|
+
|
|
185
|
+
**1) 事件觸發:把任務丟進 Kafka**
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { Kafka } from 'kafkajs'
|
|
189
|
+
|
|
190
|
+
const kafka = new Kafka({ clientId: 'orders', brokers: ['localhost:9092'] })
|
|
191
|
+
const producer = kafka.producer()
|
|
192
|
+
|
|
193
|
+
await producer.connect()
|
|
194
|
+
await producer.send({
|
|
195
|
+
topic: 'order.created',
|
|
196
|
+
messages: [
|
|
197
|
+
{ key: 'ORD-001', value: JSON.stringify({ orderId: 'ORD-001', userId: 'u_1' }) },
|
|
198
|
+
],
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**2) 消費者接到任務後交給 Flux**
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import { Kafka } from 'kafkajs'
|
|
206
|
+
import { createWorkflow, FluxEngine, MemoryStorage } from '@gravito/flux'
|
|
207
|
+
|
|
208
|
+
const orderFlow = createWorkflow('order-flow')
|
|
209
|
+
.input<{ orderId: string; userId: string }>()
|
|
210
|
+
.step('validate', async (ctx) => {
|
|
211
|
+
ctx.data.validated = true
|
|
212
|
+
})
|
|
213
|
+
.step('reserve', async (ctx) => {
|
|
214
|
+
ctx.data.reserved = true
|
|
215
|
+
})
|
|
216
|
+
.commit('notify', async () => {})
|
|
217
|
+
|
|
218
|
+
const flux = new FluxEngine({ storage: new MemoryStorage() })
|
|
219
|
+
|
|
220
|
+
const kafka = new Kafka({ clientId: 'orders-worker', brokers: ['localhost:9092'] })
|
|
221
|
+
const consumer = kafka.consumer({ groupId: 'order-workers' })
|
|
222
|
+
|
|
223
|
+
await consumer.connect()
|
|
224
|
+
await consumer.subscribe({ topic: 'order.created' })
|
|
225
|
+
await consumer.run({
|
|
226
|
+
eachMessage: async ({ message }) => {
|
|
227
|
+
const payload = JSON.parse(message.value?.toString() ?? '{}')
|
|
228
|
+
await flux.execute(orderFlow, payload)
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## 分支流程(多支點)
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
const flow = createWorkflow('event-routing')
|
|
237
|
+
.input<{ payload: EventPayload }>()
|
|
238
|
+
.step('classify', async (ctx) => {
|
|
239
|
+
ctx.data.route = classify(ctx.input.payload)
|
|
240
|
+
})
|
|
241
|
+
.step(
|
|
242
|
+
'auto-handle',
|
|
243
|
+
async (ctx) => {
|
|
244
|
+
ctx.data.result = await autoProcess(ctx.input.payload)
|
|
245
|
+
},
|
|
246
|
+
{ when: (ctx) => ctx.data.route === 'auto' }
|
|
247
|
+
)
|
|
248
|
+
.step(
|
|
249
|
+
'manual-review',
|
|
250
|
+
async (ctx) => {
|
|
251
|
+
ctx.data.ticketId = await ticketing.create(ctx.input.payload)
|
|
252
|
+
},
|
|
253
|
+
{ when: (ctx) => ctx.data.route === 'manual' }
|
|
254
|
+
)
|
|
255
|
+
.step(
|
|
256
|
+
'risk-audit',
|
|
257
|
+
async (ctx) => {
|
|
258
|
+
ctx.data.auditId = await auditQueue.enqueue(ctx.input.payload)
|
|
259
|
+
},
|
|
260
|
+
{ when: (ctx) => ctx.data.route === 'risk' }
|
|
261
|
+
)
|
|
262
|
+
.commit('notify', async (ctx) => {
|
|
263
|
+
await notifier.send(ctx.data)
|
|
264
|
+
})
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## 多節點任務(類似 n8n)
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
const flow = createWorkflow('multi-node')
|
|
271
|
+
.input<{ type: 'email' | 'slack' | 'webhook'; payload: unknown }>()
|
|
272
|
+
.step('classify', async (ctx) => {
|
|
273
|
+
ctx.data.route = ctx.input.type
|
|
274
|
+
})
|
|
275
|
+
.step(
|
|
276
|
+
'send-email',
|
|
277
|
+
async (ctx) => {
|
|
278
|
+
await email.send(ctx.input.payload)
|
|
279
|
+
},
|
|
280
|
+
{ when: (ctx) => ctx.data.route === 'email' }
|
|
281
|
+
)
|
|
282
|
+
.step(
|
|
283
|
+
'send-slack',
|
|
284
|
+
async (ctx) => {
|
|
285
|
+
await slack.send(ctx.input.payload)
|
|
286
|
+
},
|
|
287
|
+
{ when: (ctx) => ctx.data.route === 'slack' }
|
|
288
|
+
)
|
|
289
|
+
.step(
|
|
290
|
+
'call-webhook',
|
|
291
|
+
async (ctx) => {
|
|
292
|
+
await webhook.post(ctx.input.payload)
|
|
293
|
+
},
|
|
294
|
+
{ when: (ctx) => ctx.data.route === 'webhook' }
|
|
295
|
+
)
|
|
296
|
+
.commit('audit', async (ctx) => {
|
|
297
|
+
await audit.save(ctx.data)
|
|
298
|
+
})
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
<img
|
|
302
|
+
src="./assets/flux-branching.svg"
|
|
303
|
+
alt="Flux branching diagram"
|
|
304
|
+
style="max-width: 720px; width: 100%; height: auto; display: block; margin: 12px 0;"
|
|
305
|
+
/>
|
|
306
|
+
|
|
307
|
+
上圖對應 `when` 條件:只會走符合條件的分支,未符合者會被標記為 skipped。
|
|
308
|
+
|
|
309
|
+
### 可執行分支範例
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
bun run examples/branching.ts
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## 本地開發視覺化
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import { FluxEngine, JsonFileTraceSink } from '@gravito/flux'
|
|
319
|
+
|
|
320
|
+
const engine = new FluxEngine({
|
|
321
|
+
trace: new JsonFileTraceSink({ path: './.flux/trace.ndjson', reset: true }),
|
|
322
|
+
})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
flux dev --trace ./.flux/trace.ndjson --port 4280
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### 驗證流程(本 repo)
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
bun run examples/trace-viewer.ts
|
|
333
|
+
bun run dev:viewer
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## 企業級追蹤
|
|
337
|
+
|
|
338
|
+
透過 `FluxTraceSink` 可以把事件流送到你自己的監控、排程或分析模組,建立完整的執行查詢、重播與告警能力。
|
|
339
|
+
|
|
340
|
+
## 以微服務或 AWS Lambda 部署
|
|
341
|
+
|
|
342
|
+
Flux 可抽離成無狀態 workflow runner。把 workflow 定義與 input 交給函式執行,再將狀態與 trace 寫入外部儲存即可:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
import { FluxEngine, JsonFileTraceSink, MemoryStorage, createWorkflow } from '@gravito/flux'
|
|
346
|
+
|
|
347
|
+
const workflow = createWorkflow('lambda-flow')
|
|
348
|
+
.input<{ orderId: string }>()
|
|
349
|
+
.step('prepare', async (ctx) => {
|
|
350
|
+
ctx.data.ready = true
|
|
351
|
+
})
|
|
352
|
+
.commit('notify', async () => {})
|
|
353
|
+
|
|
354
|
+
const engine = new FluxEngine({
|
|
355
|
+
storage: new MemoryStorage(),
|
|
356
|
+
trace: new JsonFileTraceSink({ path: '/tmp/flux-trace.ndjson', reset: false }),
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
export const handler = async (event: { orderId: string }) => {
|
|
360
|
+
const result = await engine.execute(workflow, { orderId: event.orderId })
|
|
361
|
+
return { status: result.status, id: result.id }
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### 其他雲端函式範例
|
|
366
|
+
|
|
367
|
+
GCP Cloud Functions(HTTP,需安裝對應套件並確認最新版本):
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
import type { HttpFunction } from '@google-cloud/functions-framework'
|
|
371
|
+
|
|
372
|
+
export const runFlux: HttpFunction = async (req, res) => {
|
|
373
|
+
const payload = req.body ?? {}
|
|
374
|
+
const result = await engine.execute(workflow, payload)
|
|
375
|
+
res.json({ status: result.status, id: result.id })
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Azure Functions(HTTP Trigger,需安裝對應套件並確認最新版本):
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
import type { AzureFunction, Context, HttpRequest } from '@azure/functions'
|
|
383
|
+
|
|
384
|
+
const httpTrigger: AzureFunction = async (context: Context, req: HttpRequest) => {
|
|
385
|
+
const payload = req.body ?? {}
|
|
386
|
+
const result = await engine.execute(workflow, payload)
|
|
387
|
+
context.res = { status: 200, body: { status: result.status, id: result.id } }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export default httpTrigger
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
## 儲存介面
|
|
394
|
+
|
|
395
|
+
### MemoryStorage
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
import { FluxEngine, MemoryStorage } from '@gravito/flux'
|
|
399
|
+
const engine = new FluxEngine({ storage: new MemoryStorage() })
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### BunSQLiteStorage (Bun only)
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
import { FluxEngine } from '@gravito/flux'
|
|
406
|
+
import { BunSQLiteStorage } from '@gravito/flux/bun'
|
|
407
|
+
|
|
408
|
+
const engine = new FluxEngine({
|
|
409
|
+
storage: new BunSQLiteStorage({ path: './data/workflows.db' })
|
|
410
|
+
})
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
## Gravito 整合
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
import { OrbitFlux } from '@gravito/flux'
|
|
417
|
+
|
|
418
|
+
const core = await PlanetCore.boot({
|
|
419
|
+
orbits: [
|
|
420
|
+
new OrbitFlux({
|
|
421
|
+
storage: 'sqlite',
|
|
422
|
+
dbPath: './data/workflows.db',
|
|
423
|
+
})
|
|
424
|
+
]
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const flux = core.services.get<FluxEngine>('flux')
|
|
428
|
+
await flux.execute(myWorkflow, input)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## 平台支援
|
|
432
|
+
|
|
433
|
+
| Feature | Bun | Node.js |
|
|
434
|
+
|---------|-----|---------|
|
|
435
|
+
| FluxEngine | ✅ | ✅ |
|
|
436
|
+
| MemoryStorage | ✅ | ✅ |
|
|
437
|
+
| BunSQLiteStorage | ✅ | ❌ |
|
|
438
|
+
| OrbitFlux | ✅ | ✅ |
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 240" role="img" aria-label="Flux branching diagram">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="fluxNode" x1="0" x2="1">
|
|
4
|
+
<stop offset="0%" stop-color="#141c2b" />
|
|
5
|
+
<stop offset="100%" stop-color="#0b1220" />
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="glow-blue" x1="0" x2="1">
|
|
8
|
+
<stop offset="0%" stop-color="#38bdf8" />
|
|
9
|
+
<stop offset="100%" stop-color="#60a5fa" />
|
|
10
|
+
</linearGradient>
|
|
11
|
+
<linearGradient id="glow-violet" x1="0" x2="1">
|
|
12
|
+
<stop offset="0%" stop-color="#a78bfa" />
|
|
13
|
+
<stop offset="100%" stop-color="#8b5cf6" />
|
|
14
|
+
</linearGradient>
|
|
15
|
+
<linearGradient id="glow-amber" x1="0" x2="1">
|
|
16
|
+
<stop offset="0%" stop-color="#fbbf24" />
|
|
17
|
+
<stop offset="100%" stop-color="#f59e0b" />
|
|
18
|
+
</linearGradient>
|
|
19
|
+
<linearGradient id="glow-emerald" x1="0" x2="1">
|
|
20
|
+
<stop offset="0%" stop-color="#34d399" />
|
|
21
|
+
<stop offset="100%" stop-color="#10b981" />
|
|
22
|
+
</linearGradient>
|
|
23
|
+
<linearGradient id="glow-rose" x1="0" x2="1">
|
|
24
|
+
<stop offset="0%" stop-color="#fb7185" />
|
|
25
|
+
<stop offset="100%" stop-color="#f43f5e" />
|
|
26
|
+
</linearGradient>
|
|
27
|
+
<filter id="nodeShadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
28
|
+
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#0b1120" flood-opacity="0.6" />
|
|
29
|
+
</filter>
|
|
30
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
|
31
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill="#60a5fa" />
|
|
32
|
+
</marker>
|
|
33
|
+
</defs>
|
|
34
|
+
|
|
35
|
+
<!-- classify -->
|
|
36
|
+
<g filter="url(#nodeShadow)">
|
|
37
|
+
<rect x="30" y="100" width="160" height="50" rx="14" fill="url(#fluxNode)" stroke="#1f2937" />
|
|
38
|
+
<rect x="42" y="112" width="26" height="26" rx="8" fill="url(#glow-blue)" />
|
|
39
|
+
<path d="M52 119h6l4 6-4 6h-6l-4-6z" fill="#0f172a" />
|
|
40
|
+
<text x="120" y="130" fill="#e2e8f0" font-size="12" text-anchor="middle">classify</text>
|
|
41
|
+
</g>
|
|
42
|
+
|
|
43
|
+
<!-- send-email -->
|
|
44
|
+
<g filter="url(#nodeShadow)">
|
|
45
|
+
<rect x="250" y="20" width="180" height="46" rx="14" fill="url(#fluxNode)" stroke="#1f2937" />
|
|
46
|
+
<rect x="262" y="32" width="26" height="26" rx="8" fill="url(#glow-amber)" />
|
|
47
|
+
<path d="M268 38h14l-7 6z" fill="#0f172a" />
|
|
48
|
+
<rect x="268" y="40" width="14" height="10" rx="2" fill="none" stroke="#0f172a" stroke-width="1.6" />
|
|
49
|
+
<text x="350" y="48" fill="#e2e8f0" font-size="12" text-anchor="middle">send-email</text>
|
|
50
|
+
</g>
|
|
51
|
+
|
|
52
|
+
<!-- send-slack -->
|
|
53
|
+
<g filter="url(#nodeShadow)">
|
|
54
|
+
<rect x="250" y="96" width="180" height="46" rx="14" fill="url(#fluxNode)" stroke="#1f2937" />
|
|
55
|
+
<rect x="262" y="108" width="26" height="26" rx="8" fill="url(#glow-violet)" />
|
|
56
|
+
<path d="M269 115h10a4 4 0 0 1 4 4v6l-5-3h-9a4 4 0 0 1-4-4v-3a4 4 0 0 1 4-4z" fill="#0f172a" />
|
|
57
|
+
<text x="350" y="124" fill="#e2e8f0" font-size="12" text-anchor="middle">send-slack</text>
|
|
58
|
+
</g>
|
|
59
|
+
|
|
60
|
+
<!-- call-webhook -->
|
|
61
|
+
<g filter="url(#nodeShadow)">
|
|
62
|
+
<rect x="250" y="172" width="180" height="46" rx="14" fill="url(#fluxNode)" stroke="#1f2937" />
|
|
63
|
+
<rect x="262" y="184" width="26" height="26" rx="8" fill="url(#glow-emerald)" />
|
|
64
|
+
<path d="M268 198h14m-7-6v12" stroke="#0f172a" stroke-width="2" stroke-linecap="round" />
|
|
65
|
+
<text x="350" y="200" fill="#e2e8f0" font-size="12" text-anchor="middle">call-webhook</text>
|
|
66
|
+
</g>
|
|
67
|
+
|
|
68
|
+
<!-- audit -->
|
|
69
|
+
<g filter="url(#nodeShadow)">
|
|
70
|
+
<rect x="520" y="100" width="160" height="50" rx="14" fill="url(#fluxNode)" stroke="#1f2937" />
|
|
71
|
+
<rect x="532" y="112" width="26" height="26" rx="8" fill="url(#glow-rose)" />
|
|
72
|
+
<path d="M538 118h14v12h-14z" fill="none" stroke="#0f172a" stroke-width="1.6" />
|
|
73
|
+
<path d="M541 122h8M541 126h8M541 130h6" stroke="#0f172a" stroke-width="1.6" stroke-linecap="round" />
|
|
74
|
+
<text x="610" y="130" fill="#e2e8f0" font-size="12" text-anchor="middle">audit</text>
|
|
75
|
+
</g>
|
|
76
|
+
|
|
77
|
+
<line x1="190" y1="125" x2="250" y2="43" stroke="#60a5fa" stroke-width="2" marker-end="url(#arrow)" />
|
|
78
|
+
<line x1="190" y1="125" x2="250" y2="119" stroke="#60a5fa" stroke-width="2" marker-end="url(#arrow)" />
|
|
79
|
+
<line x1="190" y1="125" x2="250" y2="195" stroke="#60a5fa" stroke-width="2" marker-end="url(#arrow)" />
|
|
80
|
+
|
|
81
|
+
<line x1="430" y1="43" x2="520" y2="125" stroke="#38bdf8" stroke-width="2" marker-end="url(#arrow)" />
|
|
82
|
+
<line x1="430" y1="119" x2="520" y2="125" stroke="#38bdf8" stroke-width="2" marker-end="url(#arrow)" />
|
|
83
|
+
<line x1="430" y1="195" x2="520" y2="125" stroke="#38bdf8" stroke-width="2" marker-end="url(#arrow)" />
|
|
84
|
+
</svg>
|
package/bin/flux.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2)
|
|
8
|
+
const command = args[0]
|
|
9
|
+
|
|
10
|
+
const help = () => {
|
|
11
|
+
console.log(`Flux CLI
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
flux dev --trace ./.flux/trace.ndjson --port 4280
|
|
15
|
+
`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!command || command === '--help' || command === '-h') {
|
|
19
|
+
help()
|
|
20
|
+
process.exit(0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (command !== 'dev') {
|
|
24
|
+
console.error(`Unknown command: ${command}`)
|
|
25
|
+
help()
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const getArg = (name, fallback) => {
|
|
30
|
+
const idx = args.indexOf(name)
|
|
31
|
+
if (idx === -1) return fallback
|
|
32
|
+
return args[idx + 1] ?? fallback
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const port = Number(getArg('--port', '4280'))
|
|
36
|
+
const host = getArg('--host', '127.0.0.1')
|
|
37
|
+
const tracePath = resolve(getArg('--trace', './.flux/trace.ndjson'))
|
|
38
|
+
|
|
39
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
40
|
+
const __dirname = dirname(__filename)
|
|
41
|
+
const viewerRoot = resolve(join(__dirname, '..', 'dev', 'viewer'))
|
|
42
|
+
|
|
43
|
+
const contentType = (filePath) => {
|
|
44
|
+
const ext = extname(filePath)
|
|
45
|
+
switch (ext) {
|
|
46
|
+
case '.html':
|
|
47
|
+
return 'text/html; charset=utf-8'
|
|
48
|
+
case '.js':
|
|
49
|
+
return 'text/javascript; charset=utf-8'
|
|
50
|
+
case '.css':
|
|
51
|
+
return 'text/css; charset=utf-8'
|
|
52
|
+
case '.json':
|
|
53
|
+
return 'application/json; charset=utf-8'
|
|
54
|
+
case '.svg':
|
|
55
|
+
return 'image/svg+xml'
|
|
56
|
+
default:
|
|
57
|
+
return 'text/plain; charset=utf-8'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const server = createServer(async (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(req.url ?? '/', `http://${host}:${port}`)
|
|
64
|
+
if (url.pathname === '/trace') {
|
|
65
|
+
try {
|
|
66
|
+
const data = await readFile(tracePath, 'utf8')
|
|
67
|
+
res.writeHead(200, {
|
|
68
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
69
|
+
'cache-control': 'no-store',
|
|
70
|
+
})
|
|
71
|
+
res.end(data)
|
|
72
|
+
} catch {
|
|
73
|
+
res.writeHead(204, { 'cache-control': 'no-store' })
|
|
74
|
+
res.end('')
|
|
75
|
+
}
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const reqPath = url.pathname === '/' ? '/index.html' : url.pathname
|
|
80
|
+
const filePath = join(viewerRoot, reqPath)
|
|
81
|
+
const data = await readFile(filePath)
|
|
82
|
+
res.writeHead(200, {
|
|
83
|
+
'content-type': contentType(filePath),
|
|
84
|
+
'cache-control': 'no-store',
|
|
85
|
+
})
|
|
86
|
+
res.end(data)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
89
|
+
res.end('Not Found')
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
server.listen(port, host, () => {
|
|
94
|
+
console.log(`Flux dev viewer running at http://${host}:${port}`)
|
|
95
|
+
console.log(`Trace file: ${tracePath}`)
|
|
96
|
+
})
|