Skip to content

Latest commit

Β 

History

133 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

CycleWire

npm CI Core size Dependencies License: MIT

Zero-initial-JS selective activation engine. Turn server-rendered HTML into instant interactivity on intent.

CycleWire takes the architecture behind Qwik's loader and makes it usable with any backend or frontend. Your server renders complete HTML. CycleWire adds one small listener per event type it uses and imports the code behind a button only when someone reaches for it. It can also load that code when the element scrolls into view or when the browser is idle. Nothing is hydrated and nothing is re-rendered on boot, and there are no runtime dependencies.


✨ Why CycleWire

  • Nothing to hydrate. The HTML your server sends is the UI. The 5.2 kB core (brotli) activates it; action code is fetched per feature, on demand.
  • Intent-aware loading. Modules start downloading on hover, focus or touch, before the click lands. modulepreload fetches them without running them, and Save-Data and 2G connections are respected.
  • Predictable under pressure. Every binding has a concurrency mode (drop, restart, latest, parallel), an AbortSignal, once and debounce. Double submits and stale search results do not happen by accident.
  • Good for INP. Handlers run after the browser has painted the pressed state. Nothing heavy runs on load, and the page stays bfcache friendly.
  • Works with anything. Laravel, Rails, Django, plain PHP, Astro, Web Components, React/Vue/Svelte islands, htmx or Turbo. The attributes are short (cw-action) and pass through JSX and every template language; their prefix is an option.
  • Modern platform features where they exist:
    • Shadow DOM and declarative shadow DOM
    • Invoker Commands
    • View Transitions
    • moveBefore()
    • scheduler.yield()
    • Trusted Types
    • Speculation-rules prerendering
  • Optional batteries, pay for what you import:
    • css: stylesheets that arrive with the actions that need them
    • dom: safe html templates, inert fragments, swaps
    • morph: DOM morphing that keeps state, instead of a virtual DOM
    • signals: reactivity that resumes from server-rendered state
    • stream: HTML messages from the server that change the page
    • prefetch: data fetched on intent, next to the action's code
    • request: links, forms and buttons that fetch HTML, declared in markup the way htmx does it
    • early: taps made before CycleWire starts, kept and run once it does
    • bootstrap: Bootstrap's data API without its JavaScript

πŸš€ Quick start

1. Mark up what should be interactive. Your server renders this; it works and reads fine before any JavaScript arrives.

<button cw-action="cart#add" cw-props='{"sku": "wire-01"}'>Add to cart</button>

2. Write the action. It is a plain ES module that is fetched on first use.

// actions/cart.js
export async function add({ element, props, signal }) {
    const response = await fetch('/cart', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(props),
        signal,
    });
    element.textContent = response.ok ? 'Added βœ“' : 'Try again';
}

3. Register it and start, with a bundler:

import { start } from 'cyclewire';

start({
    actions: {
        cart: () => import('./actions/cart.js'),
    },
});

…or with no build step at all:

<script type="application/json" data-cyclewire>
    { "actions": { "cart": "/js/actions/cart.js" } }
</script>
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.global.min.js" defer></script>

That's it. Until someone reaches for the button, the page has downloaded a single 5 kB script and no action code.


πŸ“¦ Installation

npm install cyclewire
pnpm add cyclewire
yarn add cyclewire
bun add cyclewire
Entry Import For
Core cyclewire Delegation, registry, triggers, preloading, concurrency, lifecycle events
Auto start cyclewire/auto Reads <script type="application/json" data-cyclewire>, starts, sets window.CycleWire
DOM cyclewire/dom html, fragment, swap, transition
Morph cyclewire/morph morph
Signals cyclewire/signals signal, computed, effect, store, stateOf, signals() plugin
Bootstrap cyclewire/bootstrap Bootstrap 5 data API plugin

Bundlers that honour the development export condition (Vite, webpack, Rollup) get a build with helpful warnings in development and the lean one in production.

From a CDN. jsDelivr, unpkg and esm.sh serve every published version:

<!-- Classic script: core, starts itself, window.CycleWire -->
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.global.min.js" defer></script>

<!-- Classic script with every module (dom, morph, signals, bootstrap) -->
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.full.global.min.js" defer></script>

<!-- ES modules -->
<script type="module">
    import { start } from 'https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.min.js';
    import { html, swap } from 'https://cdn.jsdelivr.net/npm/cyclewire@1/dist/dom.min.js';
</script>

For production, pin an exact version (cyclewire@1.0.0) and add the Subresource Integrity hash published with each release.


