New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@async/framework

Package Overview
Dependencies
Maintainers
3
Versions
61
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@async/framework

No-build AsyncLoader app runtime with signals, command events, server calls, route partials, cache split, SSR activation, and streaming boundaries.

Source
npmnpm
Version
0.3.0
Version published
Weekly downloads
41
-83.33%
Maintainers
3
Weekly downloads
 
Created
Source

@async/framework

Layer 1 AsyncLoader plus small Layer 2 app, routing, server, cache, and SSR primitives for no-build web apps: signals, async signals, delegated command events, scoped fragment components, server calls, route partials, and out-of-order boundary swaps without a virtual DOM.

pnpm add @async/framework
<main async:container>
  <button type="button" on:click="decrement">-</button>
  <strong signal:text="count"></strong>
  <button type="button" on:click="increment">+</button>
</main>
<script type="module" src="./main.js"></script>
import {
  Async,
  createSignal
} from "@async/framework";

Async.use({
  signal: {
    count: createSignal(0)
  },
  handler: {
    increment() {
      this.signals.update("count", (count) => count + 1);
    },
    decrement() {
      this.signals.update("count", (count) => count - 1);
    }
  }
});

Async.start({ root: document });

What It Is

@async/framework is the browser bootloader layer for Async apps. It keeps the runtime small and explicit:

  • No build step for consumers.
  • No virtual DOM, diff path, hydration runtime, or component rerender loop.
  • Signals are the state boundary.
  • Async.use(...) registers app declarations before or after startup.
  • Handlers live in a registry and run through delegated DOM events.
  • Async signals use native AbortSignal cancellation and suppress stale async completions.
  • Browser and server cache declarations are structurally split.
  • Boundaries can be swapped out of order and rescanned, which keeps server streaming and partial HTML replacement simple.

Higher layers can still add JSX lowering, chunk manifests, server compilation, or resumability metadata later. Layer 1 stays plain HTML plus ESM.

Install

pnpm add @async/framework

The package is ESM-only and supports Node.js 24 and newer for tests, examples, and package lifecycle tooling. Browser consumers import ESM directly.

CDN

The package is browser-ready ESM and can be loaded from UNPKG without a build step. Use @latest for quick prototypes, and pin an exact version in production:

<main async:container>
  <button type="button" on:click="increment">+</button>
  <strong signal:text="count"></strong>
</main>

<script type="module">
  import {
    Async,
    createSignal
  } from "https://unpkg.com/@async/framework@latest";

  Async.use({
    signal: {
      count: createSignal(0)
    },
    handler: {
      increment() {
        this.signals.update("count", (count) => count + 1);
      }
    }
  });

  Async.start({ root: document });
</script>

You can also use an import map so app code imports @async/framework by name:

<script type="importmap">
{
  "imports": {
    "@async/framework": "https://unpkg.com/@async/framework@latest"
  }
}
</script>

<script type="module">
  import {
    Async,
    createSignal
  } from "@async/framework";

  Async.use({
    signal: {
      count: createSignal(0)
    },
    handler: {
      increment() {
        this.signals.update("count", (count) => count + 1);
      }
    }
  });

  Async.start({ root: document });
</script>

Core API

import {
  AsyncLoader,
  Async,
  attributeName,
  asyncSignal,
  createApp,
  createCacheRegistry,
  createComponentRegistry,
  component,
  computed,
  createSignal,
  createHandlerRegistry,
  createPartialRegistry,
  createRegistryStore,
  createRouteRegistry,
  createRouter,
  createServerProxy,
  createServerRegistry,
  createSignalRegistry,
  defineAttributeConfig,
  defineApp,
  defineCache,
  defineComponent,
  defineRoute,
  delay,
  effect,
  html,
  route,
  signal
} from "@async/framework";

App Hub

Async is an exported app hub singleton. It is not installed on globalThis unless you assign it there yourself.

import {
  Async,
  createSignal,
  defineCache,
  defineRoute
} from "@async/framework";

Async.use({
  signal: {
    count: createSignal(0)
  },
  handler: {
    increment() {
      this.signals.update("count", (count) => count + 1);
    }
  },
  server: {
    async "products.get"(id) {
      return this.cache.getOrSet(`products:${id}`, () => db.products.get(id));
    }
  },
  route: {
    "/products/:id": defineRoute("product.page")
  },
  cache: {
    browser: {
      product: defineCache({ ttl: 60_000 })
    },
    server: {
      "products.get": defineCache({ ttl: 30_000 })
    }
  }
});

