vapor-chamber 0.4.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.
Files changed (51) hide show
  1. package/README.md +549 -68
  2. package/dist/chamber-vapor.d.ts +48 -0
  3. package/dist/chamber-vapor.d.ts.map +1 -0
  4. package/dist/chamber-vapor.js +62 -0
  5. package/dist/chamber.d.ts +68 -51
  6. package/dist/chamber.d.ts.map +1 -1
  7. package/dist/chamber.js +100 -92
  8. package/dist/command-bus.d.ts +118 -28
  9. package/dist/command-bus.d.ts.map +1 -1
  10. package/dist/command-bus.js +407 -250
  11. package/dist/directives.d.ts +37 -0
  12. package/dist/directives.d.ts.map +1 -0
  13. package/dist/directives.js +190 -0
  14. package/dist/form.d.ts +72 -0
  15. package/dist/form.d.ts.map +1 -0
  16. package/dist/form.js +159 -0
  17. package/dist/http.d.ts +60 -0
  18. package/dist/http.d.ts.map +1 -0
  19. package/dist/http.js +242 -0
  20. package/dist/iife.d.ts +95 -0
  21. package/dist/iife.d.ts.map +1 -0
  22. package/dist/iife.js +90 -0
  23. package/dist/index.d.ts +45 -5
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +59 -10
  26. package/dist/plugins-core.d.ts +69 -0
  27. package/dist/plugins-core.d.ts.map +1 -0
  28. package/dist/plugins-core.js +210 -0
  29. package/dist/plugins-io.d.ts +100 -0
  30. package/dist/plugins-io.d.ts.map +1 -0
  31. package/dist/plugins-io.js +171 -0
  32. package/dist/plugins.d.ts +6 -76
  33. package/dist/plugins.d.ts.map +1 -1
  34. package/dist/plugins.js +6 -211
  35. package/dist/schema.d.ts +162 -0
  36. package/dist/schema.d.ts.map +1 -0
  37. package/dist/schema.js +266 -0
  38. package/dist/testing.d.ts +29 -4
  39. package/dist/testing.d.ts.map +1 -1
  40. package/dist/testing.js +76 -21
  41. package/dist/transports.d.ts +168 -0
  42. package/dist/transports.d.ts.map +1 -0
  43. package/dist/transports.js +225 -0
  44. package/dist/vapor-chamber.iife.js +1251 -0
  45. package/dist/vapor-chamber.iife.js.map +7 -0
  46. package/dist/vapor-chamber.iife.min.js +2 -0
  47. package/dist/vite-hmr.d.ts +50 -0
  48. package/dist/vite-hmr.d.ts.map +1 -0
  49. package/dist/vite-hmr.js +110 -0
  50. package/package.json +23 -3
  51. package/scripts/build-iife.mjs +47 -0
package/README.md CHANGED
@@ -6,6 +6,32 @@
6
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
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.
@@ -37,17 +63,17 @@ bus.on('cart:add', updateBadge); // now two handlers, hard to trace
37
63
  ```
38
64
  // After — Vapor Chamber
39
65
  // Anywhere in the app
40
- bus.dispatch('cart_add', product, { quantity: 1 });
66
+ bus.dispatch('cartAdd', product, { quantity: 1 });
41
67
 
42
68
  // One place, once:
43
- bus.register('cart_add', (cmd) => {
69
+ bus.register('cartAdd', (cmd) => {
44
70
  cart.items.push(cmd.target);
45
71
  return cart.items;
46
72
  });
47
73
 
48
74
  // Cross-cutting concerns as plugins, not scattered listeners:
49
75
  bus.use(logger());
50
- bus.use(validator({ 'cart_add': (cmd) => cmd.target.id ? null : 'Missing ID' }));
76
+ bus.use(validator({ 'cartAdd': (cmd) => cmd.target.id ? null : 'Missing ID' }));
51
77
  bus.use(analyticsPlugin);
52
78
  ```
53
79
 
