vapor-chamber
| Install | |
|---|---|
npm install vapor-chamber |
|
| Latest Version: | 1.31.0 |
| Vue: | 3 |
| License: | LGPL-2.1 |
| Last Updated: | Oct 14, 2026 |
| Links: | GitHub · npm |
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-signalsis an optional peer, installed only by apps that use thevapor-chamber/alien-signalsconnector. 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/viteplugins (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 givesbus/asyncBusfixtures, stubs restored byusing, and an MCP client for testing what an agent can reach.vaporChamberTest()adds the configurable parts, andnpx vc-vitest-mcplets 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 usesonMissing: '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:subjectcodes, 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
exportssubpath, generated from the compiler bynpm 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, stampedmeta.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 helpercreateHttpBridgebuilds 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 anIdempotency-Key. A declared wait sets when, never whether:Retry-After, else theRateLimitfield'stwhenr=0, elseX-RateLimit-Reset. With none, the wait is the async bus's: random, under 200 ms doubling per re-send, capped at 20 s. Withretryabove 0, an unidentified request (a POST or PATCH with noIdempotency-Key) that got no reply, a 502 or a 504 fails withcontext.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 andlost(no response) have their own. Pass the command'ssignaland an abort with your ownErrorreason readsabortedtoo. The async bus's retry and the outbox judge a failure by it.classifyError(error)- can a cached response stand in for this failure?transientis true for a timeout, a network failure (no response) or a 5xx. It is false for every 4xx, 408 and 429 included, soserveStaleOnErrordoes 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
Olas Vue adapter — olasPlugin, useRoot, useValue, useQuery, useInfiniteQuery, us...
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 | |
| 1.26.0 | LGPL-2.1 | |
| 1.25.0 | LGPL-2.1 | |
| 1.24.0 | LGPL-2.1 | |
| 1.23.0 | LGPL-2.1 | |
| 1.22.0 | LGPL-2.1 | |
| 1.21.0 | LGPL-2.1 | |
| 1.20.0 | LGPL-2.1 | |
| 1.19.0 | LGPL-2.1 | |
| 1.18.0 | LGPL-2.1 | |
| 1.16.0 | LGPL-2.1 | |
| 1.11.0 | LGPL-2.1 | |
| 1.9.0 | LGPL-2.1 | |
| 1.7.0 | LGPL-2.1 | |
| 1.4.0 | LGPL-2.1 | |
| 1.3.0 | LGPL-2.1 | |
| 1.2.0 | LGPL-2.1 | |
| 1.1.0 | LGPL-2.1 | |
| 1.0.0 | LGPL-2.1 | |
| 0.5.0 | LGPL-2.1 | |
| 0.4.0 | LGPL-2.1 | |
| 0.2.0 | LGPL-2.1 | |
| 0.1.0 | LGPL-2.1 |