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.