Skip to content

Repository files navigation

Purity

npm version bundle size license GitHub stars

A minimal web framework with TC39-Signals-inspired reactivity and templates that compile to direct DOM operations.

  • A tiny API — that's the whole thing
  • No virtual DOM — signals drive DOM updates directly
  • CSP-safe — no eval, no new Function (with the Vite plugin)
  • Zero runtime dependencies
  • Web Components by design — component() registers a Custom Element with Shadow DOM (see tradeoffs in the package README)
  • Race-safe async — first-class resource() with built-in cancellation, retry, polling, debouncing, and SWR by default
  • Server-side rendering — renderToString + hydrate() ship with Declarative Shadow DOM and resource-aware SSR via the @purityjs/ssr package

The signals implementation is a custom push-pull graph inspired by the TC39 Signals proposal (Stage 1). It is not a polyfill or a binding to a native engine API — no engine ships TC39 Signals yet.

Quick Start

npx @purityjs/cli my-app          # client-only
npx @purityjs/cli my-app --ssr    # SSR + hydration
cd my-app
npm install
npm run dev

Packages

Package Description Docs
@purityjs/core The framework README
@purityjs/ssr renderToString + renderToStream + DSD + resource awaiting README
@purityjs/vite-plugin AOT template compilation (client + SSR) README
@purityjs/cli Project scaffolding (--ssr flag available) README

At a Glance

import { state, compute, html, css, component, mount } from '@purityjs/core';

component('p-counter', () => {
  const count = state(0);
  const doubled = compute(() => count() * 2);

  css`
    button {
      padding: 0.5rem 1rem;
    }
  `;

  return html`
    <p>${() => count()} (x2: ${() => doubled()})</p>
    <button @click=${() => count((v) => v + 1)}>+1</button>
  `;
});

mount(() => html`<p-counter></p-counter>`, document.getElementById('app')!);

Async data, race-safe by default

import { state, resource, lazyResource, debounced } from '@purityjs/core';

const id = state(1);

// Reactive resource with retry and polling baked in
const user = resource(
  () => id(),
  (id, { signal }) => fetch(`/u/${id}`, { signal }).then((r) => r.json()),
  { retry: 3, pollInterval: 30_000 },
);

user(); // current data (tracked) — preserved across refetches (SWR)
user.loading(); // boolean (tracked)
user.error(); // unknown (tracked)
user.refresh(); // re-fetch with the same deps
user.mutate(v); // optimistic update
user.dispose(); // tear down (or auto-cleans on component unmount)

// Imperative form for mutations / button-triggered fetches
const save = lazyResource((data: SaveArgs, { signal }) =>
  fetch('/save', { method: 'POST', body: JSON.stringify(data), signal }),
);
save.fetch({ name: 'x' });

// Debounce a signal before driving a resource
const search = state('');
const query = debounced(search, 300);
const results = resource(
  () => query() || null,
  (q, { signal }) => fetch(`/search?q=${q}`, { signal }).then((r) => r.json()),
);

Stale requests are aborted automatically when id changes or the component unmounts. Out-of-order resolutions are dropped via a monotonic run counter. Retries honor the abort signal — a dep change cancels mid-backoff.

Server-side rendering

// entry.server.ts
import { renderToString } from '@purityjs/ssr';
import { App } from './app.ts';

export const render = (_url: string) => renderToString(App);

// entry.client.ts
import { hydrate } from '@purityjs/core';
import { App } from './app.ts';

hydrate(document.getElementById('app')!, App);

The same component code runs on Node and in the browser. Custom elements ship as Declarative Shadow DOM (<template shadowrootmode="open">) so the browser parses a real shadow tree before any JS loads. Resources created during render are awaited; the resolved values are embedded as a JSON payload that hydrate() reads to skip the first refetch. Call hydrate() on the containing app root: it binds component properties before hydrating nested shadow trees, so typed props retain their values.

Scaffold with npx @purityjs/cli my-app --ssr or see examples/ssr for a working setup.

See each package README for full API documentation.

Benchmarks

Framework comparisons require current versions and equivalent, framework-native workloads. The benchmark source records the scenarios and run method; rerun it before quoting performance results.

Live demo — a polling dashboard built end-to-end on Purity (state, compute, resource with retry+pollInterval, lazyResource, debounced, each, mount): koalafacts.github.io/Purity/dashboard (source)

Status

Pre-1.0 (0.3.0). The API may break between minor versions until 1.0. See the changelog for upgrade notes. There is no public versioning policy yet, and we don't know of any production users. If you ship Purity to users, please open an issue so we can keep your use case in mind for the breaking-change discussions.

What this framework does NOT do

Knowing what's missing matters more than what's there. As of 0.3.0:

  • DevTools has limited scope. The opt-in development panel shows the reactive graph, without source locations, a component hierarchy or time travel; see the debugging guide.
  • No production track record. Pre-1.0; we know of zero production deployments. Treat as a serious side-project, not a battle-tested tool.
  • Accessibility needs application-level verification. Browser fixtures cover form and Shadow DOM patterns, but NVDA and VoiceOver behavior has not been verified. See the accessibility guide.

Docs

Long-form guides live in /docs:

Development

npm test                # all workspace test scripts
npm run test:published-cli # fresh npm scaffold, build, preview, and browser smoke
npm run check           # format check + lint (oxfmt + oxlint)
npm run check:fix       # auto-fix

License

MIT

About

No description, website, or topics provided.

Resources

Security policy

Accessibility

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages