cd /news/developer-tools/show-hn-nodiff-a-framework-that-live… · home topics developer-tools article
[ARTICLE · art-83758] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Show HN: NoDiff, a framework that lives in your monorepo

A developer released NoDiff, a TypeScript framework for rich-client apps that treats TSX as browser syntax, built with LLM assistance and designed to live in a monorepo. The framework, published as @nodiffjs/core, provides JSX ergonomics, real DOM nodes, explicit store subscriptions, zod-checked data, localStorage caching, token auth, forms, and routing without React, axios, a virtual DOM, or a scheduler. It aims to reduce hidden layers and improve developer and coding agent integration, with the demo app consuming source files via Vite aliases for a clean development loop.

read20 min views1 publishedAug 2, 2026
Show HN: NoDiff, a framework that lives in your monorepo
Image: source

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 (
    <button onClick={() => counter.setState((state) => ({ count: state.count + 1 }))}>
      Count: {text(counter, (state) => state.count)}
    </button>
  );
}

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

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.

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

andhashchange

  • 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.

TSX is useful even when React is absent.

With this config:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@nodiffjs/core"
  }
}

this:

<section class="panel">
  <h1>Hello</h1>
  <button onClick={save}>Save</button>
</section>

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:

<p>{text(session, (state) => state.user?.name ?? "anonymous")}</p>;

{
  view(
    posts,
    (state) => state.visible,
    (items) => (
      <ul>
        {items.map((item) => (
          <li>{item.title}</li>
        ))}
      </ul>
    ),
  );
}

You can read the code for those pieces in a few files.

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 , 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

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.

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

andtsgo

  • 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.

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 withtrustedHTML(...)

or passed throughsanitizeHTML(...)

, dangerous tags are blocked, URL attributes are scheme/origin checked, CSS execution sinks are rejected, event props must be functions, and_blank

links getnoopener 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 configuredbaseUrl

, 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 routererror

/onError

hooks render redacted failures by default, so route exceptions do not become stack traces or secret-bearing messages in the UI.contentSecurityPolicy(...)

andsecurityHeaders(...)

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 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.

This section walks through the app from zero to a useful screen.

NoDiff does not ship its own state container. It expects a simple vanilla store shape:

interface ReadableStore<T> {
  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",
}));

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 (
    <section class="panel">
      <h1>Direct DOM TSX</h1>
      <button onClick={() => preferences.setState((state) => ({ count: state.count + 1 }))}>
        Increment
      </button>
    </section>
  );
}

onClick

becomes addEventListener("click", ...)

. When the node is removed by mount

, view

, or another cleanup-aware path, the listener is removed too.

Use text

when only a text node needs to change.

<p>Count: {text(preferences, (state) => state.count)}</p>

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.value

keeps an input and a store field in sync.

<input
  placeholder="Search"
  use={bind.value(
    preferences,
    (state) => state.search,
    (search) => ({ search }),
  )}
/>

The use

prop accepts an action. An action receives an element and may return cleanup.

const focusOnMount: Action<HTMLInputElement> = (input) => {
  input.focus();
};
<input use={focusOnMount} />

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.

Use view

when a region depends on state.

{
  view(
    postsVm,
    (state) => state.visible,
    (posts) => (
      <div class="post-list">
        {posts.map((post) => (
          <article class="post-card">
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </article>
        ))}
      </div>
    ),
  );
}

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: <p>No posts match this filter.</p>,
    children: (post) => (
      <article class="post-card">
        <h2>{post.title}</h2>
        <p>{post.body}</p>
      </article>
    ),
  });
}

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.

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(),
});

const PostsSchema = z.array(PostSchema);

const api = createApi({
  baseUrl: "https://jsonplaceholder.typicode.com",
});

const posts = await api.get("/posts", {
  schema: PostsSchema,
});

Parsed data is typed. Bad data fails before it reaches your UI.

Create a cache once:

const apiCache = createLocalCache("demo:http:");

const api = createApi({
  baseUrl: "https://jsonplaceholder.typicode.com",
  cache: apiCache,
});

