vapor-chamber

Lightweight command bus for Vue Vapor - plugins, hooks, undo/redo, Vue 3.6 Vapor aligned
4,837 3
Install
npm install vapor-chamber
Latest Version:1.31.0
Vue:3
License:LGPL-2.1
Last Updated:Oct 14, 2026
Links: GitHub  ·  npm
Maintainer: lucianofpereira

Each user action is a command with one handler, a plugin pipeline around it, and signal-native reactive state. It replaces scattered event listeners and prop drilling with one flow.

import { getCommandBus, logger, validator } from 'vapor-chamber';   // the bus: no Vue needed
import { useCommand } from 'vapor-chamber/vue';                       // composables: Vue wired at build time

const bus = getCommandBus();
bus.register('cartAdd', (cmd) => addToCart(cmd.target));
bus.use(logger());
bus.use(validator({ cartAdd: (cmd) => cmd.target.id ? null : 'Missing ID' }));

// In a component - same shared bus, with reactive state
const { dispatch, loading, lastError } = useCommand();
dispatch('cartAdd', { id: product.id });

emit is fire-and-forget, with many listeners. dispatch has one handler and a plugin pipeline, so each action has one place to read and to test.

What's in the can

Core (the bus) dispatch/query/emit, plugin pipeline, wildcard listeners - framework-agnostic, no Vue import, 4.8 KB brotli
Vue composables useCommand, useCommandState, shared state, defineVaporCommand, full Vapor wrappers
Router (opt-in) URL-addressed reads for Vue 3.6 over a server catch-all - route tables and loaders as data
Plugins (opt-in) logger, validator, history (undo/redo), debounce, throttle, persist, serialize, idempotent, auth guard. Retry is the async bus's own
Transports (opt-in) HTTP bridge, batching HTTP, WebSocket, SSE, Laravel Echo/Reverb
Extras (opt-in) SSR dehydrate/rehydrate, form bus, HTTP client, streaming JSON parser, schema validation, transitions, devtools, Vite HMR, testing, MCP server, offline outbox
  • Vue 3.6.0-rc.11 aligned: signals, onScopeDispose, getCurrentScope, alien-signals internals. Tracked per release in the CHANGELOG
  • No runtime dependency. alien-signals is an optional peer, installed only by apps that use the vapor-chamber/alien-signals connector. Unimported modules tree-shake to zero
  • ESM-only, plus three IIFE <script> drop-ins for no-bundler pages
  • 100.0% coverage on all four axes - statements, branches, functions and lines, across 3932 tests in 383 files (full table). Every branch in the measured surface is taken by a test

Contents

Install | Quick start | Router | Vapor mode | Core concepts | Plugins | Transports | HTTP client | Composables | Bundle sizes | Testing | Examples | API reference

Install

npm install vapor-chamber        # npm registry (releases may lag the repo)

# or straight from the repo: the authoritative source while Vue 3.6 is in RC
# (a `prepare` script builds it on install):
npm install github:lucianofedericopereira/vapor-chamber

It needs Node >= 22.12. Vue is an optional peer dep: >= 3.5 for composables, >= 3.6.0-rc.11 for the full Vapor surface. The core bus runs without Vue entirely. Vite >= 5 and @vitejs/plugin-vue

= 5 are needed only for the vapor-chamber/vite plugins (HMR, vaporChamberWire()) and Vapor SFC support.

ESM-only, no CJS build: Node >= 22 import, bundlers and <script type="module"> all work. For classic <script> tags use the IIFE variants.

