A Livewire v4-equivalent reactive component system for AdonisJS v7 + Edge.js Modelled feature-for-feature after Livewire v4
- Project Overview
- Architecture
- Folder Structure
- How It Works (The Lifecycle)
- Feature Checklist
- Build Phases
- API Design (Developer-Facing)
- VineJS Validation Design
- Edge.js Template API
- Client-Side Directives
- TypeScript Decorators
- Security Model
- Testing Strategy
- Ace Commands Reference
- Package Publishing
Adowire is a full-stack reactive component library for AdonisJS v7 that brings the developer experience of Livewire v4 to the Node.js ecosystem.
Instead of writing AJAX boilerplate or reaching for a frontend framework, developers write TypeScript classes + Edge.js templates. Adowire handles the network roundtrip, DOM morphing, state management, and event system automatically.
| Goal | Description |
|---|---|
| Zero-JS for developers | Write TypeScript + Edge templates. No manual fetch/axios. |
| Livewire v4 feature parity | Every Livewire v4 feature has an adowire equivalent |
| Edge.js native | First-class integration with AdonisJS Edge.js template engine |
| Alpine.js bridge | Full $wire object integration, same as Livewire |
| TypeScript-first | Decorators, type-safe properties, full IDE support |
| VineJS validation | Uses AdonisJS's own VineJS for all validation — no external validator |
| AdonisJS v7 patterns | Providers, Ace commands, IoC container, config files |
| Layer | Technology |
|---|---|
| Server framework | AdonisJS v7 |
| Template engine | Edge.js v6 |
| Validation | VineJS v4 (AdonisJS's official validator) |
| DOM morphing | morphdom |
| JS companion | Alpine.js v3 |
| Build tool | tsdown (Rolldown-based) |
| Test runner | Japa v5 |
| TypeScript | v5.9+ |
VineJS is AdonisJS's official validation library — it is already a peerDependency of @adonisjs/core, so every AdonisJS app already has it available. Using it means:
- Zero extra dependencies for adowire consumers
- Native AdonisJS error formatting that matches the rest of the app
- Full access to VineJS custom rules, custom messages providers, and metadata API
- 5–10× faster than Zod/Yup
- Handles HTML form serialization quirks natively (strings cast to numbers/booleans, empty strings, etc.)
┌─────────────────────────────────────────────────────┐
│ Browser │
│ │
│ Edge HTML + wire:* attributes + Alpine $wire │
│ │ ▲ │
│ │ POST /adowire/msg │ JSON response │
└────────────┼────────────────────┼───────────────────┘
│ │
┌────────────▼────────────────────┼───────────────────┐
│ AdonisJS Server │
│ │
│ WireRequestHandler │
│ │ │
│ ├── SnapshotManager (dehydrate / hydrate) │
│ ├── ComponentRegistry (discover & resolve) │
│ ├── WireComponent (your TS class) │
│ │ ├── mount / boot / hydrate / dehydrate │
│ │ ├── updating / updated │
│ │ ├── rendering / rendered │
│ │ └── exception │
│ ├── WireValidator (VineJS integration) │
│ │ ├── @Validate decorator → vine schema │
│ │ ├── validate() method │
│ │ ├── rules() method override │
│ │ └── SimpleMessagesProvider support │
│ └── Edge.js (render template → HTML) │
└─────────────────────────────────────────────────────┘
1. Browser sends POST /adowire/message
Body: { components: [{ snapshot, calls, updates }] }
2. Server: verify HMAC checksum on snapshot
3. Server: hydrate component from snapshot state
4. Server: run boot() hook
5. Server: run hydrate() hook
6. Server: apply property updates (wire:model mutations)
- for each update:
- check property is not @Locked
- run updating(name, value) hook
- set property
- run updated(name, value) hook
- if @Validate(onUpdate: true) on property: run VineJS validation
7. Server: call action methods
- check method is public and callable ($isCallable guard)
- resolve DI params from AdonisJS IoC container
- call method
- if @Renderless: set skipRender = true
- catch ValidationException from VineJS → populate $errors
8. Server: run dehydrate() hook
9. Server: if !skipRender → render Edge template → HTML
- inject morph markers around @if / @each blocks
10. Server: dehydrate new snapshot (HMAC sign)
11. Server: return JSON { components: [{ snapshot, effects }] }
effects: { html, redirect, dispatches, js, streams, download, title }
12. Browser: morphdom(oldHtml, newHtml)
13. Browser: process effects
(redirect, dispatch browser events, run JS actions, stream text chunks)
adowire/
│
├── src/ # Server-side TypeScript source
│ ├── component.ts # WireComponent base class
│ ├── component_registry.ts # Auto-discover + register components
│ ├── snapshot.ts # Dehydrate / Hydrate state ↔ JSON
│ ├── morph_markers.ts # Inject HTML comment markers for morphdom
│ ├── request_handler.ts # POST /adowire/message endpoint
│ ├── form.ts # WireForm base class
│ ├── validator.ts # VineJS validation engine wrapper
│ ├── wire_exception.ts # WireException, ValidationException
│ ├── synthesizers/ # Custom type serializers
│ │ ├── synthesizer.ts # Base Synthesizer interface
│ │ ├── date_synthesizer.ts # Date / DateTime
│ │ ├── map_synthesizer.ts # Map
│ │ └── set_synthesizer.ts # Set
│ ├── decorators/ # TypeScript property/method decorators
│ │ ├── index.ts # Re-export all decorators
│ │ ├── computed.ts # @Computed
│ │ ├── locked.ts # @Locked
│ │ ├── validate.ts # @Validate(schema, opts)
│ │ ├── url.ts # @Url
│ │ ├── on.ts # @On('event-name')
│ │ ├── reactive.ts # @Reactive
│ │ ├── modelable.ts # @Modelable
│ │ ├── lazy.ts # @Lazy
│ │ ├── session.ts # @Session
│ │ ├── async.ts # @Async
│ │ ├── renderless.ts # @Renderless
│ │ ├── defer.ts # @Defer
│ │ ├── isolate.ts # @Isolate
│ │ ├── json.ts # @Json
│ │ ├── title.ts # @Title
│ │ └── layout.ts # @Layout
│ ├── concerns/ # Mixins / Traits
│ │ ├── with_pagination.ts # WithPagination mixin
│ │ └── with_file_uploads.ts # WithFileUploads mixin
│ ├── edge/ # Edge.js plugin
│ │ ├── plugin.ts # Registers all tags + globals
│ │ └── tags/
│ │ ├── wire_styles.ts # @!wireStyles()
│ │ ├── wire_scripts.ts # @!wireScripts()
│ │ ├── wire_component.ts # @adowire('name', props)
│ │ ├── error.ts # @error('field') / @enderror
│ │ ├── island.ts # @island / @endisland
│ │ ├── placeholder.ts # @placeholder / @endplaceholder
│ │ ├── persist.ts # @persist / @endpersist
│ │ └── teleport.ts # @teleport / @endteleport
│ ├── testing/
│ │ └── wire_test.ts # Adowire.test() helper
│ └── types.ts # All shared TypeScript interfaces
│
├── client/ # Browser-side TypeScript → wire.js
│ ├── index.ts # Adowire client bootstrap + global API
│ ├── component.ts # Client-side component class
│ ├── connection.ts # HTTP request pool + queue management
│ ├── morph.ts # morphdom integration
│ ├── store.ts # Client component state store
│ ├── alpine_bridge.ts # $wire magic object for Alpine.js
│ └── directives/ # wire:* attribute handlers
│ ├── index.ts # Register all directives
│ ├── model.ts # wire:model
│ ├── click.ts # wire:click
│ ├── submit.ts # wire:submit
│ ├── keydown.ts # wire:keydown + key modifiers
│ ├── loading.ts # wire:loading
│ ├── navigate.ts # wire:navigate
│ ├── poll.ts # wire:poll
│ ├── intersect.ts # wire:intersect
│ ├── confirm.ts # wire:confirm
│ ├── transition.ts # wire:transition
│ ├── init.ts # wire:init
│ ├── offline.ts # wire:offline
│ ├── ignore.ts # wire:ignore
│ ├── replace.ts # wire:replace
│ ├── show.ts # wire:show
│ ├── cloak.ts # wire:cloak
│ ├── dirty.ts # wire:dirty
│ ├── ref.ts # wire:ref
│ ├── sort.ts # wire:sort
│ ├── stream.ts # wire:stream
│ ├── text.ts # wire:text
│ ├── current.ts # wire:current
│ └── bind.ts # wire:bind
│
├── providers/
│ └── wire_provider.ts # AdonisJS ServiceProvider
│
├── commands/
│ ├── make_adowire.ts # node ace make:adowire
│ └── make_adowire_form.ts # node ace make:adowire:form
│
├── stubs/
│ ├── component.stub # Component class stub
│ ├── component_view.stub # Edge template stub
│ └── form.stub # Form class stub
│
├── configure.ts # node ace configure adowire
├── index.ts # Package main entry point
├── package.json
├── tsconfig.json
├── eslint.config.js
├── PLAN.md # This file
└── README.md
1. AdonisJS route handler renders an Edge template
2. Developer uses @adowire('component-name', props) tag in the template
3. Adowire instantiates the component, calls mount(props), renders it
4. HTML is wrapped with wire:id="<ulid>" and wire:snapshot="<json>"
5. Full page HTML is sent to the browser
6. Browser: adowire.js boots, Alpine.js initialises
7. Each wire:id element becomes a live Alpine-backed component
8. wire:cloak elements become visible, wire:init actions fire
1. User interacts (click, input, submit, poll, intersect, etc.)
2. Client collects pending updates and calls into one request:
{ snapshot, calls: [{ method, params }], updates: { prop: value } }
3. POST /adowire/message
4. Server runs full lifecycle (see Architecture section)
5. Response: { snapshot, effects: { html, redirect, dispatches, js, streams } }
6. Client: morphdom(currentEl, newHtml)
7. Client: process effects sequentially
{
"state": {
"count": 1,
"title": "Hello",
"createdAt": ["2024-01-01T00:00:00.000Z", { "s": "date" }],
"items": [["a", "b"], { "s": "set" }]
},
"memo": {
"name": "counter",
"id": "01HXYZ1234567890ABCDEF",
"children": {},
"errors": {},
"locale": "en",
"path": "/counter",
"method": "GET"
},
"checksum": "hmac-sha256-hex-signature"
}-
WireComponentbase class-
mount(props)lifecycle hook — first request only -
boot()lifecycle hook — every request -
hydrate()lifecycle hook — subsequent requests only -
dehydrate()lifecycle hook — end of every request -
updating(name, value)hook — before property set -
updated(name, value)hook — after property set -
updatedPropertyName(value)shorthand hooks -
updatingPropertyName(value)shorthand hooks -
updatedPropertyName(value, key)for array properties -
rendering(view, data)hook — before render -
rendered(view, html)hook — after render -
exception(e, stopPropagation)hook -
fill(data)— bulk assign properties -
reset(...props)— reset to initial state -
pull(...props)— reset and retrieve value -
only(...props)— return subset of state -
all()— return all public state as object -
$getPublicState()— internal state collector -
$isCallable(method)— security guard; improved: lifecycle hooks always blocked; base-class utilities (reset,fill,pull, etc.) only blocked when inherited fromWireComponent— if the concrete subclass defines a method with the same name it is treated as a user action and allowed - Trait/mixin prefixed lifecycle hooks (
mountMyTrait(),bootMyTrait(), etc.)
-
-
SnapshotManager-
dehydrate(component)→WireSnapshot -
hydrate(component, snapshot)→ void - HMAC-SHA256 checksum signing (using
APP_KEY) - Checksum verification (throw on tamper)
- Primitive type pass-through (string, number, boolean, null, array, plain object)
- Tuple format for complex types:
[data, { s: 'type' }] - Date synthesizer
- Map synthesizer
- Set synthesizer
- Custom
Synthesizerinterface (extensible by users) -
Adowire.synthesizer(impl)— register custom synthesizer
-
-
ComponentRegistry- Auto-discover components from
app/adowire/directory (recursive) - Name resolution: file path → dot-notation name (
posts/create.ts→posts.create) - Namespace support (
pages::post.create,admin::users) - Manual registration:
Adowire.register(name, ComponentClass) - Component factory:
registry.make(name)→ new instance - Namespace configuration in
config/adowire.ts
- Auto-discover components from
-
WireRequestHandler- Handle
POST /adowire/message - Support batched component updates (multiple components per request)
- Apply property updates with locked-property guard
- Call action methods with AdonisJS IoC container DI
- Run full lifecycle: boot → hydrate → update → action → dehydrate → render
- Catch VineJS
E_VALIDATION_ERRORand populate$errors, re-render - Catch
WireExceptionfor component errors - Return JSON response with effects
- Handle
- Component class file:
app/adowire/counter.ts - Component view file:
resources/views/adowire/counter.edge - Page components with
@Layoutdecorator - Props via
mount(props)method - Auto-prop assignment — props whose keys match public property names are pre-assigned before
mount()runs; works for both@adowire()tag embeds androuter.adowire()page routes -
@Computeddecorator — memoized per-request computed values (src/decorators/computed.ts, hydration bug fixed: computed keys excluded from snapshot dehydration/hydration) -
render()override — pass extra data to view -
$this.computedPropaccess in Edge templates — computed values resolved fresh at render time and injected into template data - Component namespaces (
pages::,admin::, custom) -
@adowire('component-name', { prop: value })Edge tag - Dynamic components:
@adowire($dynamicName, props)— component name is a plain JS expression; fixed:adowireComponentTag.compile()now runs the jsArg throughparser.utils.transformAstso template-state identifiers (e.g.activeTab) are rewritten tostate.activeTab; Edge.js v6 does NOT usewith(state)— bare identifiers were undefined at runtime until this fix
HTML-style component tag syntax (src/edge/adowire_html_processor.ts — Edge.js raw preprocessor)
Bug fixed:
registerAdowireTags()was missing theedge.processor.process('raw', adowireHtmlProcessor)call in the compiled build (stale build — processor was added to source but package was never recompiled). All<adowire:*>tags rendered as empty until rebuilt.
-
<adowire:counter />— self-closing HTML tag; transforms to@adowire('counter')\n@endbefore Edge compilation -
<adowire:post.create />— dot-notation names (maps topost.createcomponent) -
<adowire:pages::dashboard />— namespace syntax (maps topages::dashboardcomponent) -
<adowire:counter title="Hello" />— static string props;title="Hello"→title: 'Hello' -
<adowire:counter :count="$count" />— dynamic props;:count="$count"→count: $count(expression passed verbatim) -
<adowire:counter disabled />— boolean props;disabled→disabled: true -
<adowire:counter initial-count="5" />— kebab-case auto-converted to camelCase;initial-count→initialCount -
<adowire:dynamic-component :is="activeTab" />— dynamic component via:is; the:isexpression replaces the static tag name, stripped from props, and now correctly transformed through Edge.js AST rewriter (see dynamic component fix above) -
Block form:
<adowire:counter title="Hi">...slot content...</adowire:counter>— transforms to@adowire('counter', { title: 'Hi' })\n...slot content...\n@end; block tags expand iteratively so nested components of any depth work correctly -
Fast-path: templates without
<adowire:are skipped entirely (zero overhead) -
Idempotent registration:
WeakSetguard inregisterAdowireTags()prevents the processor being registered twice when both directregisterAdowireTags(edge)and deferrededge.use()paths run -
Debug
console.logstatements removed fromadowireHtmlProcessorafter confirming processor fires for file-based templates -
Recursive components
-
Component slots — default slot via
{{{ await $slots.main() }}} -
Named slots via
@adowire.slot('name')/$slots['name'] -
$slots.has('name')— check if slot provided -
$attributes— forward HTML attributes from parent -
adowire:key— stable identity in loops -
Force re-render by changing
adowire:key -
Child components are independent (skip re-render on parent update)
- Public class properties auto-synced between server ↔ client snapshot
- Protected/private properties stay on server only (not in snapshot) — convention-based (
$/_prefix filtering in$getPublicState()) -
fill(data)— bulk assign from object/model -
reset('prop')/reset(['a','b'])— reset to pre-mount value -
pull('prop')/pull(['a','b'])— reset and return value -
only(['a','b'])— subset of public state -
all()— all public properties as plain object - Supported native types:
string,number,boolean,null,array,object -
Dateauto-serialized via built-in synthesizer - Custom
Wireableinterface — user-defined serializable types -
@Locked— prevents client-side mutation (src/decorators/locked.tsfully implemented, runtime guard in request handler viaWIRE_LOCKED_KEY)
Adowire uses VineJS (
@vinejs/vine) as its sole validation engine. VineJS is already a dependency of AdonisJS — zero extra installs for consumers.
-
@Validate(vine.string().minLength(3))decorator — fully implemented insrc/decorators/validate.ts; stores rule + options as reflect-metadata underWIRE_VALIDATE_KEY -
@Validate(vine.string().minLength(3), { message: 'Too short', as: 'title', onUpdate: false })— all options (message,as,onUpdate) supported by decorator - Multiple
@Validatedecorators on one property (stacked rules) — metadata is keyed by property name, so multiple decorators on the same property overwrite; stacking not supported -
onUpdate: false— supported viaopts.onUpdatein@Validate;maybeValidateOnUpdate()skips whenfalse -
validate()— fully implemented inWireComponent; reads@Validaterules via reflect-metadata, callsWireValidator.validateProperties(), populates$errors, throwsValidationExceptionon failure -
validate({ title: vine.string().minLength(3) })— inline schema override supported; explicit rules map passed tovalidate(rules?)takes priority over decorator rules -
validateUsing(compiledValidator)— validate full component state against a pre-compiledvine.compile(schema)validator; coerced values written back to component properties; VineJS native errors caught and converted to$errors; typed return value -
rules()method — define full VineJS schema as object (not implemented) -
messages()method — custom error messages (not implemented) -
validationAttributes()method — custom field labels (not implemented) - Real-time validation on property update —
maybeValidateOnUpdate()in request handler checks@ValidateonUpdateflag -
addError(field, message)— manually inject error -
resetValidation(field?)— clear specific or all errors -
getErrorBag()— raw error bag access -
withValidator(fn)— hook into VineJS validator instance before validation runs -
@error('field')/@enderrorEdge tag — fully implemented as block tag with{{ message }}and{{ messages }} -
@error('form.field')— nested form object errors -
$errors.has('field')— client-side check (Alpine bridge does not expose$errors) -
$errors.first('field')— client-side first message -
$errors.get('field')— all messages for field -
$errors.all()— all errors flat object -
$errors.clear('field?')— clear errors client-side - VineJS
E_VALIDATION_ERRORcaught by request handler — only internalValidationExceptionis caught, not VineJS's native error class
src/concerns/directory is empty — noWireFormbase class or mixins exist yet.
-
WireFormbase class-
@Validatedecorators on form properties -
validate()— run VineJS validation across form properties -
reset(...props)— reset form fields -
pull(...props)— pull and reset -
all()— all form properties as object -
only([...props])— subset of form properties -
setModel(model)— fill form from a plain object/model -
updating(name, value)/updated(name, value)hooks inside form -
updatedFieldName(value)shorthand hooks inside form
-
- Form used as typed property on component:
public PostForm $form -
adowire:model="form.title"— nested dot-notation binding -
@error('form.title')— nested error display -
$this->form->validate()— explicit form validation -
node ace make:adowire:form PostForm— scaffold form class
-
mount(props)— first request only (like constructor) -
boot()— every request, initial and subsequent -
hydrate()— subsequent requests only (after boot) -
dehydrate()— end of every request (before snapshot) -
updating(name, value)— before any property is set from client -
updated(name, value)— after property is set -
updatedPropertyName(value)— targeted shorthand, e.g.updatedTitle(val) -
updatingPropertyName(value)— targeted shorthand -
updatedPropertyName(value, arrayKey)— for array element updates -
rendering(view, data)— before Edge renders -
rendered(view, html)— after Edge renders -
exception(error, stopPropagation)— intercept any thrown error - Mixin/trait hooks:
mountHasPostForm(),bootHasPostForm(),dehydrateHasPostForm(), etc. - Form object
updating/updatedhooks run when form properties update (WireForm not implemented)
- Public methods callable from template directives
- Parameters from template:
wire:click="delete({{ id }})"— clientclick.tsparses method + params - IoC DI: type-hinted non-primitive params resolved from AdonisJS container —
resolveMethodArgs()in request handler -
$refreshmagic action — re-render without method call -
$set('prop', value)magic action -
$toggle('prop')magic action -
$dispatch('event', params)magic action from template — handled incallMagicAction() -
$parent.method(params)— call parent method directly from child template (adowire:click="$parent.method()") -
skipRender()— programmatic render skip -
@Renderlessdecorator — runtime consumer exists (WIRE_RENDERLESS_KEYchecked incallAction), but decorator function missing -
@Asyncdecorator —$isAsync()helper exists but request handler never checks it -
wire:click.async— inline async modifier (no modifier parsing in client) -
wire:click.renderless— inline renderless modifier -
wire:click.preserve-scroll— maintain scroll position -
wire:confirm="Are you sure?"— browser confirm dialog before action -
wire:confirm.prompt="Type DELETE|DELETE"— text prompt confirm -
$isCallable()guard — blocks lifecycle hooks,$-prefixed,_-prefixed, and 20 hardcoded method names
-
this.dispatch('event-name', { key: value })—$dispatch()pushes to$effects.dispatches -
this.dispatch('event').to(ComponentClass)— chainable API not implemented (use$dispatchToinstead) -
this.dispatch('event').self()— chainable API not implemented (use$dispatchSelfinstead) -
this.dispatchSelf('event', params)— shorthand self -
this.dispatchTo('component-name', 'event', params)— named target -
@On('event-name')decorator on action method (metadata key +$getEventListeners()consumer exist, decorator function missing) -
@On('post-updated.{post.id}')— dynamic event names using component state -
getListeners()method —$getEventListeners()readsWIRE_ON_KEYmetadata - Child listener syntax in Edge:
@adowire('edit-post', {}, { '@saved': '$refresh' }) -
$dispatch('event', params)from Edge template (client-side, no request) -
$dispatchTo('component', 'event', params)from Edge template - JS inside component:
this.$on('event', fn) - JS inside component:
this.$dispatch('event', data) - JS inside component:
this.$dispatchSelf('event') - Global JS:
Adowire.on('event', fn)— returns cleanup function - Alpine:
x-on:event-name="..."intercepts wire events — client emitsCustomEventon component element fromeffects.dispatches - Alpine:
x-on:event-name.window="..."for global catch —dispatch.selfflag emits onwindow
-
@adowire('component-name', { prop: value })Edge tag - Props passed into child
mount(props) - Child components are independent — don't re-render on parent network request
-
adowire:keyon nested@adowirecalls — required for stable identity in loops -
@Reactivedecorator — prop re-syncs when parent updates (opt-in) (metadata key defined, no consumer or decorator) -
@Modelabledecorator — expose child property for parentadowire:model(metadata key defined, no consumer or decorator) -
adowire:model="childProp"binding to@Modelablechild property - Slots: default slot
{{{ await $slots.main() }}}— @adowire tag block body ignored in Phase 1 - Named slots:
@adowire.slot('actions')/$slots['actions'] -
$slots.has('name')conditional slot check -
$attributes— forwarded HTML attributes from parent tag -
$parent.method()from child template - Dynamic:
@adowire($dynamicName, props) - Recursive nesting (with user-defined base case)
- Force re-render: change
adowire:keyvalue
Note: All directives use the
adowire:prefix (notwire:). The rename fromwire:*→adowire:*was applied across the entire codebase: Edge tags, client-side directive handlers, attribute names, and all example templates. The plan below uses the canonicaladowire:prefix.
-
adowire:model="prop"— deferred binding (values read at form submit time viaserialiseFormUpdates()) -
adowire:model.live="prop"— real-time commit on everyinputevent (clientdirectives/model.tswith delegated listeners) -
adowire:model.live.blur="prop"— sync on blur only (capture-phaseblurlistener) -
adowire:model.live.debounce.Xms="prop"— debounced live (default 250ms, custom via.Xmsmodifier) -
adowire:model.live.throttle.Xms="prop"— throttled live (leading+trailing edge strategy) -
adowire:click="method(param)"— call action on click (full delegated handler with argument parsing) -
adowire:click.async— async modifier (no modifier parsing) -
adowire:click.renderless— renderless modifier -
adowire:click.preserve-scroll— preserve scroll -
adowire:submit="method"— intercept form submit (full delegated handler, serialisesadowire:modelfields) -
adowire:keydown="method"— on keydown event -
adowire:keydown.enter="method"— specific key -
adowire:keydown.shift.enter="method"— key combination - Key modifiers:
.enter,.tab,.escape,.space,.up,.down,.left,.right,.shift,.ctrl,.cmd,.meta,.alt,.caps-lock,.equal,.period,.slash - Event modifiers:
.prevent,.stop,.window,.document,.once,.self,.outside,.camel,.dot,.passive,.capture - Debounce/throttle on any event:
.debounce.Xms,.throttle.Xms -
adowire:loading— show element while any request in-flight (clientdirectives/loading.ts) -
adowire:loading.class="class-name"— add class while loading -
adowire:loading.class.remove="class-name"— remove class while loading -
adowire:loading.attr="disabled"— set attribute while loading -
adowire:loading.attr.remove="attr"— remove attribute while loading -
adowire:loading.delay— delay before loading indicator shows -
adowire:loading.delay.Xms— custom delay -
adowire:target="method"— scope loading to specific action -
adowire:target="propName"— scope loading to property update -
data-loadingattribute auto-set on component root during requests -
adowire:navigateon<a>— SPA navigation (no full reload) -
adowire:navigate.hover— prefetch page on hover -
adowire:current— addaria-current="page"to active links -
adowire:cloak— hide until component boots (display:none removed post-boot) — server-side CSS via@adowireStyles(), clientdirectives/cloak.tswithuncloakComponent() -
adowire:dirty— show element when local state differs from server (clientdirectives/dirty.tswith per-property tracking viaadowire:target) -
adowire:dirty.class="class"— add class when dirty -
adowire:dirty.class.remove="class"— remove class when dirty -
adowire:confirm="message"— browser confirm() before action -
adowire:confirm.prompt="label|expected"— input prompt confirm -
adowire:transition— apply CSS transition on morph -
adowire:transition.in.duration.Xms/adowire:transition.out.duration.Xms -
adowire:init="method"— call action when component first boots on client -
adowire:intersect="method"— IntersectionObserver trigger -
adowire:intersect.once— fire only once -
adowire:intersect.enter="method"— on enter viewport -
adowire:intersect.leave="method"— on leave viewport -
adowire:poll— auto$refreshevery 2s (default) (clientdirectives/poll.tswith visibility pause) -
adowire:poll.Xs— custom interval -
adowire:poll.visible— only poll when element is visible on screen -
adowire:poll.keep-alive— poll even when browser tab is hidden -
adowire:offline— show element when browser goes offline -
adowire:ignore— never morph this element (preserve DOM subtree) — handled inmorph.ts -
adowire:ignore.self— morph children but not the root element (not distinguished fromadowire:ignore) -
adowire:ref="name"— expose element via$wire.$refs.name -
adowire:replace— replace children instead of morphing -
adowire:show="jsExpression"— toggle visibility via CSS (no morph) — clientdirectives/show.tswithinitShow()+applyShowState(); usesadowire:cloakto prevent FOUC -
adowire:sort— drag-and-drop list sorting -
adowire:sort.item— mark sortable item -
adowire:sort.handle— drag handle within sortable item -
adowire:stream="name"— real-time SSE streaming target element (server pushes chunks viatext/event-stream, client appends in real-time) -
adowire:stream.replace— replace mode (vs default append mode) -
adowire:text="jsExpression"— reactive text node (updates without morph) -
adowire:bind:attr="jsExpression"— reactive attribute binding
All 16 metadata keys and option interfaces are defined in
src/types.ts.src/decorators/contains@Computed,@Locked,@Validate,@Title, and@Layout(all fully working). 9 of 16 decorators have runtime consumers incomponent.ts/request_handler.ts(Tier 1). 7 only have the metadata key defined (Tier 2).
-
@Computed()— method becomes a memoized computed property (Tier 1: decorator insrc/decorators/computed.ts, runtime consumer in$getPublicState,$resolveComputed, hydration exclusion insnapshot.ts) -
@Locked()— property cannot be updated from client (Tier 1: decorator insrc/decorators/locked.ts, runtime guard inapplyUpdateswithLockedPropertyException) -
@Validate(vineSchema, opts?)— VineJS schema applied to property (Tier 1: decorator insrc/decorators/validate.ts, runtime inmaybeValidateOnUpdate, engine insrc/validator.ts) -
@Validate(vine.string().email(), { message: 'Bad email', as: 'email address', onUpdate: false })— with options (message override, display label, onUpdate toggle) -
@Url(opts?)— sync property to URL query parameter (Tier 2: key only) -
@Url({ as: 'q', history: 'push', except: '' })— with options -
@On('event-name')— listen for dispatched event (Tier 1: runtime in$getEventListeners) -
@On('post-updated.{post.id}')— dynamic event name -
@Reactive()— prop is reactive from parent (Tier 2: key only) -
@Modelable()— expose property for parentwire:model(Tier 2: key only) -
@Lazy(opts?)— defer component initial render (Tier 2: key only) -
@Session(opts?)— persist property value in AdonisJS session (Tier 2: key only) -
@Session({ key: 'custom_session_key' }) -
@Async()— action is fire-and-forget (Tier 1:$isAsync()helper exists but handler doesn't use it) -
@Renderless()— action skips template re-render (Tier 1: runtime incallAction) -
@Defer()— property update batched until next action call (Tier 2: key only) -
@Isolate()— action prevents event dispatches from bubbling up (Tier 2: key only) -
@Json()— action returns JSON directly to JS (Tier 1:$isJson()helper exists) -
@Title('Page Title')— set browser<title>for page components (Tier 1:$getTitle()exists) -
@Layout('layouts/adowire')— wrap page component output in layout (Tier 1:$getLayout()exists) -
@Layout('layouts/adowire', { slot: 'main' })— custom slot name
-
@island/@endisland— marks a region for independent re-rendering -
@island(name: 'stats')— named island -
@island(lazy: true)— defer island render until after page load -
@island(defer: true)— render via explicitwire:inittrigger -
@placeholder/@endplaceholder— shown while lazy island loads - Islands share parent component state and methods
- Multiple named islands per component
- Islands re-render independently; parent render skips them
-
@Lazyclass decorator — defer component's initial render - Server renders
@placeholdercontent on initial load - Component sends
wire:initAJAX call after page loads -
@island(lazy: true)for island-level deferred render - Progress / skeleton UI via
@placeholder
-
adowire:loading— show element while any request in-flight (clientdirectives/loading.ts) -
adowire:loading.class/.class.remove— class toggling -
adowire:loading.attr/.attr.remove— attribute toggling -
adowire:loading.remove— hide element while loading (inverse of show) -
adowire:loading.delay/adowire:loading.delay.Xms— delay before indicator -
adowire:target="actionName"— scope to specific action -
adowire:target="propName"— scope to property update -
data-loadingauto-attribute on component root during in-flight requests - Tailwind variant support:
data-loading:opacity-50,not-data-loading:hidden - Auto-disable all
<input>and<button>insideadowire:submitforms during request
-
WithFileUploadsmixin applied to component -
adowire:model="photo"on<input type="file"> -
TemporaryUploadedFileclass with.store(path),.move(path),.getSize(),.getMime() - VineJS validation:
@Validate(vine.file().maxSize(2 * 1024 * 1024).extnames(['jpg','png'])) - Multiple file uploads:
wire:model="photos"withvine.file().array() - Upload progress events via
wire:loadingintegration - Client-side preview:
$wire.photo.temporaryUrl() - Temporary files cleaned up after request
-
WithPaginationmixin applied to component -
this.pageproperty auto-managed -
this.previousPage()/this.nextPage()methods -
this.gotoPage(n)method -
this.resetPage()method -
@Urlauto-applied topageproperty (page in URL) -
@adowire/paginateEdge tag for rendering pagination links
-
@Urldecorator — syncs property to URL query string -
@Url({ as: 'q' })— custom query param name -
@Url({ history: 'push' })— push vs replace history entry -
@Url({ except: '' })— omit default value from URL - Multiple
@Urlproperties per component - Changing URL param triggers component hydrate + update
- Back/forward browser navigation triggers component update
-
adowire:navigateon<a>— intercept click, fetch next page, morph full page DOM -
adowire:navigate.hover— prefetch page HTML on hover - Browser
pushStatehistory management - Scroll-to-top on navigation (configurable)
-
adowire:current— auto-setsaria-current="page"on matching links -
@persist('key')/@endpersist— preserve DOM elements across navigations (audio players, etc.) - Navigation lifecycle browser events:
adowire:navigate— before navigation startsadowire:navigated— after DOM morph completes
- Back/forward button support via
popstatelistener - Page scripts re-evaluated on navigate
-
@teleport('#target')/@endteleport— render content into a different DOM node - Useful for modals, toasts, drawers rendered into
<body>or#app - Multiple teleport targets per component
The Alpine.js
$wiremagic is available inside anyx-datascope that is a descendant of an[adowire:id]element. It proxies reads/writes to the component's snapshot state and method calls tocommit().
-
$wire.property— read component property value (Proxygettrap readssnapshot.state[prop]) -
$wire.property = value— set property (Proxysettrap callscomp.commit([], { [prop]: value })) -
await $wire.method(params)— call action, returns Promise (unknown state keys return(...params) => comp.commit(...)) -
$wire.set('prop', value)— forwarded tocomp.$setviaCOMPONENT_METHODS -
$wire.set('prop', value, false)— set without network request (third arg not handled) -
$wire.get('prop')— not explicitly implemented (can use$wire.propinstead) -
$wire.$refresh()— forwarded tocomp.$refreshviaCOMPONENT_METHODS -
$wire.$toggle('prop')— not inCOMPONENT_METHODS, falls through to server call -
$wire.$dispatch('event', params)— not inCOMPONENT_METHODS, falls through to server call -
$wire.$errors— not implemented (should read fromsnapshot.memo.errors) -
$wire.$errors.has('field') -
$wire.$errors.first('field') -
$wire.$errors.get('field')— all messages for field -
$wire.$errors.all() -
$wire.$errors.clear('field?') -
$wire.$refs.name— accessadowire:refelements -
$wire.$js.actionName()— call JavaScript actions defined in<script> -
$wire.$upload('prop', file, onProgress, onError, onFinish)— programmatic upload
No test wrapper/harness exists yet. Unit tests in
tests/exercise internals directly via Japa.
-
Adowire.test(ComponentClass)— creates test wrapper -
.mount({ prop: value })— set mount props -
.set('prop', value)— set component property -
.call('method', ...params)— call action method -
.dispatch('event-name', params)— dispatch event to component -
.assertSee('text')— assert text present in rendered HTML -
.assertDontSee('text')— assert text not present -
.assertSet('prop', value)— assert property equals value -
.assertNotSet('prop', value)— assert property does not equal value -
.assertHasErrors('field')— assert validation error exists -
.assertHasErrors({ field: [vineRule] })— assert specific rule failed -
.assertNoErrors()— assert no validation errors -
.assertDispatched('event-name')— assert event was dispatched -
.assertDispatched('event-name', params)— with specific params -
.assertNotDispatched('event-name')— assert event was not dispatched -
.assertRedirect('/url')— assert redirect effect -
.assertStatus(200)— assert HTTP status of last response
-
WireProvider— AdonisJS Service Provider- Register
POST /adowire/messageroute (before user routes) - Register
GET /adowire/adowire.jsstatic asset route - Register
GET /adowire/adowire.js.mapsource map route - Register Edge.js plugin (tags + globals) —
registerAdowireTags(edge)+edge.global('$adowire', ...) - Bind
Adowiresingleton to IoC container —container.singleton(ADOWIRE_BINDING, ...) - Auto-discover components from configured path on boot —
registry.discover(appRootPath) -
router.adowire(path, componentName)— page-component routing (Livewire-style direct route → component)
- Register
-
configure.ts—node ace configure adowire(stub only — empty function body)- Publish
config/adowire.ts - Register
WireProviderinadonisrc.ts - Create
app/adowire/directory - Create
resources/views/adowire/directory - Create default layout stub at
resources/views/layouts/adowire.edge - Show next-steps instructions in terminal
- Publish
-
config/adowire.ts— config reading works (test app has config file)-
prefix: '/adowire'— URL prefix -
componentsPath: 'app/adowire'— component scan directory -
viewPrefix: 'adowire'— Edge view prefix -
secret: env('APP_KEY')— HMAC secret (handles AdonisJS Secret wrapper) -
injectMorphMarkers: true— config key accepted (but morph markers not yet injected) -
maxUploadSize: 12 * 1024 * 1024— file upload limit (config key exists in types, no upload implementation) -
navigate.enabled: true— enable SPA navigation (not implemented) -
navigate.prefetch: true— prefetch on hover (not implemented)
-
7 commands implemented. All stubs created.
commands/directory fully wired into the Ace loader.
-
node ace make:adowire counter— scaffoldapp/adowire/counter.ts+resources/views/adowire/counter.edge -
node ace make:adowire posts/create— nested:app/adowire/posts/create.ts+ view -
node ace make:adowire pages/dashboard --page— page component with@Layoutand@Titledecorators -
node ace make:adowire:form PostForm— scaffoldapp/adowire/forms/post_form.ts(deferred — WireForm not yet implemented) -
node ace adowire:list— list all registered components with their names, view, and class path -
node ace adowire:layout— scaffold default layout template -
node ace adowire:move <from> <to>— rename/move component class + view, updates class name -
node ace adowire:delete <name>— delete component class + view with confirmation prompt -
node ace adowire:stubs— publish stubs tostubs/vendor/adowire/for user customization -
configure.ts— full configure hook (publishes config, registers provider + commands inadonisrc.ts, creates scaffold dirs) -
stubs/make/component.stub— basic WireComponent class -
stubs/make/page.stub— page component with@Layout+@Title -
stubs/make/view.stub— Edge.js template -
stubs/make/layout.stub— default Adowire layout (written directly to avoid tempura/Edge conflict) -
stubs/config/adowire.stub—config/adowire.tspublished by configure hook -
commands/main.ts— Ace loader (getMetaData/getCommand) -
commands/commands.json— command metadata manifest -
package.json—./commandssubpath export +stubs/main.tsandcommands/main.tstsdown entries
| Command | Aliases | Flags | Description |
|---|---|---|---|
make:adowire <name> |
— | --page/-p, --class/-c, --view/-v |
Scaffold component class + view (default: both) |
adowire:list |
— | --json/-j |
List all registered components in a table or as JSON |
adowire:layout |
— | --name/-n, --force/-f |
Create resources/views/layouts/adowire.edge layout |
adowire:move <from> <to> |
— | — | Move/rename component class + view, updates class name |
adowire:delete <name> |
— | --force/-f |
Delete component class + view (prompts unless --force) |
adowire:stubs |
— | --force/-f |
Publish stubs to stubs/vendor/adowire/ for customisation |
node ace configure adowire |
— | — | Publish config, register provider + commands in adonisrc.ts |
- HMAC-SHA256 snapshot checksum —
createHmac('sha256', secret)insnapshot.ts,timingSafeEqualverification -
@Lockedproperties — decorator insrc/decorators/locked.tsfully implemented; runtime guard inapplyUpdatesthrowsLockedPropertyException -
$isCallable()guard — blocks lifecycle hooks,$-prefixed,_-prefixed, andReservedMethodNamesset (20 names includingreset,fill,validate, etc.) -
MethodNotCallableExceptionwith clear error message when client attempts to call reserved/private methods - Action parameters treated as untrusted user input — documentation/guidance only
- CSRF protection — AdonisJS CSRF middleware; token injected via
@adowireScriptsmeta tag, sent asX-CSRF-TOKENheader - Protected/private class members never included in snapshot —
$getPublicState()filters$/_prefixed keys - CSP nonce support for inline scripts injected by
@!adowireScripts()
-
morphdomintegration — surgical DOM diffing and patching (morph.tswraps morphdom) -
adowire:keyattribute used as morph key for stable element identity (getNodeKeycallback inmorph.ts) - Alpine.js state preserved during morph (no
__x_dataStack/_x_dataStackpreservation logic) -
adowire:ignore— skip morphing subtree entirely (onBeforeElUpdated+onBeforeNodeDiscarded) -
adowire:ignore.self— morph children but not root element (not distinguished fromadowire:ignore) -
adowire:replace— replace children wholesale instead of morphing - Server-side morph marker injection around
@if/@eachEdge blocks:<!--[if BLOCK]> <![endif]--> ...conditional content... <!--[if ENDBLOCK]> <![endif]-->
- Look-ahead algorithm in morphdom config — detects insertions vs replacements
-
injectMorphMarkers: falseconfig option to disable markers (config key accepted but no injection code)
-
Adowire.init()— boot all components found on the page (called automatically onDOMContentLoaded) -
Adowire.on('event', fn)— global Livewire-style event listener, returns cleanup() -
Adowire.find(id)— find component instance byadowire:idattribute value -
Adowire.getByName('counter')— find first component by registered name -
Adowire.all()— array of all active component instances on the page (componentsMap exists but no.all()helper) -
Adowire.navigate(url)— programmatic SPA navigation -
Adowire.hook('request', fn)— intercept all requests -
Adowire.hook('response', fn)— intercept all responses - Browser events dispatched on
document:adowire:init— after all components bootadowire:request— before each AJAX requestadowire:response— after each AJAX responseadowire:error— on AJAX or server erroradowire:morph— before morphdom runsadowire:morphed— after morphdom completesadowire:navigate— before SPA navigationadowire:navigated— after SPA navigation DOM update
Goal: A counter with
wire:click="increment"works end-to-end.
-
src/types.ts -
src/component.ts— WireComponent base class with all lifecycle hooks -
src/snapshot.ts— SnapshotManager (dehydrate/hydrate + HMAC) -
src/component_registry.ts -
src/request_handler.ts -
providers/wire_provider.ts -
src/edge/plugin.ts -
src/edge/tags/adowire_scripts.ts -
src/edge/tags/adowire_styles.ts -
src/edge/tags/adowire_component.ts -
client/index.ts -
client/connection.ts -
client/morph.ts -
client/alpine_bridge.ts -
client/directives/click.ts -
client/directives/submit.ts
Milestone: Counter increments without page reload. ✅
Goal: A todos list with
wire:modellive updates works.
-
client/directives/model.ts— deferred binding (submit-time serialisation) -
client/directives/loading.ts— show/hide, class, attr toggling while in-flight -
client/directives/cloak.ts -
client/directives/dirty.ts -
src/decorators/locked.ts -
src/decorators/computed.ts -
fill(),reset(),pull(),only(),all()on WireComponent
Milestone: Model-submit form with deferred binding works. Model live modifiers (live, blur, debounce, throttle) implemented. Loading states work. Bulk state helpers implemented. Cloak, dirty, @Locked, @Computed decorators implemented. ✅
Goal: Create-post form with real-time validation and
@errordisplay works.
-
src/validator.ts—WireValidatorclass withvalidateProperty()andvalidateProperties(), lazy dynamic VineJS import -
src/wire_exception.ts—ValidationException,LockedPropertyException,MethodNotCallableException,RenderException -
src/decorators/validate.ts—@Validate(rule, opts?)withmessage,as,onUpdateoptions -
src/edge/tags/error.ts—@error('field')/@enderrorblock tag exposingmessageandmessages -
validate(),addError(),resetValidation()on component —validate()usesWireValidator, clears passing props, throwsValidationExceptionon failure -
validateUsing(compiledValidator)on component — runs a pre-compiledvine.compile(schema)validator against full public state; assigns coerced values back to properties; catches native VineJS errors and converts toValidationException -
withValidator()on component — programmatic validator attachment (not yet implemented) -
$errorsclient-side object (has, first, get, all, clear) — client-side structured error API not yet built - VineJS
E_VALIDATION_ERRORcaught → errors in response → re-render (request handlermaybeValidateOnUpdateruns per-property validation on update)
Milestone: Form with @Validate(vine.string().minLength(3)), real-time errors, @error tags. ✅
Goal: Reusable WireForm objects, used across create + edit pages.
-
src/form.ts - Form as typed property:
public PostForm $form -
wire:model="form.title"nested binding -
@error('form.field')nested errors -
node ace make:adowire:form PostForm
Milestone: PostForm reusable across CreatePost and EditPost. ✅
Goal: Parent/child communication,
@On,@Reactive, slots all work.
-
dispatch()and all variants -
src/decorators/on.ts -
src/edge/tags/wire_component.ts(fully working with children) - Child independent re-render logic
-
src/decorators/reactive.ts -
src/decorators/modelable.ts - Slots (default + named)
-
$attributesforwarding -
$parent.method()template support
Milestone: Todos parent + TodoItem child with $parent, events, reactive count. ✅
Goal: Full directive set, URL sync, polling, intersection, session, etc.
-
src/decorators/url.ts -
src/decorators/session.ts -
src/decorators/async.ts -
src/decorators/renderless.ts -
src/decorators/defer.ts -
src/decorators/isolate.ts -
src/decorators/json.ts -
src/decorators/title.ts -
src/decorators/layout.ts -
client/directives/poll.ts— interval + visibility pause -
client/directives/intersect.ts -
client/directives/confirm.ts -
client/directives/init.ts -
client/directives/offline.ts -
client/directives/ref.ts -
client/directives/ignore.ts— handled viamorph.tscallback -
client/directives/replace.ts -
client/directives/show.ts—initShow()+applyShowState()with expression evaluation against snapshot -
client/directives/text.ts -
client/directives/current.ts -
client/directives/transition.ts -
client/directives/bind.ts
Milestone (partial): @Title, @Layout, poll, ignore directives working. Remaining decorators & directives pending.
-
src/edge/tags/island.ts -
src/edge/tags/placeholder.ts - Island independent re-rendering
-
src/decorators/lazy.ts
Milestone: Islands and lazy-loaded components work. ✅
-
client/directives/navigate.ts - SPA page morphing + history management
-
wire:navigate.hoverprefetch -
src/edge/tags/persist.ts -
src/edge/tags/teleport.ts - Navigation browser events
Milestone: Full app SPA navigation with back/forward, persisted elements. ✅
-
src/concerns/with_file_uploads.ts -
TemporaryUploadedFile+ VineJS file schema integration -
src/concerns/with_pagination.ts - Pagination Edge component
-
client/directives/sort.ts
Milestone: File upload with progress, pagination with URL sync, drag-drop sort. ✅
-
$stream(name, content, replace?)on WireComponent — with$streamWritercallback for real-time push - Client-side stream handling in
component.ts_applyStreamChunk()+_applyResponse() - SSE streaming over
text/event-streamresponse — request handler detectsAccept: text/event-stream, opens SSE connection, pushesevent: streamper chunk, sendsevent: responseat end - Client
Connection.requestStreaming()— parses SSE ReadableStream, dispatches chunks in real-time - Automatic SSE mode: client sends
Accept: text/event-streamwhen action calls are present; server falls back to JSON for non-streaming actions
Milestone: AI/LLM streaming text into wire:stream element — words appear in real-time. ✅
CLI — ✅ Complete
-
commands/make_adowire.ts—node ace make:adowire <name> [--page] [--class] [--view] -
commands/adowire_list.ts—node ace adowire:list [--json] -
commands/adowire_layout.ts—node ace adowire:layout [--name] [--force] -
commands/adowire_move.ts—node ace adowire:move <from> <to> -
commands/adowire_delete.ts—node ace adowire:delete <name> [--force] -
commands/adowire_stubs.ts—node ace adowire:stubs [--force] -
commands/main.ts— Ace loader (getMetaData/getCommand) -
commands/commands.json— command metadata manifest -
configure.ts— full configure hook (config stub,rcFile.addProvider,rcFile.addCommand, scaffold dirs) -
stubs/make/component.stub -
stubs/make/page.stub -
stubs/make/view.stub -
stubs/make/layout.stub -
stubs/config/adowire.stub -
stubs/main.tsadded as own tsdown entry (fixesstubsRootpath in build) -
package.json./commandssubpath export
Testing — ⏳ Pending
-
src/testing/wire_test.ts - All
assert*methods -
commands/make_adowire_form.ts(blocked on WireForm)
Milestone: node ace make:adowire scaffolds correctly ✅ — full Japa test suite pending.
-
src/synthesizers/— Date, Set, Map synthesizers (with customSynthesizerinterface) -
Adowire.synthesizer(impl)— user-extensible -
README.md— full developer documentation - All CI checks passing (lint, typecheck, tests)
- npm publish with provenance
Milestone: v1.0.0 published to npm. ✅
Added outside original phases — provides runtime + compile-time type safety for Edge templates.
-
src/dev_proxy.ts— development-modeProxywrapper for template data- Top-level undefined variable warnings (
{{ naem }}→ warns with available keys) - Deep recursive proxying for nested objects (
{{ submittedData.naem }}→ warns with full dotted path) -
$-prefixed framework objects excluded from deep proxy ($errors.nameno false positive) -
WeakMapproxy cache — no duplicate proxies on repeated access -
SILENT_PROPSset — suppresses warnings for JS internals, Edge.js internals, Symbols -
flushDevWarnings()— collect warnings for testing -
isDevProxyEnabled(config)— resolves from config orNODE_ENV -
maybeDevProxy(data, name, enabled)— conditional wrapper
- Top-level undefined variable warnings (
- Integrated into
WireComponent.render()— automatic in dev mode - Integrated into
@adowireSSR tag — initial page renders also covered -
devProxyconfig option inAdowireConfig(defaults toNODE_ENV !== 'production') -
ViewData<T>utility type — extracts template data shape from any WireComponent subclass- Filters out
$-prefixed internals,_-prefixed privates, and methods - Includes
$errorsand$componentframework injections - Exported from package entrypoint for IDE hover/documentation
- Filters out
Working example components in
adowire-test/demo app exercising each directive.
-
examples/click_demo—adowire:clickcounter -
examples/model_submit—adowire:model+adowire:submitform with validation, typedContactFormDatainterface -
examples/loading_demo—adowire:loadingshow/hide + attr toggling -
examples/poll_demo—adowire:pollwith visibility pause -
examples/stream_demo—adowire:streamreal-time SSE word-by-word streaming -
examples/ignore_demo—adowire:ignoreDOM preservation -
examples/key_demo—adowire:keystable identity in loops -
examples/model_live_demo—adowire:model.livewith all modifiers (live, blur, debounce, debounce.500ms, throttle) + search filtering + update counter -
examples/validate_demo—@Validatedecorator with VineJS rules,@errortags, real-time validation viaadowire:model.live.debounce,onUpdate: falsedemo -
examples/html_components_demo— full showcase of<adowire:*>HTML tag syntax vs@adowire()Edge tag syntax; static/dynamic/boolean/kebab-case props;:isdynamic component with tab switching; auto-prop assignment flow diagram;examples/components/counterandexamples/components/info_cardchild components - Home page with links to all examples
-
router.adowire()page routing for all examples
// app/adowire/counter.ts
import { WireComponent } from 'adowire'
import { Computed, Locked, Validate, On } from 'adowire/decorators'
import vine from '@vinejs/vine'
export default class Counter extends WireComponent {
count = 0
@Locked()
userId!: number
@Validate(vine.number().min(1).max(100))
limit = 10
async mount({ initialCount = 0 }: { initialCount?: number }) {
this.count = initialCount
this.userId = this.$ctx.auth.user!.id
}
increment() {
this.count++
}
decrement() {
if (this.count > 0) this.count--
}
@Computed()
get doubled() {
return this.count * 2
}
@On('count-reset')
resetCount() {
this.count = 0
}
}{{-- resources/views/adowire/counter.edge --}}
<div>
<h1>
Count: {{ count }}
</h1>
<h2>
Doubled: {{ await $this.doubled }}
</h2>
<button wire:click="decrement">-</button>
<button wire:click="increment">+</button>
<button wire:click="$refresh">Refresh</button>
<button wire:click="$toggle('showDetails')">Toggle</button>
@if(showDetails)
<p wire:transition>
Details here
</p>
@end
<span wire:loading>Updating...</span>
</div>{{-- resources/views/layouts/adowire.edge --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
My App
</title>
@!wireStyles()
</head>
<body>
{{{ await $slots.main() }}}
@!wireScripts()
</body>
</html>// app/adowire/forms/post_form.ts
import { WireForm } from 'adowire'
import { Validate } from 'adowire/decorators'
import vine from '@vinejs/vine'
export default class PostForm extends WireForm {
@Validate(vine.string().minLength(3).maxLength(255))
title = ''
@Validate(vine.string().minLength(10))
content = ''
async store() {
await this.validate()
// save to DB ...
this.reset()
}
async update(post: Post) {
await this.validate()
await post.merge(this.only(['title', 'content'])).save()
this.reset()
}
}// app/adowire/posts/create.ts
import { WireComponent } from 'adowire'
import { Layout, Title } from 'adowire/decorators'
import PostForm from '#wire/forms/post_form'
@Title('Create Post')
@Layout('layouts/adowire')
export default class CreatePost extends WireComponent {
declare form: PostForm
async mount() {
this.form = new PostForm(this)
}
async save() {
await this.form.store()
this.$redirect('/posts')
}
}// start/routes.ts
import router from '@adonisjs/core/services/router'
// Regular page — component embedded inside a view
router.get('/dashboard', async ({ view }) => {
return view.render('pages/dashboard')
})
// Wire page routes — component IS the page
router.wire('/posts/create', 'posts.create')
router.wire('/posts/:id/edit', 'posts.edit')The @Validate decorator accepts a VineJS schema type directly — not a string rule like 'required|min:3'. This is the key difference from Livewire's PHP attribute approach.
import vine from '@vinejs/vine'
import { Validate } from 'adowire/decorators'
// Simple rule
@Validate(vine.string().minLength(3))
title = ''
// With options
@Validate(vine.string().email(), {
message: 'Please enter a valid email address',
as: 'email address', // replaces field name in default messages
onUpdate: true, // default: validate on every property update
})
email = ''
// Stacked rules (multiple decorators)
@Validate(vine.string().minLength(3), { message: 'Title is too short' })
@Validate(vine.string().maxLength(255), { message: 'Title is too long' })
title = ''When this.validate() is called, adowire:
- Collects all
@Validatedecorated properties and their VineJS schemas - Builds a
vine.object({ title: vine.string()..., content: vine.string()... }) - Pre-compiles it with
vine.create(schema) - Applies the
SimpleMessagesProviderwith any custom messages from options - Calls
validator.validate(this.all()) - On
E_VALIDATION_ERROR: populatesthis.$errorsfromerror.messages, throwsValidationException - On success: returns validated+typed data
For scenarios requiring VineJS's full power (unions, conditional groups, cross-field validation):
export default class RegisterUser extends WireComponent {
username = ''
email = ''
password = ''
passwordConfirmation = ''
isBusinessAccount = false
companyName = ''
// Override rules() for complex VineJS schemas
protected rules() {
const base = vine.object({
username: vine.string().minLength(3).alphaNumeric(),
email: vine.string().email().normalizeEmail(),
password: vine.string().minLength(8).confirmed(),
})
// Conditional group — companyName required for business accounts
const businessGroup = vine.group([
vine.group.if((data) => vine.helpers.isTrue(data.isBusinessAccount), {
isBusinessAccount: vine.literal(true),
companyName: vine.string().minLength(2),
}),
vine.group.else({
isBusinessAccount: vine.literal(false),
}),
])
return base.merge(businessGroup)
}
protected messages() {
return {
'username.minLength': 'Username must be at least {{ min }} characters',
'email.required': 'Please enter your email address',
'password.confirmed': 'Passwords do not match',
'companyName.required': 'Company name is required for business accounts',
}
}
protected validationAttributes() {
return {
passwordConfirmation: 'password confirmation',
companyName: 'company name',
}
}
async register() {
await this.validate()
// ...
}
}When wire:model.live or wire:model.blur is used, adowire runs the @Validate schema for only the updated property on each server round-trip:
<input type="text" wire:model.live.blur="title" />
@error('title')
<p class="text-red-500">
{{ message }}
</p>
@enderror@Validate(vine.string().minLength(3).maxLength(255))
title = ''Adowire validates title alone (not the whole form) on every blur event. The full validate() is still called on submit.
<input wire:model="email" type="email" />
<div wire:show="$wire.$errors.has('email')" class="text-red-500">
<span wire:text="$wire.$errors.first('email')"></span>
</div>| Tag | Description |
|---|---|
@!wireStyles() |
Inject adowire CSS (placeholder, future use) |
@!wireScripts() |
Inject Alpine.js + adowire wire.js client bundle |
@adowire('name', props?) |
Render a nested wire component |
@adowire($dynamicName, props?) |
Dynamic component rendering |
@adowire.slot('name') |
Named slot content passed to child |
@island / @endisland |
Independent re-render region |
@island(name: 'x', lazy: true) |
Named + lazy island |
@placeholder / @endplaceholder |
Shown while lazy component/island loads |
@persist('key') / @endpersist |
Preserve element across SPA navigations |
@teleport('#target') / @endteleport |
Render into different DOM node |
@error('field') / @enderror |
Show first validation error for field |
| Variable | Type | Description |
|---|---|---|
$this |
Component instance | Access computed properties |
$errors |
ErrorBag | Server-side validation errors |
$slots |
SlotsBag | Named slots from parent |
$attributes |
AttributesBag | HTML attributes forwarded from parent |
$wire |
AlpineBridge | Client-side wire bridge (for inline Alpine) |
| Directive | Key Modifiers | Description |
|---|---|---|
wire:model |
.live, .blur, .debounce.Xms, .throttle.Xms |
Two-way binding |
wire:click |
.async, .renderless, .preserve-scroll + event modifiers |
Server action |
wire:submit |
event modifiers | Form submit interception |
wire:keydown |
key modifiers + event modifiers | Keyboard action |
wire:loading |
.class, .attr, .remove, .delay, .delay.Xms |
Loading state |
wire:target |
— | Scope wire:loading to specific action/prop |
wire:navigate |
.hover |
SPA navigation |
wire:current |
— | Active link marker |
wire:poll |
.Xs, .visible, .keep-alive |
Auto-refresh |
wire:intersect |
.once, .enter, .leave |
Viewport trigger |
wire:init |
— | On component client-boot |
wire:confirm |
.prompt |
Confirmation dialog |
wire:transition |
.in, .out, .duration.Xms |
CSS transitions |
wire:offline |
— | Show when offline |
wire:dirty |
.class, .class.remove |
Dirty state |
wire:cloak |
— | Hide until component boots |
wire:ignore |
.self |
Prevent morphdom touching element |
wire:replace |
— | Replace children wholesale |
wire:ref |
— | Named element reference |
wire:show |
— | Reactive CSS visibility |
wire:text |
— | Reactive text node |
wire:bind:attr |
— | Reactive attribute |
wire:sort |
.item, .handle |
Drag-drop sorting |
wire:stream |
.replace |
Streaming text target |
| Decorator | Target | Description |
|---|---|---|
@Computed() |
Method (getter) | Memoize result per request; accessible as $this.prop in Edge |
@Locked() |
Property | Block all client-side mutation attempts |
@Validate(vineSchema, opts?) |
Property | VineJS schema rule on property |
@Url(opts?) |
Property | Sync to URL query parameter |
@On('event') |
Method | Listen for dispatched component event |
@On('event.{prop.id}') |
Method | Dynamic event name with component state |
@Reactive() |
Property | Re-sync from parent on every parent update |
@Modelable() |
Property | Expose for parent wire:model binding |
@Lazy(opts?) |
Class | Defer initial render until after page load |
@Session(opts?) |
Property | Persist value in AdonisJS session store |
@Async() |
Method | Fire-and-forget; does not block request queue |
@Renderless() |
Method | Action executes but skips re-render |
@Defer() |
Property | Batch update until next explicit action |
@Isolate() |
Method | Prevents dispatched events from bubbling |
@Json() |
Method | Returns JSON directly for JS consumption (skips re-render) |
@Title('text') |
Class | Sets <title> tag for page components |
@Layout('view', opts?) |
Class | Wraps page component in a layout view |
| Threat | Mitigation |
|---|---|
| Snapshot state tampering | HMAC-SHA256 on every snapshot; verified before hydration |
Injecting values into @Locked properties |
Server throws E_LOCKED_PROPERTY before hydration |
| Calling private/protected methods | $isCallable() guard — only callable public methods |
| Calling lifecycle hooks directly | All hook names explicitly blocked in $isCallable() |
| Action parameter forgery | Documented: treat as untrusted input, always validate/authorize |
| CSRF attacks | AdonisJS CSRF middleware; token sent in X-CSRF-TOKEN header on all AJAX |
| Sensitive data leakage to client | Protected/private properties excluded from snapshot |
| Type coercion attacks on properties | VineJS schema types enforce strict casting |
SnapshotManager— dehydrate/hydrate round-trips for all types- HMAC checksum signing and tamper detection
ComponentRegistry— name resolution, namespace handling- Decorator metadata —
@Validate,@Locked,@On,@Computedetc. WireValidator— VineJS schema composition from@Validatedecoratorsmorph_markers— correct injection around@if/@eachblocks
- Full request lifecycle: mount → dehydrate → AJAX → hydrate → action → dehydrate → render
- VineJS
E_VALIDATION_ERRORcaught and re-rendered with errors - Events dispatched and received by
@Onlisteners @Lockedproperties throw on mutation- Redirect effects in response
- Nested component rendering and independence
@Reactiveprop re-sync
wire:model.livetwo-way binding in browserwire:clickaction call and DOM updatewire:submitwith VineJS validation errors shownwire:loadingvisibility during request- DOM morphing correctness with
wire:key - SPA navigation with
wire:navigate
Status: ✅ Implemented — 7 commands, all tested in
adowire-test.
| Command | Short flags | Description |
|---|---|---|
make:adowire <name> |
--page/-p --class/-c --view/-v |
Scaffold component class + Edge view. Default: both. |
adowire:list |
--json/-j |
Pretty-print table of all registered components (name, view, class path). |
adowire:layout |
--name/-n --force/-f |
Create resources/views/layouts/adowire.edge (or custom name). |
adowire:move <from> <to> |
— | Rename/move class + view; rewrites the class declaration to match the new name. |
adowire:delete <name> |
--force/-f |
Delete class + view with confirmation prompt (skip with --force). |
adowire:stubs |
--force/-f |
Publish all stubs to stubs/vendor/adowire/ for customisation. Skips existing unless --force. |
configure adowire |
— | Publish config/adowire.ts, register provider + commands in adonisrc.ts, create scaffold dirs. |
Scaffolds a wire component class and its Edge.js view template.
# Basic component — creates BOTH class and view (default)
node ace make:adowire counter
# → app/adowire/counter.ts
# → resources/views/adowire/counter.edge
# Nested path
node ace make:adowire posts/create
# → app/adowire/posts/create.ts
# → resources/views/adowire/posts/create.edge
# Page component (adds @Layout + @Title decorators)
node ace make:adowire pages/dashboard --page
# → app/adowire/pages/dashboard.ts ← with @Title('Dashboard') @Layout('layouts/adowire')
# → resources/views/adowire/pages/dashboard.edge
# Class only — no view
node ace make:adowire widgets/badge --class
# → app/adowire/widgets/badge.ts
# View only — no class
node ace make:adowire widgets/badge --view
# → resources/views/adowire/widgets/badge.edge
# --class and --view together = same as default (both)
node ace make:adowire widgets/badge --class --view
# → app/adowire/widgets/badge.ts
# → resources/views/adowire/widgets/badge.edge| Flag | Alias | Description |
|---|---|---|
--page |
-p |
Page variant: adds @Title and @Layout('layouts/adowire') decorators to the class |
--class |
-c |
Generate class file only; skip the Edge view |
--view |
-v |
Generate Edge view only; skip the class file |
Note: AdonisJS generators singularise names (
stats→stat,posts→post). This matches the same behaviour asmake:model.
Lists all auto-discovered and manually registered components.
node ace adowire:list
# ┌─────────────────┬─────────────────────────────┬─────────────────────────────────────────┐
# │ Name │ View │ Class Path │
# ├─────────────────┼─────────────────────────────┼─────────────────────────────────────────┤
# │ counter │ adowire/counter │ .../app/adowire/counter.ts │
# │ posts.create │ adowire/posts/create │ .../app/adowire/posts/create.ts │
# │ pages.dashboard │ adowire/pages/dashboard │ .../app/adowire/pages/dashboard.ts │
# └─────────────────┴─────────────────────────────┴─────────────────────────────────────────┘
# Total: 3 component(s)
node ace adowire:list --json
# [{ "name": "counter", "classPath": "...", "viewName": "adowire/counter" }, ...]| Flag | Alias | Description |
|---|---|---|
--json |
-j |
Output component list as a JSON array instead of a table |
Creates the default Adowire layout template. Writes the file directly (not through tempura) so Edge.js expressions like {{ $title }} and {{{ $body }}} are preserved verbatim.
# Create resources/views/layouts/adowire.edge (default name)
node ace adowire:layout
# Create with a custom name
node ace adowire:layout --name app
# Overwrite if already exists
node ace adowire:layout --forceGenerated output (resources/views/layouts/adowire.edge):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ $title ?? 'App' }}</title>
@adowireStyles
</head>
<body>
{{{ $body }}}
@adowireScripts
</body>
</html>| Flag | Alias | Description |
|---|---|---|
--name |
-n |
Layout filename without .edge extension (default: adowire) |
--force |
-f |
Overwrite the file if it already exists |
Renames or moves a component. Moves both the class file and the view file, and rewrites the class declaration inside the class file to match the new name.
# Rename a flat component
node ace adowire:move counter widgets/counter
# DONE: move .../app/adowire/counter.ts → .../app/adowire/widgets/counter.ts
# DONE: move .../resources/views/adowire/counter.edge → .../resources/views/adowire/widgets/counter.edge
# (class renamed Counter → Counter — same in this case)
# Rename across paths, class name changes
node ace adowire:move posts/create forms/post_create
# class PostCreate → class PostCreate (etc.)Files that don't exist are skipped with a warning rather than causing an error.
Deletes a component class and its view. Prompts for confirmation unless --force is given.
# With confirmation prompt
node ace adowire:delete counter
# ? Delete component "counter" and its view? This cannot be undone. (y/N)
# Skip prompt
node ace adowire:delete counter --force
# DONE: delete .../app/adowire/counter.ts
# DONE: delete .../resources/views/adowire/counter.edge| Flag | Alias | Description |
|---|---|---|
--force |
-f |
Skip the confirmation prompt |
Publishes the package's built-in stubs to stubs/vendor/adowire/ so developers can customise the scaffolding templates. AdonisJS automatically prefers stubs found in the app's own stubs/ directory over package defaults.
# Publish (skips files that already exist)
node ace adowire:stubs
# DONE: create stubs/vendor/adowire/make/component.stub
# DONE: create stubs/vendor/adowire/make/page.stub
# DONE: create stubs/vendor/adowire/make/view.stub
# DONE: create stubs/vendor/adowire/make/layout.stub
# DONE: create stubs/vendor/adowire/config/adowire.stub
# Overwrite existing stubs
node ace adowire:stubs --force| Flag | Alias | Description |
|---|---|---|
--force |
-f |
Overwrite stubs that already exist in the destination |
Published stubs:
| Stub file | Used by |
|---|---|
make/component.stub |
make:adowire (basic class) |
make/page.stub |
make:adowire --page |
make/view.stub |
make:adowire (Edge template) |
make/layout.stub |
(reference only — adowire:layout writes directly) |
config/adowire.stub |
node ace configure adowire |
Run once after installing the package. Uses AdonisJS codemods to:
- Publish
config/adowire.ts(viastubs/config/adowire.stub) - Register
adowire/wire_providerinadonisrc.tsproviders - Register
adowire/commandsinadonisrc.tscommands - Create
app/adowire/andresources/views/adowire/scaffold directories
node ace configure adowire
# ✔ Adowire configured successfully!
#
# Next steps:
# node ace make:adowire counter # basic component
# node ace make:adowire dashboard --page # page component
# node ace adowire:list # list all components
⚠️ Not yet implemented — blocked onWireForm(Phase 4).
Will scaffold a WireForm class alongside or independently of a component.
node ace make:adowire:form PostForm
# → app/adowire/forms/post_form.tsStatus: Publishable as alpha
0.x.x. Not ready for stable1.0.0yet.
{
"exports": {
".": "./build/index.js",
"./types": "./build/src/types.js",
"./wire_provider": "./build/providers/wire_provider.js",
"./client": "./build/adowire.js",
"./commands": "./build/commands/main.js"
}
}| Subpath | Resolves to | Purpose |
|---|---|---|
. |
build/index.js |
Main entry — exports WireComponent, decorators, types, Edge plugin, etc. |
./types |
build/src/types.js |
AdowireConfig, WireSnapshot, ComponentDefinition, etc. |
./wire_provider |
build/providers/wire_provider.js |
AdonisJS service provider (registered in adonisrc.ts) |
./client |
build/adowire.js |
Browser-side IIFE bundle (morphdom, directives, connection) |
./commands |
build/commands/main.js |
Ace loader — getMetaData() / getCommand() for all 7 commands |
Planned (not yet implemented):
| Subpath | Purpose | Blocked on |
|---|---|---|
./decorators |
Standalone decorator imports | — |
./concerns |
Reusable trait/concern mixins | Phase 5 |
./testing |
WireTest test utilities |
Phase 11 testing |
build/
index.js + index.d.ts ← main entry (server-side exports)
configure.js + configure.d.ts ← node ace configure adowire
adowire.js ← client-side IIFE bundle (27 KB)
chunk-*.js ← shared chunk (reflect-metadata re-export)
wire_provider-*.js ← provider chunk (95 KB — component registry, snapshot, Edge plugin, request handler)
dev_proxy-*.js ← dev-mode template proxy chunk
stubs/
main.js + main.d.ts ← stubsRoot = import.meta.dirname
make/component.stub ← basic WireComponent class
make/page.stub ← page component (@Layout + @Title)
make/view.stub ← Edge.js template with {{-- comment --}}
make/layout.stub ← layout reference (adowire:layout writes directly)
config/adowire.stub ← config/adowire.ts published by configure hook
commands/
main.js + main.d.ts ← Ace loader (getMetaData / getCommand + all 7 commands bundled)
commands.json ← command metadata manifest
*.d.ts ← declaration files for each command
providers/
wire_provider.js + .d.ts ← re-export entry for the provider
src/
*.d.ts ← all server-side type declarations
client/
*.d.ts ← client-side type declarations
Excluded from the tarball (via "files" in package.json):
build/bin/— test runnerbuild/tests/— test spec declarations
Tarball size: ~86 KB compressed, ~289 KB unpacked.
# 1. Log in to npm (one-time)
npm login
# 2. Publish via release-it (recommended — builds, tags, publishes, creates GitHub release)
npx release-it # prompts for version bump
npx release-it patch # 0.1.0 → 0.1.1
npx release-it minor # 0.1.0 → 0.2.0
# Or manually:
npm run build
npm publish- Symlinked dev (
file:../adowire): No. The symlink always points at the livebuild/directory. Justnpm run compilein the adowire folder and restart the dev server. - Published package (
npm install adowire): Yes — consumers runnpm update adowireto get the new version.
| Item | Status |
|---|---|
package.json name, version, description |
✅ |
package.json author, repository, bugs, homepage |
✅ |
package.json license (MIT) |
✅ |
package.json exports (5 subpaths) |
✅ |
package.json files (excludes tests/bin) |
✅ |
package.json engines (>=24.0.0) |
✅ |
package.json peerDependencies (@adonisjs/core ^7, edge.js ^6) |
✅ |
publishConfig (public, provenance) |
✅ |
release-it config (git tags, GitHub release, conventional changelog) |
✅ |
prepublishOnly script (auto-builds) |
✅ |
| Build output clean (no test/bin leaks) | ✅ |
Type declarations (.d.ts) generated |
✅ |
| README.md | |
| CI (GitHub Actions) | ❌ Not set up yet |
| Test suite | ❌ No automated tests (testing harness is Phase 11) |
| Area | Status | Completion |
|---|---|---|
| Core Engine (snapshot, hydration, morphing) | ✅ Ship-ready | 100% |
| Properties & Model binding | ✅ Ship-ready | 90% |
| Lifecycle Hooks | ✅ Ship-ready | 92% |
Streaming ($stream, adowire:stream) |
✅ Ship-ready | 100% |
| CLI / Ace Commands (7 commands) | ✅ Ship-ready | 94% |
| Dev-Mode Template Safety | ✅ Ship-ready | 100% |
| AdonisJS Integration (provider, Edge, router.adowire) | 🟡 Partial | 54% |
| Validation (VineJS) | 🟡 Partial | 43% |
| Directives (HTML attributes) | 🟡 Partial | 38% |
| Actions | 🟡 Partial | 52% |
| Decorators | 🟡 Partial | 33% |
| Security (HMAC, CSRF, callable guard) | 🟡 Partial | 75% |
| Forms (WireForm) | ❌ Not started | 0% |
| Events & Nesting | ❌ Not started | 0% |
| Islands & Lazy Loading | ❌ Not started | 0% |
| File Uploads | ❌ Not started | 0% |
| Pagination | ❌ Not started | 0% |
| URL Query Parameters | ❌ Not started | 0% |
| Navigate (SPA Mode) | ❌ Not started | 0% |
| Teleport | ❌ Not started | 0% |
| Testing Harness | ❌ Not started | 0% |
- Semantic Versioning (semver)
- Conventional commits → auto-changelog via
@release-it/conventional-changelog - Pre-release:
0.x.xduring active development - Stable
1.0.0after Phases 1–9 features complete and tested (Forms, Events, Nesting, Directives, Islands, Navigate, File Uploads, Pagination) - CI: GitHub Actions — lint + typecheck + tests on Ubuntu + Windows, Node.js 24+
Adowire Build Plan v1.1