Async.start({ root: document });

You can also create isolated app hubs and runtimes:

const app = defineApp();
app.use("signal", { count: createSignal(0) });

const runtime = createApp(app, { root: document }).start();
runtime.use("handler", {
  increment() {
    this.signals.update("count", (count) => count + 1);
  }
});

Naming rules:

ShapeMeaning
define*Declaration or app shape that can be registered before runtime
create*Runtime instance or mutable runtime primitive
Async.use(...)App-level declaration registration
registry.register(...)Low-level registration on a concrete runtime registry

Singular registry keys are canonical: signal, handler, server, partial, route, component, and nested cache.browser / cache.server.

Registry Inspection

Async.registry is the global inspection surface for registered app pieces. Every runtime and concrete registry also points at the same backing store:

Async.registry.keys("signal");
Async.registry.entries("route");
Async.registry.snapshot();

const runtime = Async.start({ root: document });

runtime.registry.keys("handler");
runtime.signals.registry === runtime.registry;
runtime.browser.cache.registry === runtime.registry;

Supported inspection types:

signal
handler
server
partial
route
component
cache.browser
cache.server
cache.browser.entries
cache.server.entries

Browser runtime inspection exposes server ids as descriptors, not executable server functions, and does not expose server cache contents:

runtime.registry.keys("server");
runtime.registry.get("server", "products.get");
// { id: "products.get", kind: "server" }

runtime.registry.snapshot().entries.server;
// {}

Signals

const signals = createSignalRegistry();

signals.register("count", createSignal(0));
signals.register("products", createSignal([]));

signals.get("count");
signals.set("count", 1);
signals.update("count", (count) => count + 1);
signals.subscribe("count", (count) => console.log(count));
signals.ref("count").value;

Initializer maps are supported:

const signals = createSignalRegistry({
  count: createSignal(0),
  products: createSignal([])
});

Nested paths read through the first registered signal id:

signals.register("product", createSignal({ title: "Keyboard" }));
signals.get("product.title");
signals.set("product.title", "Headphones");

signal(...) remains a compatibility alias for createSignal(...).

Async Signals

Async signals add loading state, error state, versions, refresh, and cancel to a normal signal value.

const signals = createSignalRegistry({
  productId: createSignal("sku-1")
});

const product = signals.asyncSignal("product", async function () {
  const id = this.signals.get("productId");
  const response = await fetch(`/api/products/${id}`, {
    signal: this.abort
  });

  return response.json();
});

The async function context includes:

FieldPurpose
this.signalsThe signal registry
this.idCurrent async signal id
this.versionRun version
this.abortNative AbortSignal with non-enumerable cancel(reason?)
this.refresh()Start a new run

this.abort can be passed directly to fetch or to delay:

await delay(250, this.abort);

If a dependency read through this.signals.get(...) changes, the async signal reruns and the previous run is aborted.

HTML Protocol

AsyncLoader scans regular HTML attributes:

AttributeBehavior
async:containerMarks a scannable app root
on:click="selectProduct"Delegated command event
on:submit="preventDefault; save"Sequential command chain
on:click="server.cart.add(productId)"Server command with signal args
signal:text="product.title"Text binding
signal:value="productId"Form value binding with writeback
signal:attr:disabled="product.$loading"Attribute binding
signal:class:selected="selected"Class toggle
async:boundary="product"Async or streamed replacement boundary
async:loading="product"Boundary loading template
async:ready="product"Boundary ready template
async:error="product"Boundary error template
<section async:boundary="product">
  <template async:loading="product">
    <p>Loading...</p>
  </template>
  <template async:ready="product">
    <h1 signal:text="product.title"></h1>
  </template>
  <template async:error="product">
    <p signal:text="product.$error.message"></p>
  </template>
</section>

The default prefixes are async:, signal:, and on:. You can switch to data attributes when a host needs that shape:

Async.start({
  root: document,
  attributes: {
    async: "data-async-",
    signal: "data-signal-",
    on: "data-on-"
  }
});

