Show HN: NoDiff, a framework that lives in your monorepo
NoDiff is a minimal TypeScript framework that treats TSX as browser syntax, compiling directly to real DOM nodes without React or a virtual DOM. It bundles state bindings, zod-validated API data, TTL caching, token auth, routing, forms, and security defaults into a fork-first monorepo package that evolves alongside your app.
Notifications You must be signed in to change notification settings
Fork 0
Star 2
BranchesTags
Open more actions menu
Folders and files
NameName
Last commit message
Last commit date
Latest commit
History
46 Commits
46 Commits
apps/demo
apps/demo
packages/nodiff
packages/nodiff
.gitignore
.gitignore
.oxfmtrc.json
.oxfmtrc.json
LICENSE
LICENSE
README.md
README.md
bun.lock
bun.lock
package.json
package.json
tsconfig.base.json
tsconfig.base.json
Repository files navigation
A tiny TypeScript framework for rich-client apps that treats TSX as browser syntax. This repo (which was built with LLM assistance) is a work-in-progress!
In the LLM era, you do not need a fat web framework for every browser app, especially when your project grows. You often want a lean runtime that lives in the same monorepo as your app and evolves with it.
That shape is easier for people and coding agents to integrate, inspect, change, and reason about. Fewer hidden layers means fewer bugs, faster feedback, and less time spent reverse-engineering framework behavior.
NoDiff gives you JSX ergonomics, real DOM nodes, explicit store subscriptions, zod-checked data, localStorage caching, token auth, forms, and routing. It does this without React, axios, a virtual DOM, a scheduler, or a framework runtime that owns your app.
The whole idea is small enough to keep in your head:
const counter = createStore(() => ({ count: 0 }));
function App() { return (
); }
mount("#app", App);
TSX compiles through Vite into calls to @nodiffjs/core/jsx-runtime. Those calls create real DOM nodes (like Svelte). Stores decide when small regions update. Data enters the app through zod schemas. Cache entries carry expiry. Auth is just a bearer header helper over an app-owned token.
This repo contains both the framework package and a demo app.
nodiff/ packages/nodiff/ framework package, published name @nodiffjs/core apps/demo/ demo rich-client app
Fork-first workflow
NoDiff is meant to be forked as a monorepo. The cleanest development loop is to edit the framework package and the app together, then keep the pieces that fit the app you are building.
The demo app intentionally consumes packages/nodiff/src through Vite and TypeScript aliases for @nodiffjs/core. That keeps HMR and editor navigation on source files while you change the runtime, bindings, router, API client, and app code in one workspace.
The package build is still kept clean. packages/nodiff emits dist, declares package exports, uses publishable dependency ranges, and ships package-local README/LICENSE files. Treat that artifact as a consumer/publishing check, not as the source path used by the demo during normal development.
Why this exists
Modern front-end apps often begin with a large default stack. A JSX renderer. A request client. A router. A global state tool. A form library. A cache layer. Auth glue. Then the browser shows up at the end.
NoDiff starts from the other side.
The browser already has:
a mutable DOM tree
event listeners
custom validity on forms
fetch
AbortController
localStorage
history and hashchange
native modules
The missing piece is a clean TypeScript surface that makes those primitives pleasant enough for a real app.
NoDiff is that surface. It keeps the familiar parts of a modern app, such as TSX, typed stores, schema-checked data, cached reads, and route components, while making each moving part visible.
The core bet
TSX is useful even when React is absent.
With this config:
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@nodiffjs/core" } }
this:
Hello
compiles to calls into this package:
import { jsx, jsxs } from "@nodiffjs/core/jsx-runtime";
The runtime creates HTMLElement instances directly, applies props, attaches event listeners, runs actions, and appends children. No virtual DOM tree is built. No diff pass runs. No component replay is needed for a button click.
State-driven parts opt in explicitly:
{text(session, (state) => state.user?.name ?? "anonymous")}
;
{ view( posts, (state) => state.visible, (items) => (
{items.map((item) => (
{item.title}
))}
), ); }
You can read the code for those pieces in a few files.
What you get
Area Included
TSX runtime jsx, jsxs, jsxDEV, Fragment, intrinsic element typing
DOM mount, append, direct node creation, events, refs, actions
Lifecycle cleanup on unmount and region redraw
State bindings text, view, when, Show, list, For, ResourceView, Await, bind.*
Stores works with zustand/vanilla and any compatible store shape
API client fetch, query params, JSON body handling, zod parsing, auth headers, 401 hook
Cache localStorage envelopes with TTL, tags, stale reads, schema validation
Auth bearer token controller over zustand vanilla with optional persistence
Resource state loading, stale, success, error, abort, refresh, mutate
Router hash or history mode, route params, query params, active links
Forms zod-backed submit action, native validity messages
Security DOM sink hardening, strict request policy, CSRF helpers, safe error fallbacks
Tooling Bun workspaces, Vite 8, TypeScript 7 native preview, oxlint, oxfmt
Run it
Prerequisite: Bun.
bun install bun run dev
Open the local Vite URL printed in the terminal.
Useful commands:
bun run typecheck bun run lint bun run format bun run check bun run security:check bun run build
The root scripts run the framework package first, then the demo app where that ordering matters.
During bun run dev, the demo resolves @nodiffjs/core to packages/nodiff/src instead of dist. This is deliberate: framework edits and app edits should update together in the fork.
Current toolchain
The repo is intentionally modern:
Bun workspaces with dependency versions centralized in the root catalog
Vite 8 for the demo app
TypeScript 7 native preview through @typescript/native-preview and tsgo
oxlint with type-aware linting through oxlint-tsgolint
oxfmt for formatting
ES2022 browser target
bun run check runs strict typechecking, type-aware linting, format checking, and tests. bun run security:check adds the dependency audit and production build on top.
Security defaults
NoDiff tries to make the safe path the short path for rich-client apps:
DOM text is escaped by construction. The innerHTML prop is rejected, raw HTML must be wrapped with trustedHTML(...) or passed through sanitizeHTML(...), dangerous tags are blocked, URL attributes are scheme/origin checked, CSS execution sinks are rejected, event props must be functions, and _blank links get noopener noreferrer.
configureSecurityPolicy(...) installs a process-wide policy for strict origin checks, allowed URL schemes, HTTPS enforcement, cache TTL/schema rules, CSRF mode, and security event reporting.
createApi(...) resolves requests against a configured baseUrl, strips managed auth headers from other origins, supports required auth, zod response schemas, cache partitioning by auth, strict cached-response schemas, bounded cache TTLs, request timeouts, external-origin opt in, and CSRF headers.
createAuth(...) keeps tokens in memory by default. localStorage persistence is explicit, and refresh-token persistence requires a second explicit opt in.
catchRender and router error/onError hooks render redacted failures by default, so route exceptions do not become stack traces or secret-bearing messages in the UI.
contentSecurityPolicy(...) and securityHeaders(...) produce strict server headers that match the same policy model.
The demo app opts into strict mode with one policy:
export const securityPolicy = configureSecurityPolicy({ mode: "strict", allowedOrigins: ["self", "https://jsonplaceholder.typicode.com"], allowedUrlSchemes: ["http:", "https:"], enforceHttps: true, cache: { requireSchema: true, maxTtl: 5 * 60 * 1000 }, csrf: "double-submit-cookie", });
export const publicApi = createApi({ baseUrl: "https://jsonplaceholder.typicode.com", security: securityPolicy, });
export const sessionApi = createApi({ baseUrl: "/api", security: securityPolicy, getAuthHeaders: auth.authHeaders, csrf: { getToken: readCsrfToken, required: "state-changing" }, });
The demo keeps public JSONPlaceholder reads on publicApi, without auth headers, and reserves sessionApi for first-party authenticated requests. apps/demo/public/_headers applies the matching CSP and security headers for static deployments. For server-rendered shells or deployments that generate headers differently, start from:
const headers = securityHeaders({ policy: securityPolicy, hsts: true, });
The demo app
The demo is intentionally plain. It shows the framework surface without hiding it behind another abstraction.
Route What it demonstrates
/ direct DOM TSX, persisted zustand state, live text binding, theme preference
/posts zod-validated API data, cached GET request, resource state, search binding
/auth fake token login, bearer token headers, zod form submit
/cache cache key inspection, localStorage clearing, auth reset
The API data comes from JSONPlaceholder. The app parses every post through zod before it enters UI state.
Tutorial: build the mental model
This section walks through the app from zero to a useful screen.
- Create a store
NoDiff does not ship its own state container. It expects a simple vanilla store shape:
interface ReadableStore { getState(): T; subscribe(listener: (state: T, previous: T) => void): () => void; }
That is the shape exposed by zustand/vanilla.
import { createStore } from "zustand/vanilla";
const preferences = createStore(() => ({ count: 0, search: "", theme: "system" as "system" | "light" | "dark", }));
- Render real DOM with TSX
A component is just a function that returns a child value. A child can be a node, text, a fragment, an array, null, or another component result.
function HomePage() { return (
Direct DOM TSX
); }
onClick becomes addEventListener("click", ...). When the node is removed by mount, view, or another cleanup-aware path, the listener is removed too.
- Bind text to state
Use text when only a text node needs to change.
Count: {text(preferences, (state) => state.count)}
This creates one Text node and subscribes it to the store. When count changes, the node data changes. The surrounding paragraph stays in place.
- Bind form controls
bind.value keeps an input and a store field in sync.
state.search, (search) => ({ search }), )} />
The use prop accepts an action. An action receives an element and may return cleanup.
const focusOnMount: Action = (input) => { input.focus(); };
Actions are the main escape hatch. Use them for observers, subscriptions, custom events, animations, third-party widgets, and anything else that attaches behavior to a node.
- Redraw a region
Use view when a region depends on state.
{ view( postsVm, (state) => state.visible, (posts) => (
{posts.map((post) => (
{post.title}
{post.body}
))}
), ); }
view places two comment markers in the DOM. When the selected value changes, it removes the nodes between those markers, runs cleanup for them, and inserts freshly rendered nodes.
This is intentionally simple. For small and medium regions it is easy to reason about. For keyed lists that should preserve stable rows across insertions, removals, and reorders, use For.
{ For({ store: postsVm, each: (state) => state.visible, by: (post) => post.id, fallback:
No posts match this filter.
, children: (post) => (
{post.title}
{post.body}
), }); }
The by function is used instead of a key prop because JSX treats key as special metadata. Rows with the same key and the same item identity are moved in place. Rows with changed item identity are redrawn and cleaned up.
- Parse API data at the edge
The API client wraps fetch and accepts a zod schema.
import { z } from "zod";
const PostSchema = z.object({ userId: z.number(), id: z.number(), title: z.string(), body: z.string(), });
c
[truncated for AI cost control]