Building a library on it? Mark vapor-chamber and every vapor-chamber/* subpath external. A subpath left in your bundle is a second copy of the library. Two copies do not share state: a store from one is refused by the other's bus (tests/library-names.test.ts).

RC tracking. This lib follows Vue 3.6 through its release candidates. The Vapor wrappers are transitional and will realign once 3.6 ships stable. ROADMAP.md lists what is stable today and what is transitional.

  • Vitest - docs/integrations/vitest.md. One setup-file line gives matchers in Vitest's spy vocabulary (toHaveBeenDispatchedWith) and a recorded shared bus. It also gives bus / asyncBus fixtures, stubs restored by using, and an MCP client for testing what an agent can reach. vaporChamberTest() adds the configurable parts, and npx vc-vitest-mcp lets an agent run the suite and read its coverage gaps.

  • Laravel - docs/integrations/laravel.md covers the backend deliverables (route, controller, action classes, CSRF flows, Sanctum, Inertia coexistence, Filament panels, Reverb realtime, queued commands). Runnable PHP companions in examples/laravel-backend/.

  • Astro - examples/exo-astro is a declarative directive set (v-scope, v-command, v-bind-text, v-show, v-each) for coordinating independent page sections. It uses onMissing: 'buffer', so sections can dispatch before their handlers hydrate.

  • Performance & tuning - docs/performance.md: what's optimized by default, the tuning knobs (persist({ coalesce: true }), configureUid, configureSignal), variant selection, benchmark snapshot.

  • Failures and the wire - docs/contract.md: the owner:condition:subject codes, the conditions, and the request and answer shapes both sides of the wire read.

  • Accessibility - docs/accessibility.md: what the library guarantees to keyboard and screen reader users, and what only the app can do.

  • Retry - docs/retry.md: which failures the async bus re-sends, when, how often, and how an action declares itself safe to send twice.

  • Timestamps - docs/timestamps.md: every timestamp the library produces and its form (epoch ms inside the process, RFC 3339 text at a boundary).

  • API reference - docs/api/: every published exports subpath, generated from the compiler by npm run docs. It is committed, so an added or changed export shows up in the diff.

Quick start

import { createCommandBus, logger, validator } from 'vapor-chamber';

const bus = createCommandBus();

bus.use(logger());
bus.use(validator({
  cartAdd: (cmd) => cmd.payload?.quantity > 0 ? null : 'Quantity required',
}));

bus.register('cartAdd', (cmd) => {
  cart.items.push({ ...cmd.target, quantity: cmd.payload.quantity });
  return cart.items;
});

const result = bus.dispatch('cartAdd', product, { quantity: 2 });
result.ok ? console.log('Added:', result.value) : console.error(result.error);
// commands.ts
import { defineSchema, createSchemaCommandBus, setCommandBus, type InferMap } from 'vapor-chamber';

export const schema = defineSchema({
  cartAdd: {
    description: 'Add a product to the cart',
    target:  { id: 'number', name: 'string' },
    payload: { qty: 'number' },
    result:  { count: 'number', total: 'number' },
  },
});

setCommandBus(createSchemaCommandBus(schema));   // typed dispatch + runtime validation

declare module 'vapor-chamber' {                 // typed useCommand() everywhere
  interface GlobalCommands extends InferMap<typeof schema> {}
}

From the same schema:

  • bus.toTools(): tools for Anthropic/OpenAI.
  • vapor-chamber/mcp: agents drive your commands over MCP, allowlisted, stamped meta.origin.
  • node scripts/generate-laravel.mjs commands.mjs: the Laravel config registry and action-class stubs with validation rules.

A misspelled action or field in a component is a compile error, before any request is sent.

Import from the static entry, vapor-chamber/vue (or vapor-chamber/vapor when the app calls the library's Vapor API, such as createVaporChamberApp). It carries the composables (useCommand, useCommandState, signal, ...) and the root's whole surface: the bus, plugins, transports. The static entry hands Vue to the library at build time, the moment it is imported, so even module-scope state is reactive:

import { createCommandBus, setCommandBus, useCommand, signal } from 'vapor-chamber/vue';

export const count = signal(0);   // reactive, no waiting

They are the root's own functions, so code that still takes the bus from the root shares the same bus.

The root has to work with no Vue in the tree, so it can only look for Vue at runtime. It does so through a bare import('vue') that resolves under a dev server and fails in a production bundle. There, composables imported from the root get plain { value } state (no reactivity), arm no automatic cleanup and skip the KeepAlive guard, measured in tests/root-only-prod-fixture.test.ts. The library logs one warning, in production too, when it sees Vue running with nothing wired. It adds a DEV warning on the first composable call when Vue arrived through that runtime lookup.

waitForVueDetection() waits on that same runtime lookup, so in a bundled app it cannot help: it waits for the channel that fails. It is for no-build pages only, where the lookup can resolve, for example through an import map for vue. On those pages configureVue(Vue) is still the more reliable choice.

Router

vapor-chamber/router is a router for Vue 3.6 over one thin server catch-all (/admin/{any?} -> shell -> one island). Path = navigation, query = state. Route tables and data loaders are delivered as data, not as a hand-written config module.

The split with the bus is deliberate: the bus owns writes (commands), the router owns reads (URL-addressed data).

import { createRouter } from 'vapor-chamber/router';
import { fetchLoaders } from 'vapor-chamber/router-fetch';
import { RouterOutlet } from 'vapor-chamber/router/vdom';

const router = createRouter({
  base: '/admin',
  routes: adminRoutes,                    // generated module, or { inline }; { url } also needs `http`
  loaders: fetchLoaders(),                // or your own LoaderHandlers preset
  components: {
    'Catalog/ListPage': () => import('./pages/CatalogList.vue'),
  },
});

app.use(router);   // register RouterOutlet locally where you render it

Composables: useRouter useRoute useQueryParam useRouteData useRouteError useMenu useBreadcrumbs usePagination onBeforeLeave - all scope-auto-disposing.

change what happens
path resolve -> guards -> components + loaders in parallel (aborted on supersede) -> one atomic frozen snapshot commit. A page never renders with the previous page's data.
query / hash fast path: location commits immediately - no matching, no guards, no remount. Only loaders that depend on a changed key refetch, patching snapshot.data when they land.

So page.value = 3 on a typed query param repaginates a list without ever unmounting it.

RouterOutlet is a defineComponent + h() component, so anything that reaches it statically pins Vue's virtual-DOM runtime into your bundle. It therefore lives behind its own subpath, and app.use(router) deliberately does not register it globally. Measured on built dist/:

entry bindings retained from vue brotli
vapor-chamber/router computed customRef getCurrentScope inject onScopeDispose shallowRef 10.8 KB
vapor-chamber/router/vdom defineComponent h inject provide 0.7 KB
vapor-chamber/router/vapor createDynamicComponent createIf createSlot defineVaporComponent inject provide 0.7 KB

A Vapor app that never renders an outlet pays nothing for the vDOM runtime. Blade rows take a fetchBlade from you (bladeFetcher() from vapor-chamber/router/remote is the in-box one). The blade component still needs no import. The router pulls makeBladeComponent in on demand, as its own chunk, the first time it renders one.

A pure-Vapor app can skip the vDOM renderer entirely (experimental, v1.x). vapor-chamber/router/vapor exports the same RouterOutlet name built from Vapor's own helpers, so rendering a route needs no vaporInteropPlugin. Take the startup chunk of a Vite production build. It comes out 23.05 KB brotli / 73.8 KB raw smaller than the same app rendering through the vDOM outlet plus interop. The same harness derives that baseline (tests/vapor/vapor-outlet-size.test.ts).

Route components on it must be defineVaporComponent output. Anything else throws a coded router:invalid:component rather than silently re-installing interop. Blade rows still need the vDOM outlet, since makeBladeComponent is itself defineComponent/h.

Vapor interop, measured on rc.4 (not inferred from the roadmap): provide/inject works in Vapor at both levels. App-level backs every composable, and component-level backs nested outlet depth.

Full guide, loader SPI, and Blade migration path: docs/router.md.

Vue 3.6 Vapor Mode

Vue Vapor compiles templates to direct DOM operations using signals instead of diffing a virtual tree. Vapor Chamber's state is built on the same signals. It works in three contexts.

import { createVaporChamberApp } from 'vapor-chamber/vapor';
import App from './App.vue';

createVaporChamberApp(App).mount('#app');   // no vDOM runtime
<script setup vapor>
import { useCommand } from 'vapor-chamber/vapor';
const { dispatch, loading } = useCommand();
</script>
import { createApp, vaporInteropPlugin } from 'vue';

createApp(App).use(vaporInteropPlugin).mount('#app');
<script setup vapor>
// the same import in vDOM and Vapor components alike
import { useCommand } from 'vapor-chamber/vue';
const { dispatch, loading } = useCommand();
</script>

Vapor and vDOM components can now nest inside each other. Take vaporInteropPlugin from vue directly. This library's getVaporInteropPlugin() returns it only once it has been handed over (configureVue({ vaporInteropPlugin })). No entry wires it, on purpose, since it pulls in the whole vDOM interop renderer.

Everything works without Vapor - signal() is wired to Vue's shallowRef() (at build time when the composables come from vapor-chamber/vue). In Vue 3.6+ that is alien-signals backed.

import { isVaporAvailable } from 'vapor-chamber';
if (isVaporAvailable()) { /* Vue 3.6+ with createVaporApp available */ }

In a bundler, a bare import 'vue' carries Vapor: vue.runtime.esm-bundler.js re-exports @vue/runtime-vapor, so no alias is needed (pinned by tests/vue-bundler-vapor-exports.test.ts). On a no-build page Vapor ships only as vue/dist/vue.runtime-with-vapor.esm-browser*.js: load that file and hand it over with configureVue(Vue). Never mix two Vue dists in one page: they are two disconnected reactivity instances, and the failure is silent.

Core Concepts

A command has three parts - action (what to do), target (what to act on), and an optional payload:

bus.dispatch('cartAdd', product, { quantity: 2 });

Every dispatch returns { ok: boolean, value?: any, error?: Error }.

One handler per action. Returns a value or throws:

bus.register('cartAdd', (cmd) => {
  cart.items.push(cmd.target);
  return cart.items;    // becomes result.value
});

// with undo support and per-command throttling
bus.register('cartAdd', addHandler, {
  undo: (cmd) => { cart.items.pop(); },
  throttle: 300,   // max once per 300ms per target
});