That maps to data-async-container, data-on-click="save", and data-signal-text="product.title".

Command Events

on:* works with any native DOM event name. on:mount and on:visible are reserved pseudo-events with cleanup support.

Command chains use semicolons and are awaited sequentially:

<form on:submit="preventDefault; server.products.save(productId, $form)">
  <input name="title">
  <button>Save</button>
</form>

Plain commands resolve through the handler registry. Built-ins are registered by default:

preventDefault
stopPropagation
stopImmediatePropagation

server.<id>(...) resolves through the server registry or client proxy. Bare arguments read signals. $* arguments read event locals:

ArgumentValue
productIdsignals.get("productId")
cart.quantitysignals.get("cart.quantity")
$valueCurrent element value
$checkedCurrent element checked state
$formCurrent form as a plain object
$datasetCurrent element dataset as a plain object
$eventRaw DOM event, client-only
$elCurrent element, client-only

$event and $el are intentionally not serializable and cannot be passed to server.*(...) commands.

Inline commands are not JavaScript. There is no eval, assignment, branching, arithmetic, or inline await. Complex logic belongs in a registered handler:

handlers.register("addToCart", async function () {
  const productId = this.signals.get("productId");
  const result = await this.server.cart.add(productId);
  this.signals.set("cart", result.cart);
});

Server Calls

Server registries run locally on the server and proxies call an HTTP endpoint from the browser. Both expose the same dotted call shape.

const server = createServerRegistry({
  "cart.add"(productId, quantity) {
    return {
      value: { ok: true },
      signals: {
        cartCount: 3
      }
    };
  }
});

Client proxy:

const server = createServerProxy({
  endpoint: "/__async/server",
  signals,
  loader,
  router
});

await server.cart.add("sku-1", 2);

Server responses can include value, signals, boundary, html, redirect, or error. Signal patches are applied before boundary swaps and redirects.

Router And Partials

Partials are server-rendered fragment functions. They return HTML, html templates, DOM fragments, or a response envelope.

const partials = createPartialRegistry({
  "product.page": async function ({ id }) {
    const product = await this.server.products.get(id);
    return html`<h1>${product.title}</h1>`;
  }
});

The router swaps route partials into a boundary. csr starts from an empty route boundary, renders the current route partial locally, then keeps future navigation local too:

Async.use({
  partial: {
    home() {
      return html`<h1>Home</h1>`;
    },
    "product.page"({ id }) {
      return html`<h1>Product ${id}</h1>`;
    }
  },
  route: {
    "/": defineRoute("home"),
    "/products/:id": defineRoute("product.page")
  }
});

Async.start({
  mode: "csr",
  boundary: "route",
  root: document
});

route(...) remains a compatibility alias for defineRoute(...).

Router modes:

| Mode | Initial route | Later navigation | | --- | --- | | csr | Client renders local partial into boundary | Client renders local partial and swaps | | spa | Existing HTML may already contain route | Client renders local partial and swaps | | ssr | Server rendered document | Browser navigates normally | | ssr-spa | Server rendered document/route boundary | Fetch route partial, apply effects, swap | | mpa | Any document source | Browser navigates normally |

CSR startup can use an empty route boundary:

<main async:container>
  <nav>
    <a href="/">Home</a>
    <a href="/products/sku-1">Product</a>
  </nav>

  <section async:boundary="route"></section>
</main>

Router state lives under router.* signals:

router.url
router.path
router.params
router.query
router.route
router.pending
router.error

Register a wildcard route for an explicit fallback page:

Async.use({
  route: {
    "/": defineRoute("home.page"),
    "/products/:id": defineRoute("product.page"),
    "*": defineRoute("notFound.page")
  }
});

Cache

Cache declarations are split by runtime target:

Async.use({
  cache: {
    browser: {
      product: defineCache({ ttl: 60_000 })
    },
    server: {
      "products.get": defineCache({ ttl: 30_000 })
    }
  },
  server: {
    async "products.get"(id) {
      return this.cache.getOrSet(`products:${id}`, () => db.products.get(id));
    }
  }
});

Browser handlers and browser async signals receive runtime.browser.cache. Server functions and server partials receive runtime.server.cache. Server cache config and contents are never serialized to the browser. Browser cache is seeded only by explicit SSR response data.