🧩 How it works

 Server renders HTML ──► browser paints it, fully usable as links and forms
                              β”‚
                     CycleWire core, 5.2 kB: one listener per event type
                              β”‚
         pointer / focus ─────┼────► preload: modulepreload the action (no execution)
                              β”‚
         click / submit / … ──┴────► resolve element β†’ import action β†’ yield β†’ run
                                             β–²
         cw-trigger: load Β· idle Β· visible Β· media:(…) β”€β”˜

Every interactive element carries its intent in markup (cw-action="cart#add"). One delegated listener per event type finds the element, looks the name up in the action registry, imports that module the first time, and calls the handler with a context object. This is Qwik's resumability without Qwik's compiler: the server is the source of truth, and the client never rebuilds a component tree. Concepts walks through it.


🏷️ HTML attributes

All attributes use the cw- prefix by default. start({ prefix: 'x-' }) moves them to data-x-action and friends, and prefix: '' drops the prefix.

Attribute Purpose
cw-action="name" Runs name on the element's natural event: submit for forms, input or change for controls, toggle for <details>, click otherwise
cw-on-<event>="name" Runs name on any delegated event, e.g. cw-on-keydown, cw-on-command
cw-trigger Runs without an event: load, idle, visible, media:(min-width: 60em)
cw-preload When to fetch the module: intent (default), visible, idle, load, none
cw-props='{…}' JSON handed to the handler as ctx.props
cw-prevent preventDefault() for all bound events, or a list ("click keydown"); none disables the automatic prevent for forms and submit buttons
cw-once Only one successful run
cw-debounce="ms" Wait for a pause in events
cw-concurrency drop (clicks, submits), restart (input), latest (change, toggle), parallel
cw-ignore Stop looking for bindings above this element (use it on user-generated content)
cw-pending Set by CycleWire while a run is in flight; style it

Names are module or module#export. A module's run export, or its default export, handles bare names. The full reference is in docs/html-api.md.


βš™οΈ Writing actions

export async function run(ctx) {
    ctx.event;    // the triggering Event (null for triggers and run())
    ctx.target;   // the innermost event target
    ctx.element;  // the element that carries the binding
    ctx.signal;   // AbortSignal: a newer run, removal or stop() aborts it
    ctx.props;    // parsed cw-props
    ctx.action;   // "module#export"
    ctx.wire;     // the CycleWire API
}

Return values are dispatched with cw:done, and errors with cw:error or your onError. Because the handler runs after the event has been dispatched, calling ctx.event.preventDefault() there has no effect; use cw-prevent instead. Details, patterns and caveats are in docs/actions.md.


πŸ”Œ Core first, modules when you need them

The core covers activation on its own: clicks, forms, inputs, triggers, preloading, concurrency and shadow DOM. Add a module when a feature needs it, ideally by importing it from the action that uses it, so it downloads with that action and pages that never use it never pay for it. docs/modules.md has core-only recipes, a decision table and every way to load a module.

import { css } from 'cyclewire/css';
await css('/css/vendor/flatpickr.css', element); // applied before the widget is built

import { html, swap } from 'cyclewire/dom';
swap(list, html`${items.map((item) => html`<li>${item.name}</li>`)}`); // escaped by context

import { morph } from 'cyclewire/morph';
const res = await fetch('/cart/partial');
await morph(cart, html.raw(await res.text()), { transition: true }); // keeps focus and state

import { start } from 'cyclewire';
import { signals } from 'cyclewire/signals';
start({ plugins: [signals()] }); // cw-state + cw-bind, resumed on first touch

import { bootstrap } from 'cyclewire/bootstrap';
start({ plugins: [bootstrap({ global: true })] }); // Bootstrap data API, no bootstrap.js

import { connect } from 'cyclewire/stream';
connect('/rooms/42/events'); // the server streams <cw-stream op="append" target="messages"> updates

import { prefetch } from 'cyclewire/prefetch';
start({ plugins: [prefetch()] }); // cw-prefetch data arrives with the code, not after it

start({ actions: { request: () => import('cyclewire/request') } });
// <button cw-action="request" cw-get="/cart" cw-target="#cart" cw-swap="morph">: HTML from the server, no JS of yours

import { earlyScript } from 'cyclewire/early'; // on the server, inline at the top of <head>:
const head = `<script>${earlyScript()}</script>`; // taps before start() are kept, then run

🎨 Styles that ship with an action

The CSS of visible content has to arrive before it paints, so it stays render-blocking on purpose. But a date picker, an editor or a map only exists after its action runs, so its stylesheet can wait for the action:

import { start } from 'cyclewire';
import { styles } from 'cyclewire/css';