Then cache a request:

const posts = await api.get("/posts", {
  schema: PostsSchema,
  cache: {
    key: "posts",
    ttl: 5 * 60 * 1000,
    swr: true,
    tags: ["posts"],
  },
});

Cache entries look like this:

type CacheEnvelope<T> = {
  value: T;
  updatedAt: number;
  expiresAt: number | null;
  tags: string[];
};

When a schema is passed on read, cached data is parsed again. Corrupt or outdated cache shapes are removed.

createResource

gives you a zustand store around an async function.

const posts = createResource({
  immediate: true,
  load: (_args, { signal }) =>
    api.get("/posts", {
      signal,
      schema: PostsSchema,
      cache: { key: "posts", ttl: 300_000, swr: true },
    }),
});

When a resource has required args, immediate: true

must include initialArgs

. refresh()

reuses the last successful load(...)

args or initialArgs

; before any args are known it resolves to undefined

without calling your .

The resource state is explicit:

type ResourceState<T> = {
  status: "idle" | "" | "success" | "error";
  data: T | null;
  error: Error | null;
  updatedAt: number | null;
  : boolean;
  stale: boolean;
};

Render it with view

:

{
  view(
    posts.store,
    (state) => state,
    (state) => {
      if (state. && !state.data) return <p>...</p>;
      if (state.error && !state.data) return <pre>{state.error.message}</pre>;
      return <PostList posts={state.data ?? []} />;
    },
  );
}

createAuth

keeps a token in memory by default and exposes helpers for the API client.

const auth = createAuth<{ email: string; name: string }>();

const api = createApi({
  baseUrl: "/api",
  getAuthHeaders: auth.authHeaders,
  onUnauthorized: () => auth.logout(),
});

Set a token after login:

auth.setToken(
  {
    accessToken: "token-from-server",
    expiresAt: Date.now() + 60 * 60 * 1000,
  },
  { email: "demo@example.com", name: "demo" },
);

Make a protected request:

await api.get("/me", {
  auth: "required",
  schema: UserSchema,
});

The auth helper is intentionally narrow. It handles bearer token state and headers. Your app still owns login, refresh policy, secure cookie decisions, and server protocol. CSRF helpers are available for cookie-backed requests.

If an app deliberately accepts the localStorage tradeoff, persistence is explicit:

const auth = createAuth<User>({
  persist: true,
  storageKey: "auth",
});

Routes are data:

const router = createRouter(
  [
    { path: "/", title: "Home", component: HomePage },
    { path: "/posts", title: "Posts", component: PostsPage },
    { path: "/posts/:id", title: "Post", component: PostPage },
  ],
  { mode: "hash" },
);

Render the outlet:

function App() {
  const Link = (props: Parameters<typeof router.Link>[0]) => router.Link(props);

  return (
    <main>
      <nav>
        <Link to="/" exact activeClass="active">
          Home
        </Link>
        <Link to="/posts" activeClass="active">
          Posts
        </Link>
      </nav>
      {router.outlet()}
    </main>
  );
}

router.start();
mount("#app", App);

Route params and query params arrive in the component context:

function PostPage(ctx: RouteContext) {
  return <h1>Post {ctx.params.id}</h1>;
}

zodSubmit

converts FormData

to an object, parses it, and sends zod issues into native form validity where possible.

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

function LoginForm() {
  return (
    <form use={zodSubmit(LoginSchema, login)}>
      <input name="email" type="email" autocomplete="email" />
      <input name="password" type="password" autocomplete="current-password" />
      <button type="submit">Sign in</button>
    </form>
  );
}

For numeric fields, use zod coercion because browser form values are strings:

const ProfileSchema = z.object({
  age: z.coerce.number().int().min(13),
});
jsx(type, props);
jsxs(type, props);
jsxDEV(type, props);
Fragment(props);
catchRender({ render, fallback?, onError? });
ErrorBoundary(props);
mount(host, componentOrNode, props?);
append(parent, child);
fragment(children?);
on(type, handler, options?);
setText(value);
trustedHTML(html);
sanitizeHTML(html);

