@nemanjan00/qrp
v0.7.0
Published
A data-first, declarative, low-overhead framework for dashboards. Zero runtime dependencies, no build step required (works with any bundler — Vite/webpack/Rollup/esbuild — or none). The devDependencies below are only for tests, lint, and git hooks.
Maintainers
Readme
Turn data into a dashboard. Nothing else.
A data-first, declarative frontend framework for the browser — reactivity is a Proxy, the DOM is real, and no build step is required — bring a bundler or skip it, your call.
🚀 Getting started · 📦 npm · ▶ Live demo · 📖 API reference · 📝 Changelog · 💡 Why it exists
Zero runtime dependencies and no required build step. Drop it into a
<script type="module"> and run with zero tooling — or npm install it and
let your bundler (Vite, webpack, Rollup, esbuild) resolve subpaths and tree-shake.
Same library either way; the build is your choice, not qrp's. Reactivity is a
Proxy, the DOM is real, and the published core is ~4.6 KB min+gzip — the
number that lines up next to Solid (~7 KB) or React (~45 KB). The whole library,
every module, is ~18.7 KB min+gzip.
The npm package ships a minified build (esbuild) — a flat per-module mirror in
dist/, so a browser pulls the small file — still no build step on your end. The readable source, JSDoc and all, is ~16 KB gzipped if you load it raw; CDNs like esm.sh minify it on the fly, so only the vendored-raw path serves the larger file.
import { state, el, mount } from "@nemanjan00/qrp";
const counter = state({ n: 0 });
mount(document.body, () =>
el("button", { onclick: () => counter.n++ }, () => `clicked ${counter.n}×`));That's the entire setup. No createRoot, no providers, no hydration. You mutate
data, the DOM follows.
⚠️ Before implementing anything, read
docs/SHARP-EDGES.md. qrp uses the platform directly, so a few behaviors follow from how the DOM andProxyactually work — thunk-vs-value reactivity,list()key uniqueness, the runaway-effect guard. None are bugs; each bites exactly once. Five minutes there saves an afternoon of "why doesn't this update / where did my rows go".
🛰️ Small enough to run on a microcontroller
Because the whole library is ~18.7 KB min+gzip, a useful qrp app doesn't need a
server or a CDN — it fits in flash on an ESP32. Not a static form, either: a
live dashboard — telemetry streaming into reactive bindings, an LED you drive,
a config form that rewrites itself with when(), and a WiFi-scan modal built from
portal + trapFocus + dismissable.
▶ Live demo · full example → (PlatformIO project + build)
| what the browser loads | size | |---|---| | qrp core + behaviors + the app, minified | ~12.7 KB | | the whole self-contained HTML page | ~17.4 KB | | gzipped — the total baked into flash | ~7 KB |
react-dom alone is ~45 KB gzipped — ~6× this entire live dashboard, before a
line of your own UI. The chip serves the page from PROGMEM over its own WiFi AP:
no filesystem, no internet, no build step on the device. This is the
zero-dependency premise at its literal limit — the "server" is an $8
microcontroller and the "build pipeline" is gzip | xxd -i.
// the entire web app, served straight from flash:
void handleRoot() {
server.sendHeader("Content-Encoding", "gzip");
server.send_P(200, "text/html", dashboard_html_gz, dashboard_html_gz_len);
}📥 Install
New here? The Getting Started guide is a ten-minute, zero-to-dashboard walkthrough of the five core ideas. This section is the reference; that's the tutorial.
npm i @nemanjan00/qrpThe bare @nemanjan00/qrp/* specifiers in the snippets below resolve through the
package's exports map, so with any bundler they just work — and unused
subpaths tree-shake away.
Prefer no tooling? Vendor it + an import map — the solid no-build path. Copy
the package's dist/ folder to your server and map the name once. dist/ is a
flat, minified mirror — one file per module, plain names, no hashes — so serving
it is just static files:
<script type="importmap">
{ "imports": {
"@nemanjan00/qrp": "/vendor/qrp/qrp.js",
"@nemanjan00/qrp/table": "/vendor/qrp/table.js",
"@nemanjan00/qrp/behaviors/portal": "/vendor/qrp/behaviors/portal.js"
} }
</script>
<script type="module">
import { state, el, mount } from "@nemanjan00/qrp";
import { table } from "@nemanjan00/qrp/table";
</script>Copy the whole dist/ directory — modules import their siblings by relative path
(table.js → ./qrp.js), so the core is shared, not duplicated. This site's own
pages vendor it this way (see the import map in index.html); it's
what I'd reach for in production.
Quick demo? A CDN works too — no install at all:
<script type="module">
import { state, el, mount } from "https://esm.sh/@nemanjan00/qrp";
import { table } from "https://esm.sh/@nemanjan00/qrp/table";
</script>Fine for a prototype or a REPL, but a CDN is a network dependency: one flaky fetch and the page comes up blank, with no graceful degradation. For anything real, vendor it (above) — it's the same code, served by you.
🧭 What is qrp?
qrp does one thing well: it turns data into a dashboard — the settings panels, forms, tables, and control UIs that make up an admin or internal tool. It is not trying to be a general-purpose app framework that competes with React. It's the right tool for the enormous class of apps that are, at heart, "render this data, let me edit it, reflect the changes."
- Reactive state is a
Proxy. Read a key inside aneffect, and that effect re-runs when the key changes — nothing else does. - The DOM is real.
el()returns actual elements; there's no virtual DOM and no reconcile pass on update. A field edit touches exactly one text node. - Keyed lists reuse elements. Sort, filter, or paginate 10,000 rows and qrp moves nodes instead of rebuilding them.
- Batteries where dashboards need them: declarative forms, a data table, a fetch client, a toast system, an event bus — each an independent module you import only if you use it.
💡 Why it exists
Modern frontend split one app into two — a "frontend app" and a "backend app" — and in doing so duplicated state, validation, types, and auth across the wire. Then it papered over the duplication with more machinery than it removed: a compiler, a bundler, a hydration pass, a client-side cache to hold a copy of data the server already has.
For a dashboard behind a login, most of that machinery is paying for problems you
don't have. Nobody SEOs an admin panel, so you don't need SSR or hydration. Your
data lives one fetch away, so you don't need a normalized client store. What you
actually need is: show the data, let me sort and edit it, send it back.
qrp keeps the good ideas the platform already ships — the DOM, URL,
EventTarget, the History API, Proxy — and adds only the thin reactive layer
that ties them together. The result is small, fast, and boring in the way
infrastructure should be.
🚀 5-minute example
A live, sortable, filterable table with a modal — the whole thing, no build step required:
import { state, el } from "@nemanjan00/qrp";
import { table } from "@nemanjan00/qrp/table";
import { portal } from "@nemanjan00/qrp/behaviors/portal";
import { trapFocus } from "@nemanjan00/qrp/behaviors/trap-focus";
import { dismissable } from "@nemanjan00/qrp/behaviors/dismissable";
import { notify, toasts } from "@nemanjan00/qrp/toasts";
import { html } from "@nemanjan00/qrp/html";
const users = state({ rows: await fetch("/api/users").then(r => r.json()) });
const filter = state({ q: "" });
const t = table({
rows: () => users.rows,
key: (u) => u.id, // the :key equivalent
filter,
filterFn: (u, f) => u.name.toLowerCase().includes(f.q.toLowerCase()),
page: state({ index: 0, size: 20 }),
fields: [
{ key: "name", label: "Name", sortable: true },
{ key: "email", label: "Email", sortable: true },
{ key: "signups",label: "Signups",sortable: true, formatter: (v) => v.toLocaleString() },
{ key: "actions",label: "", render: (u) => el("button", { onclick: () => open(u) }, "View") }
]
});
// a search box wired to the filter, written as HTML:
const search = html`
<input type="search" placeholder="Filter users…"
oninput=${(e) => { filter.q = e.target.value; }}>`;
document.querySelector("#app").append(search, t);
// a modal from three headless behaviors + an html template
function open(user) {
const dialog = html`
<div class="qrp-modal">
<h3>${user.name}</h3>
<p>${user.email} — ${() => user.signups.toLocaleString()} signups</p>
<button onclick=${() => close()}>Close</button>
</div>`;
const backdrop = el("div", { class: "qrp-modal-backdrop" }, dialog);
const remove = portal(backdrop); // → document.body
const untrap = trapFocus(dialog); // focus trap + restore
const undismiss = dismissable(dialog, () => close()); // Esc / outside-click
function close() { undismiss(); untrap(); remove(); notify.info("Closed"); }
}Sorting, filtering, pagination, keyed row reuse, a focus-trapped modal, and
toasts — with no framework runtime to boot. Ship it through your bundler or drop
it in a <script>; the code above is the same either way.
✨ Features
Two ways to write markup. Prefer functions, or prefer HTML — both are first-class and produce the same real DOM.
// el() — plain-DOM helper; function props/children are reactive
el("span", {}, () => `count: ${counter.n}`);
// html`` — for people who think in HTML. Interpolated text values are escaped;
// ${() => …} holes are reactive; onX=${fn} wires listeners.
html`<button onclick=${() => counter.n++}>${() => counter.n}</button>`;Storable templates. ${} is JavaScript's interpolation — gone before html()
sees it. For a template you keep in a file or config and fill later, use
html.template with #{} placeholders — parsed once, reactive when filled with
state:
const row = html.template("<tr><td>#{name}</td><td>#{email}</td></tr>");
row(user); // → DOM bound to user.name / user.email
row(state({ name: "R2" })); // reactive; #{} fields escaped as textOn escaping (the precise guarantee). A value interpolated in text
position (a child hole, ${} or #{}) is rendered as a text node — it can never
inject an element, <script>, or event handler, whatever string it holds.
Attribute holes are set as the attribute value verbatim (via setAttribute /
property, never re-parsed as HTML — so a value can't break out into a new
attribute or tag), but qrp does not sanitize URL schemes: a javascript:
value in an href passes through, same as Lit. Don't put untrusted data in
href/src/style without your own check. The full set of attack vectors —
<script>/<img onerror> in text holes, unquoted-attribute breakout attempts,
the javascript: boundary — lives in test/html-xss.test.js and is verified in
real Chromium.
And to drop a live node into a plain concatenated string (no tagged template),
ref() gives you an opt-in token — no prototype patching:
html("<div class='card'>" + ref(myButton) + "</div>"); // real node, injectedReactive state that tracks per key.
const s = state({ first: "Ada", last: "Lovelace", tags: [] });
const full = derive(() => `${s.first} ${s.last}`); // recomputed on change
effect(() => console.log(full.value)); // logs on change
s.tags.push("math"); // arrays are reactive tooDeclarative forms with an open input registry. Describe fields as data; add your own input types at runtime.
registerInput("callsign", (settings, key, field) => {
const input = inputs.text(settings, key, field);
input.addEventListener("input", () => settings[key] = input.value.toUpperCase());
return input;
});
form({ settings, fields: {
name: { label: "Name", type: "text" },
call: { label: "Callsign", type: "callsign" }, // your custom type
mode: { label: "Mode", type: "select", options: { dmr: "DMR", ysf: "YSF" } }
}});Keyed lists that reuse elements. The primitive under every table.
el("tbody", {}, list(
() => view.items(), // reactive source (items() is a method)
(row) => row.id, // stable key
(row) => html`<tr><td>${() => row.name}</td></tr>` // built once per key
));Conditional subtrees, with cleanup. when() swaps a branch on a condition
and disposes the old branch's effects — no leaks, no DOM surgery. It's
value-keyed, so it drives tabs directly (re-renders when the value changes,
not just on true⇄false):
// edit vs display
el("div", {}, when(() => editing.on,
() => html`<input value=${row.name}>`,
() => html`<span>${() => row.name}</span>`));
// a tab switcher — re-renders on every tab
el("div", {}, when(() => ui.tab, (tab) => TABS[tab]()));A fetch client built for dashboards. URL shaping, auth headers, a reactive in-flight loader, and errors routed to the toast bus.
const http = createHttp({ baseUrl: "/api", token: () => session.token });
http.get("/things", { params: { page: 2, ids: [1, 2, 3] } }); // → parsed JSON
effect(() => bar.hidden = http.loading.pending === 0); // a spinner in one line
http.get("/export.msgpack", { responseType: "arraybuffer" }); // binary, not just JSONDashboard utilities that carry the boring parts. Rate-limit, debounce, validate — the stuff every admin tool hand-rolls.
const search = debounce((q) => http.get("/find", { params: { q } }), 300);
const sync = limit((id) => http.post(`/follow/${id}`), { max: 5 }); // ≤5 in flight
const { errors, value } = validate(schema, form); // value is coerced; [] = okReactive routing & owned side-effects. The route is state; UI built outside a render stays owned.
el("a", { class: () => currentRoute.path === "/users" ? "active" : "" }, "Users");
const { value: modal, dispose } = scoped(() => buildDialog()); // no ownerless effects
onEffectError((err) => Sentry.captureException(err)); // central crash reportingPlus: a global event bus, cross-tab persistence, HTML5 routing with :param
patterns (keep-alive on same-pattern navigation), and headless behaviors for
modals, dropdowns, tooltips, and disclosures.
📦 Modules
Each module is an independent file — import only what you use; with a bundler, unused exports tree-shake away.
| Module | What it gives you |
|--------|-------------------|
| @nemanjan00/qrp | Core: state, effect, derive, untracked, raw, onEffectError, el, reactive, bind, list (keyed), when, clear, mount, scope, onDispose, router, navigate, currentRoute, compilePath |
| @nemanjan00/qrp/html | html `` / html() (inline, ${} holes), html.template (storable, #{} placeholders), ref (inject a live node into a plain string) — author DOM as HTML; text holes escaped (see escaping guarantee above) |
| @nemanjan00/qrp/forms | Declarative forms + open input-type registry (registerInput, field, form, parseKV) |
| @nemanjan00/qrp/table | Declarative data table: sortable headers, keyed row reuse, per-column accessor/formatter/render |
| @nemanjan00/qrp/collection | Reactive sort/filter/paginate over a dataset — drives a keyed list() |
| @nemanjan00/qrp/datagrid | Headless data-grid state over collection: keyed selection (select-all/indeterminate), column visibility, page-size, windowed pager — no markup |
| @nemanjan00/qrp/http | createHttp — fetch client with auth headers, reactive loader, error bus routing, per-request responseType (json/text/binary), pluggable fetch |
| @nemanjan00/qrp/events | Global event bus on native EventTarget: bus, emitter, request/respond, channel |
| @nemanjan00/qrp/toasts | Notifications off the bus: notify.*, mountable toasts; content is any renderable |
| @nemanjan00/qrp/browser | Reactive wrappers over native APIs: persisted, query, media, viewport, online, cookies, seen |
| @nemanjan00/qrp/behaviors/* | Headless helpers to build styled components: portal, dismissable, trapFocus, anchored, disclosure, busyWhile |
| @nemanjan00/qrp/utils/* | Pure data helpers for dashboards: memoize (+ttl/invalidate), lru, cache, paginate, limit (concurrency/rate/timeout), debounce/throttle, validate, loadScript |
| @nemanjan00/qrp/proto | Prototype-level enhancement (objects & __proto__, no classes): findProto, wrapMethod, onceOnly, delegate |
| qrp.css | Optional minimal baseline (design tokens + semantic classes). Link it yourself. |
📊 Performance
The headline: change one field in a 10,000-row table
This is the operation where qrp's architecture is categorically different from
a virtual-DOM framework — so it deserves its own measurement. Mutate one row's
field; the Proxy setter fires exactly one tracked effect, which writes the one
text node it already holds a reference to. Measured in real Chromium with a
MutationObserver watching the whole table:
1 DOM node touched, out of 40,002. No diff. No component re-render. No reconcile pass. The cost is ~0.8 µs of reactivity bookkeeping.
That "1 of 40,002" is the important number — it's machine-independent and falsifiable: rerun it on any hardware and you get the same answer, because it's architectural, not a timing.
Measured against React 18, same operation, same MutationObserver. The
setup, stated precisely so the number defends itself: React 18.2 production
build; rows are keyed and wrapped in React.memo; the update replaces one row
object in a new array; flushSync makes the reconcile synchronous so it can be
timed.
| Change one cell in 10,000 rows | JS per update | DOM nodes touched | |---|---|---| | qrp | ~0.8 µs | 1 of 40,002 | | React 18 | ~800 µs | 1 of 40,002 |
Both touch exactly one DOM node. Where React's ~800 µs goes: React.memo
stops each row's DOM from re-rendering, but the parent still re-runs and
allocates 10,000 element descriptors, and the reconciler still walks all 10,000
children to find the one that changed — that walk is the cost. qrp has no walk;
the Proxy subscription already points at the one text node, so it spends
~0.8 µs. Same DOM outcome, ~1000× the work — the reconcile pass qrp doesn't
have. It's O(1) and independent of table size.
Two things people rightly challenge, answered up front:
flushSyncis cheating / concurrent mode defers.flushSyncdoesn't add work — it stops batching from smearing the reconcile across a later frame so it can be timed. Concurrent mode can slice that walk and interleave it, but the total CPU is the same; we're measuring it, not hiding it behind a frame.- You wrote slow React. Colocating state per row (a store per row) does narrow the gap — but that's the point: you have to restructure to avoid the reconcile; qrp is O(1) for free with the ordinary "data in a parent array" pattern.
Reproduce it: examples/react-compare.html (the objections are answered in its
comments too).
To put ~0.8 µs in perspective without overclaiming: it's ~20,000 single-cell updates' worth of bookkeeping inside one 16 ms frame budget. In practice you'd never do that many — layout and paint dominate long before (see "update every 10th row" below, which is 8 ms paint-timed for 1,000 cells: ~10% reactivity, ~90% paint). That internal consistency is the point: the reactivity is real and small, and paint is the rest.
Honest footnote — vs hand-written vanilla, not React: this is a win over
virtual-DOM frameworks. A bare textContent = when you already hold the node
reference is ~0.27 µs; finding the cell with querySelector first is ~0.46 µs.
qrp's ~0.8 µs is slightly more — reactivity isn't free, and that ~0.5 µs is
exactly what buys the automatic dependency tracking. qrp derives the node
reference from the subscription, so you never write or maintain the id → node
map the fast vanilla depends on.
The rest of the suite
Paint-timed (time until the frame is painted, not just the synchronous write), median of 5 after warmup, against a hand-written keyed vanilla-DOM control — "the floor", not another framework.
| Operation | qrp | vanilla DOM | ratio | |---|---|---|---| | create 1,000 rows | 29 ms | 31 ms | 0.9× | | create 10,000 rows | 271 ms | 214 ms | 1.3× | | replace all 1,000 rows | 32 ms | 33 ms | 1.0× | | update every 10th row | 8 ms | 9 ms | 0.9× | | swap 2 rows in 10,000 | 44 ms | 34 ms | 1.3× | | remove 1 row in 10,000 | 58 ms | 48 ms | 1.2× | | select 1 row in 10,000 | 10 ms | 7 ms | 1.5× | | clear 10,000 rows | 40 ms | 22 ms | 1.8× |
On create, replace, update, and remove, qrp is at hand-written DOM parity
(0.9–1.3×) — there's no reconcile pass to pay for, because updates are
fine-grained. The swap case was 5.9× before a longest-increasing-subsequence
reconcile brought it to 1.3×. The remaining gaps are per-row subscriptions
(select) and scope disposal (clear). clear at 1.8× is the honest weak
spot: it disposes each row scope's effects individually, so it scales with
bindings-per-row — fatter rows make it worse. A bulk scope-drop that frees
children without visiting each dep-set would fix it; not done yet.
Run it yourself: examples/bench.html exposes the suite on window.bench
(runAll(), updateOneNs(), mutationsForOne()).
🧠 Philosophy
Four commitments gate every design decision:
- Data first. Reactive state is the single source of truth; the DOM is a reflection of it. You never imperatively poke the DOM to stay in sync.
- Declarative first. Forms are data, routes are patterns, tables are column configs. You say what, not how.
- Low overhead. No runtime to boot, no hydration, no virtual DOM. Small gzipped size, fast first paint, cheap updates.
- Do one thing well. Small, sharp, composable modules. qrp ships helpers
to build styled components, not components — a table is
collection+list; a modal isportal+trapFocus+dismissable. You bring the markup and CSS; the helpers carry the platform and a11y hard parts.
And underneath all of it: use the platform. URL, URLSearchParams,
EventTarget, IntersectionObserver, the History API — qrp wraps them reactively
instead of reinventing them.
🔬 Advanced implementation details
Reactivity is a Proxy with per-key dependency tracking. state() wraps a
plain object; the get trap records (effect, key) pairs, the set trap
re-runs exactly the effects that read that key. Writes use Object.is, so
NaN-over-NaN doesn't spuriously re-trigger. Arrays track length explicitly
so push() updates length-only readers. Frozen objects are returned as-is —
Object.freeze(bigStaticData) is the documented opt-out from reactivity, and it
makes reads free.
Ownership-based cleanup. Every effect created during a component's render is
adopted by its scope; unmounting disposes them all — no manual unsubscribe.
onDispose(fn) registers arbitrary cleanup against the current scope, which is
how list(), when(), and every browser/ factory tear down their
subscriptions on unmount. An effect that throws is torn down (unsubscribed) and
the error propagates — no dangling subscriptions.
Keyed reconciliation with minimal moves. list() keeps a Map<key, element>
and a WeakMap<element, item> (the latter powers itemFor() for one-listener
event delegation over huge lists). On change it diffs new positions against old,
keeps the longest increasing subsequence as a stable backbone that never moves,
and inserts only new or genuinely-displaced rows. A 2-row swap does 1 DOM move,
not O(n).
The DOM rejects Proxy-wrapped nodes (appendChild brand-checks its
argument), verified in Chromium — so reactive(node) unwraps to the raw node on
insert, and state() never proxies DOM nodes, Map/Set, or class instances
stored inside it. A DOM node also lives in exactly one place, so reusing content
across sites means a thunk () => el(...), never a shared live node.
Synchronous by design. qrp writes to the DOM synchronously — there's no
scheduler and no batching, which is why updates are cheap and why it's not
interruptible the way React's concurrent renderer is. For a dashboard that's the
right trade; if you need to coalesce a high-frequency stream, do it upstream of
state.
▶️ Running the demos
The demos use HTML5 History routing, so serve over HTTP (not file://):
python -m http.server 8000
# then open http://localhost:8000/examples/table.htmlexamples/ — table.html (styled data table + modal), todomvc.html (the
classic, built with list() + when()), index.html (forms, routing, toasts),
bench.html (the performance harness).
Note: a plain static server has no SPA fallback, so refreshing a deep route (e.g.
/settings/user) 404s. That's expected for a History-API app, not a qrp bug.
🟦 TypeScript
Types ship as *.d.ts next to each module, so importing ./qrp/index.js
resolves them automatically — no @types package, no build step, no change to
how qrp loads. Generics flow through: state<T>, list<T>, collection<T>,
table<T>, memoize, and the rest.
import { state, el, list } from "@nemanjan00/qrp";
interface User { id: number; name: string; }
const users = state<{ rows: User[] }>({ rows: [] });
el("ul", {}, list(() => users.rows, (u) => u.id, (u) => el("li", {}, () => u.name)));npm run typecheck runs tsc --noEmit over the declarations and a usage suite
in strict mode.
🧫 Testable in plain Node
Because qrp writes to the real DOM — no virtual DOM, no hydration, no
framework-specific test renderer — a headless DOM like happy-dom (or jsdom) is
all you need to unit-test a component in a plain node --test script. Mount it,
write to state, assert the DOM changed. No browser, no @testing-library
adapter, no jsdom-vs-framework glue.
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register(); // a DOM in Node — `npm i -D @happy-dom/global-registrator`
import test from "node:test";
import assert from "node:assert/strict";
import { state, el, list, mount } from "@nemanjan00/qrp";
test("rows render and react to a state write", () => {
const store = state({ rows: [] });
const { dispose } = mount(document.body, (view) =>
view.appendChild(el("table", {}, el("tbody", {}, list(
() => store.rows, (r) => r.id, (r) => el("tr", {}, el("td", {}, () => r.name)))))));
assert.equal(document.querySelectorAll("tbody tr").length, 0);
store.rows = [{ id: 1, name: "Ada" }, { id: 2, name: "Bea" }, { id: 3, name: "Cy" }];
assert.equal(document.querySelectorAll("tbody tr").length, 3); // reacted, synchronously
dispose();
});Writes are synchronous, so there's nothing to await — the assertion runs right
after the state write. This is how qrp tests itself (260 tests, node --test +
happy-dom); your app tests the same way.
📖 API reference
Full reference — every export, signature, and a usage snippet per module — in
docs/API.md. Gotchas worth knowing once: docs/SHARP-EDGES.md. Styling & theming: docs/STYLING.md.
🧪 Tests & tooling
npm install # dev-only: happy-dom (tests), eslint, typescript, husky
npm test # node --test — 260 tests across every module
npm run lint # eslint (eslint:recommended + house style)
npm run typecheck # tsc --noEmit over the .d.ts + a usage suite (strict)The framework has zero runtime dependencies; everything in devDependencies
is for tests, lint, types, and the pre-commit hook. Nothing is required to use
qrp — just load the modules.
📄 License
MIT © Nemanja Nedeljkovic
Named after QRP — the ham-radio practice of getting the job done with the least possible power.