Enforce naming conventions at register and dispatch time:

const bus = createCommandBus({
  naming: { pattern: /^[a-z][a-zA-Z0-9]+$/, onViolation: 'throw' },  // or 'warn' / 'ignore'
});
bus.register('cartAdd', handler);    // ok
bus.register('cart_add', handler);   // throws

Names containing $ are the library's (a store's cart$reset) and are not checked against the pattern.

Plugins wrap handlers - they can modify commands, short-circuit, observe results, or transform output:

import { onSettled, type Plugin } from 'vapor-chamber';

const timingPlugin: Plugin = (cmd, next) => {
  const start = Date.now();
  return onSettled(next(), (result) => {
    console.log(`${cmd.action} took ${Date.now() - start}ms`);
    return result;
  });
};
bus.use(timingPlugin);

A Plugin runs on either bus, so next() may be a promise: read the result through onSettled, which stays synchronous on the sync bus. The type enforces it (result.ok straight off next() does not compile). A plugin for one bus only is a SyncPlugin (its next() is always a result) or an AsyncPlugin.

A plugin that answers without calling next() builds its result with ok(value) or err(error), the bus's own factories, so every result keeps one hidden class. A refusal is err(fail(code, message)): fail, the third argument, mints the failure's owner from the plugin's id. countOption(value, fallback) is the library's rule for a numeric option (NaN falls back to the default, negatives clamp, fractions truncate):

import { err, type Plugin } from 'vapor-chamber';

const qtyGuard: Plugin = Object.assign(
  (cmd, next, fail) => cmd.payload?.qty > 0 ? next() : err(fail('invalid:payload', 'qty must be positive')),
  { id: 'qtyGuard' },  // the code reads qtyGuard:invalid:payload
);

Execution is by priority (highest first), then registration order:

bus.use(validatorPlugin, { priority: 10 }); // first
bus.use(analyticsPlugin, { priority: 1 });
bus.use(loggerPlugin);                      // priority 0 (default), last

Before hooks run ahead of the handler. Throw to cancel: the dispatch returns { ok: false } with a core:refused:hook error whose cause is what you threw.

bus.onBefore((cmd) => {
  if (!user.isAuth && protectedActions.includes(cmd.action)) throw new Error('Unauthenticated');
});
bus.onBefore(() => { isLoading.value = true; });
bus.onAfter(()  => { isLoading.value = false; });

// on an async bus, hooks can be async
asyncBus.onBefore(async (cmd) => { await rateLimiter.check(cmd.action); });
bus.on('*', (cmd, result) => analytics.track(cmd.action));   // all commands
bus.on('cart*', (cmd, result) => console.log(cmd.action));   // prefix
bus.once('cartAdd', () => showConfetti());                   // fires once
bus.offAll('cart*');                                         // remove by pattern
bus.offAll();                                                // remove all

// Auto-unsubscribe on abort - matches DOM addEventListener semantics
bus.on('cartAdd', trackAdd, { signal: controller.signal });

// Every unsubscribe fn also carries Symbol.dispose, for `using`
{
  using off = bus.once('checkout', showConfetti);
} // unsubscribed automatically at scope exit

query() is dispatch() minus the onBefore hooks - reads shouldn't trigger mutation gates (auth checks, spinners, optimistic updates). Plugins and onAfter still fire. This is the CQRS separation: dispatch() writes, query() reads.

bus.register('getUser', (cmd) => db.users.find(cmd.target.id));
const result = bus.query('getUser', { id: 42 });

emit() fires a domain event - notifies on() listeners, needs no handler, returns no result:

bus.on('orderCreated', (cmd) => analytics.track('order', cmd.target));
bus.emit('orderCreated', { orderId: 42, total: 99.50 });

Every dispatched command is auto-stamped with meta:

bus.onAfter((cmd) => {
  cmd.meta.id;              // unique per dispatch (counter-based, UUID through configureUid)
  cmd.meta.ts;              // Date.now(), read once per microtask turn
  cmd.meta.correlationId;   // trace ID for command chains
});

bus.dispatch('orderShip', order, {
  __correlationId: originalCommand.meta.id,
  __causationId:   originalCommand.meta.id,
});

Every failure a dispatch or a bridge returns is a BusError, with two exceptions. A handler's own throw and an Error given as the abort reason come back as themselves. A BusError's code is owner:condition:subject: who raised it, what went wrong, and what it is about. The bus sets the owner, never the raiser.

import { BusError, conditionOf, ownerOf } from 'vapor-chamber';

const result = bus.dispatch('missing', {});
if (!result.ok && result.error instanceof BusError) {
  result.error.code;          // 'core:missing:handler'
  ownerOf(result.error);      // 'core'
  conditionOf(result.error);  // 'missing'
  result.error.context;       // what the message carries (e.g. retryIn on every `limited` refusal)
}

A plugin's failures carry its declared id as the owner (throttle:limited:handler, circuitBreaker:limited:action). A backend's problem is remote:<condition of its status>:<its code>, inside a 2xx { problem } too.

The async bus's retry re-sends a transient failure (limited, a 408 timeout) for any action. An uncertain one (no reply, a 502 or 504, a 500) is re-sent only for an action declared idempotent or a keyed command. A declared retryIn sets the wait, never whether. An unidentified command that got no reply is not re-sent: it fails with context.outcome: 'unknown', since it may have landed.

Never re-sent:

  • a verdict (invalid, refused, missing, already, conflict), unless identified and answered with a wait
  • an expired session (unauthenticated: sign in, then dispatch again)
  • an abort, a depth bound or a plugin's own throw

A status maps to one condition: 404/410 missing, 409/412 conflict, 401/419 unauthenticated, 403 refused, 429/503 limited, 408/504 timeout. The conditions read 1:1 against gRPC's canonical codes (docs/contract.md 3). ERROR_CODE_REGISTRY is the full table, with fix suggestions. Production messages state the fact and leave the fix to it.

<id>:failed:plugin means a plugin threw or rejected: cause is the original, context.index its place in the chain. Every built-in plugin declares its id (serialize:failed:plugin). The bridges are transport, like their other failures. A plugin of yours without one reads plugin:failed:plugin. It is a bug in the pipeline, not a failing server. The bus does not re-run it, and circuitBreaker neither counts it nor resets on it.

inspectBus() returns a topology snapshot - tree-shakeable, not bundled unless imported:

const info = inspectBus(bus);
info.actions;          // ['cartAdd', 'cartRemove', ...]
info.undoActions;      // actions with registered undo handlers
info.pluginCount;      // 3
info.pluginPriorities; // [10, 5, 0]
info.plugins;          // [{ id: 'logger', priority: 10, actions: undefined, actionFilter: false, transport: false }, ...]
info.sealed;           // false
info.dispatchDepth;    // 0 (increments during nested dispatch)
info.activeTimers;     // throttle timers currently running
import { createChamber, createWorkflow, createReaction } from 'vapor-chamber';

// Group handlers under a namespace
const cart = createChamber('cart', { add: handleAdd, remove: handleRemove });
cart.install(bus);   // registers cartAdd, cartRemove