Common props:

<div
  class={{ active: true, muted: false }}
  style={{ display: "grid", gap: "1rem" }}
  dataset={{ id: 123 }}
  aria={{ busy: false }}
  unsafeHTML={sanitizeHTML("<strong>trusted markup only</strong>")}
  ref={(node) => console.log(node)}
  use={(node) => () => console.log("cleanup", node)}
  onClick={(event) => console.log(event.currentTarget)}
/>

Raw HTML must use unsafeHTML

with trustedHTML(...)

or sanitizeHTML(...)

. The ordinary innerHTML

prop is rejected so HTML injection is visible at the call site.

Events are inferred from onX

prop names. onClick

maps to click

, onInput

maps to input

, and so on.

Use catchRender({ render })

around app shells or isolated render callbacks:

mount("#app", catchRender({ render: App }));

It only catches exceptions thrown while the render

callback runs. Already-created JSX children are evaluated before a boundary function receives them, so ErrorBoundary({ children: <App /> })

cannot catch App

render errors. ErrorBoundary({ render: App })

and function children are kept for compatibility, but new code should prefer catchRender

.

text(store, selector, format?, equality?);
view(store, selector, render, options?);
when(store, predicate, yes, no?);
Show({ store, when, children, fallback?, equality? });
list(store, selector, render, options?);
For({ store, each, by, children, fallback?, equality? });
ResourceView({ resource, children, pending?, error?, empty?, equality? });
Await({ resource, children, pending?, error?, empty?, equality? });
derivedStore(stores, derive, options?);
effect(store, selector, run, equality?);
subscribeSelector(store, selector, listener, equality?);

Use derivedStore

when UI state depends on multiple explicit stores:

const postsVm = derivedStore([posts.store, preferences], () => readPostsVm());

It is a read-only store. Dependencies stay visible in the call site; there is no hidden dependency tracking.

bind

helpers:

bind.text(store, selector, format?, equality?);
bind.attr(name, store, selector, equality?);
bind.class(name, store, selector, equality?);
bind.classes(store, selector, equality?);
bind.style(store, selector, equality?);
bind.prop(name, store, selector, equality?);
bind.props(store, selector, equality?);
bind.dataset(name, store, selector, equality?);
bind.aria(name, store, selector, equality?);
bind.value(store, selector, commit, options?);
bind.checked(store, selector, commit, equality?);
bind.number(store, selector, commit, options?);
bind.checkedGroup(store, selector, commit, options?);
bind.radio(store, selector, commit, options?);
bind.selected(store, selector, commit, equality?);
bind.files(store, commit);

Use text

for inline text nodes. Use bind.text

when the element already exists and should receive textContent

. Use view

for regions. Use effect

for side effects tied to a node lifecycle.

Use bind.props

when several element states should move together:

<button
  use={bind.props(postsVm, (state) => ({
    disabled: state.,
    aria: { busy: state. },
    class: { "btn-disabled": state. },
    title: state. ? "Refreshing posts" : "Refresh posts",
  }))}
/>
js
const cache = createLocalCache("app:");

cache.set("preferences", value, {
  ttl: 24 * 60 * 60 * 1000,
  tags: ["preferences"],
});

const entry = cache.get("preferences", PreferencesSchema, {
  allowStale: true,
});

cache.remove("preferences");
cache.clearTag("preferences");
cache.clear();
cache.keys();

The cache is for client-side convenience, not durable storage. It is best for preferences, response snapshots, and quick reloads.

const api = createApi({
  baseUrl: "/api",
  headers: { "X-App": "demo" },
  cache,
  security: securityPolicy,
  getAuthHeaders: auth.authHeaders,
  csrf: { getToken: readCsrfToken, required: "state-changing" },
  timeout: 10_000,
  refreshAuth: refreshToken,
  onUnauthorized: () => auth.logout(),
});

Request helpers:

api.get(path, options);
api.post(path, body, options);
api.put(path, body, options);
api.patch(path, body, options);
api.delete(path, options);
api.request(path, options);

Request options:

type ApiRequestOptions<T> = {
  method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  query?: Record<string, string | number | boolean | null | undefined>;
  body?: unknown;
  headers?: HeadersInit;
  signal?: AbortSignal;
  schema?: z.ZodType<T>;
  cache?:
    | false
    | {
        key?: string;
        ttl?: number;
        tags?: string[];
        swr?: boolean;
      };
  auth?: false | "optional" | "required";
  csrf?: boolean | CsrfRequestOptions;
  external?: boolean;
  timeout?: number;
  credentials?: RequestCredentials;
};

The client serializes plain bodies as JSON, leaves FormData

, URLSearchParams

, Blob

, and buffers intact, parses JSON responses by content type, and throws ApiError

for failed HTTP responses.

const auth = createAuth<User>();

auth.setToken(token, user);
auth.setUser(user);
auth.logout();
auth.getToken();
auth.authHeaders();
auth.isExpired();
auth.isAuthenticated();

The auth store is a zustand vanilla store:

auth.store.getState();
auth.store.subscribe((state) => {
  console.log(state.status);
});
js
const users = createResource<User[], { search: string }>({
  initialArgs: { search: "" },
  initialData: [],
  load: ({ search }, { signal }) =>
    api.get("/users", {
      query: { search },
      signal,
      schema: UsersSchema,
    }),
});

users.refresh(); // uses initialArgs until load(...) provides new args
users.load({ search: "ada" });
users.refresh();
users.mutate((current) => [...(current ?? []), newUser]);
users.abort();
users.reset();
js
const router = createRouter(routes, {
  mode: "history",
  fallback: () => <NotFound />,
  error: () => <p role="alert">Something went wrong.</p>,
  onError: (error) => reportError(error),
});

router.href("/posts");
router.navigate("/posts", { replace: true });
router.start();
router.stop();
router.outlet();
router.Link({ to: "/posts", children: "Posts" });

Route paths support static segments, params, and a wildcard:

/
/posts
/posts/:id
/docs/*
formValues(form);
clearFormValidity(form);
applyZodValidity(form, error);
zodSubmit(schema, handler, options?);

Use zodSubmit

as a form action:

<form use={zodSubmit(Schema, save, { resetOnSuccess: true })}>...</form>
js
const cache = createLocalCache("app:");

const PreferencesSchema = z.object({
  theme: z.enum(["system", "light", "dark"]),
  sidebarOpen: z.boolean(),
});

const saved = cache.get("preferences", PreferencesSchema, { allowStale: true })?.value;

const preferences = createStore(() => ({
  theme: saved?.theme ?? "system",
  sidebarOpen: saved?.sidebarOpen ?? true,
}));

preferences.subscribe((state) => {
  cache.set("preferences", state, { tags: ["preferences"] });
});
js
<button use={bind.class("selected", tabs, (state) => state.current === "settings")}>
  Settings
</button>
js
<button use={bind.attr("disabled", saveState, (state) => state.)}>Save</button>
js
const pageTitle = effect(
  session,
  (state) => state.user?.name ?? "anonymous",
  (name) => {
    document.title = `${name} | Admin`;
  },
);
<div use={pageTitle} />

The subscription is removed when the element is cleaned up.

const hit = cache.get("posts", PostsSchema, { allowStale: true });

if (hit?.stale) {
  console.log("show old data, refresh soon");
}
api.cache.clearTag("posts");
await api.get("/public", { auth: false });
await api.get("/me", { auth: "required", schema: UserSchema });
js
const router = createRouter(routes, { mode: "history" });

For production history mode, configure your server to return index.html

for app routes.

There is no component instance model. A component call creates nodes. Persistent updates live in stores, actions, and resources.

If code creates a node, code can also clean it. The lifecycle module stores cleanup callbacks in a WeakMap<Node, Cleanup[]>

. Redrawing a view

or unmounting a root walks the nodes and runs those callbacks.

A static subtree remains static. A text node subscribed with text

updates itself. A view

redraws only its own marker-bounded region. An action may subscribe to anything and return cleanup.

The API client and cache both accept zod schemas. The goal is to reject bad network data and stale cache shapes at the boundary.

Every write stores an envelope with updatedAt

, expiresAt

, and tags

. A read can reject stale entries, allow stale entries, or remove invalid entries after schema parsing fails.

Routing state is a zustand vanilla store. The outlet is a view

over the current path. Links are anchors with click handling and optional active class binding.

This project is small by choice. Some things are better added per app than baked into the core.

Skipped Reason
Virtual DOM diffing The runtime creates DOM nodes directly and updates explicit regions.
Keyed list reconciliation Useful for very large lists, but not needed for the demo. Add it as a focused helper.
SSR and hydration The target is a modern browser rich client.
Server components Outside the scope of this client runtime.
Suspense semantics Resource state is explicit and ordinary.
Built-in OAuth flow Servers and threat models vary too much.
IndexedDB cache localStorage keeps the example small. Use IndexedDB for larger payloads.
Component context system Plain modules and stores cover the current app.

This architecture fits apps where:

  • the browser is the main runtime
  • TSX syntax is desired
  • the app wants strong network parsing without a large UI runtime
  • data and auth flows are custom
  • the team prefers direct ownership of DOM behavior
  • the codebase should be readable from top to bottom
  • coding agents should be able to reason across app and framework code in one repository

Choose a larger UI framework when you need a mature component ecosystem, server rendering, hydration, a11y component kits, animation systems, or many third-party UI widgets with framework adapters.

To understand the framework, read these files in order:

packages/nodiff/src/dom.ts

for TSX, props, events, refs, actions, and mountpackages/nodiff/src/lifecycle.ts

for cleanuppackages/nodiff/src/store.ts

for text, view, list, and form control bindingspackages/nodiff/src/cache.ts

for localStorage envelopespackages/nodiff/src/api.ts

for zod-backed fetch and auth headerspackages/nodiff/src/resource.ts

for async statepackages/nodiff/src/router.ts

for navigationapps/demo/src/main.tsx

for the whole app in one place

packages/nodiff/src/
  api.ts              fetch client with zod, auth, cache
  auth.ts             bearer token store with optional persistence
  cache.ts            localStorage cache with TTL and tags
  csrf.ts             CSRF token readers and double-submit helpers
  dom.ts              direct DOM TSX runtime
  errors.ts           redacted error messages for UI fallbacks
  forms.ts            zod form action and validity helpers
  index.ts            public exports
  jsx-dev-runtime.ts  Vite/dev automatic JSX runtime entry
  jsx-runtime.ts      TypeScript automatic JSX runtime entry
  lifecycle.ts        cleanup registry
  resource.ts         async resource store
  router.ts           hash/history router
  security.ts         policy, CSP, and security header helpers
  store.ts            zustand-compatible bindings

The repo targets modern browsers only:

{
  "target": "ES2022",
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
  "moduleResolution": "Bundler",
  "strict": true,
  "noUncheckedIndexedAccess": true,
  "exactOptionalPropertyTypes": true
}

The demo Vite build target is also es2022

.

@nodiffjs/core

has package exports for the framework and JSX runtimes:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./dom": {
      "types": "./dist/dom.d.ts",
      "import": "./dist/dom.js"
    },
    "./api": {
      "types": "./dist/api.d.ts",
      "import": "./dist/api.js"
    },
    "./jsx-runtime": {
      "types": "./dist/jsx-runtime.d.ts",
      "import": "./dist/jsx-runtime.js"
    },
    "./jsx-dev-runtime": {
      "types": "./dist/jsx-dev-runtime.d.ts",
      "import": "./dist/jsx-dev-runtime.js"
    }
  }
}

The package source keeps extensionless local imports for the monorepo development path. The package build rewrites only the emitted dist

specifiers so standard ESM consumers can import the packed artifact directly. Package metadata avoids workspace-only dependency protocols, so npm pack

output can be installed outside this workspace.

── more in #developer-tools 4 stories · sorted by recency
── more on @nodiff 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-hn-nodiff-a-fra…] indexed:0 read:20min 2026-08-02 ·