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, nonew 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/ssrpackage
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.
npx @purityjs/cli my-app # client-only
npx @purityjs/cli my-app --ssr # SSR + hydration
cd my-app
npm install
npm run dev| 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 |
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')!);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.
// 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.
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)
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.
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.
Long-form guides live in /docs:
-
Public documentation site — searchable guides and architecture decisions, built with Purity
-
Migration cheatsheet (React / SolidJS / Vue / Svelte → Purity)
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-fixMIT