// Saga: sequential steps with automatic compensation
const checkout = createWorkflow([
  { action: 'cartValidate' },
  { action: 'paymentReserve', compensate: 'paymentRelease' },
  { action: 'orderCreate',    compensate: 'orderCancel' },
]);
await checkout.run(bus, { cartId });   // compensates on failure

// Declarative cross-domain reaction
createReaction('cartAdd', 'inventoryCheck', {
  when: (cmd, result) => result.ok,
  map:  (cmd) => ({ itemId: cmd.payload.itemId }),
}).install(bus);

Aborts the stale request through AbortController rather than ignoring it on arrival. Use it for type-ahead search, autosave, or any rapidly re-fired command where only the latest matters.

import { createAsyncCommandBus, supersede } from 'vapor-chamber';

const bus = createAsyncCommandBus();
bus.use(supersede());   // default key = commandKey(action, target)

bus.use(supersede({
  actions: ['searchQuery', 'draftSave'],                   // globs; default: all
  key: (cmd) => `${cmd.action}:${cmd.target?.id ?? ''}`,   // null/undefined to skip
}));

Because the HTTP bridge forwards cmd.signal into its outbound fetch, a superseded HTTP request is cancelled at the network layer. The batching bridge settles a superseded command at once and drops it from a batch not yet sent. It cancels a sent batch once every command in it has aborted.

const result = bus.dispatchBatch([
  { action: 'cartAdd',      target: cart, payload: item },
  { action: 'totalsUpdate', target: cart },
]);
// stops at first failure by default

bus.dispatchBatch(commands, { continueOnError: true });
// result.successCount / result.failCount / result.results

transactional: true gives all-or-nothing execution. On failure, previously successful commands roll back through their registered undo handlers:

bus.register('paymentCharge', chargeHandler, { undo: refundHandler });

const result = bus.dispatchBatch([
  { action: 'inventoryReserve', target: item },
  { action: 'paymentCharge',    target: payment },
], { transactional: true });

if (!result.ok) console.log('Rollbacks:', result.rollbacks);

onMissing sets what happens when no handler is registered:

createCommandBus()                                // default: { ok: false, error }
createCommandBus({ onMissing: 'throw' })
createCommandBus({ onMissing: 'ignore' })         // { ok: true, value: undefined }
createCommandBus({ onMissing: (cmd) => { ... } })   // custom fallback
import { createAsyncCommandBus } from 'vapor-chamber';

const bus = createAsyncCommandBus();
bus.register('userFetch', async (cmd) => (await fetch(`/api/users/${cmd.target.id}`)).json());
const result = await bus.dispatch('userFetch', { id: 123 });

Request/response with timeout - falls back to normal dispatch() if no responder is registered:

bus.respond('getAuthToken', async () => (await fetch('/api/token')).json());
const result = await bus.request('getAuthToken', { userId: 42 }, undefined, { timeout: 3000 });

Schemas for LLM system prompts, so models don't hallucinate methods or error codes:

import { describeErrorCodes, busApiSchema, getErrorEntry } from 'vapor-chamber';

const errorTable = describeErrorCodes();
const apiSchema  = busApiSchema();
getErrorEntry('core:missing:handler')?.fix;   // "Register a handler with bus.register(...)"

Built-in Plugins

Plugin Description
logger(options?) Log commands to console
validator(rules) Validate commands before execution
history(options?) Track command history for undo/redo
debounce(actions, wait) Delay execution until activity stops
throttle(actions, wait) Limit execution frequency
authGuard(options) Block protected commands when unauthenticated
optimistic(handlers) Apply optimistic updates, rollback on failure
optimisticUndo(bus, actions, opts?) Auto-rollback through registered undo handlers
persist(options) Auto-save state to localStorage after commands
createChannel(options) Mirror emitted facts to other tabs. Not a bus plugin: it takes a fast lane, not the bus

Extras (same import): cache, circuitBreaker, rateLimit, metrics.

// logger / validator
bus.use(logger({ collapsed: true, filter: (cmd) => cmd.action.startsWith('cart') }));
bus.use(validator({
  cartAdd: (cmd) => cmd.target?.id ? null : 'Product must have an ID',   // null = valid
}));

// history - optionally bus-backed, so undo() dispatches <action>$undo, which runs the registered inverse
const historyPlugin = history({ maxSize: 100, bus });
bus.use(historyPlugin);
historyPlugin.undo();
historyPlugin.redo();
historyPlugin.getState();   // { past, future, canUndo, canRedo }
// Undo is local: a command a bridge sent reads canUndo false, so the server's
// write is never reversed on the client alone. Send a compensating command.

// debounce / throttle
bus.use(debounce(['searchQuery'], 300));
bus.use(throttle(['uiScroll'], 100));

// authGuard
bus.use(authGuard({
  isAuthenticated: () => !!user.value,
  protected: ['shopCart', 'admin*'],          // prefixes: 'admin*' is admin, '*' every action
  onUnauthenticated: (cmd) => router.push('/login'),
}));

// optimistic - apply returns its own rollback
bus.use(optimistic({
  cartAdd: { apply: (cmd) => { cartCount.value++; return () => { cartCount.value--; }; } },
}));

// extras
bus.use(cache({ ttl: 60_000, actions: ['getUser*'] }));
bus.use(circuitBreaker({ threshold: 5, resetTimeout: 30_000 }));
bus.use(rateLimit({ max: 10, window: 1000 }));

const m = metrics();
bus.use(m);
m.summary();   // { cartAdd: { count: 42, avgMs: 1.2, errorRate: 0.02 } }

A plugin that takes actions also takes actionFilter, a predicate on the action name. actions lists names or prefix* patterns, and absent or [] means every action. Build a filter from CloudEvents filter expressions (Subscriptions API 3.2.4: exact, prefix, suffix, all, any, not) with createActionFilter. It rejects what the spec rejects (core:invalid:filter). Given both, both must match. The bus asks it once per action, never per dispatch, and only an app that calls createActionFilter carries its code. It is also createMcpHandler's allowlist, its one selection.

import { createActionFilter } from 'vapor-chamber';

const reads = createActionFilter([{ all: [{ suffix: { action: 'Get' } }, { not: { prefix: { action: 'admin' } } }] }]);
bus.use(cache({ ttl: 60_000, actionFilter: reads }));

optimisticUndo - automatic rollback using registered undo handlers, on sync or async buses. The handler makes the local change and sends the write. The inverse reverses the local change only: an inverse never does I/O. Undoing a write the server applied is the app's compensating command, not an inverse.

bus.register('cartAdd', async (cmd) => {
  cart.items.push(cmd.target);              // local, at once
  return api.addToCart(cmd.target);         // the write
}, {
  // local only: the write failed, so there is nothing on the server to remove
  undo: (cmd) => { cart.items = cart.items.filter((item) => item.id !== cmd.target.id); },
});