@@ -60,7 +86,7 @@ Traditional event systems scatter logic across components. A command bus central
60
86
  ```
61
87
  Event-driven (scattered) Command bus (centralized)
62
88
  ───────────────────────── ─────────────────────────
63
- Component A emits 'add' → dispatch('cart_add', product)
89
+ Component A emits 'add' → dispatch('cartAdd', product)
64
90
  Component B listens... ↓
65
91
  Component C also listens... Handler executes once
66
92
  Who handles what? When? Plugins observe/modify
@@ -68,11 +94,62 @@ Who handles what? When? Plugins observe/modify
68
94
  ```
69
95
 
70
96
  **Benefits:**
71
- - **Semantic actions** — `cart_add` is clearer than `emit('add')`
97
+ - **Semantic actions** — `cartAdd` is clearer than `emit('add')`
72
98
  - **Single handler** — One place to look, debug, test
73
99
  - **Plugin pipeline** — Cross-cutting concerns (logging, validation, analytics) without cluttering handlers
74
100
  - **Undo/redo** — Command history is natural when actions are explicit
75
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
+ ---
152
+
76
153
  ## Install
77
154
 
78
155
  ```bash
@@ -91,17 +168,17 @@ const bus = createCommandBus();
91
168
  // Add plugins
92
169
  bus.use(logger());
93
170
  bus.use(validator({
94
- 'cart_add': (cmd) => cmd.payload?.quantity > 0 ? null : 'Quantity required'
171
+ 'cartAdd': (cmd) => cmd.payload?.quantity > 0 ? null : 'Quantity required'
95
172
  }));
96
173
 
97
174
  // Register handler
98
- bus.register('cart_add', (cmd) => {
175
+ bus.register('cartAdd', (cmd) => {
99
176
  cart.items.push({ ...cmd.target, quantity: cmd.payload.quantity });
100
177
  return cart.items;
101
178
  });
102
179
 
103
180
  // Dispatch
104
- const result = bus.dispatch('cart_add', product, { quantity: 2 });
181
+ const result = bus.dispatch('cartAdd', product, { quantity: 2 });
105
182
  if (result.ok) {
106
183
  console.log('Added:', result.value);
107
184
  } else {
@@ -167,7 +244,7 @@ A command has three parts:
167
244
 
168
245
  ```typescript
169
246
  bus.dispatch(
170
- 'cart_add', // action - what to do
247
+ 'cartAdd', // action - what to do
171
248
  product, // target - what to act on
172
249
  { quantity: 2 } // payload - additional data (optional)
173
250
  );
@@ -180,13 +257,13 @@ Enforce consistent action names at register and dispatch time:
180
257
  ```typescript
181
258
  const bus = createCommandBus({
182
259
  naming: {
183
- pattern: /^[a-z][a-z0-9]*(_[a-z][a-z0-9]*)+$/, // snake_case
260
+ pattern: /^[a-z][a-zA-Z0-9]+$/, // camelCase
184
261
  onViolation: 'throw' // or 'warn' or 'ignore'
185
262
  }
186
263
  });
187
264
 
188
- bus.register('cart_add', handler); // ✓ passes
189
- bus.register('cartAdd', handler); // ✗ throws
265
+ bus.register('cartAdd', handler); // ✓ passes
266
+ bus.register('cart_add', handler); // ✗ throws
190
267
  ```
191
268
 
192
269
  ### Handlers
@@ -194,7 +271,7 @@ bus.register('cartAdd', handler); // ✗ throws
194
271
  One handler per action. Returns a value or throws:
195
272
 
196
273
  ```typescript
197
- bus.register('cart_add', (cmd) => {
274
+ bus.register('cartAdd', (cmd) => {
198
275
  cart.items.push(cmd.target);
199
276
  return cart.items; // becomes result.value
200
277
  });
@@ -203,7 +280,7 @@ bus.register('cart_add', (cmd) => {
203
280
  Register with options for undo support and per-command throttling:
204
281
 
205
282
  ```typescript
