{"slug": "show-hn-nodiff-a-framework-that-lives-in-your-monorepo", "title": "Show HN: NoDiff, a framework that lives in your monorepo", "summary": "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.", "body_md": "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!\n\nIn 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.\n\nThat 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.\n\nNoDiff 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.\n\nThe whole idea is small enough to keep in your head:\n\n``` js\nconst counter = createStore(() => ({ count: 0 }));\n\nfunction App() {\n  return (\n    <button onClick={() => counter.setState((state) => ({ count: state.count + 1 }))}>\n      Count: {text(counter, (state) => state.count)}\n    </button>\n  );\n}\n\nmount(\"#app\", App);\n```\n\nTSX compiles through Vite into calls to `@nodiffjs/core/jsx-runtime`\n\n. 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.\n\nThis repo contains both the framework package and a demo app.\n\n```\nnodiff/\n  packages/nodiff/   framework package, published name @nodiffjs/core\n  apps/demo/         demo rich-client app\n```\n\nNoDiff 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.\n\nThe demo app intentionally consumes `packages/nodiff/src`\n\nthrough Vite and TypeScript aliases for\n`@nodiffjs/core`\n\n. That keeps HMR and editor navigation on source files while you change the runtime,\nbindings, router, API client, and app code in one workspace.\n\nThe package build is still kept clean. `packages/nodiff`\n\nemits `dist`\n\n, declares package exports, uses\npublishable dependency ranges, and ships package-local README/LICENSE files. Treat that artifact as a\nconsumer/publishing check, not as the source path used by the demo during normal development.\n\nModern 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.\n\nNoDiff starts from the other side.\n\nThe browser already has:\n\n- a mutable DOM tree\n- event listeners\n- custom validity on forms\n`fetch`\n\n`AbortController`\n\n`localStorage`\n\n`history`\n\nand`hashchange`\n\n- native modules\n\nThe missing piece is a clean TypeScript surface that makes those primitives pleasant enough for a real app.\n\nNoDiff 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.\n\nTSX is useful even when React is absent.\n\nWith this config:\n\n```\n{\n  \"compilerOptions\": {\n    \"jsx\": \"react-jsx\",\n    \"jsxImportSource\": \"@nodiffjs/core\"\n  }\n}\n```\n\nthis:\n\n```\n<section class=\"panel\">\n  <h1>Hello</h1>\n  <button onClick={save}>Save</button>\n</section>\n```\n\ncompiles to calls into this package:\n\n``` js\nimport { jsx, jsxs } from \"@nodiffjs/core/jsx-runtime\";\n```\n\nThe runtime creates `HTMLElement`\n\ninstances 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.\n\nState-driven parts opt in explicitly:\n\n``` js\n<p>{text(session, (state) => state.user?.name ?? \"anonymous\")}</p>;\n\n{\n  view(\n    posts,\n    (state) => state.visible,\n    (items) => (\n      <ul>\n        {items.map((item) => (\n          <li>{item.title}</li>\n        ))}\n      </ul>\n    ),\n  );\n}\n```\n\nYou can read the code for those pieces in a few files.\n\n| Area | Included |\n|---|---|\n| TSX runtime | `jsx` , `jsxs` , `jsxDEV` , `Fragment` , intrinsic element typing |\n| DOM | `mount` , `append` , direct node creation, events, refs, actions |\n| Lifecycle | cleanup on unmount and region redraw |\n| State bindings | `text` , `view` , `when` , `Show` , `list` , `For` , `ResourceView` , `Await` , `bind.*` |\n| Stores | works with `zustand/vanilla` and any compatible store shape |\n| API client | `fetch` , query params, JSON body handling, zod parsing, auth headers, 401 hook |\n| Cache | localStorage envelopes with TTL, tags, stale reads, schema validation |\n| Auth | bearer token controller over zustand vanilla with optional persistence |\n| Resource state | loading, stale, success, error, abort, refresh, mutate |\n| Router | hash or history mode, route params, query params, active links |\n| Forms | zod-backed submit action, native validity messages |\n| Security | DOM sink hardening, strict request policy, CSRF helpers, safe error fallbacks |\n| Tooling | Bun workspaces, Vite 8, TypeScript 7 native preview, oxlint, oxfmt |\n\nPrerequisite: Bun.\n\n```\nbun install\nbun run dev\n```\n\nOpen the local Vite URL printed in the terminal.\n\nUseful commands:\n\n```\nbun run typecheck\nbun run lint\nbun run format\nbun run check\nbun run security:check\nbun run build\n```\n\nThe root scripts run the framework package first, then the demo app where that ordering matters.\n\nDuring `bun run dev`\n\n, the demo resolves `@nodiffjs/core`\n\nto `packages/nodiff/src`\n\ninstead of `dist`\n\n.\nThis is deliberate: framework edits and app edits should update together in the fork.\n\nThe repo is intentionally modern:\n\n- Bun workspaces with dependency versions centralized in the root catalog\n- Vite 8 for the demo app\n- TypeScript 7 native preview through\n`@typescript/native-preview`\n\nand`tsgo`\n\n- oxlint with type-aware linting through\n`oxlint-tsgolint`\n\n- oxfmt for formatting\n- ES2022 browser target\n\n`bun run check`\n\nruns strict typechecking, type-aware linting, format checking, and tests.\n`bun run security:check`\n\nadds the dependency audit and production build on top.\n\nNoDiff tries to make the safe path the short path for rich-client apps:\n\n- DOM text is escaped by construction. The\n`innerHTML`\n\nprop is rejected, raw HTML must be wrapped with`trustedHTML(...)`\n\nor passed through`sanitizeHTML(...)`\n\n, dangerous tags are blocked, URL attributes are scheme/origin checked, CSS execution sinks are rejected, event props must be functions, and`_blank`\n\nlinks get`noopener noreferrer`\n\n. `configureSecurityPolicy(...)`\n\ninstalls a process-wide policy for strict origin checks, allowed URL schemes, HTTPS enforcement, cache TTL/schema rules, CSRF mode, and security event reporting.`createApi(...)`\n\nresolves requests against a configured`baseUrl`\n\n, 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(...)`\n\nkeeps tokens in memory by default. localStorage persistence is explicit, and refresh-token persistence requires a second explicit opt in.`catchRender`\n\nand router`error`\n\n/`onError`\n\nhooks render redacted failures by default, so route exceptions do not become stack traces or secret-bearing messages in the UI.`contentSecurityPolicy(...)`\n\nand`securityHeaders(...)`\n\nproduce strict server headers that match the same policy model.\n\nThe demo app opts into strict mode with one policy:\n\n``` js\nexport const securityPolicy = configureSecurityPolicy({\n  mode: \"strict\",\n  allowedOrigins: [\"self\", \"https://jsonplaceholder.typicode.com\"],\n  allowedUrlSchemes: [\"http:\", \"https:\"],\n  enforceHttps: true,\n  cache: { requireSchema: true, maxTtl: 5 * 60 * 1000 },\n  csrf: \"double-submit-cookie\",\n});\n\nexport const publicApi = createApi({\n  baseUrl: \"https://jsonplaceholder.typicode.com\",\n  security: securityPolicy,\n});\n\nexport const sessionApi = createApi({\n  baseUrl: \"/api\",\n  security: securityPolicy,\n  getAuthHeaders: auth.authHeaders,\n  csrf: { getToken: readCsrfToken, required: \"state-changing\" },\n});\n```\n\nThe demo keeps public JSONPlaceholder reads on `publicApi`\n\n, without auth headers, and reserves\n`sessionApi`\n\nfor first-party authenticated requests. `apps/demo/public/_headers`\n\napplies the\nmatching CSP and security headers for static deployments. For server-rendered shells or deployments\nthat generate headers differently, start from:\n\n``` js\nconst headers = securityHeaders({\n  policy: securityPolicy,\n  hsts: true,\n});\n```\n\nThe demo is intentionally plain. It shows the framework surface without hiding it behind another abstraction.\n\n| Route | What it demonstrates |\n|---|---|\n`/` |\ndirect DOM TSX, persisted zustand state, live text binding, theme preference |\n`/posts` |\nzod-validated API data, cached GET request, resource state, search binding |\n`/auth` |\nfake token login, bearer token headers, zod form submit |\n`/cache` |\ncache key inspection, localStorage clearing, auth reset |\n\nThe API data comes from JSONPlaceholder. The app parses every post through zod before it enters UI state.\n\nThis section walks through the app from zero to a useful screen.\n\nNoDiff does not ship its own state container. It expects a simple vanilla store shape:\n\n```\ninterface ReadableStore<T> {\n  getState(): T;\n  subscribe(listener: (state: T, previous: T) => void): () => void;\n}\n```\n\nThat is the shape exposed by `zustand/vanilla`\n\n.\n\n``` js\nimport { createStore } from \"zustand/vanilla\";\n\nconst preferences = createStore(() => ({\n  count: 0,\n  search: \"\",\n  theme: \"system\" as \"system\" | \"light\" | \"dark\",\n}));\n```\n\nA component is just a function that returns a child value. A child can be a node, text, a fragment, an array, `null`\n\n, or another component result.\n\n```\nfunction HomePage() {\n  return (\n    <section class=\"panel\">\n      <h1>Direct DOM TSX</h1>\n      <button onClick={() => preferences.setState((state) => ({ count: state.count + 1 }))}>\n        Increment\n      </button>\n    </section>\n  );\n}\n```\n\n`onClick`\n\nbecomes `addEventListener(\"click\", ...)`\n\n. When the node is removed by `mount`\n\n, `view`\n\n, or another cleanup-aware path, the listener is removed too.\n\nUse `text`\n\nwhen only a text node needs to change.\n\n``` js\n<p>Count: {text(preferences, (state) => state.count)}</p>\n```\n\nThis creates one `Text`\n\nnode and subscribes it to the store. When `count`\n\nchanges, the node data changes. The surrounding paragraph stays in place.\n\n`bind.value`\n\nkeeps an input and a store field in sync.\n\n``` js\n<input\n  placeholder=\"Search\"\n  use={bind.value(\n    preferences,\n    (state) => state.search,\n    (search) => ({ search }),\n  )}\n/>\n```\n\nThe `use`\n\nprop accepts an action. An action receives an element and may return cleanup.\n\n``` js\nconst focusOnMount: Action<HTMLInputElement> = (input) => {\n  input.focus();\n};\n<input use={focusOnMount} />\n```\n\nActions 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.\n\nUse `view`\n\nwhen a region depends on state.\n\n``` js\n{\n  view(\n    postsVm,\n    (state) => state.visible,\n    (posts) => (\n      <div class=\"post-list\">\n        {posts.map((post) => (\n          <article class=\"post-card\">\n            <h2>{post.title}</h2>\n            <p>{post.body}</p>\n          </article>\n        ))}\n      </div>\n    ),\n  );\n}\n```\n\n`view`\n\nplaces 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.\n\nThis 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`\n\n.\n\n``` js\n{\n  For({\n    store: postsVm,\n    each: (state) => state.visible,\n    by: (post) => post.id,\n    fallback: <p>No posts match this filter.</p>,\n    children: (post) => (\n      <article class=\"post-card\">\n        <h2>{post.title}</h2>\n        <p>{post.body}</p>\n      </article>\n    ),\n  });\n}\n```\n\nThe `by`\n\nfunction is used instead of a `key`\n\nprop because JSX treats `key`\n\nas 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.\n\nThe API client wraps `fetch`\n\nand accepts a zod schema.\n\n``` js\nimport { z } from \"zod\";\n\nconst PostSchema = z.object({\n  userId: z.number(),\n  id: z.number(),\n  title: z.string(),\n  body: z.string(),\n});\n\nconst PostsSchema = z.array(PostSchema);\n\nconst api = createApi({\n  baseUrl: \"https://jsonplaceholder.typicode.com\",\n});\n\nconst posts = await api.get(\"/posts\", {\n  schema: PostsSchema,\n});\n```\n\nParsed data is typed. Bad data fails before it reaches your UI.\n\nCreate a cache once:\n\n``` js\nconst apiCache = createLocalCache(\"demo:http:\");\n\nconst api = createApi({\n  baseUrl: \"https://jsonplaceholder.typicode.com\",\n  cache: apiCache,\n});\n```\n\nThen cache a request:\n\n``` js\nconst posts = await api.get(\"/posts\", {\n  schema: PostsSchema,\n  cache: {\n    key: \"posts\",\n    ttl: 5 * 60 * 1000,\n    swr: true,\n    tags: [\"posts\"],\n  },\n});\n```\n\nCache entries look like this:\n\n```\ntype CacheEnvelope<T> = {\n  value: T;\n  updatedAt: number;\n  expiresAt: number | null;\n  tags: string[];\n};\n```\n\nWhen a schema is passed on read, cached data is parsed again. Corrupt or outdated cache shapes are removed.\n\n`createResource`\n\ngives you a zustand store around an async function.\n\n``` js\nconst posts = createResource({\n  immediate: true,\n  load: (_args, { signal }) =>\n    api.get(\"/posts\", {\n      signal,\n      schema: PostsSchema,\n      cache: { key: \"posts\", ttl: 300_000, swr: true },\n    }),\n});\n```\n\nWhen a resource has required args, `immediate: true`\n\nmust include `initialArgs`\n\n. `refresh()`\n\nreuses\nthe last successful `load(...)`\n\nargs or `initialArgs`\n\n; before any args are known it resolves to\n`undefined`\n\nwithout calling your loader.\n\nThe resource state is explicit:\n\n```\ntype ResourceState<T> = {\n  status: \"idle\" | \"loading\" | \"success\" | \"error\";\n  data: T | null;\n  error: Error | null;\n  updatedAt: number | null;\n  loading: boolean;\n  stale: boolean;\n};\n```\n\nRender it with `view`\n\n:\n\n``` js\n{\n  view(\n    posts.store,\n    (state) => state,\n    (state) => {\n      if (state.loading && !state.data) return <p>Loading...</p>;\n      if (state.error && !state.data) return <pre>{state.error.message}</pre>;\n      return <PostList posts={state.data ?? []} />;\n    },\n  );\n}\n```\n\n`createAuth`\n\nkeeps a token in memory by default and exposes helpers for the API client.\n\n``` js\nconst auth = createAuth<{ email: string; name: string }>();\n\nconst api = createApi({\n  baseUrl: \"/api\",\n  getAuthHeaders: auth.authHeaders,\n  onUnauthorized: () => auth.logout(),\n});\n```\n\nSet a token after login:\n\n```\nauth.setToken(\n  {\n    accessToken: \"token-from-server\",\n    expiresAt: Date.now() + 60 * 60 * 1000,\n  },\n  { email: \"demo@example.com\", name: \"demo\" },\n);\n```\n\nMake a protected request:\n\n```\nawait api.get(\"/me\", {\n  auth: \"required\",\n  schema: UserSchema,\n});\n```\n\nThe 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.\n\nIf an app deliberately accepts the localStorage tradeoff, persistence is explicit:\n\n``` js\nconst auth = createAuth<User>({\n  persist: true,\n  storageKey: \"auth\",\n});\n```\n\nRoutes are data:\n\n``` js\nconst router = createRouter(\n  [\n    { path: \"/\", title: \"Home\", component: HomePage },\n    { path: \"/posts\", title: \"Posts\", component: PostsPage },\n    { path: \"/posts/:id\", title: \"Post\", component: PostPage },\n  ],\n  { mode: \"hash\" },\n);\n```\n\nRender the outlet:\n\n``` js\nfunction App() {\n  const Link = (props: Parameters<typeof router.Link>[0]) => router.Link(props);\n\n  return (\n    <main>\n      <nav>\n        <Link to=\"/\" exact activeClass=\"active\">\n          Home\n        </Link>\n        <Link to=\"/posts\" activeClass=\"active\">\n          Posts\n        </Link>\n      </nav>\n      {router.outlet()}\n    </main>\n  );\n}\n\nrouter.start();\nmount(\"#app\", App);\n```\n\nRoute params and query params arrive in the component context:\n\n```\nfunction PostPage(ctx: RouteContext) {\n  return <h1>Post {ctx.params.id}</h1>;\n}\n```\n\n`zodSubmit`\n\nconverts `FormData`\n\nto an object, parses it, and sends zod issues into native form validity where possible.\n\n``` js\nconst LoginSchema = z.object({\n  email: z.string().email(),\n  password: z.string().min(8),\n});\n\nfunction LoginForm() {\n  return (\n    <form use={zodSubmit(LoginSchema, login)}>\n      <input name=\"email\" type=\"email\" autocomplete=\"email\" />\n      <input name=\"password\" type=\"password\" autocomplete=\"current-password\" />\n      <button type=\"submit\">Sign in</button>\n    </form>\n  );\n}\n```\n\nFor numeric fields, use zod coercion because browser form values are strings:\n\n``` js\nconst ProfileSchema = z.object({\n  age: z.coerce.number().int().min(13),\n});\njsx(type, props);\njsxs(type, props);\njsxDEV(type, props);\nFragment(props);\ncatchRender({ render, fallback?, onError? });\nErrorBoundary(props);\nmount(host, componentOrNode, props?);\nappend(parent, child);\nfragment(children?);\non(type, handler, options?);\nsetText(value);\ntrustedHTML(html);\nsanitizeHTML(html);\n```\n\nCommon props:\n\n```\n<div\n  class={{ active: true, muted: false }}\n  style={{ display: \"grid\", gap: \"1rem\" }}\n  dataset={{ id: 123 }}\n  aria={{ busy: false }}\n  unsafeHTML={sanitizeHTML(\"<strong>trusted markup only</strong>\")}\n  ref={(node) => console.log(node)}\n  use={(node) => () => console.log(\"cleanup\", node)}\n  onClick={(event) => console.log(event.currentTarget)}\n/>\n```\n\nRaw HTML must use `unsafeHTML`\n\nwith `trustedHTML(...)`\n\nor `sanitizeHTML(...)`\n\n. The ordinary\n`innerHTML`\n\nprop is rejected so HTML injection is visible at the call site.\n\nEvents are inferred from `onX`\n\nprop names. `onClick`\n\nmaps to `click`\n\n, `onInput`\n\nmaps to `input`\n\n, and so on.\n\nUse `catchRender({ render })`\n\naround app shells or isolated render callbacks:\n\n```\nmount(\"#app\", catchRender({ render: App }));\n```\n\nIt only catches exceptions thrown while the `render`\n\ncallback runs. Already-created JSX children are\nevaluated before a boundary function receives them, so `ErrorBoundary({ children: <App /> })`\n\ncannot\ncatch `App`\n\nrender errors. `ErrorBoundary({ render: App })`\n\nand function children are kept for\ncompatibility, but new code should prefer `catchRender`\n\n.\n\n```\ntext(store, selector, format?, equality?);\nview(store, selector, render, options?);\nwhen(store, predicate, yes, no?);\nShow({ store, when, children, fallback?, equality? });\nlist(store, selector, render, options?);\nFor({ store, each, by, children, fallback?, equality? });\nResourceView({ resource, children, pending?, error?, empty?, equality? });\nAwait({ resource, children, pending?, error?, empty?, equality? });\nderivedStore(stores, derive, options?);\neffect(store, selector, run, equality?);\nsubscribeSelector(store, selector, listener, equality?);\n```\n\nUse `derivedStore`\n\nwhen UI state depends on multiple explicit stores:\n\n``` js\nconst postsVm = derivedStore([posts.store, preferences], () => readPostsVm());\n```\n\nIt is a read-only store. Dependencies stay visible in the call site; there is no hidden dependency tracking.\n\n`bind`\n\nhelpers:\n\n```\nbind.text(store, selector, format?, equality?);\nbind.attr(name, store, selector, equality?);\nbind.class(name, store, selector, equality?);\nbind.classes(store, selector, equality?);\nbind.style(store, selector, equality?);\nbind.prop(name, store, selector, equality?);\nbind.props(store, selector, equality?);\nbind.dataset(name, store, selector, equality?);\nbind.aria(name, store, selector, equality?);\nbind.value(store, selector, commit, options?);\nbind.checked(store, selector, commit, equality?);\nbind.number(store, selector, commit, options?);\nbind.checkedGroup(store, selector, commit, options?);\nbind.radio(store, selector, commit, options?);\nbind.selected(store, selector, commit, equality?);\nbind.files(store, commit);\n```\n\nUse `text`\n\nfor inline text nodes. Use `bind.text`\n\nwhen the element already exists and should receive `textContent`\n\n. Use `view`\n\nfor regions. Use `effect`\n\nfor side effects tied to a node lifecycle.\n\nUse `bind.props`\n\nwhen several element states should move together:\n\n``` js\n<button\n  use={bind.props(postsVm, (state) => ({\n    disabled: state.loading,\n    aria: { busy: state.loading },\n    class: { \"btn-disabled\": state.loading },\n    title: state.loading ? \"Refreshing posts\" : \"Refresh posts\",\n  }))}\n/>\njs\nconst cache = createLocalCache(\"app:\");\n\ncache.set(\"preferences\", value, {\n  ttl: 24 * 60 * 60 * 1000,\n  tags: [\"preferences\"],\n});\n\nconst entry = cache.get(\"preferences\", PreferencesSchema, {\n  allowStale: true,\n});\n\ncache.remove(\"preferences\");\ncache.clearTag(\"preferences\");\ncache.clear();\ncache.keys();\n```\n\nThe cache is for client-side convenience, not durable storage. It is best for preferences, response snapshots, and quick reloads.\n\n``` js\nconst api = createApi({\n  baseUrl: \"/api\",\n  headers: { \"X-App\": \"demo\" },\n  cache,\n  security: securityPolicy,\n  getAuthHeaders: auth.authHeaders,\n  csrf: { getToken: readCsrfToken, required: \"state-changing\" },\n  timeout: 10_000,\n  refreshAuth: refreshToken,\n  onUnauthorized: () => auth.logout(),\n});\n```\n\nRequest helpers:\n\n```\napi.get(path, options);\napi.post(path, body, options);\napi.put(path, body, options);\napi.patch(path, body, options);\napi.delete(path, options);\napi.request(path, options);\n```\n\nRequest options:\n\n```\ntype ApiRequestOptions<T> = {\n  method?: \"GET\" | \"POST\" | \"PUT\" | \"PATCH\" | \"DELETE\";\n  query?: Record<string, string | number | boolean | null | undefined>;\n  body?: unknown;\n  headers?: HeadersInit;\n  signal?: AbortSignal;\n  schema?: z.ZodType<T>;\n  cache?:\n    | false\n    | {\n        key?: string;\n        ttl?: number;\n        tags?: string[];\n        swr?: boolean;\n      };\n  auth?: false | \"optional\" | \"required\";\n  csrf?: boolean | CsrfRequestOptions;\n  external?: boolean;\n  timeout?: number;\n  credentials?: RequestCredentials;\n};\n```\n\nThe client serializes plain bodies as JSON, leaves `FormData`\n\n, `URLSearchParams`\n\n, `Blob`\n\n, and buffers intact, parses JSON responses by content type, and throws `ApiError`\n\nfor failed HTTP responses.\n\n``` js\nconst auth = createAuth<User>();\n\nauth.setToken(token, user);\nauth.setUser(user);\nauth.logout();\nauth.getToken();\nauth.authHeaders();\nauth.isExpired();\nauth.isAuthenticated();\n```\n\nThe auth store is a zustand vanilla store:\n\n``` js\nauth.store.getState();\nauth.store.subscribe((state) => {\n  console.log(state.status);\n});\njs\nconst users = createResource<User[], { search: string }>({\n  initialArgs: { search: \"\" },\n  initialData: [],\n  load: ({ search }, { signal }) =>\n    api.get(\"/users\", {\n      query: { search },\n      signal,\n      schema: UsersSchema,\n    }),\n});\n\nusers.refresh(); // uses initialArgs until load(...) provides new args\nusers.load({ search: \"ada\" });\nusers.refresh();\nusers.mutate((current) => [...(current ?? []), newUser]);\nusers.abort();\nusers.reset();\njs\nconst router = createRouter(routes, {\n  mode: \"history\",\n  fallback: () => <NotFound />,\n  error: () => <p role=\"alert\">Something went wrong.</p>,\n  onError: (error) => reportError(error),\n});\n\nrouter.href(\"/posts\");\nrouter.navigate(\"/posts\", { replace: true });\nrouter.start();\nrouter.stop();\nrouter.outlet();\nrouter.Link({ to: \"/posts\", children: \"Posts\" });\n```\n\nRoute paths support static segments, params, and a wildcard:\n\n```\n/\n/posts\n/posts/:id\n/docs/*\nformValues(form);\nclearFormValidity(form);\napplyZodValidity(form, error);\nzodSubmit(schema, handler, options?);\n```\n\nUse `zodSubmit`\n\nas a form action:\n\n```\n<form use={zodSubmit(Schema, save, { resetOnSuccess: true })}>...</form>\njs\nconst cache = createLocalCache(\"app:\");\n\nconst PreferencesSchema = z.object({\n  theme: z.enum([\"system\", \"light\", \"dark\"]),\n  sidebarOpen: z.boolean(),\n});\n\nconst saved = cache.get(\"preferences\", PreferencesSchema, { allowStale: true })?.value;\n\nconst preferences = createStore(() => ({\n  theme: saved?.theme ?? \"system\",\n  sidebarOpen: saved?.sidebarOpen ?? true,\n}));\n\npreferences.subscribe((state) => {\n  cache.set(\"preferences\", state, { tags: [\"preferences\"] });\n});\njs\n<button use={bind.class(\"selected\", tabs, (state) => state.current === \"settings\")}>\n  Settings\n</button>\njs\n<button use={bind.attr(\"disabled\", saveState, (state) => state.loading)}>Save</button>\njs\nconst pageTitle = effect(\n  session,\n  (state) => state.user?.name ?? \"anonymous\",\n  (name) => {\n    document.title = `${name} | Admin`;\n  },\n);\n<div use={pageTitle} />\n```\n\nThe subscription is removed when the element is cleaned up.\n\n``` js\nconst hit = cache.get(\"posts\", PostsSchema, { allowStale: true });\n\nif (hit?.stale) {\n  console.log(\"show old data, refresh soon\");\n}\napi.cache.clearTag(\"posts\");\nawait api.get(\"/public\", { auth: false });\nawait api.get(\"/me\", { auth: \"required\", schema: UserSchema });\njs\nconst router = createRouter(routes, { mode: \"history\" });\n```\n\nFor production history mode, configure your server to return `index.html`\n\nfor app routes.\n\nThere is no component instance model. A component call creates nodes. Persistent updates live in stores, actions, and resources.\n\nIf code creates a node, code can also clean it. The lifecycle module stores cleanup callbacks in a `WeakMap<Node, Cleanup[]>`\n\n. Redrawing a `view`\n\nor unmounting a root walks the nodes and runs those callbacks.\n\nA static subtree remains static. A text node subscribed with `text`\n\nupdates itself. A `view`\n\nredraws only its own marker-bounded region. An action may subscribe to anything and return cleanup.\n\nThe API client and cache both accept zod schemas. The goal is to reject bad network data and stale cache shapes at the boundary.\n\nEvery write stores an envelope with `updatedAt`\n\n, `expiresAt`\n\n, and `tags`\n\n. A read can reject stale entries, allow stale entries, or remove invalid entries after schema parsing fails.\n\nRouting state is a zustand vanilla store. The outlet is a `view`\n\nover the current path. Links are anchors with click handling and optional active class binding.\n\nThis project is small by choice. Some things are better added per app than baked into the core.\n\n| Skipped | Reason |\n|---|---|\n| Virtual DOM diffing | The runtime creates DOM nodes directly and updates explicit regions. |\n| Keyed list reconciliation | Useful for very large lists, but not needed for the demo. Add it as a focused helper. |\n| SSR and hydration | The target is a modern browser rich client. |\n| Server components | Outside the scope of this client runtime. |\n| Suspense semantics | Resource state is explicit and ordinary. |\n| Built-in OAuth flow | Servers and threat models vary too much. |\n| IndexedDB cache | localStorage keeps the example small. Use IndexedDB for larger payloads. |\n| Component context system | Plain modules and stores cover the current app. |\n\nThis architecture fits apps where:\n\n- the browser is the main runtime\n- TSX syntax is desired\n- the app wants strong network parsing without a large UI runtime\n- data and auth flows are custom\n- the team prefers direct ownership of DOM behavior\n- the codebase should be readable from top to bottom\n- coding agents should be able to reason across app and framework code in one repository\n\nChoose 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.\n\nTo understand the framework, read these files in order:\n\n`packages/nodiff/src/dom.ts`\n\nfor TSX, props, events, refs, actions, and mount`packages/nodiff/src/lifecycle.ts`\n\nfor cleanup`packages/nodiff/src/store.ts`\n\nfor text, view, list, and form control bindings`packages/nodiff/src/cache.ts`\n\nfor localStorage envelopes`packages/nodiff/src/api.ts`\n\nfor zod-backed fetch and auth headers`packages/nodiff/src/resource.ts`\n\nfor async state`packages/nodiff/src/router.ts`\n\nfor navigation`apps/demo/src/main.tsx`\n\nfor the whole app in one place\n\n```\npackages/nodiff/src/\n  api.ts              fetch client with zod, auth, cache\n  auth.ts             bearer token store with optional persistence\n  cache.ts            localStorage cache with TTL and tags\n  csrf.ts             CSRF token readers and double-submit helpers\n  dom.ts              direct DOM TSX runtime\n  errors.ts           redacted error messages for UI fallbacks\n  forms.ts            zod form action and validity helpers\n  index.ts            public exports\n  jsx-dev-runtime.ts  Vite/dev automatic JSX runtime entry\n  jsx-runtime.ts      TypeScript automatic JSX runtime entry\n  lifecycle.ts        cleanup registry\n  resource.ts         async resource store\n  router.ts           hash/history router\n  security.ts         policy, CSP, and security header helpers\n  store.ts            zustand-compatible bindings\n```\n\nThe repo targets modern browsers only:\n\n```\n{\n  \"target\": \"ES2022\",\n  \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n  \"moduleResolution\": \"Bundler\",\n  \"strict\": true,\n  \"noUncheckedIndexedAccess\": true,\n  \"exactOptionalPropertyTypes\": true\n}\n```\n\nThe demo Vite build target is also `es2022`\n\n.\n\n`@nodiffjs/core`\n\nhas package exports for the framework and JSX runtimes:\n\n```\n{\n  \"exports\": {\n    \".\": {\n      \"types\": \"./dist/index.d.ts\",\n      \"import\": \"./dist/index.js\"\n    },\n    \"./dom\": {\n      \"types\": \"./dist/dom.d.ts\",\n      \"import\": \"./dist/dom.js\"\n    },\n    \"./api\": {\n      \"types\": \"./dist/api.d.ts\",\n      \"import\": \"./dist/api.js\"\n    },\n    \"./jsx-runtime\": {\n      \"types\": \"./dist/jsx-runtime.d.ts\",\n      \"import\": \"./dist/jsx-runtime.js\"\n    },\n    \"./jsx-dev-runtime\": {\n      \"types\": \"./dist/jsx-dev-runtime.d.ts\",\n      \"import\": \"./dist/jsx-dev-runtime.js\"\n    }\n  }\n}\n```\n\nThe package source keeps extensionless local imports for the monorepo development path. The package\nbuild rewrites only the emitted `dist`\n\nspecifiers so standard ESM consumers can import the packed\nartifact directly. Package metadata avoids workspace-only dependency protocols, so `npm pack`\n\noutput\ncan be installed outside this workspace.", "url": "https://wpnews.pro/news/show-hn-nodiff-a-framework-that-lives-in-your-monorepo", "canonical_source": "https://github.com/hbbio/nodiff", "published_at": "2026-08-02 14:42:07+00:00", "updated_at": "2026-08-02 14:52:39.675810+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["NoDiff", "@nodiffjs/core", "Vite", "TypeScript", "React", "Svelte", "LLM"], "alternates": {"html": "https://wpnews.pro/news/show-hn-nodiff-a-framework-that-lives-in-your-monorepo", "markdown": "https://wpnews.pro/news/show-hn-nodiff-a-framework-that-lives-in-your-monorepo.md", "text": "https://wpnews.pro/news/show-hn-nodiff-a-framework-that-lives-in-your-monorepo.txt", "jsonld": "https://wpnews.pro/news/show-hn-nodiff-a-framework-that-lives-in-your-monorepo.jsonld"}}