vapor-chamber 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +750 -113
- package/dist/chamber-vapor.d.ts +48 -0
- package/dist/chamber-vapor.d.ts.map +1 -0
- package/dist/chamber-vapor.js +62 -0
- package/dist/chamber.d.ts +92 -18
- package/dist/chamber.d.ts.map +1 -1
- package/dist/chamber.js +262 -49
- package/dist/command-bus.d.ts +146 -23
- package/dist/command-bus.d.ts.map +1 -1
- package/dist/command-bus.js +460 -151
- package/dist/devtools.d.ts.map +1 -1
- package/dist/devtools.js +3 -1
- package/dist/directives.d.ts +37 -0
- package/dist/directives.d.ts.map +1 -0
- package/dist/directives.js +190 -0
- package/dist/form.d.ts +72 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +159 -0
- package/dist/http.d.ts +60 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +242 -0
- package/dist/iife.d.ts +95 -0
- package/dist/iife.d.ts.map +1 -0
- package/dist/iife.js +90 -0
- package/dist/index.d.ts +45 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -9
- package/dist/plugins-core.d.ts +69 -0
- package/dist/plugins-core.d.ts.map +1 -0
- package/dist/plugins-core.js +210 -0
- package/dist/plugins-io.d.ts +100 -0
- package/dist/plugins-io.d.ts.map +1 -0
- package/dist/plugins-io.js +171 -0
- package/dist/plugins.d.ts +8 -44
- package/dist/plugins.d.ts.map +1 -1
- package/dist/plugins.js +8 -133
- package/dist/schema.d.ts +162 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +266 -0
- package/dist/testing.d.ts +31 -7
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +101 -24
- package/dist/transports.d.ts +168 -0
- package/dist/transports.d.ts.map +1 -0
- package/dist/transports.js +225 -0
- package/dist/vapor-chamber.iife.js +1251 -0
- package/dist/vapor-chamber.iife.js.map +7 -0
- package/dist/vapor-chamber.iife.min.js +2 -0
- package/dist/vite-hmr.d.ts +50 -0
- package/dist/vite-hmr.d.ts.map +1 -0
- package/dist/vite-hmr.js +110 -0
- package/package.json +32 -9
- package/scripts/build-iife.mjs +47 -0
package/README.md
CHANGED
|
@@ -3,12 +3,40 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
A lightweight command bus designed for <a href="https://github.com/vuejs/vue-vapor">Vue Vapor</a>. ~2KB gzipped. Optional DevTools integration.
|
|
6
|
+
A lightweight command bus designed for <a href="https://github.com/vuejs/vue-vapor">Vue Vapor</a>. ~2KB gzipped. Vue 3.6 Vapor aligned. Optional DevTools integration.
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
|
+
## What is Vapor Chamber?
|
|
10
|
+
|
|
11
|
+
Vapor Chamber is a **command bus for Vue 3.6+ Vapor mode**. It gives every user action a single handler, a composable plugin pipeline, and signal-native reactive state — replacing scattered event listeners and prop-drilling with one predictable, testable flow.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createCommandBus, useCommand } from 'vapor-chamber';
|
|
15
|
+
|
|
16
|
+
const bus = createCommandBus();
|
|
17
|
+
|
|
18
|
+
bus.register('cartAdd', (cmd) => addToCart(cmd.target));
|
|
19
|
+
bus.use(logger());
|
|
20
|
+
bus.use(validator({ cartAdd: (cmd) => cmd.target.id ? null : 'Missing ID' }));
|
|
21
|
+
|
|
22
|
+
// In a component
|
|
23
|
+
const { dispatch, loading, lastError } = useCommand('cartAdd');
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **~2 KB gzipped** — zero runtime dependencies
|
|
27
|
+
- **Framework-agnostic core** — the bus itself has no Vue import
|
|
28
|
+
- **Vue 3.6 Vapor aligned** — signals, `onScopeDispose`, alien-signals internals
|
|
29
|
+
- **Full plugin pipeline** — logger, validator, debounce, throttle, retry, persist, sync, and more
|
|
30
|
+
- **Transport layer** — HTTP bridge, WebSocket bridge, SSE bridge
|
|
31
|
+
- **SSR-safe** — per-request bus isolation, no shared singletons
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
9
35
|
## What is Vue Vapor?
|
|
10
36
|
|
|
11
|
-
Vue Vapor is Vue's
|
|
37
|
+
Vue Vapor is Vue's compilation strategy that eliminates the Virtual DOM. Instead of diffing virtual trees, Vapor compiles templates to direct DOM operations using **signals** — reactive primitives that update only what changed.
|
|
38
|
+
|
|
39
|
+
**As of Vue 3.6 beta**, Vapor mode is feature-complete for all stable APIs. The reactivity engine has been rewritten atop [alien-signals](https://github.com/stackblitz/alien-signals), delivering ~14% less memory and faster dependency tracking. `ref()` is now a signal internally.
|
|
12
40
|
|
|
13
41
|
**Vapor Chamber** embraces this philosophy: minimal abstraction, direct updates, signal-native reactivity.
|
|
14
42
|
|
|
@@ -35,17 +63,17 @@ bus.on('cart:add', updateBadge); // now two handlers, hard to trace
|
|
|
35
63
|
```
|
|
36
64
|
// After — Vapor Chamber
|
|
37
65
|
// Anywhere in the app
|
|
38
|
-
bus.dispatch('
|
|
66
|
+
bus.dispatch('cartAdd', product, { quantity: 1 });
|
|
39
67
|
|
|
40
68
|
// One place, once:
|
|
41
|
-
bus.register('
|
|
69
|
+
bus.register('cartAdd', (cmd) => {
|
|
42
70
|
cart.items.push(cmd.target);
|
|
43
71
|
return cart.items;
|
|
44
72
|
});
|
|
45
73
|
|
|
46
74
|
// Cross-cutting concerns as plugins, not scattered listeners:
|
|
47
75
|
bus.use(logger());
|
|
48
|
-
bus.use(validator({ '
|
|
76
|
+
bus.use(validator({ 'cartAdd': (cmd) => cmd.target.id ? null : 'Missing ID' }));
|
|
49
77
|
bus.use(analyticsPlugin);
|
|
50
78
|
```
|
|
51
79
|
|
|
@@ -58,7 +86,7 @@ Traditional event systems scatter logic across components. A command bus central
|
|
|
58
86
|
```
|
|
59
87
|
Event-driven (scattered) Command bus (centralized)
|
|
60
88
|
───────────────────────── ─────────────────────────
|
|
61
|
-
Component A emits 'add' → dispatch('
|
|
89
|
+
Component A emits 'add' → dispatch('cartAdd', product)
|
|
62
90
|
Component B listens... ↓
|
|
63
91
|
Component C also listens... Handler executes once
|
|
64
92
|
Who handles what? When? Plugins observe/modify
|
|
@@ -66,10 +94,61 @@ Who handles what? When? Plugins observe/modify
|
|
|
66
94
|
```
|
|
67
95
|
|
|
68
96
|
**Benefits:**
|
|
69
|
-
- **Semantic actions**
|
|
70
|
-
- **Single handler**
|
|
71
|
-
- **Plugin pipeline**
|
|
72
|
-
- **Undo/redo**
|
|
97
|
+
- **Semantic actions** — `cartAdd` is clearer than `emit('add')`
|
|
98
|
+
- **Single handler** — One place to look, debug, test
|
|
99
|
+
- **Plugin pipeline** — Cross-cutting concerns (logging, validation, analytics) without cluttering handlers
|
|
100
|
+
- **Undo/redo** — Command history is natural when actions are explicit
|
|
101
|
+
|
|
102
|
+
## Module Architecture
|
|
103
|
+
|
|
104
|
+
vapor-chamber is built in layers. The **core** is framework-agnostic, has zero dependencies, and is the only part required for v1.0. Everything else is optional and tree-shaken when not imported.
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
┌─────────────────────────────────────────────────────────┐
|
|
108
|
+
│ CORE (zero deps · fully tested · framework-agnostic) │
|
|
109
|
+
│ command-bus.ts · testing.ts │
|
|
110
|
+
└────────────────────────┬────────────────────────────────┘
|
|
111
|
+
│ optional layers (tree-shaken)
|
|
112
|
+
┌───────────────┼───────────────┐
|
|
113
|
+
▼ ▼ ▼
|
|
114
|
+
Vue composables Plugins Transport
|
|
115
|
+
chamber.ts plugins-core http.ts
|
|
116
|
+
chamber-vapor.ts plugins-io transports.ts
|
|
117
|
+
│
|
|
118
|
+
▼
|
|
119
|
+
Extras (per-feature opt-in)
|
|
120
|
+
form.ts · schema.ts · devtools.ts · directives.ts · vite-hmr.ts
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Coverage & stability at v0.5.0
|
|
124
|
+
|
|
125
|
+
| Layer | Module | Coverage | Status |
|
|
126
|
+
|-------|--------|----------|--------|
|
|
127
|
+
| **Core** | `command-bus.ts` | 90% | ✅ Stable |
|
|
128
|
+
| **Core** | `testing.ts` | 96% | ✅ Stable |
|
|
129
|
+
| Plugins | `plugins-core.ts` | 90% | ✅ Stable |
|
|
130
|
+
| Plugins | `plugins-io.ts` | 88% | ✅ Stable |
|
|
131
|
+
| Transport | `http.ts` | 80% | ✅ Stable |
|
|
132
|
+
| Transport | `transports.ts` | 91% | ✅ Stable |
|
|
133
|
+
| Vue | `chamber.ts` | 76% | ✅ Stable |
|
|
134
|
+
| Extras | `form.ts` | 99% | ✅ Stable |
|
|
135
|
+
| Extras | `schema.ts` | 92% | ✅ Stable |
|
|
136
|
+
| Vue 3.6 | `chamber-vapor.ts` | — | ⚠️ Requires Vue 3.6 runtime to test |
|
|
137
|
+
| Vue | `directives.ts` | — | ⚠️ Requires Vue DOM environment to test |
|
|
138
|
+
| Build | `devtools.ts` | — | ⚠️ Requires browser DevTools API to test |
|
|
139
|
+
| Build | `vite-hmr.ts` | — | ⚠️ Requires Vite runtime to test |
|
|
140
|
+
| Build | `iife.ts` | — | 🔧 Bundle entry, not a public API |
|
|
141
|
+
|
|
142
|
+
Sub-path exports avoid pulling in optional modules:
|
|
143
|
+
```
|
|
144
|
+
'vapor-chamber' → core + composables + everything (tree-shaken)
|
|
145
|
+
'vapor-chamber/transports' → HTTP + WebSocket + SSE bridges only
|
|
146
|
+
'vapor-chamber/directives' → v-command Vue directive only
|
|
147
|
+
'vapor-chamber/vite' → Vite HMR plugin only
|
|
148
|
+
'vapor-chamber/iife' → IIFE bundle
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
73
152
|
|
|
74
153
|
## Install
|
|
75
154
|
|
|
@@ -77,6 +156,8 @@ Who handles what? When? Plugins observe/modify
|
|
|
77
156
|
npm install vapor-chamber
|
|
78
157
|
```
|
|
79
158
|
|
|
159
|
+
**Requirements:** Node.js ≥20.19.0 | Vue ≥3.5.0 (optional peer dep) | Vite 7/8 compatible
|
|
160
|
+
|
|
80
161
|
## Quick Start
|
|
81
162
|
|
|
82
163
|
```typescript
|
|
@@ -87,17 +168,17 @@ const bus = createCommandBus();
|
|
|
87
168
|
// Add plugins
|
|
88
169
|
bus.use(logger());
|
|
89
170
|
bus.use(validator({
|
|
90
|
-
'
|
|
171
|
+
'cartAdd': (cmd) => cmd.payload?.quantity > 0 ? null : 'Quantity required'
|
|
91
172
|
}));
|
|
92
173
|
|
|
93
174
|
// Register handler
|
|
94
|
-
bus.register('
|
|
175
|
+
bus.register('cartAdd', (cmd) => {
|
|
95
176
|
cart.items.push({ ...cmd.target, quantity: cmd.payload.quantity });
|
|
96
177
|
return cart.items;
|
|
97
178
|
});
|
|
98
179
|
|
|
99
180
|
// Dispatch
|
|
100
|
-
const result = bus.dispatch('
|
|
181
|
+
const result = bus.dispatch('cartAdd', product, { quantity: 2 });
|
|
101
182
|
if (result.ok) {
|
|
102
183
|
console.log('Added:', result.value);
|
|
103
184
|
} else {
|
|
@@ -105,6 +186,56 @@ if (result.ok) {
|
|
|
105
186
|
}
|
|
106
187
|
```
|
|
107
188
|
|
|
189
|
+
## Vue 3.6 Vapor Mode
|
|
190
|
+
|
|
191
|
+
Vapor Chamber v0.4.0 is aligned with Vue 3.6 beta. It works in three contexts:
|
|
192
|
+
|
|
193
|
+
### 1. Pure Vapor App (smallest bundle)
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { createVaporChamberApp, getCommandBus } from 'vapor-chamber';
|
|
197
|
+
import App from './App.vue';
|
|
198
|
+
|
|
199
|
+
// No VDOM runtime — ~10KB baseline
|
|
200
|
+
createVaporChamberApp(App).mount('#app');
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```vue
|
|
204
|
+
<script setup vapor>
|
|
205
|
+
import { useCommand } from 'vapor-chamber';
|
|
206
|
+
|
|
207
|
+
const { dispatch, loading } = useCommand();
|
|
208
|
+
</script>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 2. Mixed VDOM + Vapor (gradual migration)
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import { createApp } from 'vue';
|
|
215
|
+
import { getVaporInteropPlugin } from 'vapor-chamber';
|
|
216
|
+
|
|
217
|
+
const app = createApp(App);
|
|
218
|
+
const interop = getVaporInteropPlugin();
|
|
219
|
+
if (interop) app.use(interop);
|
|
220
|
+
app.mount('#app');
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Now Vapor and VDOM components can nest inside each other. Useful for incremental migration.
|
|
224
|
+
|
|
225
|
+
### 3. Standard Vue 3 (no Vapor)
|
|
226
|
+
|
|
227
|
+
Everything works without Vapor. The signal shim auto-detects Vue's `ref()` for reactivity. In Vue 3.6+ this is alien-signals backed.
|
|
228
|
+
|
|
229
|
+
### Vapor Detection
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
import { isVaporAvailable } from 'vapor-chamber';
|
|
233
|
+
|
|
234
|
+
if (isVaporAvailable()) {
|
|
235
|
+
// Vue 3.6+ with createVaporApp available
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
108
239
|
## Core Concepts
|
|
109
240
|
|
|
110
241
|
### Commands
|
|
@@ -113,27 +244,48 @@ A command has three parts:
|
|
|
113
244
|
|
|
114
245
|
```typescript
|
|
115
246
|
bus.dispatch(
|
|
116
|
-
'
|
|
247
|
+
'cartAdd', // action - what to do
|
|
117
248
|
product, // target - what to act on
|
|
118
249
|
{ quantity: 2 } // payload - additional data (optional)
|
|
119
250
|
);
|
|
120
251
|
```
|
|
121
252
|
|
|
253
|
+
### Naming Convention
|
|
254
|
+
|
|
255
|
+
Enforce consistent action names at register and dispatch time:
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
const bus = createCommandBus({
|
|
259
|
+
naming: {
|
|
260
|
+
pattern: /^[a-z][a-zA-Z0-9]+$/, // camelCase
|
|
261
|
+
onViolation: 'throw' // or 'warn' or 'ignore'
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
bus.register('cartAdd', handler); // ✓ passes
|
|
266
|
+
bus.register('cart_add', handler); // ✗ throws
|
|
267
|
+
```
|
|
268
|
+
|
|
122
269
|
### Handlers
|
|
123
270
|
|
|
124
271
|
One handler per action. Returns a value or throws:
|
|
125
272
|
|
|
126
273
|
```typescript
|
|
127
|
-
bus.register('
|
|
128
|
-
// cmd.action = 'cart.add'
|
|
129
|
-
// cmd.target = product
|
|
130
|
-
// cmd.payload = { quantity: 2 }
|
|
131
|
-
|
|
274
|
+
bus.register('cartAdd', (cmd) => {
|
|
132
275
|
cart.items.push(cmd.target);
|
|
133
276
|
return cart.items; // becomes result.value
|
|
134
277
|
});
|
|
135
278
|
```
|
|
136
279
|
|
|
280
|
+
Register with options for undo support and per-command throttling:
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
bus.register('cartAdd', addHandler, {
|
|
284
|
+
undo: (cmd) => { cart.items.pop(); },
|
|
285
|
+
throttle: 300, // max once per 300ms per target
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
137
289
|
### Results
|
|
138
290
|
|
|
139
291
|
Every dispatch returns a result:
|
|
@@ -169,6 +321,68 @@ bus.use(analyticsPlugin, { priority: 1 }); // runs after validation
|
|
|
169
321
|
bus.use(loggerPlugin); // priority 0 (default, runs last)
|
|
170
322
|
```
|
|
171
323
|
|
|
324
|
+
### Before Hooks
|
|
325
|
+
|
|
326
|
+
Run logic before a command reaches its handler. Throw to cancel — the dispatch returns `{ ok: false }`:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
// Global auth gate
|
|
330
|
+
bus.onBefore((cmd) => {
|
|
331
|
+
if (!user.isAuth && protectedActions.includes(cmd.action)) {
|
|
332
|
+
throw new Error('Unauthenticated');
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Loading indicator
|
|
337
|
+
bus.onBefore(() => { isLoading.value = true; });
|
|
338
|
+
bus.onAfter(() => { isLoading.value = false; });
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
On an async bus the hook can be async:
|
|
342
|
+
```typescript
|
|
343
|
+
asyncBus.onBefore(async (cmd) => {
|
|
344
|
+
await rateLimiter.check(cmd.action);
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Wildcard Listeners
|
|
349
|
+
|
|
350
|
+
Subscribe to command patterns without being a handler:
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
// All commands
|
|
354
|
+
bus.on('*', (cmd, result) => analytics.track(cmd.action));
|
|
355
|
+
|
|
356
|
+
// Prefix matching
|
|
357
|
+
bus.on('cart*', (cmd, result) => console.log('Cart event:', cmd.action));
|
|
358
|
+
|
|
359
|
+
// Exact match — fires once, then removes itself
|
|
360
|
+
bus.once('cartAdd', (cmd, result) => showConfetti());
|
|
361
|
+
|
|
362
|
+
// Remove all listeners for a pattern
|
|
363
|
+
bus.offAll('cart*');
|
|
364
|
+
|
|
365
|
+
// Remove all listeners
|
|
366
|
+
bus.offAll();
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Request / Response
|
|
370
|
+
|
|
371
|
+
Async request/response pattern with timeout:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// Register a responder
|
|
375
|
+
bus.respond('get_auth_token', async (cmd) => {
|
|
376
|
+
const response = await fetch('/api/token');
|
|
377
|
+
return response.json();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Request with timeout
|
|
381
|
+
const result = await bus.request('get_auth_token', { userId: 42 }, { timeout: 3000 });
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Falls back to normal `dispatch()` if no responder is registered.
|
|
385
|
+
|
|
172
386
|
## Built-in Plugins
|
|
173
387
|
|
|
174
388
|
| Plugin | Description |
|
|
@@ -178,18 +392,23 @@ bus.use(loggerPlugin); // priority 0 (default, runs last)
|
|
|
178
392
|
| `history(options?)` | Track command history for undo/redo |
|
|
179
393
|
| `debounce(actions, wait)` | Delay execution until activity stops |
|
|
180
394
|
| `throttle(actions, wait)` | Limit execution frequency |
|
|
395
|
+
| `authGuard(options)` | Block protected commands when unauthenticated |
|
|
396
|
+
| `optimistic(handlers)` | Apply optimistic updates, rollback on failure |
|
|
397
|
+
| `retry(options)` | Retry failed async dispatches with backoff |
|
|
398
|
+
| `persist(options)` | Auto-save state to localStorage after commands |
|
|
399
|
+
| `sync(options, bus?)` | Broadcast commands across browser tabs |
|
|
181
400
|
|
|
182
401
|
### logger
|
|
183
402
|
|
|
184
403
|
```typescript
|
|
185
|
-
bus.use(logger({ collapsed: true, filter: (cmd) => cmd.action.startsWith('cart
|
|
404
|
+
bus.use(logger({ collapsed: true, filter: (cmd) => cmd.action.startsWith('cart') }));
|
|
186
405
|
```
|
|
187
406
|
|
|
188
407
|
### validator
|
|
189
408
|
|
|
190
409
|
```typescript
|
|
191
410
|
bus.use(validator({
|
|
192
|
-
'
|
|
411
|
+
'cartAdd': (cmd) => {
|
|
193
412
|
if (!cmd.target?.id) return 'Product must have an ID';
|
|
194
413
|
return null; // null = valid
|
|
195
414
|
}
|
|
@@ -207,60 +426,245 @@ historyPlugin.redo();
|
|
|
207
426
|
historyPlugin.getState(); // { past, future, canUndo, canRedo }
|
|
208
427
|
```
|
|
209
428
|
|
|
429
|
+
With bus-backed undo (executes inverse handlers):
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
const historyPlugin = history({ maxSize: 100, bus });
|
|
433
|
+
bus.use(historyPlugin);
|
|
434
|
+
|
|
435
|
+
// If cartAdd was registered with { undo: fn }, calling undo() executes it
|
|
436
|
+
historyPlugin.undo();
|
|
437
|
+
```
|
|
438
|
+
|
|
210
439
|
### debounce
|
|
211
440
|
|
|
212
441
|
```typescript
|
|
213
|
-
bus.use(debounce(['
|
|
442
|
+
bus.use(debounce(['searchQuery'], 300)); // wait 300ms after last call
|
|
214
443
|
```
|
|
215
444
|
|
|
216
445
|
### throttle
|
|
217
446
|
|
|
218
447
|
```typescript
|
|
219
|
-
bus.use(throttle(['
|
|
448
|
+
bus.use(throttle(['uiScroll'], 100)); // max once per 100ms
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
### authGuard
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
bus.use(authGuard({
|
|
455
|
+
isAuthenticated: () => !!user.value,
|
|
456
|
+
protected: ['shopCart', 'shopWishlist'],
|
|
457
|
+
onUnauthenticated: (cmd) => router.push('/login'),
|
|
458
|
+
}));
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### optimistic
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
bus.use(optimistic({
|
|
465
|
+
'cartAdd': {
|
|
466
|
+
apply: (cmd) => {
|
|
467
|
+
cartCount.value++;
|
|
468
|
+
return () => { cartCount.value--; }; // rollback function
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}));
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
### retry
|
|
475
|
+
|
|
476
|
+
Async plugin that retries failed dispatches with configurable backoff. Install on an `AsyncCommandBus`:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
import { createAsyncCommandBus, retry } from 'vapor-chamber';
|
|
480
|
+
|
|
481
|
+
const bus = createAsyncCommandBus();
|
|
482
|
+
|
|
483
|
+
// All actions, exponential backoff (default)
|
|
484
|
+
bus.use(retry({ maxAttempts: 3, baseDelay: 200 }));
|
|
485
|
+
|
|
486
|
+
// Only retry network actions, fixed delay
|
|
487
|
+
bus.use(retry({
|
|
488
|
+
actions: ['api*'],
|
|
489
|
+
maxAttempts: 5,
|
|
490
|
+
baseDelay: 500,
|
|
491
|
+
strategy: 'fixed',
|
|
492
|
+
isRetryable: (err) => err.message !== 'Unauthorized',
|
|
493
|
+
}));
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### persist
|
|
497
|
+
|
|
498
|
+
Auto-save state to localStorage after each successful command. Rehydrate on startup:
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
import { persist } from 'vapor-chamber';
|
|
502
|
+
|
|
503
|
+
const cartPersist = persist({
|
|
504
|
+
key: 'vc:cart',
|
|
505
|
+
getState: () => cartState.value,
|
|
506
|
+
});
|
|
507
|
+
bus.use(cartPersist);
|
|
508
|
+
|
|
509
|
+
// On app start — rehydrate before rendering
|
|
510
|
+
const saved = cartPersist.load();
|
|
511
|
+
if (saved) cartState.value = saved;
|
|
512
|
+
|
|
513
|
+
// Manual operations
|
|
514
|
+
cartPersist.save(); // force save now
|
|
515
|
+
cartPersist.clear(); // remove from storage
|
|
516
|
+
|
|
517
|
+
// Custom backend (sessionStorage, IndexedDB adapter, etc.)
|
|
518
|
+
bus.use(persist({ key: 'vc:cart', getState, storage: sessionStorage }));
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### sync
|
|
522
|
+
|
|
523
|
+
Broadcast successful commands to all other open tabs via `BroadcastChannel`:
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
import { sync } from 'vapor-chamber';
|
|
527
|
+
|
|
528
|
+
const tabSync = sync(
|
|
529
|
+
{
|
|
530
|
+
channel: 'vapor-chamber:app',
|
|
531
|
+
filter: (cmd) => cmd.action.startsWith('cart') || cmd.action.startsWith('auth'),
|
|
532
|
+
},
|
|
533
|
+
bus // pass the bus so received messages are re-dispatched locally
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
bus.use(tabSync);
|
|
537
|
+
|
|
538
|
+
// Teardown (component unmount, app destroy)
|
|
539
|
+
tabSync.close();
|
|
540
|
+
tabSync.isOpen(); // → false
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
## Transport Layer
|
|
544
|
+
|
|
545
|
+
Send commands to a backend over HTTP, WebSocket, or SSE. Import from `vapor-chamber/transports`
|
|
546
|
+
or directly from `vapor-chamber`:
|
|
547
|
+
|
|
548
|
+
### createHttpBridge
|
|
549
|
+
|
|
550
|
+
Async plugin that POSTs command envelopes to a backend endpoint. Unhandled commands (no local handler) fall through to the server:
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
import { createAsyncCommandBus } from 'vapor-chamber';
|
|
554
|
+
import { createHttpBridge } from 'vapor-chamber/transports';
|
|
555
|
+
|
|
556
|
+
const bus = createAsyncCommandBus({ onMissing: 'ignore' });
|
|
557
|
+
|
|
558
|
+
bus.use(createHttpBridge({
|
|
559
|
+
endpoint: '/api/commands',
|
|
560
|
+
csrf: true, // reads XSRF-TOKEN cookie / meta tag automatically
|
|
561
|
+
csrfCookieUrl: '/sanctum/csrf-cookie', // default; set '' to disable the refresh fetch
|
|
562
|
+
retry: 2, // retry up to 2 times on 5xx / 429 / 408
|
|
563
|
+
noRetry: ['paymentCharge', 'orderPlace'], // never retry non-idempotent commands
|
|
564
|
+
timeout: 8000, // ms
|
|
565
|
+
actions: ['order*'], // only forward order* actions; others stay local
|
|
566
|
+
}));
|
|
567
|
+
|
|
568
|
+
const result = await bus.dispatch('orderCreate', { items: cart });
|
|
569
|
+
// → POST /api/commands { command: 'orderCreate', target: { items: ... } }
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
The backend response shape:
|
|
573
|
+
```json
|
|
574
|
+
{ "state": { "orderId": 42, "status": "pending" } }
|
|
575
|
+
```
|
|
576
|
+
`result.value` will be the contents of `state`.
|
|
577
|
+
|
|
578
|
+
### createWsBridge
|
|
579
|
+
|
|
580
|
+
WebSocket transport with auto-reconnect:
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
import { createWsBridge } from 'vapor-chamber/transports';
|
|
584
|
+
|
|
585
|
+
const ws = createWsBridge({
|
|
586
|
+
url: 'wss://api.example.com/commands',
|
|
587
|
+
actions: ['chat*', 'presence*'],
|
|
588
|
+
timeout: 10_000, // per-message response timeout, ms (default: 10_000)
|
|
589
|
+
maxQueueSize: 100, // max queued messages during disconnect (default: 100)
|
|
590
|
+
reconnect: true, // auto-reconnect on close (default: true)
|
|
591
|
+
maxReconnects: 10, // give up after N reconnect attempts (default: 10)
|
|
592
|
+
});
|
|
593
|
+
bus.use(ws);
|
|
594
|
+
ws.connect();
|
|
595
|
+
|
|
596
|
+
// Lifecycle
|
|
597
|
+
ws.isConnected(); // → boolean
|
|
598
|
+
ws.disconnect(); // intentional close — suppresses reconnect
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
### createSseBridge
|
|
602
|
+
|
|
603
|
+
Server-sent events — server pushes commands to the client:
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
import { createSseBridge } from 'vapor-chamber/transports';
|
|
607
|
+
|
|
608
|
+
bus.use(createSseBridge({
|
|
609
|
+
url: '/api/events',
|
|
610
|
+
}));
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
## HTTP Client
|
|
614
|
+
|
|
615
|
+
`postCommand` is exposed for use outside the transport plugin when you need direct HTTP control:
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
import { postCommand } from 'vapor-chamber';
|
|
619
|
+
|
|
620
|
+
const response = await postCommand('/api/commands', {
|
|
621
|
+
command: 'cartAdd',
|
|
622
|
+
target: product,
|
|
623
|
+
payload: { quantity: 2 },
|
|
624
|
+
}, {
|
|
625
|
+
csrf: true,
|
|
626
|
+
timeout: 5000,
|
|
627
|
+
retry: 2,
|
|
628
|
+
onSessionExpired: (status) => router.push('/login'),
|
|
629
|
+
});
|
|
220
630
|
```
|
|
221
631
|
|
|
222
632
|
## Batch Dispatch
|
|
223
633
|
|
|
224
|
-
Dispatch multiple commands as a unit. Stops on the first failure:
|
|
634
|
+
Dispatch multiple commands as a unit. Stops on the first failure by default:
|
|
225
635
|
|
|
226
636
|
```typescript
|
|
227
637
|
const result = bus.dispatchBatch([
|
|
228
|
-
{ action: '
|
|
229
|
-
{ action: '
|
|
230
|
-
{ action: '
|
|
638
|
+
{ action: 'cartAdd', target: cart, payload: item },
|
|
639
|
+
{ action: 'totalsUpdate', target: cart },
|
|
640
|
+
{ action: 'analyticsTrack', target: session, payload: item },
|
|
231
641
|
]);
|
|
232
642
|
|
|
233
643
|
if (result.ok) {
|
|
234
644
|
console.log('All succeeded:', result.results);
|
|
235
645
|
} else {
|
|
236
646
|
console.error('Stopped at failure:', result.error);
|
|
237
|
-
console.log('Partial results:', result.results);
|
|
238
647
|
}
|
|
239
648
|
```
|
|
240
649
|
|
|
241
|
-
|
|
650
|
+
Use `continueOnError` to run all commands regardless of failures, then check counts:
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
const result = bus.dispatchBatch(commands, { continueOnError: true });
|
|
654
|
+
console.log(`${result.successCount} of ${result.results.length} succeeded`);
|
|
655
|
+
// result.failCount — how many failed
|
|
656
|
+
// result.results — all CommandResult objects, in order
|
|
657
|
+
```
|
|
242
658
|
|
|
243
659
|
## Dead Letter Handling
|
|
244
660
|
|
|
245
661
|
Configure what happens when a command has no registered handler:
|
|
246
662
|
|
|
247
663
|
```typescript
|
|
248
|
-
//
|
|
249
|
-
createCommandBus()
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
createCommandBus({ onMissing: 'throw' })
|
|
253
|
-
|
|
254
|
-
// Silently succeed (useful for optional commands)
|
|
255
|
-
createCommandBus({ onMissing: 'ignore' })
|
|
256
|
-
|
|
257
|
-
// Custom fallback
|
|
258
|
-
createCommandBus({
|
|
259
|
-
onMissing: (cmd) => {
|
|
260
|
-
console.warn(`Unhandled: ${cmd.action}`);
|
|
261
|
-
return { ok: true, value: null };
|
|
262
|
-
}
|
|
263
|
-
})
|
|
664
|
+
createCommandBus() // default: returns { ok: false, error }
|
|
665
|
+
createCommandBus({ onMissing: 'throw' }) // throws the error
|
|
666
|
+
createCommandBus({ onMissing: 'ignore' }) // returns { ok: true, value: undefined }
|
|
667
|
+
createCommandBus({ onMissing: (cmd) => { ... } }) // custom fallback
|
|
264
668
|
```
|
|
265
669
|
|
|
266
670
|
## Async Command Bus
|
|
@@ -272,22 +676,22 @@ import { createAsyncCommandBus } from 'vapor-chamber';
|
|
|
272
676
|
|
|
273
677
|
const bus = createAsyncCommandBus();
|
|
274
678
|
|
|
275
|
-
bus.register('
|
|
679
|
+
bus.register('userFetch', async (cmd) => {
|
|
276
680
|
const response = await fetch(`/api/users/${cmd.target.id}`);
|
|
277
681
|
return response.json();
|
|
278
682
|
});
|
|
279
683
|
|
|
280
|
-
const result = await bus.dispatch('
|
|
684
|
+
const result = await bus.dispatch('userFetch', { id: 123 });
|
|
281
685
|
```
|
|
282
686
|
|
|
283
687
|
## Vapor Composables
|
|
284
688
|
|
|
285
|
-
For Vue Vapor components:
|
|
286
|
-
|
|
287
689
|
### useCommand
|
|
288
690
|
|
|
691
|
+
Dispatch commands with reactive loading/error state:
|
|
692
|
+
|
|
289
693
|
```vue
|
|
290
|
-
<script setup>
|
|
694
|
+
<script setup vapor>
|
|
291
695
|
import { useCommand } from 'vapor-chamber';
|
|
292
696
|
|
|
293
697
|
const { dispatch, loading, lastError } = useCommand();
|
|
@@ -299,16 +703,36 @@ const { dispatch, loading, lastError } = useCommand();
|
|
|
299
703
|
</template>
|
|
300
704
|
```
|
|
301
705
|
|
|
706
|
+
### defineVaporCommand
|
|
707
|
+
|
|
708
|
+
Zero-overhead dispatch for hot paths — no reactive `loading`/`lastError` signals created.
|
|
709
|
+
Ideal for GA4 tracking, scroll events, debounced search, fire-and-forget patterns:
|
|
710
|
+
|
|
711
|
+
```vue
|
|
712
|
+
<script setup vapor>
|
|
713
|
+
import { defineVaporCommand } from 'vapor-chamber';
|
|
714
|
+
|
|
715
|
+
const { dispatch } = defineVaporCommand('analyticsTrack', (cmd) => {
|
|
716
|
+
gtag('event', cmd.target.event, cmd.target.params);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Fire-and-forget — no reactive overhead in the alien-signals graph
|
|
720
|
+
dispatch({ event: 'page_view', params: { page: '/shop' } });
|
|
721
|
+
</script>
|
|
722
|
+
```
|
|
723
|
+
|
|
302
724
|
### useCommandState
|
|
303
725
|
|
|
726
|
+
State managed by commands:
|
|
727
|
+
|
|
304
728
|
```vue
|
|
305
|
-
<script setup>
|
|
729
|
+
<script setup vapor>
|
|
306
730
|
import { useCommandState } from 'vapor-chamber';
|
|
307
731
|
|
|
308
732
|
const { state: cart } = useCommandState(
|
|
309
733
|
{ items: [], total: 0 },
|
|
310
734
|
{
|
|
311
|
-
'
|
|
735
|
+
'cartAdd': (state, cmd) => ({
|
|
312
736
|
items: [...state.items, cmd.target],
|
|
313
737
|
total: state.total + cmd.target.price
|
|
314
738
|
})
|
|
@@ -319,47 +743,146 @@ const { state: cart } = useCommandState(
|
|
|
319
743
|
|
|
320
744
|
### useCommandHistory
|
|
321
745
|
|
|
746
|
+
Reactive undo/redo:
|
|
747
|
+
|
|
322
748
|
```vue
|
|
323
|
-
<script setup>
|
|
749
|
+
<script setup vapor>
|
|
324
750
|
import { useCommandHistory } from 'vapor-chamber';
|
|
325
751
|
|
|
326
752
|
const { canUndo, canRedo, undo, redo } = useCommandHistory({
|
|
327
|
-
filter: (cmd) => cmd.action.startsWith('
|
|
753
|
+
filter: (cmd) => cmd.action.startsWith('editor_')
|
|
328
754
|
});
|
|
329
755
|
</script>
|
|
330
756
|
```
|
|
331
757
|
|
|
758
|
+
### useCommandGroup
|
|
759
|
+
|
|
760
|
+
Namespace isolation for large apps and multi-team projects. All calls are automatically prefixed in camelCase — prevents action name collisions when composing multiple feature modules:
|
|
761
|
+
|
|
762
|
+
```typescript
|
|
763
|
+
import { useCommandGroup } from 'vapor-chamber';
|
|
764
|
+
|
|
765
|
+
// Cart feature module
|
|
766
|
+
const cart = useCommandGroup('cart');
|
|
767
|
+
cart.register('add', handler); // registers 'cartAdd'
|
|
768
|
+
cart.dispatch('add', product); // dispatches 'cartAdd'
|
|
769
|
+
cart.on('*', listener); // listens to 'cart*'
|
|
770
|
+
|
|
771
|
+
// Orders feature — completely isolated
|
|
772
|
+
const orders = useCommandGroup('orders');
|
|
773
|
+
orders.dispatch('cancel', { id }); // dispatches 'ordersCancel'
|
|
774
|
+
|
|
775
|
+
// Access the namespace
|
|
776
|
+
cart.namespace; // → 'cart'
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
Auto-cleanup on Vue scope disposal. `dispose()` is also available for manual teardown.
|
|
780
|
+
|
|
781
|
+
### useCommandError
|
|
782
|
+
|
|
783
|
+
Component-scoped error boundary. Reactively captures all failed command results:
|
|
784
|
+
|
|
785
|
+
```typescript
|
|
786
|
+
import { useCommandError } from 'vapor-chamber';
|
|
787
|
+
|
|
788
|
+
// Watch all failed commands
|
|
789
|
+
const { errors, latestError, clearErrors } = useCommandError();
|
|
790
|
+
|
|
791
|
+
// Narrow to a subset
|
|
792
|
+
const { latestError } = useCommandError({
|
|
793
|
+
filter: (cmd) => cmd.action.startsWith('cart'),
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// In template
|
|
797
|
+
// latestError.value?.message
|
|
798
|
+
// errors.value.length
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
### createFormBus
|
|
802
|
+
|
|
803
|
+
Reactive form state manager built on the command bus. Per-field validation, dirty tracking, and full plugin pipeline on every form command:
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
import { createFormBus, logger } from 'vapor-chamber';
|
|
807
|
+
|
|
808
|
+
const form = createFormBus({
|
|
809
|
+
fields: { email: '', password: '' },
|
|
810
|
+
rules: {
|
|
811
|
+
// Sync rule — runs on every set() for live feedback
|
|
812
|
+
email: (v) => v.includes('@') ? null : 'Invalid email',
|
|
813
|
+
password: (v) => v.length >= 8 ? null : 'Too short',
|
|
814
|
+
// Async rule — only awaited on submit() (no UI jank during typing)
|
|
815
|
+
username: async (v) => {
|
|
816
|
+
const taken = await api.isUsernameTaken(v);
|
|
817
|
+
return taken ? 'Username already taken' : null;
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
onSubmit: async (values) => await api.login(values),
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// Attach plugins — logger, throttle, authGuard, etc.
|
|
824
|
+
form.use(logger());
|
|
825
|
+
|
|
826
|
+
// Reactive state
|
|
827
|
+
form.values.value // { email: '', password: '' }
|
|
828
|
+
form.errors.value // { email: 'Invalid email', ... }
|
|
829
|
+
form.isDirty.value // true when any field has changed
|
|
830
|
+
form.isValid.value // true when no errors
|
|
831
|
+
form.isSubmitting.value // true while onSubmit is in flight
|
|
832
|
+
|
|
833
|
+
// Actions
|
|
834
|
+
form.set('email', 'user@example.com'); // updates field + re-runs validation
|
|
835
|
+
form.touch('email'); // marks field as interacted with
|
|
836
|
+
await form.submit(); // validate → onSubmit → returns bool
|
|
837
|
+
form.reset(); // restore initial values
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
Template usage (Vue 3):
|
|
841
|
+
|
|
842
|
+
```vue
|
|
843
|
+
<input :value="form.values.value.email"
|
|
844
|
+
@input="form.set('email', $event.target.value)"
|
|
845
|
+
@blur="form.touch('email')" />
|
|
846
|
+
<span v-if="form.touched.value.email && form.errors.value.email">
|
|
847
|
+
{{ form.errors.value.email }}
|
|
848
|
+
</span>
|
|
849
|
+
<button :disabled="!form.isValid.value || form.isSubmitting.value"
|
|
850
|
+
@click="form.submit()">
|
|
851
|
+
Submit
|
|
852
|
+
</button>
|
|
853
|
+
```
|
|
854
|
+
|
|
332
855
|
### useCommandBus
|
|
333
856
|
|
|
334
|
-
Lightweight
|
|
857
|
+
Lightweight access to the shared bus — tree-shakeable:
|
|
335
858
|
|
|
336
859
|
```typescript
|
|
337
860
|
import { useCommandBus } from 'vapor-chamber';
|
|
338
861
|
|
|
339
862
|
const bus = useCommandBus();
|
|
340
|
-
bus.dispatch('
|
|
863
|
+
bus.dispatch('cartAdd', product, { quantity: 1 });
|
|
341
864
|
```
|
|
342
865
|
|
|
343
|
-
Use `useCommand()` when you need reactive `loading`/`lastError` signals. Use `useCommandBus()` when you just need to dispatch.
|
|
866
|
+
Use `useCommand()` when you need reactive `loading`/`lastError` signals. Use `defineVaporCommand()` for zero-overhead hot paths. Use `useCommandBus()` when you just need to dispatch.
|
|
344
867
|
|
|
345
868
|
### configureSignal
|
|
346
869
|
|
|
347
|
-
Inject
|
|
870
|
+
Inject a custom signal factory. In Vue 3.6+, `ref()` is auto-detected and backed by alien-signals — calling `configureSignal` is only needed for custom signal implementations:
|
|
348
871
|
|
|
349
872
|
```typescript
|
|
350
|
-
import {
|
|
873
|
+
import { ref } from 'vue';
|
|
351
874
|
import { configureSignal } from 'vapor-chamber';
|
|
352
875
|
|
|
353
|
-
configureSignal(
|
|
876
|
+
configureSignal(ref); // explicit — usually auto-detected
|
|
354
877
|
```
|
|
355
878
|
|
|
356
879
|
### Testing
|
|
357
880
|
|
|
358
|
-
`createTestBus()` records all dispatched commands without executing real handlers
|
|
881
|
+
`createTestBus()` records all dispatched commands without executing real handlers:
|
|
359
882
|
|
|
360
883
|
```typescript
|
|
361
|
-
import { createTestBus, setCommandBus } from 'vapor-chamber';
|
|
362
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
884
|
+
import { createTestBus, setCommandBus, resetCommandBus } from 'vapor-chamber';
|
|
885
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
363
886
|
|
|
364
887
|
describe('CartButton', () => {
|
|
365
888
|
let bus: TestBus;
|
|
@@ -369,20 +892,39 @@ describe('CartButton', () => {
|
|
|
369
892
|
setCommandBus(bus);
|
|
370
893
|
});
|
|
371
894
|
|
|
372
|
-
|
|
895
|
+
afterEach(() => {
|
|
896
|
+
resetCommandBus();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('dispatches cartAdd on click', () => {
|
|
373
900
|
// ... render component, click button ...
|
|
374
|
-
expect(bus.wasDispatched('
|
|
375
|
-
expect(bus.getDispatched('
|
|
901
|
+
expect(bus.wasDispatched('cartAdd')).toBe(true);
|
|
902
|
+
expect(bus.getDispatched('cartAdd')[0].cmd.payload).toEqual({ quantity: 1 });
|
|
376
903
|
});
|
|
377
904
|
});
|
|
378
905
|
```
|
|
379
906
|
|
|
380
|
-
|
|
907
|
+
**Snapshot & time-travel** — replay command sequences for debugging or testing:
|
|
381
908
|
|
|
382
909
|
```typescript
|
|
383
|
-
bus
|
|
384
|
-
|
|
385
|
-
|
|
910
|
+
const bus = createTestBus();
|
|
911
|
+
|
|
912
|
+
bus.dispatch('login', user);
|
|
913
|
+
bus.dispatch('cartAdd', product, { quantity: 1 });
|
|
914
|
+
bus.dispatch('cartAdd', product2, { quantity: 2 });
|
|
915
|
+
bus.dispatch('checkout', cart);
|
|
916
|
+
|
|
917
|
+
// Immutable snapshot — mutations don't affect bus.recorded
|
|
918
|
+
const snap = bus.snapshot(); // → RecordedDispatch[]
|
|
919
|
+
|
|
920
|
+
// Commands 0..N inclusive (returns Command[])
|
|
921
|
+
bus.travelTo(1); // → [login, cartAdd]
|
|
922
|
+
|
|
923
|
+
// All commands up to last occurrence of 'cartAdd'
|
|
924
|
+
bus.travelToAction('cartAdd'); // → [login, cartAdd, cartAdd]
|
|
925
|
+
|
|
926
|
+
// Out-of-range indices are clamped
|
|
927
|
+
bus.travelTo(999); // → full history
|
|
386
928
|
```
|
|
387
929
|
|
|
388
930
|
### setupDevtools
|
|
@@ -398,12 +940,6 @@ setupDevtools(getCommandBus(), app);
|
|
|
398
940
|
app.mount('#app');
|
|
399
941
|
```
|
|
400
942
|
|
|
401
|
-
The inspector shows:
|
|
402
|
-
- Every dispatched command with its action, target, payload, and result
|
|
403
|
-
- Green `ok` / red `error` tags at a glance
|
|
404
|
-
- Full detail (value or error message) when a command is selected
|
|
405
|
-
- Filterable tree by action name
|
|
406
|
-
|
|
407
943
|
## Examples
|
|
408
944
|
|
|
409
945
|
See the [`examples/`](./examples) folder for complete, runnable examples:
|
|
@@ -417,11 +953,6 @@ See the [`examples/`](./examples) folder for complete, runnable examples:
|
|
|
417
953
|
| [`custom-plugins.ts`](./examples/custom-plugins.ts) | Analytics, auth guard, rate limiter plugins |
|
|
418
954
|
| [`vue-vapor-component.vue`](./examples/vue-vapor-component.vue) | Full Vue Vapor todo app |
|
|
419
955
|
|
|
420
|
-
Run TypeScript examples with:
|
|
421
|
-
```bash
|
|
422
|
-
npx ts-node examples/shopping-cart.ts
|
|
423
|
-
```
|
|
424
|
-
|
|
425
956
|
## API Reference
|
|
426
957
|
|
|
427
958
|
### Core
|
|
@@ -430,64 +961,170 @@ npx ts-node examples/shopping-cart.ts
|
|
|
430
961
|
|----------|-------------|
|
|
431
962
|
| `createCommandBus(options?)` | Create a synchronous command bus |
|
|
432
963
|
| `createAsyncCommandBus(options?)` | Create an async command bus |
|
|
433
|
-
| `createTestBus(options?)` | Create a test bus that records dispatches
|
|
964
|
+
| `createTestBus(options?)` | Create a test bus that records dispatches |
|
|
434
965
|
|
|
435
966
|
**`CommandBusOptions`**
|
|
436
967
|
|
|
437
968
|
| Option | Type | Default | Description |
|
|
438
969
|
|--------|------|---------|-------------|
|
|
439
|
-
| `onMissing` | `'error' \| 'throw' \| 'ignore' \| fn` | `'error'` | Behavior when no handler is registered
|
|
970
|
+
| `onMissing` | `'error' \| 'throw' \| 'ignore' \| fn` | `'error'` | Behavior when no handler is registered |
|
|
971
|
+
| `naming` | `{ pattern: RegExp, onViolation?: string }` | — | Enforce naming convention on actions |
|
|
440
972
|
|
|
441
973
|
### Command Bus Methods
|
|
442
974
|
|
|
443
975
|
| Method | Description |
|
|
444
976
|
|--------|-------------|
|
|
445
977
|
| `dispatch(action, target, payload?)` | Execute a command |
|
|
446
|
-
| `dispatchBatch(commands[])` | Execute multiple commands
|
|
447
|
-
| `register(action, handler)` | Register a handler
|
|
448
|
-
| `use(plugin, options?)` | Add a plugin
|
|
449
|
-
| `
|
|
978
|
+
| `dispatchBatch(commands[], options?)` | Execute multiple commands. Returns `{ successCount, failCount, results }` |
|
|
979
|
+
| `register(action, handler, options?)` | Register a handler. Options: `{ undo?, throttle? }` |
|
|
980
|
+
| `use(plugin, options?)` | Add a plugin. `options.priority` controls order |
|
|
981
|
+
| `onBefore(hook)` | Run hook before every command. Throw to cancel dispatch. |
|
|
982
|
+
| `onAfter(hook)` | Run hook after every command |
|
|
983
|
+
| `on(pattern, listener)` | Subscribe to commands matching a pattern (`*`, `prefix*`, exact). Returns unsub. |
|
|
984
|
+
| `once(pattern, listener)` | Like `on()` but auto-unsubscribes after first match |
|
|
985
|
+
| `offAll(pattern?)` | Remove all listeners for a pattern, or all listeners if omitted |
|
|
986
|
+
| `request(action, target, payload?, options?)` | Async request/response with timeout (default 5s) |
|
|
987
|
+
| `respond(action, handler)` | Register a responder for `request()` calls |
|
|
988
|
+
| `hasHandler(action)` | Returns true if a handler is registered for the action |
|
|
989
|
+
| `clear()` | Remove all handlers, plugins, hooks, and listeners |
|
|
990
|
+
| `getUndoHandler(action)` | Get the undo handler for an action (`@internal`) |
|
|
450
991
|
|
|
451
992
|
### Composables
|
|
452
993
|
|
|
453
994
|
| Composable | Description |
|
|
454
995
|
|------------|-------------|
|
|
455
|
-
| `
|
|
456
|
-
| `
|
|
457
|
-
| `useCommandState(initial, handlers)` | State managed by commands
|
|
458
|
-
| `useCommandHistory(options?)` | Reactive undo/redo
|
|
459
|
-
| `
|
|
996
|
+
| `useCommand()` | Dispatch with reactive loading/error state |
|
|
997
|
+
| `defineVaporCommand(action, handler, options?)` | Zero-overhead dispatch for hot paths |
|
|
998
|
+
| `useCommandState(initial, handlers)` | State managed by commands |
|
|
999
|
+
| `useCommandHistory(options?)` | Reactive undo/redo |
|
|
1000
|
+
| `useCommandGroup(namespace)` | Namespace isolation — prefixes all calls in camelCase |
|
|
1001
|
+
| `useCommandError(options?)` | Reactive error boundary for failed dispatches |
|
|
1002
|
+
| `useCommandBus()` | Get shared bus instance |
|
|
1003
|
+
| `getCommandBus()` | Get shared bus instance (non-composable) |
|
|
460
1004
|
| `setCommandBus(bus)` | Set shared bus instance |
|
|
461
|
-
| `
|
|
462
|
-
| `
|
|
1005
|
+
| `resetCommandBus()` | Reset shared bus to null (useful in tests) |
|
|
1006
|
+
| `configureSignal(fn)` | Inject a custom signal factory |
|
|
1007
|
+
| `isVaporAvailable()` | Returns true if Vue 3.6+ Vapor mode is detected |
|
|
1008
|
+
| `createVaporChamberApp(component, props?)` | Create a Vapor app instance (requires Vue 3.6+) |
|
|
1009
|
+
| `getVaporInteropPlugin()` | Returns `vaporInteropPlugin` for mixed trees |
|
|
1010
|
+
| `setupDevtools(bus, app)` | Connect bus to Vue DevTools |
|
|
463
1011
|
|
|
464
1012
|
## Roadmap
|
|
465
1013
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
|
469
|
-
|
|
470
|
-
|
|
|
471
|
-
|
|
|
472
|
-
|
|
|
473
|
-
|
|
|
474
|
-
|
|
|
475
|
-
|
|
|
1014
|
+
### Core — target: 100% feature-complete at v1.0
|
|
1015
|
+
|
|
1016
|
+
| Feature | Module | Status | Tests |
|
|
1017
|
+
|---------|--------|--------|-------|
|
|
1018
|
+
| Dispatch / register / unregister | `command-bus` | ✅ v0.1.0 | ✅ 90% coverage |
|
|
1019
|
+
| Plugin pipeline (sync + async) | `command-bus` | ✅ v0.1.0 | ✅ 90% coverage |
|
|
1020
|
+
| Plugin priority ordering | `command-bus` | ✅ v0.2.0 | ✅ covered |
|
|
1021
|
+
| `onAfter` hooks | `command-bus` | ✅ v0.2.0 | ✅ covered |
|
|
1022
|
+
| Dead letter handling (`onMissing`) | `command-bus` | ✅ v0.2.0 | ✅ covered |
|
|
1023
|
+
| Command batching + `continueOnError` + `successCount`/`failCount` | `command-bus` | ✅ v0.6.0 | ✅ covered |
|
|
1024
|
+
| Naming convention enforcement | `command-bus` | ✅ v0.3.0 | ✅ covered |
|
|
1025
|
+
| Wildcard listeners (`on`, `prefix*`) | `command-bus` | ✅ v0.3.0 | ✅ covered |
|
|
1026
|
+
| `once()` — one-shot listener | `command-bus` | ✅ v0.6.0 | ✅ covered |
|
|
1027
|
+
| `offAll(pattern?)` — mass unsubscribe | `command-bus` | ✅ v0.6.0 | ✅ covered |
|
|
1028
|
+
| `onBefore(hook)` — pre-dispatch hook, cancelable | `command-bus` | ✅ v0.6.0 | ✅ covered |
|
|
1029
|
+
| Request / response pattern + timeout | `command-bus` | ✅ v0.3.0 | ✅ covered |
|
|
1030
|
+
| Per-command throttle + undo at register | `command-bus` | ✅ v0.3.0 | ✅ covered |
|
|
1031
|
+
| `bus.hasHandler()` introspection | `command-bus` | ✅ v0.3.0 | ✅ covered |
|
|
1032
|
+
| `bus.clear()` | `command-bus` | ✅ v0.5.0 | ✅ covered |
|
|
1033
|
+
| `BaseBus` structural interface | `command-bus` | ✅ v0.6.0 | ✅ covered |
|
|
1034
|
+
| `commandKey(action, target)` export | `command-bus` | ✅ v0.6.0 | ✅ covered |
|
|
1035
|
+
| SSR isolation (independent bus instances) | `command-bus` | ✅ v0.5.0 | ✅ covered |
|
|
1036
|
+
| `createTestBus` record + assert | `testing` | ✅ v0.2.0 | ✅ 96% coverage |
|
|
1037
|
+
| `createTestBus` snapshot & time-travel | `testing` | ✅ v0.4.3 | ✅ covered |
|
|
1038
|
+
| `TestBus.on()` / `once()` / `offAll()` real implementations | `testing` | ✅ v0.6.0 | ✅ covered |
|
|
1039
|
+
|
|
1040
|
+
### Plugins — optional, fully implemented
|
|
1041
|
+
|
|
1042
|
+
| Feature | Module | Status | Tests |
|
|
1043
|
+
|---------|--------|--------|-------|
|
|
1044
|
+
| `logger` | `plugins-core` | ✅ v0.1.0 | ✅ 90% coverage |
|
|
1045
|
+
| `validator` | `plugins-core` | ✅ v0.1.0 | ✅ covered |
|
|
1046
|
+
| `history` + bus-backed undo/redo | `plugins-core` | ✅ v0.3.0 | ✅ covered |
|
|
1047
|
+
| `debounce` (stale-closure fix) | `plugins-core` | ✅ v0.3.0 | ✅ covered |
|
|
1048
|
+
| `throttle` | `plugins-core` | ✅ v0.3.0 | ✅ covered |
|
|
1049
|
+
| `authGuard` | `plugins-core` | ✅ v0.3.0 | ✅ covered |
|
|
1050
|
+
| `optimistic` | `plugins-core` | ✅ v0.3.0 | ✅ covered |
|
|
1051
|
+
| `retry` with configurable backoff + glob filter | `plugins-io` | ✅ v0.4.2 | ✅ 88% coverage |
|
|
1052
|
+
| `persist` (localStorage / custom storage) | `plugins-io` | ✅ v0.4.2 | ✅ covered |
|
|
1053
|
+
| `sync` (BroadcastChannel cross-tab) | `plugins-io` | ✅ v0.4.2 | ✅ covered |
|
|
1054
|
+
|
|
1055
|
+
### Transport layer — optional, fully implemented
|
|
1056
|
+
|
|
1057
|
+
| Feature | Module | Status | Tests |
|
|
1058
|
+
|---------|--------|--------|-------|
|
|
1059
|
+
| `postCommand` — POST with retry, CSRF, timeout, session | `http` | ✅ v0.5.0 | ✅ 80% coverage |
|
|
1060
|
+
| `readCsrfToken` — meta / cookie / hidden input | `http` | ✅ v0.5.0 | ✅ covered |
|
|
1061
|
+
| `HttpError.code` — machine-readable code from response body | `http` | ✅ v0.6.0 | ✅ covered |
|
|
1062
|
+
| 419 vs 401 fix — CSRF expiry ≠ session expiry | `http` | ✅ v0.6.0 | ✅ covered |
|
|
1063
|
+
| `createHttpBridge` — fetch plugin | `transports` | ✅ v0.4.2 | ✅ 91% coverage |
|
|
1064
|
+
| `HttpBridgeOptions.noRetry` — per-action retry disable | `transports` | ✅ v0.6.0 | ✅ covered |
|
|
1065
|
+
| `createWsBridge` — WebSocket plugin + reconnect + bounded queue | `transports` | ✅ v0.6.0 | ✅ covered |
|
|
1066
|
+
| `createSseBridge` — server-push EventSource, accepts `BaseBus` | `transports` | ✅ v0.6.0 | ✅ covered |
|
|
1067
|
+
|
|
1068
|
+
### Vue composables — optional, requires Vue ≥3.5
|
|
1069
|
+
|
|
1070
|
+
| Feature | Module | Status | Tests |
|
|
1071
|
+
|---------|--------|--------|-------|
|
|
1072
|
+
| `useCommand` — reactive loading/error | `chamber` | ✅ v0.1.0 | ✅ 76% coverage |
|
|
1073
|
+
| `useCommandState` | `chamber` | ✅ v0.2.0 | ✅ covered |
|
|
1074
|
+
| `useCommandHistory` — reactive undo/redo | `chamber` | ✅ v0.2.0 | ✅ covered |
|
|
1075
|
+
| `useCommandGroup` — namespace isolation | `chamber` | ✅ v0.4.1 | ✅ covered |
|
|
1076
|
+
| `useCommandError` — error boundary | `chamber` | ✅ v0.4.1 | ✅ covered |
|
|
1077
|
+
| `getCommandBus` / `setCommandBus` / `resetCommandBus` | `chamber` | ✅ v0.1.0 | ✅ covered |
|
|
1078
|
+
| Signal shim + `configureSignal` | `chamber` | ✅ v0.3.0 | ✅ covered |
|
|
1079
|
+
| `onScopeDispose` lifecycle alignment | `chamber` | ✅ v0.4.0 | ✅ covered |
|
|
1080
|
+
| `isVaporAvailable()` | `chamber` | ✅ v0.4.0 | ✅ covered |
|
|
1081
|
+
| `createVaporChamberApp` / `getVaporInteropPlugin` / `defineVaporCommand` | `chamber-vapor` | ✅ v0.4.0 | ⚠️ requires Vue 3.6 runtime |
|
|
1082
|
+
|
|
1083
|
+
### Extras — optional, per-feature opt-in
|
|
1084
|
+
|
|
1085
|
+
| Feature | Module | Status | Tests |
|
|
1086
|
+
|---------|--------|--------|-------|
|
|
1087
|
+
| `createFormBus` — reactive form + sync/async validation | `form` | ✅ v0.6.0 | ✅ 99% coverage |
|
|
1088
|
+
| Schema layer — `createSchemaCommandBus`, `toTools`, `synthesize` | `schema` | ✅ v0.5.0 | ✅ 92% coverage |
|
|
1089
|
+
| `SynthesizeOptions.adapter` — custom LLM adapter | `schema` | ✅ v0.6.0 | ✅ covered |
|
|
1090
|
+
| `setupDevtools` — Vue DevTools panel | `devtools` | ✅ v0.4.0 | ⚠️ requires browser DevTools API |
|
|
1091
|
+
| `createDirectivePlugin` — `v-command` directive | `directives` | ✅ v0.5.0 | ⚠️ requires Vue DOM environment |
|
|
1092
|
+
| Vite HMR plugin | `vite-hmr` | ✅ v0.5.0 | ⚠️ requires Vite runtime |
|
|
1093
|
+
| IIFE / CDN bundle | `iife` | ✅ v0.5.0 | 🔧 bundle entry |
|
|
1094
|
+
|
|
1095
|
+
### v1.0 checklist
|
|
1096
|
+
|
|
1097
|
+
| Item | Status |
|
|
1098
|
+
|------|--------|
|
|
1099
|
+
| Core (`command-bus` + `testing`) at 90%+ coverage | ✅ Done |
|
|
1100
|
+
| All tests green (318/318, 0 failures) | ✅ Done |
|
|
1101
|
+
| Optional modules clearly marked in exports | ✅ Done |
|
|
1102
|
+
| Transport layer fully tested (HTTP + WS + SSE) | ✅ Done |
|
|
1103
|
+
| Plugins fully tested | ✅ Done |
|
|
1104
|
+
| camelCase naming convention locked in | ✅ Done |
|
|
1105
|
+
| `onBefore` / `offAll` / `once` on both buses | ✅ Done (v0.6.0) |
|
|
1106
|
+
| `BaseBus` structural interface for cross-bus utilities | ✅ Done (v0.6.0) |
|
|
1107
|
+
| CSRF / 419 / session-expiry correctness | ✅ Done (v0.6.0) |
|
|
1108
|
+
| Form async validation | ✅ Done (v0.6.0) |
|
|
1109
|
+
| `HttpError.code` structured error codes | ✅ Done (v0.6.0) |
|
|
1110
|
+
| WS queue cap (`maxQueueSize`) | ✅ Done (v0.6.0) |
|
|
1111
|
+
| `synthesize` LLM adapter (proxy / OpenAI support) | ✅ Done (v0.6.0) |
|
|
1112
|
+
| Architectural whitepaper | ✅ Done (v0.6.0) |
|
|
1113
|
+
| `chamber.ts` branch coverage | 🔄 76% → target 85% |
|
|
1114
|
+
| Publish to npm as `vapor-chamber@1.0.0` | ⬜ Pending |
|
|
476
1115
|
|
|
477
1116
|
## Documentation
|
|
478
1117
|
|
|
479
|
-
See
|
|
480
|
-
|
|
481
|
-
- [Whitepaper](./docs/whitepaper.md) - Design philosophy and architecture
|
|
482
|
-
- [SSR Guide](./docs/ssr.md) - Server-side rendering and hydration
|
|
1118
|
+
See [`docs/whitepaper.md`](./docs/whitepaper.md) for design philosophy, architecture, camelCase naming rationale, Vue 3.6 Vapor alignment, SSR guide, and migration strategy.
|
|
483
1119
|
|
|
484
1120
|
## Design Goals
|
|
485
1121
|
|
|
486
|
-
1. **Minimal**
|
|
487
|
-
2. **Vapor-native**
|
|
488
|
-
3. **Composable**
|
|
489
|
-
4. **Type-safe**
|
|
490
|
-
5. **Predictable**
|
|
1122
|
+
1. **Minimal** — ~1KB core, no dependencies
|
|
1123
|
+
2. **Vapor-native** — Built for signals, not VDOM
|
|
1124
|
+
3. **Composable** — Plugins for everything
|
|
1125
|
+
4. **Type-safe** — Full TypeScript support
|
|
1126
|
+
5. **Predictable** — Sync by default, explicit async
|
|
1127
|
+
6. **Progressive** — Works in VDOM, Vapor, and mixed trees
|
|
491
1128
|
|
|
492
1129
|
## License
|
|
493
1130
|
|