206
- bus.register('cart_add', addHandler, {
283
+ bus.register('cartAdd', addHandler, {
207
284
  undo: (cmd) => { cart.items.pop(); },
208
285
  throttle: 300, // max once per 300ms per target
209
286
  });
@@ -244,6 +321,30 @@ bus.use(analyticsPlugin, { priority: 1 }); // runs after validation
244
321
  bus.use(loggerPlugin); // priority 0 (default, runs last)
245
322
  ```
246
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
+
247
348
  ### Wildcard Listeners
248
349
 
249
350
  Subscribe to command patterns without being a handler:
@@ -253,10 +354,16 @@ Subscribe to command patterns without being a handler:
253
354
  bus.on('*', (cmd, result) => analytics.track(cmd.action));
254
355
 
255
356
  // Prefix matching
256
- bus.on('shop_*', (cmd, result) => console.log('Shop event:', cmd.action));
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());
257
361
 
258
- // Exact match
259
- bus.on('cart_add', (cmd, result) => updateBadge());
362
+ // Remove all listeners for a pattern
363
+ bus.offAll('cart*');
364
+
365
+ // Remove all listeners
366
+ bus.offAll();
260
367
  ```
261
368
 
262
369
  ### Request / Response
@@ -287,18 +394,21 @@ Falls back to normal `dispatch()` if no responder is registered.
287
394
  | `throttle(actions, wait)` | Limit execution frequency |
288
395
  | `authGuard(options)` | Block protected commands when unauthenticated |
289
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 |
290
400
 
291
401
  ### logger
292
402
 
293
403
  ```typescript
294
- bus.use(logger({ collapsed: true, filter: (cmd) => cmd.action.startsWith('cart_') }));
404
+ bus.use(logger({ collapsed: true, filter: (cmd) => cmd.action.startsWith('cart') }));
295
405
  ```
296
406
 
297
407
  ### validator
298
408
 
299
409
  ```typescript
300
410
  bus.use(validator({
301
- 'cart_add': (cmd) => {
411
+ 'cartAdd': (cmd) => {
302
412
  if (!cmd.target?.id) return 'Product must have an ID';
303
413
  return null; // null = valid
304
414
  }
@@ -322,20 +432,20 @@ With bus-backed undo (executes inverse handlers):
322
432
  const historyPlugin = history({ maxSize: 100, bus });
323
433
  bus.use(historyPlugin);
324
434
 
325
- // If cart_add was registered with { undo: fn }, calling undo() executes it
435
+ // If cartAdd was registered with { undo: fn }, calling undo() executes it
326
436
  historyPlugin.undo();
327
437
  ```
328
438
 
329
439
  ### debounce
330
440
 
331
441
  ```typescript
332
- bus.use(debounce(['search_query'], 300)); // wait 300ms after last call
442
+ bus.use(debounce(['searchQuery'], 300)); // wait 300ms after last call
333
443
  ```
334
444
 
335
445
  ### throttle
336
446
 
337
447
  ```typescript
338
- bus.use(throttle(['ui_scroll'], 100)); // max once per 100ms
448
+ bus.use(throttle(['uiScroll'], 100)); // max once per 100ms
339
449
  ```
340
450
 
341
451
  ### authGuard
@@ -343,7 +453,7 @@ bus.use(throttle(['ui_scroll'], 100)); // max once per 100ms
343
453
  ```typescript
344
454
  bus.use(authGuard({
345
455
  isAuthenticated: () => !!user.value,
346
- protected: ['shop_cart_', 'shop_wishlist_'],
456
+ protected: ['shopCart', 'shopWishlist'],
347
457
  onUnauthenticated: (cmd) => router.push('/login'),
348
458
  }));
349
459
  ```
@@ -352,7 +462,7 @@ bus.use(authGuard({
352
462
 
353
463
  ```typescript
354
464
  bus.use(optimistic({
355
- 'cart_add': {
465
+ 'cartAdd': {
356
466
  apply: (cmd) => {
357
467
  cartCount.value++;
358
468
  return () => { cartCount.value--; }; // rollback function
@@ -361,15 +471,173 @@ bus.use(optimistic({
361
471
  }));
362
472
  ```
363
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
+ });
630
+ ```
631
+
364
632
  ## Batch Dispatch
365
633
 
366
- 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:
367
635
 
368
636
  ```typescript
369
637
  const result = bus.dispatchBatch([
370
- { action: 'cart_add', target: cart, payload: item },
371
- { action: 'totals_update', target: cart },
372
- { action: 'analytics_track', target: session, payload: item },
638
+ { action: 'cartAdd', target: cart, payload: item },
639
+ { action: 'totalsUpdate', target: cart },
640
+ { action: 'analyticsTrack', target: session, payload: item },
373
641
  ]);
374
642
 
375
643
  if (result.ok) {
@@ -379,6 +647,15 @@ if (result.ok) {
379
647
  }
380
648
  ```
381
649
 
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
+ ```
658
+
382
659
  ## Dead Letter Handling
383
660
 
384
661
  Configure what happens when a command has no registered handler:
@@ -399,12 +676,12 @@ import { createAsyncCommandBus } from 'vapor-chamber';
399
676
 
400
677
  const bus = createAsyncCommandBus();
401
678
 
402
- bus.register('user_fetch', async (cmd) => {
679
+ bus.register('userFetch', async (cmd) => {
403
680
  const response = await fetch(`/api/users/${cmd.target.id}`);
404
681
  return response.json();
405
682
  });
406
683
 
407
- const result = await bus.dispatch('user_fetch', { id: 123 });
684
+ const result = await bus.dispatch('userFetch', { id: 123 });
408
685
  ```
409
686
 
410
687
  ## Vapor Composables
@@ -435,7 +712,7 @@ Ideal for GA4 tracking, scroll events, debounced search, fire-and-forget pattern
435
712
  <script setup vapor>
436
713
  import { defineVaporCommand } from 'vapor-chamber';
437
714
 
438
- const { dispatch } = defineVaporCommand('analytics_track', (cmd) => {
715
+ const { dispatch } = defineVaporCommand('analyticsTrack', (cmd) => {
439
716
  gtag('event', cmd.target.event, cmd.target.params);
440
717
  });
441
718
 
@@ -455,7 +732,7 @@ import { useCommandState } from 'vapor-chamber';
455
732
  const { state: cart } = useCommandState(
456
733
  { items: [], total: 0 },
457
734
  {
458
- 'cart_add': (state, cmd) => ({
735
+ 'cartAdd': (state, cmd) => ({
459
736
  items: [...state.items, cmd.target],
460
737
  total: state.total + cmd.target.price
461
738
  })
@@ -478,6 +755,103 @@ const { canUndo, canRedo, undo, redo } = useCommandHistory({
478
755
  </script>
479
756
  ```
480
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
+
481
855
  ### useCommandBus
482
856
 
483
857
  Lightweight access to the shared bus — tree-shakeable:
@@ -486,7 +860,7 @@ Lightweight access to the shared bus — tree-shakeable:
486
860
  import { useCommandBus } from 'vapor-chamber';
487
861
 
488
862
  const bus = useCommandBus();
489
- bus.dispatch('cart_add', product, { quantity: 1 });
863
+ bus.dispatch('cartAdd', product, { quantity: 1 });
490
864
  ```
491
865
 
492
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.
@@ -507,9 +881,8 @@ configureSignal(ref); // explicit — usually auto-detected
507
881
  `createTestBus()` records all dispatched commands without executing real handlers:
508
882
 
509
883
  ```typescript
510
- import { createTestBus, setCommandBus } from 'vapor-chamber';
884
+ import { createTestBus, setCommandBus, resetCommandBus } from 'vapor-chamber';
511
885
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
512
- import { resetCommandBus } from 'vapor-chamber';
513
886
 
514
887
  describe('CartButton', () => {
515
888
  let bus: TestBus;
@@ -523,14 +896,37 @@ describe('CartButton', () => {
523
896
  resetCommandBus();
524
897
  });
525
898
 
526
- it('dispatches cart_add on click', () => {
899
+ it('dispatches cartAdd on click', () => {
527
900
  // ... render component, click button ...
528
- expect(bus.wasDispatched('cart_add')).toBe(true);
529
- expect(bus.getDispatched('cart_add')[0].cmd.payload).toEqual({ quantity: 1 });
901
+ expect(bus.wasDispatched('cartAdd')).toBe(true);
902
+ expect(bus.getDispatched('cartAdd')[0].cmd.payload).toEqual({ quantity: 1 });
530
903
  });
531
904
  });
532
905
  ```
533
906
 
907
+ **Snapshot & time-travel** — replay command sequences for debugging or testing:
908
+
909
+ ```typescript
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
928
+ ```
929
+
534
930
  ### setupDevtools
535
931
 
536
932
  Connect a bus to Vue DevTools. Adds a **Commands** timeline layer and a **Vapor Chamber** inspector panel. Requires `@vue/devtools-api` — silently no-ops if not installed:
@@ -579,14 +975,19 @@ See the [`examples/`](./examples) folder for complete, runnable examples:
579
975
  | Method | Description |
580
976
  |--------|-------------|
581
977
  | `dispatch(action, target, payload?)` | Execute a command |
582
- | `dispatchBatch(commands[])` | Execute multiple commands; stops on first failure |
583
- | `register(action, handler, options?)` | Register a handler. Options: `{ undo?, throttle?, debounce? }` |
978
+ | `dispatchBatch(commands[], options?)` | Execute multiple commands. Returns `{ successCount, failCount, results }` |
979
+ | `register(action, handler, options?)` | Register a handler. Options: `{ undo?, throttle? }` |
584
980
  | `use(plugin, options?)` | Add a plugin. `options.priority` controls order |
585
- | `onAfter(hook)` | Run callback after every command |
586
- | `on(pattern, listener)` | Subscribe to commands matching a pattern (`*`, `prefix_*`, exact) |
587
- | `request(action, target, options?)` | Async request/response with timeout |
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) |
588
987
  | `respond(action, handler)` | Register a responder for `request()` calls |
589
- | `getUndoHandler(action)` | Get the undo handler for an action |
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`) |
590
991
 
591
992
  ### Composables
592
993
 
@@ -596,6 +997,8 @@ See the [`examples/`](./examples) folder for complete, runnable examples:
596
997
  | `defineVaporCommand(action, handler, options?)` | Zero-overhead dispatch for hot paths |
597
998
  | `useCommandState(initial, handlers)` | State managed by commands |
598
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 |
599
1002
  | `useCommandBus()` | Get shared bus instance |
600
1003
  | `getCommandBus()` | Get shared bus instance (non-composable) |
601
1004
  | `setCommandBus(bus)` | Set shared bus instance |
@@ -608,33 +1011,111 @@ See the [`examples/`](./examples) folder for complete, runnable examples:
608
1011
 
609
1012
  ## Roadmap
610
1013
 
611
- | Feature | Status |
612
- |---------|--------|
613
- | DevTools integration | ✅ Done |
614
- | DevTools production strip (0KB in prod) | ✅ Done |
615
- | Command batching (`dispatchBatch`) | ✅ Done |
616
- | Middleware priority/ordering | ✅ Done |
617
- | Dead letter handling (`onMissing`) | ✅ Done |
618
- | Testing utilities (`createTestBus`) | ✅ Done |
619
- | Naming convention enforcement | ✅ Done (v0.3.0) |
620
- | Wildcard listeners (`on`) | ✅ Done (v0.3.0) |
621
- | Request/response pattern | ✅ Done (v0.3.0) |
622
- | Per-command throttle/undo at register | ✅ Done (v0.3.0) |
623
- | Auth guard plugin | ✅ Done (v0.3.0) |
624
- | Optimistic update plugin | ✅ Done (v0.3.0) |
625
- | Vue 3.6 Vapor alignment | ✅ Done (v0.4.0) |
626
- | `defineVaporCommand` zero-overhead composable | ✅ Done (v0.4.0) |
627
- | `onScopeDispose` lifecycle alignment | ✅ Done (v0.4.0) |
628
- | Persistence plugin (localStorage / IndexedDB) | Planned |
629
- | SSR support | Planned (pending Vue Vapor stabilization) |
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 |
630
1115
 
631
1116
  ## Documentation
632
1117
 
633
- See the [`docs/`](./docs) folder for detailed documentation:
634
-
635
- - [Whitepaper](./docs/whitepaper.md) — Design philosophy and architecture
636
- - [Vue 3.6 Vapor Alignment](./docs/whitepaper-vue36.md) — Alien-signals, Vapor mode, and migration strategy
637
- - [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.
638
1119
 
639
1120
  ## Design Goals
640
1121