bus.use(optimisticUndo(bus, ['cartAdd'], {
  predict: (cmd) => ({ ...cart, items: [...cart.items, cmd.target] }),
  onRollback: (cmd, error) => toast.error(`Rolled back: ${error.message}`),
  onRollbackError: (cmd, undoErr, origErr) => console.error('Undo failed:', undoErr),
}));

A failure marked context.outcome: 'unknown' (no reply, and nothing identifies the command) still rolls back, so the screen shows the last confirmed state. onRollback gets that error: re-read the server, since the write may have landed.

retry - the async bus's own, on by default. It re-sends the call that produced the outcome (a handler, or a bridge), so the plugins outside see one dispatch. It makes 3 attempts with a jittered backoff. A per-bus budget stops retries from piling onto a backend that is down:

const bus = createAsyncCommandBus({
  retry: {
    // Transient failures are re-sent for any action. An uncertain one (no
    // reply, a 500, a handler's throw) only where running twice is safe:
    actionPolicies: { 'cart*': 'idempotent', orderPay: false, searchRun: 2 }, // most specific wins
  },
});
createAsyncCommandBus({ retry: false });                        // off
createAsyncSchemaCommandBus({ cartSet: { retry: 'idempotent' } }); // on the schema

An action declared idempotent gets one Idempotency-Key for all its attempts.

persist - auto-save after each successful command:

const cartPersist = persist({ key: 'vc:cart', getState: () => cartState.value });
bus.use(cartPersist);

const saved = cartPersist.load();      // rehydrate before rendering
if (saved) cartState.value = saved;

cartPersist.save();    // force
cartPersist.clear();
bus.use(persist({ key: 'vc:cart', getState, storage: sessionStorage }));   // custom backend
bus.use(persist({ key: 'vc:cart', getState, actions: ['cart*'] }));        // cart actions only, $reset and undo included

const big = persist({ key: 'vc:cart', getState, storage: indexedDbStorage() });  // IndexedDB
const restored = await big.hydrate();  // before the first dispatch; load() reads sync storage only

persist() needs getState. Without it, it throws a TypeError at setup, since every save would otherwise fail and nothing would be stored.

createChannel - mirror emitted facts to other tabs through BroadcastChannel. What crosses is what a handler computed, not the command that caused it, so the receiving tab applies values rather than re-deriving them. The bridge is not a bus plugin and costs the dispatch path nothing:

import { createFastLane } from 'vapor-chamber/fast-lane';

const lane = createFastLane();
lane.on('cartChanged', (fact) => applyToCart(fact));   // local AND remote land here

bus.register('cartAdd', (cmd) => {
  lane.emit('cartChanged', computeCart(cmd.target));   // this is what crosses tabs
});

const tabSync = createChannel({ channel: 'vapor-chamber:app', lane, events: ['cartChanged'] });
tabSync.dispose();

Tabs mirror when the facts are absolute ("the count is 2") rather than relative ("add one"): a tab opened later then converges instead of drifting. A payload crosses through structured clone, so it cannot carry functions.

Transport Layer

Send commands to a backend over HTTP, WebSocket, or SSE. Import from vapor-chamber/transports.

Bridge Use for
createHttpBridge POST each matching command (actions, default all) to one endpoint. The rest stay local
createBatchingHttpBridge Same contract, but coalesces a tick's dispatches into one POST
createWsBridge WebSocket with auto-reconnect and a reactive connected signal
createSseBridge Server pushes commands to the client
createEchoBridge Laravel Echo / Reverb channels -> bus.emit()
import { createAsyncCommandBus } from 'vapor-chamber';
import { createHttpBridge } from 'vapor-chamber/transports';

const bus = createAsyncCommandBus({ onMissing: 'ignore' });

// The bus re-sends through the bridge, read from the answer: a 429, 503 or
// 408 for any action, after the Retry-After it declares - never a redirect.
// No reply, a 502, 504 or 500, or a verdict with a Retry-After (409 in
// progress) only for an action declared idempotent or a keyed command. An
// unkeyed command with no reply fails with context.outcome 'unknown'. The
// plugins outside see one dispatch.
bus.use(createHttpBridge({
  endpoint: '/api/commands',
  csrf: true,                                 // the meta tag, else the XSRF-TOKEN cookie
  csrfCookieUrl: '/sanctum/csrf-cookie',      // default; '' disables the refresh fetch
  timeout: 8000,
  actions: ['order*'],                        // only forward these; others stay local
}));

await bus.dispatch('orderCreate', { items: cart });
// -> POST /api/commands  { command: 'orderCreate', target: { items: ... } }

Response shape - result.value is the contents of state:

{ "state": { "orderId": 42, "status": "pending" } }

Cancel in-flight requests when a Vapor scope is disposed:

const ctrl = new AbortController();
onScopeDispose(() => ctrl.abort());
bus.use(createHttpBridge({ endpoint: '/api/vc', scopeController: ctrl }));

Same backend contract as createHttpBridge. CSRF, timeout and session-expiry all reuse the same request path, and the bus re-sends per command, each into the next batch. But commands dispatched within a window coalesce into a single POST and are matched back to each caller by id. It is invisible to the call site: each dispatch() still resolves with its own result.

bus.use(createBatchingHttpBridge({
  endpoint: '/api/vc/batch',
  csrf: true,
  window: 'microtask',   // default: same-tick coalescing, zero added latency
  // window: 20,         // or hold the queue open N ms to catch separate ticks
}));

bus.dispatch('formSet', { field: 'email' }, { value: 'a@b.com' });
bus.dispatch('cartAdd', product, { quantity: 2 });
// -> ONE HTTP round trip
// -> POST /api/vc/batch
{ "commands": [{ "id": "c1", "command": "formSet", "target": { "field": "email" } },
              { "id": "c2", "command": "cartAdd", "target": { "id": 7 }, "payload": { "quantity": 2 } }] }
// <- a 200; each command's answer on its own result, a failure as its problem
{ "results": [{ "id": "c1", "state": {} },
             { "id": "c2", "problem": { "status": 409, "code": "out_of_stock", "detail": "Out of stock" } }] }
const ws = createWsBridge({
  url: 'wss://api.example.com/commands',
  actions: ['chat*', 'presence*'],
  timeout: 10_000,     // per-message response timeout
  maxQueueSize: 100,   // queued messages while disconnected
  reconnect: true,
  maxReconnects: 10,
});
bus.use(ws);
ws.connect();

ws.isConnected();      // imperative
ws.connected.value;    // reactive signal - bindable in templates, no polling
ws.disconnect();       // intentional close, suppresses reconnect
bus.use(createSseBridge({ url: '/api/events' }));

Echo / Reverb - you pass your own instance, so the library never imports laravel-echo:

const realtime = createEchoBridge({
  echo,
  channels: [
    { name: `user.${userId}`, type: 'private',  events: ['OrderShipped'] },
    { name: 'lobby',          type: 'presence', events: ['MessagePosted'] },
  ],
});
realtime.install(bus);   // OrderShipped -> bus.emit('OrderShipped', payload)
// realtime.dispose();

HTTP Client