start({
    actions: {
        datepicker: { module: '/js/actions/datepicker.js', css: '/css/vendor/flatpickr.css' },
    },
    plugins: [styles()],
});

With the styles() plugin of cyclewire/css (0.6 kB), the stylesheet is preloaded with the module on intent and applied before the handler runs, so it never blocks the first render and the widget never appears unstyled. docs/css.md covers the rest of the CSS strategy.

Why no virtual DOM?

A virtual DOM re-renders the HTML the server already produced so it can diff it, and that is exactly the hydration cost this architecture exists to avoid. CycleWire gets the same benefits another way:

  • morph() compares DOM to DOM and keeps focus, typed input, iframes and media.
  • html parses into an inert <template> and hands over a DocumentFragment, so markup is built safely and inserted in one operation.

🧱 Works with your stack

docs/integrations.md has recipes for:

  • Backends: Laravel/Blade, Rails, Django, plain PHP.
  • Frameworks and build tools: Astro, Vite (fromGlob(import.meta.glob(…))), webpack, React/Vue/Svelte islands.
  • Alongside other libraries: htmx, Turbo, Web Components.

React, Vue and Svelte: CycleWire can load your islands, hydrating a server-rendered component only when it scrolls into view, and components can use CycleWire actions. See docs/frameworks.md.

SweetAlert2, Flatpickr, DataTables, jQuery: import a library in the action that uses it, and it loads when it is needed: the date picker on the field's first focus, the table enhancements when the table scrolls into view. See docs/libraries.md.

Both have runnable examples, tested in Chromium, Firefox and WebKit and live on GitHub Pages.


πŸ› οΈ Tools

npx cyclewire check    # every cw-* value in your templates, against your actions
  • cyclewire check reads HTML, Blade, ERB, Django, Jinja, Twig, JSX, Vue, Svelte and Astro templates and reports actions that are not registered, exports that do not exist and values CycleWire does not understand, with suggestions (did you mean "cart#add"?) and annotations on GitHub pull requests. CLI docs
  • cyclewire/vite registers an actions directory as chunks, re-registers an action when you edit it instead of reloading the page, and keeps the declarations of your action names up to date. Vite plugin
  • defineAction<Props>() types a handler's props, and with the generated names run('cart#ad') is a compile error. TypeScript
  • cyclewire/devtools is an inspector inside the page: registered and loaded actions, every run and how it ended, the development build's trace, waiting triggers, and what any element is bound to. Alt+Shift+W, or devtools: true in the Vite plugin. Devtools

🌐 Browser support

The core needs an ES2020 browser: Chrome/Edge 86+, Firefox 78+ or Safari 14+. Newer platform features are detected at runtime and fall back gracefully. The test suite runs on current Chromium, Firefox and WebKit. docs/browser-support.md lists every feature and its fallback.


πŸ“ Size

Minified, measured by npm run size and enforced in CI:

File brotli gzip
cyclewire.min.js (core) 5.2 kB 5.7 kB
css.min.js 0.6 kB 0.7 kB
dom.min.js 2.2 kB 2.4 kB
morph.min.js (includes what it needs from dom) 2.2 kB 2.5 kB
signals.min.js 3.4 kB 3.8 kB
stream.min.js (includes dom and morph) 3.8 kB 4.2 kB
prefetch.min.js (a plugin) 0.5 kB 0.6 kB
request.min.js (an action; includes morph and what it needs from stream) 3.7 kB 4.1 kB
early.min.js (inline, before the core) 0.4 kB 0.5 kB
bootstrap.min.js 2.1 kB 2.4 kB
devtools.min.js (development only) 7.1 kB 7.9 kB
cyclewire.global.min.js (core + auto start) 5.3 kB 5.9 kB
cyclewire.full.global.min.js (everything) 16.2 kB 17.8 kB

πŸ“Š Benchmark

One product page, built with each stack the way its documentation recommends and measured the same way in Chromium on GitHub's runners: charts and tables Β· methodology Β· run it yourself. The benchmark is maintained by the authors of CycleWire, which is one of the stacks measured, so every app, the runner and the raw data are in bench/, and corrections from the other projects are welcome. There is no overall score.

Medians, lower is better. Time to effect runs from the input to the frame that shows the result; "Early tap" is a tap on "Add to cart" in the first frame after first paint. The website charts each metric with its 95% confidence interval, and lists more metrics and how each app is built.