Runtime cache registries support:

cache.register("product", defineCache({ ttl: 60_000 }));
cache.get("product:sku-1");
cache.set("product:sku-1", product);
await cache.getOrSet("product:sku-1", () => loadProduct());
cache.delete("product:sku-1");
cache.clear("product:");

SSR Flow

SSR uses related app definitions: a server runtime with server functions, server cache, partials, and route rendering; and a browser runtime with DOM handlers, browser cache, signals, and usually a server proxy.

const serverRuntime = createApp(serverApp, {
  target: "server",
  request
});

const response = await serverRuntime.render("/products/123");

runtime.render(url) returns:

{
  html,
  status,
  signals,
  cache: {
    browser: {}
  }
}

The returned HTML includes a route boundary plus a JSON snapshot:

<section async:boundary="route">
  <!-- server-rendered route partial -->
</section>
<script type="application/json" async:snapshot>{}</script>

Browser activation scans the existing HTML and attaches events. It does not hydrate, diff, patch, or rerender:

createApp(browserApp, {
  root: document,
  snapshot,
  server: createServerProxy({ endpoint: "/__async/server" })
}).start();

Components

Components are scoped fragment functions. They return strings or html templates; AsyncLoader inserts and scans the result. There is no virtual node type and no rerender loop.

const Toggle = defineComponent(function Toggle() {
  const selected = this.signal("selected", false);
  const toggle = this.handler("toggle", function () {
    selected.update((value) => !value);
  });

  this.onMount((target) => {
    target.dataset.mounted = "true";
  });

  return html`
    <button
      type="button"
      on:click="${toggle}"
      signal:class:selected="${selected.id}"
      signal:attr:aria-pressed="${selected.id}"
    >
      Toggle
    </button>
  `;
});

const loader = AsyncLoader({ root: document });
loader.mount(document.querySelector("#app"), Toggle);

component(...) remains a compatibility alias for defineComponent(...).

Component helpers:

HelperBehavior
this.signal(name, initial)Scoped get-or-create signal
this.computed(name, fn)Scoped computed signal
this.asyncSignal(name, fn)Scoped async signal
this.effect(fn)Scoped effect with cleanup
this.handler(name, fn)Scoped handler registry entry
this.render(Component, props)Child fragment rendering
this.onMount(fn)One-shot mount hook
this.onVisible(fn)One-shot visibility hook

on:mount and on:visible are loader pseudo-events with cleanup support. They do not drive component rerenders.

Streaming

Out-of-order HTML can target a boundary and keep delegated handlers working:

loader.swap(
  "product",
  `
    <article>
      <h1 signal:text="product.title"></h1>
      <button type="button" on:click="selectProduct">Select</button>
    </article>
  `
);

swap(boundaryId, fragmentOrTemplate) replaces the boundary contents and rescans the inserted fragment.

Examples

ExampleShows
examples/counterSignal text binding and delegated handlers
examples/productAsync signal loading, ready, and error boundaries
examples/componentsScoped fragment components and lifecycle hooks
examples/streamingBoundary swaps with rescanned handlers
examples/server-callCommand events calling server functions
examples/routerCSR first render and local route boundary swaps
examples/partialsServer-rendered partial fragments
examples/cacheBrowser/server cache declarations
examples/ssrServer render output and browser activation snapshot

Pipeline

@async/pipeline owns GitHub Actions, Pages, and release lifecycle automation. Edit pipeline.ts, then regenerate:

pnpm run pipeline:sync:generate
pnpm run pipeline:sync:check
pnpm run pipeline:github:check

Useful commands:

pnpm run pipeline:verify
pnpm run pipeline:pages
pnpm run pipeline:release:doctor
pnpm run release:check

GitHub Pages builds through the generated pages job. This private repository needs GitHub Pages support enabled before the generated job can deploy.

Stable releases use the generated publish job: it verifies the package, creates or verifies the tag and GitHub Release, publishes npm with provenance, then runs release doctor.

Status

The core runtime is intentionally small. Bundling, lazy chunk manifests, JSX lowering, TSRX lowering, server resource compilation, and higher-level resumability metadata are deferred to later layers.

Keywords

async

FAQs

Package last updated on 17 Jun 2026

Related posts