Two levels over the same retry/timeout/CSRF machinery:

  • postCommand - the single-purpose POST helper createHttpBridge builds on. For one-off HTTP control outside the transport plugin.
  • createHttpClient - a full client (GET/POST/PUT/PATCH/DELETE) with interceptors, LRU caching, request dedup, safe mode, and file download. For any HTTP need in an app already using vapor-chamber, command-bus or not.
import { createHttpClient, problemOf } from 'vapor-chamber';

const http = createHttpClient({ baseURL: '/api', csrf: true });

await http.get('/users', { params: { page: 1 } });
await http.post('/cart', { itemId: 1, qty: 2 });
await http.delete('/cart/1');

const result = await http.safe.post('/login', credentials);   // never throws
if (result.error) console.log(problemOf(result.error)?.errors); // a BusError; problemOf reads its RFC 9457 problem

await http.download('/export/csv', 'products.csv');

http.interceptors.request.use((config) => {
  config.headers = { ...config.headers, 'X-Custom': '1' };
  return config;
});

const adminHttp = http.create({ baseURL: '/admin/api' });   // defaults merged, its own interceptors

Three named rules decide what happens to a failure, each stated once, all exported from the package root:

  • isRetryableStatus(status) - may the HTTP client send this request again? True for 408, 429 and every 5xx. The client re-sends by the bus's one rule, by default 2 retries for GET and 0 for mutations. 408, 429 and 503 are re-sent for any method. Every other 5xx and no reply are re-sent only for an idempotent method or a request with an Idempotency-Key. A declared wait sets when, never whether: Retry-After, else the RateLimit field's t when r=0, else X-RateLimit-Reset. With none, the wait is the async bus's: random, under 200 ms doubling per re-send, capped at 20 s. With retry above 0, an unidentified request (a POST or PATCH with no Idempotency-Key) that got no reply, a 502 or a 504 fails with context.outcome: 'unknown'. Any other 4xx is sent once, unless identified and answered with a wait.
  • failureCondition(error, signal?) - what went wrong, by contract? It reads a library failure's own condition, or an HTTP response's status through the status table. A timeout, an abort and lost (no response) have their own. Pass the command's signal and an abort with your own Error reason reads aborted too. The async bus's retry and the outbox judge a failure by it.
  • classifyError(error) - can a cached response stand in for this failure? transient is true for a timeout, a network failure (no response) or a 5xx. It is false for every 4xx, 408 and 429 included, so serveStaleOnError does not serve stale data for them.

Every failure is the core's BusError, the one a bridge returns for the same answer. That is remote:<condition>:<code>, transport:timeout:reply, transport:lost:reply, or the caller's transport:aborted:request, which is never retried.

Caching (GET only) - cache: true for a flat TTL, or an object for more:

// Stale-while-revalidate: a hit past `ttl` but inside `ttl + staleTtl` is served
// instantly (stale: true) while a background fetch refreshes it.
const res = await http.get('/dashboard/stats', { cache: { ttl: 30_000, staleTtl: 5 * 60_000 } });
if (res.revalidation) { const fresh = await res.revalidation; }

// serveStaleOnError: a *transient* failure with a retained entry resolves instead
// of rejecting - { data, stale: true, servedOnError: true, error }.
// Business errors (4xx) are never masked this way.
await http.get('/dashboard/stats', { cache: { ttl: 30_000, serveStaleOnError: true } });

A write that succeeds (post, put, patch, delete) drops the cached entry for its URL, as RFC 9111 asks. It also drops the Location and Content-Location its answer names on the same origin, so the next get of any of them fetches. invalidateCache(pattern) and clearCache() also cover reads already on the wire. Such a read still answers its callers, but it is not stored. A read made after the invalidation fetches again instead of joining it (tests/http-cache-writes.test.ts).

silent - per-request opt-out for a global error handler the host installs:

await http.post('/analytics/beacon', payload, { silent: true }).catch((e) => {
  e.context.silent;   // true - a global handler can check this and skip the toast
});

vapor-chamber/stream-parser - a dependency-free incremental JSON parser for progressively consuming a streamed fetch()/SSE body without buffering the whole payload (LLM completions, large exports). Subpath-only, it adds nothing to the IIFE bundles.

import { createStreamParser } from 'vapor-chamber/stream-parser';

const parser = createStreamParser({
  onValue: (key, value, path) => console.log(key, value, path),
});
await parser.stream(await fetch('/api/stream'));

Vapor Composables

Composable Use when
useCommand() You need reactive loading / lastError
defineVaporCommand() Hot path - zero reactive overhead
useCommandState() State reduced by commands
useCommandHistory() Reactive undo/redo
useCommandGroup() Namespace isolation across feature modules
useCommandError() Component-scoped error boundary
useSharedCommandState() One loading/error state per bus, and isLoading(action, target?) per key
createFormBus() Forms - per-field validation, dirty tracking

useCommand gates on hasInjectionContext(), not getCurrentInstance(), so it is Vapor-safe: the same API works in <script setup vapor> and vDOM components alike, with auto-cleanup on scope disposal.

A dispatch is an action, not a read, so nothing the handler touches should make the caller re-run. Every composable here suspends reactive tracking around its bus call. If you reach for a raw bus inside an effect, wrap it:

import { getCommandBus, untracked } from 'vapor-chamber/vue';

watchEffect(() => {
  // without untracked(), anything the HANDLER reads becomes a dependency of
  // this effect - it would re-run on state it never mentions
  untracked(() => getCommandBus().dispatch('cartSync', cart));
});

On a page with no build step (a <script> tag or an import map), untracked() cannot suspend tracking: Vue's browser builds do not export what it needs. See whitepaper 9.6.

One entry per kind of app, measured on built apps of each kind (log s35.231). /vue and /vapor carry the bus too.

App Import from Build step
App that calls the library's Vapor API (createVaporChamberApp, defineVaporComponent, defineVaporAsyncComponent, defineVaporCustomElement, getVaporInteropPlugin) vapor-chamber/vapor Optional, drops the runtime lookup: vaporChamberWire({ entry: 'vapor' }) on Vite, the define below elsewhere
Any other Vue app, vDOM or Vapor, including one that uses only Vue's own Vapor APIs or vapor-chamber/router/vapor vapor-chamber/vue Optional, the same: vaporChamberWire() on Vite, the define elsewhere
Server build (SSR) vapor-chamber/vue Optional: the define drops the runtime lookup from the server bundle
App that imports only the root the root Needed on Vite: vaporChamberWire(). Other bundlers have none, so import from a subpath
<script>-tag page VaporChamber.configureVue(Vue) None. untracked() is not available
ESM page with an import map configureVue(Vue) None. untracked() cannot suspend tracking

Import from vapor-chamber/vue in a Vue app. The Gotcha above says why. The root can only look for Vue at runtime, and that lookup fails in a production bundle. So untracked() silently becomes a pass-through there. The static entry resolves Vue at build time, and re-exports the composables (useCommand, useCommandState, ...) as the same functions, not copies.

