GitHub - hbbio/nodiff: NoDiff: Minimalistic framework that lives alongside your app

20 min read Original article ↗

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

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:

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

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.

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.

1. Create a store

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

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

3. Bind text to state

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.

4. Bind form controls

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.

5. Redraw a region

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.

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

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.

7. Add cache with TTL

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.

8. Wrap loading and errors in a resource

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

The resource state is explicit:

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

Render it with view:

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

9. Add token auth

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

10. Add a router

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>;
}

11. Handle forms with zod

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

The framework API

DOM and JSX

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.

Store bindings

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.loading,
    aria: { busy: state.loading },
    class: { "btn-disabled": state.loading },
    title: state.loading ? "Refreshing posts" : "Refresh posts",
  }))}
/>

Cache

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.

API

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.

Auth

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

Resources

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

Router

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/*

Forms

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>

Recipes

Persist a zustand store

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"] });
});

Add an active class from state

<button use={bind.class("selected", tabs, (state) => state.current === "settings")}>
  Settings
</button>

Disable a button while saving

<button use={bind.attr("disabled", saveState, (state) => state.loading)}>Save</button>

Run a custom subscription for one element

const pageTitle = effect(
  session,
  (state) => state.user?.name ?? "anonymous",
  (name) => {
    document.title = `${name} | Admin`;
  },
);

The subscription is removed when the element is cleaned up.

Read stale cache manually

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

if (hit?.stale) {
  console.log("show old data, refresh soon");
}

Clear every cached response for a tag

api.cache.clearTag("posts");

Use request auth only for selected calls

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

Use history mode

const router = createRouter(routes, { mode: "history" });

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

Design notes

TSX is only syntax

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

DOM ownership is explicit

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.

Reactivity is opt-in

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.

Data is trusted after parsing

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

localStorage is treated as a cache

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.

The router is a store

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.

What this deliberately skips

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.

When to use this shape

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.

Reading path

To understand the framework, read these files in order:

  1. packages/nodiff/src/dom.ts for TSX, props, events, refs, actions, and mount
  2. packages/nodiff/src/lifecycle.ts for cleanup
  3. packages/nodiff/src/store.ts for text, view, list, and form control bindings
  4. packages/nodiff/src/cache.ts for localStorage envelopes
  5. packages/nodiff/src/api.ts for zod-backed fetch and auth headers
  6. packages/nodiff/src/resource.ts for async state
  7. packages/nodiff/src/router.ts for navigation
  8. apps/demo/src/main.tsx for the whole app in one place

Package layout

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

Modern browser target

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.

Publishing notes

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