Mobile: slow 4G, 4Γ— CPU slowdown, touch. 15 iterations on 2026-10-01, AMD EPYC 7763 64-Core Processor (4 cores, GitHub's hosted runner), Chrome 153.0.8010.12.

Stack JavaScript LCP TBT Add to cart Category filter Live search Quick view Newsletter Early tap
Static HTML (control) 0.0 kB 1,588 ms 0 ms 1,370 ms 771 ms 799 ms 804 ms 1,407 ms by a page load, 1,619 ms
Vanilla JS (control) 1.2 kB 1,596 ms 0 ms 702 ms 120 ms 12 ms 694 ms 703 ms in the page, 832 ms
Alpine.js 18.0 kB 1,776 ms 65 ms 700 ms 125 ms 24 ms 703 ms 704 ms by a page load, 1,742 ms
Angular 87.5 kB 1,792 ms 154 ms 713 ms 142 ms 22 ms 726 ms 713 ms by a page load, 1,648 ms
Astro + Preact 13.1 kB 1,588 ms 0 ms 707 ms 121 ms 12 ms 710 ms 706 ms by a page load, 1,670 ms
CycleWire 9.0 kB 1,628 ms 0 ms 706 ms 120 ms 12 ms 616 ms 711 ms in the page, 836 ms
CycleWire, inline (variant) 3.6 kB 1,412 ms 0 ms 717 ms 135 ms 12 ms 616 ms 718 ms in the page, 855 ms
CycleWire, intent only (variant) 6.0 kB 1,624 ms 0 ms 1,203 ms 617 ms 15 ms 645 ms 708 ms in the page, 1,537 ms
CycleWire, requests from markup (variant) 9.1 kB 1,636 ms 0 ms 709 ms 711 ms 802 ms 618 ms 712 ms in the page 10/15, by a page load 5/15, 860 ms
Hotwire (Turbo + Stimulus) 31.9 kB 1,692 ms 0 ms 729 ms 783 ms 859 ms 749 ms 729 ms by a page load, 1,621 ms
htmx 15.4 kB 1,572 ms 0 ms 713 ms 759 ms 848 ms 717 ms 717 ms in the page, 858 ms
Marko 4.8 kB 1,620 ms 0 ms 706 ms 116 ms 12 ms 701 ms 705 ms in the page, 833 ms
Next.js 120.1 kB 1,880 ms 111 ms 761 ms 740 ms 838 ms 1,307 ms 716 ms by a page load, 999 ms
Next.js, client filtering (variant) 119.2 kB 1,824 ms 110 ms 756 ms 149 ms 22 ms 1,316 ms 721 ms by a page load, 983 ms
Nuxt 70.9 kB 2,016 ms 127 ms 703 ms 152 ms 28 ms 733 ms 703 ms by a page load, 1,694 ms
Qwik City 37.5 kB 1,728 ms 0 ms 816 ms 193 ms 29 ms 789 ms 822 ms in the page, 2,466 ms
SolidStart 31.1 kB 1,588 ms 0 ms 709 ms 128 ms 14 ms 722 ms 704 ms by a page load, 1,668 ms
SvelteKit 32.7 kB 1,840 ms 7 ms 1,298 ms 143 ms 19 ms 634 ms 1,300 ms by a page load, 969 ms

Where another stack beats CycleWire here (the 95% confidence intervals do not overlap and the difference is at least 3%): First Contentful Paint (Astro + Preact 1,208 ms, CycleWire 1,264 ms); Largest Contentful Paint (htmx 1,572 ms, CycleWire 1,628 ms); JavaScript (Marko 4.8 kB, CycleWire 9.0 kB); Requests (Alpine.js 24, CycleWire 26); Script (Qwik City 14 ms, CycleWire 19 ms); Event listeners (Marko 9, CycleWire 10).

Desktop: fast connection, no CPU slowdown, mouse. 15 iterations on 2026-10-01, AMD EPYC 7763 64-Core Processor (4 cores, GitHub's hosted runner), Chrome 153.0.8010.12.

Stack JavaScript LCP TBT Add to cart Category filter Live search Quick view Newsletter Early tap
Static HTML (control) 0.0 kB 380 ms 0 ms 428 ms 268 ms 317 ms 273 ms 433 ms by a page load, 519 ms
Vanilla JS (control) 1.2 kB 384 ms 0 ms 250 ms 85 ms 6 ms 251 ms 251 ms in the page, 284 ms
Alpine.js 17.9 kB 384 ms 0 ms 250 ms 100 ms 23 ms 251 ms 251 ms in the page, 289 ms
Angular 87.5 kB 392 ms 0 ms 250 ms 116 ms 26 ms 251 ms 251 ms in the page, 300 ms
Astro + Preact 13.1 kB 392 ms 0 ms 251 ms 87 ms 10 ms 251 ms 251 ms by a page load, 522 ms
CycleWire 6.3 kB 384 ms 0 ms 250 ms 98 ms 10 ms 116 ms 251 ms in the page, 283 ms
CycleWire, inline (variant) 0.8 kB 400 ms 0 ms 266 ms 116 ms 8 ms 115 ms 251 ms in the page, 300 ms
CycleWire, intent only (variant) 6.0 kB 384 ms 0 ms 248 ms 98 ms 8 ms 115 ms 251 ms in the page, 299 ms
CycleWire, requests from markup (variant) 8.9 kB 384 ms 0 ms 250 ms 251 ms 368 ms 115 ms 250 ms in the page, 284 ms
Hotwire (Turbo + Stimulus) 31.9 kB 384 ms 0 ms 265 ms 182 ms 385 ms 215 ms 266 ms in the page, 300 ms
htmx 15.3 kB 428 ms 0 ms 250 ms 255 ms 371 ms 251 ms 251 ms in the page, 285 ms
Marko 4.8 kB 384 ms 0 ms 250 ms 86 ms 12 ms 251 ms 251 ms in the page, 284 ms
Next.js 120.1 kB 396 ms 0 ms 263 ms 252 ms 370 ms 416 ms 250 ms in the page, 456 ms
Next.js, client filtering (variant) 119.2 kB 400 ms 0 ms 255 ms 99 ms 10 ms 417 ms 250 ms in the page, 367 ms
Nuxt 70.9 kB 392 ms 0 ms 250 ms 114 ms 27 ms 264 ms 251 ms in the page, 284 ms
Qwik City 37.4 kB 392 ms 0 ms 284 ms 104 ms 30 ms 267 ms 284 ms in the page, 499 ms
SolidStart 31.1 kB 392 ms 0 ms 250 ms 96 ms 6 ms 99 ms 251 ms in the page, 284 ms
SvelteKit 32.7 kB 396 ms 0 ms 412 ms 116 ms 13 ms 91 ms 401 ms in the page, 528 ms

Where another stack beats CycleWire here (the 95% confidence intervals do not overlap and the difference is at least 3%): First Contentful Paint (Astro + Preact 348 ms, CycleWire 384 ms); JavaScript (Marko 4.8 kB, CycleWire 6.3 kB); Layout (htmx 9.5 ms, CycleWire 10 ms); Quick view (SvelteKit 91 ms, CycleWire 116 ms); Event listeners (Marko 9, CycleWire 10).


πŸ”’ Security

  • Markup can only reach code through names you register. There is no eval, no new Function, and no URL is ever read from markup.
  • html escapes by context, refuses positions escaping cannot protect, and rejects javascript: URLs however the attribute value is put together.
  • Nothing inside cw-ignore activates: no actions, triggers, preloads or bindings.
  • Parsed fragments stay inert until they are inserted, and inserted <script> elements never run.
  • The library works under strict CSP and Trusted Types.

See docs/security.md, including a DOMPurify recipe for user-generated content, and SECURITY.md for reporting.


🚒 Versioning and releases

  • CycleWire follows Semantic Versioning. Every release is an annotated git tag (v1.0.0, v1.1.0, …) with a GitHub Release. Its notes come from CHANGELOG.md, and the built files and SRI hashes are attached.
  • Releases are published to npm from GitHub Actions with trusted publishing, so every version from 1.0.1 on carries a signed provenance attestation.
  • On a CDN, cyclewire@1 follows the latest 1.x release, and cyclewire@1.0.0 pins one.

The release process is documented in docs/releasing.md.


πŸ“š Documentation

Guide
Getting started Install, first action, first trigger
Concepts The architecture, compared with Qwik
HTML API Β· JavaScript API Complete reference
TypeScript Β· Command line Β· Vite plugin Β· Devtools Typed actions, template checks, edits without reloads, an in-page inspector
Actions Context, signals, concurrency, errors, patterns
css Β· dom Β· morph Β· signals Β· plugins Optional modules
Shadow DOM Web Components and declarative shadow DOM
Integrations Backends, frameworks, bundlers
Frameworks Β· Libraries Β· Examples React, Vue and Svelte islands; Flatpickr, SweetAlert2, DataTables, jQuery
Performance Β· Security Β· Browser support
Core first, modules later Β· CSS strategy Choosing what to load
Releasing For maintainers

🀝 Contributing

Issues and pull requests are welcome. CONTRIBUTING.md covers setup, tests and the size budgets.

πŸ“„ License

MIT Β© 2026 CycleChain