untracked() is a plain pass-through when Vue is absent. So the root import stays safe in code shared between Vue and non-Vue targets, though it cannot suspend tracking there. In DEV it warns once when Vue arrived through that runtime lookup rather than at build time. The warning shows in a page where DEV is on. That is a page served by Vite's dev server (measured in a real browser on Vite 8), or a test runner with a DOM. A production build drops it, from every chunk. A server never shows it: Node resolves that lookup in production too (measured), so the advice would be wrong there.

When the app calls the library's Vapor API, import from vapor-chamber/vapor instead. It needs Vue 3.6. It is a superset of vapor-chamber/vue, with the same composables and the same tracking fix. It also wires Vue's Vapor APIs statically. So there is no configureVue() call to write and no runtime probe to depend on:

import { createVaporChamberApp } from 'vapor-chamber/vapor';

createVaporChamberApp(App).mount('#app');

That matters for the same reason the subpath above does. The root's Vapor detection is the same bare-specifier lookup, so in a production bundle it can come up empty. Then createVaporChamberApp() throws "No Vue detected" on a page with Vapor bundled into it. The static entry has no such failure mode. It is a separate subpath from vapor-chamber/vue because the Vapor names do not exist on Vue 3.5. There, importing them by name is a build error.

It wires createVaporApp, defineVaporComponent and defineVaporAsyncComponent. They add +4.5 KB raw over hand-wiring createVaporApp, re-measured on every run with Vue bundled. The Vapor wiring table in docs/BUNDLE-SIZES.md has it. The whitepaper's rc.6 row has the measurement on the examples/vapor-sfc app that decided the split.

defineVaporCustomElement (+6.8 KB raw) and vaporInteropPlugin (+92.6 KB raw, the whole VDOM interop renderer) are left out on purpose. A static import is retained whether your app calls it or not. If you use either, one line adds it, and it merges with what the entry already wired:

import { defineVaporCustomElement, vaporInteropPlugin } from 'vue';
import { configureVue } from 'vapor-chamber/vapor';

configureVue({ defineVaporCustomElement, vaporInteropPlugin });

With Vite, a plugin can make the root import correct instead. vaporChamberWire() from vapor-chamber/vite works at build time. It resolves the bare vapor-chamber specifier to the real root plus a side-effect import of vapor-chamber/vue. So an app whose components import the composables from the root is wired in production with no import changed:

// vite.config.ts
import vue from '@vitejs/plugin-vue';
import { vaporChamberWire } from 'vapor-chamber/vite';

export default defineConfig({
  plugins: [vue(), vaporChamberWire()], // { entry: 'vapor' } when the app calls the library's Vapor API
});

'vapor' is your call, not a guess. It wires Vue's Vapor app and component helpers into the bundle, which an app that does not call the library's Vapor API should not pay for.

The redirect runs in builds only. The dev server resolves the runtime lookup by itself. A redirect there, over a pre-bundled install, would put two copies of the library in the page (measured). In dev the plugin only defines __VC_WIRED__. That keeps the DEV warning above from telling you to change imports the plugin already handles. In a build it also defines __VC_WIRED_BUILD__, which removes the root's runtime lookup from the bundle. It adds to the app exactly what importing the subpath would. Both halves are pinned against real Vite runs in tests/vite-wire-plugin.test.ts.

On esbuild or webpack, import from the subpath and add one define. Both bundlers resolve the root's runtime import('vue') to the whole Vue namespace. So without the define, every app that uses the composables ships all of Vue, and webpack warns "Critical dependency". Once the app imports vapor-chamber/vue (or /vapor) the lookup is dead code, and the define says so:

// esbuild
define: { __VC_WIRED_BUILD__: 'true' }
// webpack
new webpack.DefinePlugin({ __VC_WIRED_BUILD__: 'true' })

Leave it out only for an app that imports the composables from the root and wires nothing: there the lookup is what finds Vue (tests/root-probe-builds.test.ts).

A Vite app that imports from the subpath, with neither vaporChamberWire() nor the define, still ships the lookup as dead code: +2,589 B raw, +932 B brotli on a vDOM app (measured 2026-10-08, log s35.231). Either one removes it.

Both entries also carry the bus itself (createCommandBus, getCommandBus, plugins, transports), so a Vue app imports one entry. An app with no Vue imports the bus from the package root, which works with no Vue in the tree.

<script setup vapor>
import { useCommand } from 'vapor-chamber/vapor';
const { dispatch, loading, lastError } = useCommand();
</script>

<template>
  <!-- aria-disabled, not :disabled: a disabled button loses keyboard focus -->
  <button @click="!loading && dispatch('save', doc)" :aria-disabled="loading">Save</button>
  <p role="alert">{{ lastError?.message }}</p>
</template>
<script setup vapor>
const { dispatch, register, on, emit, loading, lastError, dispose } = useCommand();

register('cartAdd', (cmd) => addToCart(cmd.target));   // scoped to this component
on('cart*', (cmd, result) => console.log('Cart event:', cmd.action));
dispatch('cartAdd', product, { quantity: 1 });
emit('cartChanged', { count: 1 });
// auto-cleanup through onScopeDispose - or call dispose() manually
</script>

defineVaporCommand creates no reactive loading/lastError signals - for telemetry, scroll sampling, debounced search, autosave:

<script setup vapor>
const { dispatch } = defineVaporCommand('telemetryEvent', (cmd) => {
  sendMetric(cmd.target.event, cmd.target.params);
});
dispatch({ event: 'page_view', params: { page: '/shop' } });
</script>
// state reduced by commands
const { state: cart } = useCommandState({ items: [], total: 0 }, {
  cartAdd: (state, cmd) => ({
    items: [...state.items, cmd.target],
    total: state.total + cmd.target.price,
  }),
});

// reactive undo/redo
const { canUndo, canRedo, undo, redo } = useCommandHistory({
  filter: (cmd) => cmd.action.startsWith('editor'),
});

// namespace isolation - all calls prefixed in camelCase
const cart = useCommandGroup('cart');
cart.register('add', handler);   // registers 'cartAdd'
cart.dispatch('add', product);   // dispatches 'cartAdd'
cart.on('*', listener);          // listens to 'cart*'

// component-scoped error boundary
const { errors, latestError, clearErrors } = useCommandError({
  filter: (cmd) => cmd.action.startsWith('cart'),
});

Per-field validation, dirty tracking, and the full plugin pipeline on every form command.

const form = createFormBus({
  fields: { email: '', password: '' },
  rules: {
    email:    (v) => v.includes('@') ? null : 'Invalid email',   // sync - runs on every set()
    password: (v) => v.length >= 8   ? null : 'Too short',
    username: async (v) => await api.isUsernameTaken(v) ? 'Taken' : null,  // async - only on submit()
  },
  onSubmit: async (values) => await api.login(values),
});

form.use(logger());   // plugins attach like any bus

form.values.value; form.errors.value; form.isDirty.value; form.isValid.value; form.isSubmitting.value;

form.set('email', 'user@example.com');   // update + re-validate
form.touch('email');
await form.submit();                     // validate -> onSubmit -> boolean
form.reset();

Headless mode - reactive: false skips signal allocation for server-side, batch, or non-UI use. Every API works the same.

<input :value="form.values.value.email"
       @input="form.set('email', $event.target.value)"
       @blur="form.touch('email')" />
<span v-if="form.touched.value.email && form.errors.value.email">
  {{ form.errors.value.email }}
</span>
<button :aria-disabled="!form.isValid.value || form.isSubmitting.value"
        @click="form.isValid.value && !form.isSubmitting.value && form.submit()">
  Submit
</button>

Bundle sizes

Minified, comment-free, brotli q=11. Always-current per-export table: docs/BUNDLE-SIZES.md (npm run size:doc). npm run size:check fails CI on any regression past budget.

The two that matter: the dispatch core is 4.8 KB and the import-everything barrel is

machine-stamped rather than retyped:

Entry brotli
dispatch core (createCommandBus, tree-shaken) 4.8 KB
vapor-chamber (main barrel, import-everything) 33.1 KB
vapor-chamber/router 10.8 KB
vapor-chamber/router/vdom 0.7 KB
vapor-chamber/router/vapor 0.7 KB
vapor-chamber/router/remote 5.4 KB
vapor-chamber/router-fetch 5.8 KB
vapor-chamber/vue (with the root's surface, import-everything) 33.2 KB
vapor-chamber/vapor (the same) 33.2 KB
vapor-chamber/reactive 6.3 KB
vapor-chamber/transports 6.0 KB
vapor-chamber/outbox 2.9 KB
vapor-chamber/mcp 1.8 KB
vapor-chamber/ssr 1.0 KB
vapor-chamber/store 2.4 KB

Rows are not additive - every row includes the shared core, which your bundle carries once. The /vue and /vapor rows include the whole root they carry. An app pays only for what it imports: one import from /vapor builds examples/vapor-sfc to the same bytes as two (log s35.236). vapor-chamber is the barrel measured import-everything. Your bundler drops what you don't use.

These figures are machine-stamped from docs/BUNDLE-SIZES.md. The generated file (npm run size:doc) is the source of truth. npm run docs:stamp republishes its rows here, and lint:check fails on a stale one. A number a human retypes drifts, so none is retyped.

IIFE / CDN variants

Three <script>-tag drop-ins. Pick by audience, not feature checklist.

Variant Audience Min Brotli Gzip
core Sprinkled JS on server-rendered pages (Blade, Rails, Django, WordPress). You dispatch user actions to a backend over HTTP. 32.9 KB 10.4 KB 11.5 KB
elements Embeddable widgets (chat bubbles, checkout buttons, third-party drop-ins). You ship a <vc-widget> custom element. 34.6 KB 10.9 KB 12.0 KB
full SPAs that grew big enough to want everything (realtime, undo/redo, persistence, full Vapor surface). 47.7 KB 15.0 KB 16.5 KB
Surface core elements full
Bus (createCommandBus, createAsyncCommandBus) yes yes yes
createApp(), connect() one-liner yes yes yes
HTTP transport (http) yes yes yes
Light plugins (logger, validator, debounce, throttle, authGuard) yes yes yes
defineVaporCustomElement, defineWidget(), emitDOMEvent() - yes yes
WebSocket / SSE (ws, sse) - - yes
Heavy plugins (persist, history, optimistic) and createChannel - - yes
mount() - - yes
Full Vapor (defineVaporComponent, async/Suspense) - - yes
<!-- core: dispatch over HTTP, CSRF auto-wired -->
<script src=".../vapor-chamber-core.iife.min.js"></script>
<script>
  const { dispatch } = VaporChamber.connect({ endpoint: '/api/vc' });
  document.getElementById('add')
    .addEventListener('click', () => dispatch('cartAdd', { id: 42 }));
</script>
<!-- elements: register a custom-element widget in one call -->
<script src=".../vapor-chamber-elements.iife.min.js"></script>
<script type="module">
  // Needed: without Vue's Vapor build handed over, defineWidget returns false.
  const Vue = await import('.../vue.runtime-with-vapor.esm-browser.prod.js');
  VaporChamber.configureVue(Vue);
  VaporChamber.defineWidget('vc-cart', {
    props: { sku: String },
    // A Vapor setup() returns a BLOCK - real DOM nodes. There is no compiler
    // on a no-build page to turn a template into one, and `h` is not on the
    // VaporChamber global in any variant, so build the node directly.
    setup(props) {
      const span = document.createElement('span');
      span.textContent = `SKU ${props.sku}`;
      return span;
    },
  });
</script>
<vc-cart sku="ABC-123"></vc-cart>

Variant contents are not under semver before v2.0. While Vue 3.6 is in RC, the lib reserves the right to move APIs between IIFE variants. ESM consumers get the full surface and are unaffected.

Subpath exports

vapor-chamber                  -> core + composables + everything (tree-shaken)
vapor-chamber/vue              -> the composables with Vue's tracking primitives wired
                                 statically - the right root for a Vue app
vapor-chamber/vapor            -> superset of /vue that also wires Vue's Vapor APIs
                                 at build time (Vue 3.6 only)
vapor-chamber/router           -> the router: table, engine, dom, loader SPI (no vDOM)
vapor-chamber/router/vdom      -> RouterOutlet, makeBladeComponent (opts into vDOM)
vapor-chamber/router/vapor     -> RouterOutlet, Vapor-native (opts into Vapor; Vue 3.6 only)
vapor-chamber/router/remote    -> routerHttp, bladeFetcher (opts into the http client)
vapor-chamber/router-fetch     -> in-box loader preset for plain-JSON backends
vapor-chamber/transports       -> HTTP + WebSocket + SSE + Echo bridges
vapor-chamber/directives       -> v-vc-command, v-vc-payload, v-vc-optimistic (both renderers)
vapor-chamber/vite             -> Vite HMR plugin + vaporChamberWire() (build-time Vue wiring)
vapor-chamber/vitest           -> Vitest 5 setup file: matchers, a recorded shared bus, fixtures
vapor-chamber/vitest/pure      -> the same helpers, registering no hook and no matcher
vapor-chamber/vitest/mcp       -> Vitest as MCP tools for an agent (behind vc-vitest-mcp)
vapor

Related Packages

@kontsedal/olas-vue

Olas Vue adapter — olasPlugin, useRoot, useValue, useQuery, useInfiniteQuery, us...

450 0
zenon

A minimalist Zustand-inspired state manager for Vue 3 with TypeScript support

471 2
gulp-vue2blade

Vue2.x template to laravel blade template.

4,449
@grafikri/vue-middleware

Middleware for Vue

2,270 9
laravel-vue-pagination

Vue.js pagination component for Laravel paginators

593,998 745

Version History

Version Released License
1.31.0 LGPL-2.1
1.30.0 LGPL-2.1
1.29.0 LGPL-2.1
1.28.0 LGPL-2.1
1.27.0 LGPL-2.1