<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eugene Yakhnenko</title>
    <description>The latest articles on DEV Community by Eugene Yakhnenko (@eugenioenko).</description>
    <link>https://dev.to/eugenioenko</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1225296%2Ffaac6a57-1b68-4e94-a89a-c5bc28538079.jpeg</url>
      <title>DEV Community: Eugene Yakhnenko</title>
      <link>https://dev.to/eugenioenko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eugenioenko"/>
    <language>en</language>
    <item>
      <title>From "I Can't Click" to a Full Testing Harness: How We Built Playwright for the Terminal</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Sat, 27 Jun 2026 21:48:30 +0000</pubDate>
      <link>https://dev.to/eugenioenko/from-i-cant-click-to-a-full-testing-harness-how-we-built-playwright-for-the-terminal-1bf6</link>
      <guid>https://dev.to/eugenioenko/from-i-cant-click-to-a-full-testing-harness-how-we-built-playwright-for-the-terminal-1bf6</guid>
      <description>&lt;p&gt;I'm building &lt;a href="https://tttedit.dev" rel="noopener noreferrer"&gt;TTT&lt;/a&gt; -- a terminal text editor and IDE written in Go. Single binary, zero config, runs anywhere. Think VS Code but in your terminal. It has syntax highlighting, LSP integration, a plugin system, an integrated terminal, git integration, etc...&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/eugenioenko/ttt" rel="noopener noreferrer"&gt;source is on GitHub&lt;/a&gt; and I develop it with Claude Code as my pair programmer. This is the story of how a frustrating limitation turned into something genuinely useful: a built-in scripted interaction system that lets AI agents (or anyone) drive the editor like Playwright drives a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I was deep in revamping the widget system and building out a Lua plugin API. Phases of work stacking up -- widget rendering, panel support, tree views, input fields, command registration, keybinding hooks. The kind of work where you need to &lt;em&gt;see&lt;/em&gt; what's happening. Click a tree node, check if it expands. Open a panel, verify focus moves correctly. Run a plugin, confirm the dialog appears.&lt;/p&gt;

&lt;p&gt;Here's the thing: Claude Code can run shell commands and read files. It cannot interact with a live TUI session. The editor launches, takes over the terminal, and that's it -- Claude is blind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: tui-use (what we had)
&lt;/h2&gt;

&lt;p&gt;The project already had functional tests using &lt;a href="https://github.com/onesuper/tui-use" rel="noopener noreferrer"&gt;tui-use&lt;/a&gt;, a JavaScript library that drives a real terminal binary. It can type, press keys, wait for text to appear, and take snapshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tui&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bin/ttt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test-file.go&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test-file.go&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor.joinLines&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tui&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;joined line&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works. But it's slow -- each test spawns the binary, waits for screen renders, polls with timeouts, and parses terminal escape codes. And critically, &lt;strong&gt;it can't click&lt;/strong&gt;. Mouse events aren't supported. For a widget system with tree views, buttons, and split panels, that's a dealbreaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Debug commands (the workaround)
&lt;/h2&gt;

&lt;p&gt;So we added a &lt;code&gt;Debug: Simulate Click&lt;/code&gt; command to the editor itself. Open the command palette, type coordinates, it fires a synthetic mouse event. Problem solved? Kind of. Claude still can't drive a live session to use it.&lt;/p&gt;

&lt;p&gt;But it planted a seed: the editor can simulate its own input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Lua as a test harness
&lt;/h2&gt;

&lt;p&gt;TTT has a Lua plugin system. Plugins can register panels, commands, keybindings, and interact with the editor through a &lt;code&gt;ttt&lt;/code&gt; module. We added &lt;code&gt;ttt.click(x, y)&lt;/code&gt; directly to the Lua API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ttt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ttt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ttt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;ttt.screenshot(path)&lt;/code&gt; to dump the screen to a file, and &lt;code&gt;ttt.debug(path)&lt;/code&gt; to dump the full internal state -- widget tree, focus, selection, panels, cursor position -- as JSON.&lt;/p&gt;

&lt;p&gt;Now a Lua script could interact with the editor and capture results to files that Claude can read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ttt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ttt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ttt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ttt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/after-click.txt"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ttt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/tmp/state.json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ttt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We added a &lt;code&gt;--plugin&lt;/code&gt; CLI flag to load a Lua file on startup, and &lt;code&gt;--size WxH&lt;/code&gt; to force deterministic screen dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/ttt &lt;span class="nt"&gt;--plugin&lt;/span&gt; test.lua &lt;span class="nt"&gt;--size&lt;/span&gt; 120x40
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/after-click.txt
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/state.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a breakthrough. Claude could now write a Lua script, run the editor, and inspect the results. But writing a Lua file for every quick check felt heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: --exec (the final form)
&lt;/h2&gt;

&lt;p&gt;The insight: most debugging interactions are simple sequences. Click here, press that key, take a screenshot. Why write a file?&lt;/p&gt;

&lt;p&gt;We added &lt;code&gt;--exec&lt;/code&gt;, which takes a semicolon-separated string of commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/ttt &lt;span class="nt"&gt;--size&lt;/span&gt; 120x40 &lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s2"&gt;"wait 200; screenshot /tmp/s1.txt; click 10 5; wait 100; screenshot /tmp/s2.txt; quit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The supported commands:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;click X Y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simulate a mouse click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;key COMBO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simulate a key press (&lt;code&gt;ctrl+p&lt;/code&gt;, &lt;code&gt;enter&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type TEXT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Type a string character by character&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exec "Command"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run a command by title&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;screenshot PATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dump screen text to a file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;debug PATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dump full state JSON (widget tree, focus, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wait MS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wait milliseconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;quit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Exit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now Claude can do this in a single bash command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/ttt &lt;span class="nt"&gt;--size&lt;/span&gt; 120x40 file.go &lt;span class="nt"&gt;--exec&lt;/span&gt; &lt;span class="s2"&gt;"wait 200; screenshot /tmp/screen.txt; debug /tmp/state.json; quit"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/screen.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And instantly see what's on screen. No Lua file, no polling, no escape codes. Build, run, inspect -- milliseconds, not seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the debug dump looks like
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;debug&lt;/code&gt; command captures everything you'd want to assert on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"screen"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"line"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"col"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"buffer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file.go"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"focus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sidebar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"visible"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"explorer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"panels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"explorer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"changes"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bottom_panel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"visible"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"output"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tabs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"file.go"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"modified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"selection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"widget_tree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VStack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"y"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"children"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MenuBar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"rect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"y"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"w"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"h"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SplitPanel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"show_left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"divider_pos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"children"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sidebar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"visible"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"explorer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"children"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tree"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"props"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"focused"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screenshot gives you the text. The debug dump gives you the state. Between the two, you can verify anything -- layout, focus, widget hierarchy, selection, which panel is active, how many items are in a tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The progression
&lt;/h2&gt;

&lt;p&gt;Looking back, each step was small but unlocked something the previous couldn't:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;tui-use&lt;/strong&gt; -- keyboard-only blackbox testing, slow, no mouse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debug: Simulate Click&lt;/strong&gt; -- mouse support, but only through command palette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ttt.click()&lt;/strong&gt; in Lua -- programmatic mouse, but requires writing a plugin file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ttt.screenshot() + ttt.debug()&lt;/strong&gt; -- capture both visual and internal state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;--exec&lt;/strong&gt; -- one-liner scripted interaction, no files needed&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What started as "I can't click in tests" ended up as a general-purpose testing and debugging harness that's faster than Playwright, gives deeper insight (you get the widget tree, not just pixels), and works for both AI agents and human developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it for real
&lt;/h2&gt;

&lt;p&gt;The PR that adds all of this: &lt;a href="https://github.com/eugenioenko/ttt/pull/274" rel="noopener noreferrer"&gt;feat/debug-commands #274&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The immediate use case: I'm working through an audit of the plugin and widget system. Dozens of items to fix -- tree expand ordering, box padding, focus management, API consistency. For each fix, I can now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the fix&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bin/ttt --size 120x40 --exec "wait 200; debug /tmp/state.json; quit"&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Assert on the state&lt;/li&gt;
&lt;li&gt;Move on&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No test file to maintain. No polling. No flaky waits. Just build, run, check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;TTT is open source and available at &lt;a href="https://tttedit.dev" rel="noopener noreferrer"&gt;tttedit.dev&lt;/a&gt;. Install it, open a file, run &lt;code&gt;Debug: Dump State&lt;/code&gt; from the command palette, and look at the JSON. The full widget tree is right there.&lt;/p&gt;

&lt;p&gt;If you're building a TUI application and struggling with testing, consider this pattern: expose your internal state through a debug dump, add a scripted command interface, and let your tools (AI or otherwise) drive it programmatically. It's surprisingly little code for a lot of capability.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>go</category>
      <category>showdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>I built a terminal IDE that feels like VS Code: here's why</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:04:55 +0000</pubDate>
      <link>https://dev.to/eugenioenko/i-built-a-terminal-ide-that-feels-like-vs-code-heres-why-4lej</link>
      <guid>https://dev.to/eugenioenko/i-built-a-terminal-ide-that-feels-like-vs-code-heres-why-4lej</guid>
      <description>&lt;p&gt;If you're using terminal-based AI agents like Claude Code, you're already living in the terminal. But every time you need to review code, check a PR, or search across your codebase, you switch back to VS Code. Every switch costs context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxffrhl4l9adzoqbwik9k.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxffrhl4l9adzoqbwik9k.gif" alt=" " width="719" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Vim and Neovim solved terminal-native editing a long time ago. But modal editing is a wall. Most developers try it, struggle with it, and go back to what feels familiar.&lt;/p&gt;

&lt;p&gt;So the choice has been: stay in the terminal and learn a new paradigm, or use a familiar IDE and accept the context switching.&lt;/p&gt;

&lt;p&gt;I didn't think that tradeoff should exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is TTT?
&lt;/h2&gt;

&lt;p&gt;TTT is a terminal text editor written in Go. Single binary, zero config. But the key decision was this: it should look and feel like VS Code or Zed — not like a terminal editor.&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard keybindings&lt;/strong&gt; — Ctrl+C, Ctrl+V, Ctrl+Z. No modes to learn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command palette&lt;/strong&gt; — Ctrl+P to find files, run commands, switch themes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sidebar panels&lt;/strong&gt; — file explorer, search results, git changes. Click to navigate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mouse support&lt;/strong&gt; — click, drag, select. Right-click context menus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tabs&lt;/strong&gt; — open multiple files, switch between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Menus&lt;/strong&gt; — a full menu bar, just like a GUI editor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not a "terminal editor that's trying its best." It's an IDE that happens to run in your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;LSP support&lt;/strong&gt; — completions, diagnostics, hover, go-to-definition, references, rename, code actions, signature help. Same Language Server Protocol that powers VS Code, running over JSON-RPC/stdio.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integrated terminal&lt;/strong&gt; — a full terminal emulator inside the editor with 256-color support. Run your builds, tests, and git commands without leaving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub PR review&lt;/strong&gt; — open a pull request URL from the command palette and review diffs inline. Navigate between changed files without leaving the editor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project-wide search&lt;/strong&gt; — powered by ripgrep. Search across your entire codebase with debounced input and result navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git integration&lt;/strong&gt; — stage, unstage, commit, push, pull. View changes, open diffs, git gutter indicators in the editor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Themes&lt;/strong&gt; — multiple built-in themes with live preview when switching. The editor looks good out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code intelligence&lt;/strong&gt; — syntax highlighting, code folding, bracket pair colorization, matching bracket navigation, indent detection, EditorConfig support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use Vim/Neovim?
&lt;/h2&gt;

&lt;p&gt;Nothing against them. They're powerful, mature, and have massive ecosystems. But they require you to rewire your muscle memory. Modal editing is a fundamentally different interaction model, and for developers who've spent years in VS Code or Sublime, that's a real barrier.&lt;/p&gt;

&lt;p&gt;TTT's position is simple: you shouldn't have to choose between a familiar UX and staying in the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not VS Code in the terminal?
&lt;/h2&gt;

&lt;p&gt;VS Code's remote/terminal options exist, but they're still VS Code — an Electron app connecting to your terminal, not a native terminal application. The distinction matters when you want something that starts instantly, runs alongside your tools, and stays within the same context as your terminal workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI agent angle
&lt;/h2&gt;

&lt;p&gt;This is the part that motivated the project. When you're pair-programming with a terminal-based AI agent, your workflow lives in the terminal. The agent runs commands, edits files, runs tests — all in the terminal. If your editor also lives there, you never leave. You can review what the agent changed, search the codebase for context, open a PR diff — all without switching windows.&lt;/p&gt;

&lt;p&gt;Your agent works. You review. Same context, same window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;TTT is a single Go binary with zero runtime dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap eugenioenko/ttt
brew &lt;span class="nb"&gt;install &lt;/span&gt;ttt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Linux:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/eugenioenko/ttt/main/install.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From source:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/eugenioenko/ttt.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ttt
make build
./bin/ttt &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://tttedit.dev" rel="noopener noreferrer"&gt;tttedit.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/eugenioenko/ttt" rel="noopener noreferrer"&gt;github.com/eugenioenko/ttt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback welcome — especially from developers who've tried terminal editors before and bounced off them.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Making OAuth Testable: Rethinking OIDC Clients in JavaScript</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Sun, 03 May 2026 00:14:10 +0000</pubDate>
      <link>https://dev.to/eugenioenko/making-oauth-testable-rethinking-oidc-clients-in-javascript-2i93</link>
      <guid>https://dev.to/eugenioenko/making-oauth-testable-rethinking-oidc-clients-in-javascript-2i93</guid>
      <description>&lt;h2&gt;
  
  
  The real pain point
&lt;/h2&gt;

&lt;p&gt;Most OAuth/OIDC integrations in JavaScript are difficult to test in a meaningful way. Testing usually involves mocking network calls, faking redirects, stubbing token responses, and simulating browser state. The result is that you are not testing OAuth. You are testing your mocks.&lt;/p&gt;

&lt;p&gt;The typical test for an OIDC login flow looks something like this: intercept the fetch call to the token endpoint, return a hardcoded JSON response, check that the UI updated. You have verified that your code handles a specific shape of data. You have not verified that your code actually implements the OIDC protocol correctly.&lt;/p&gt;

&lt;p&gt;This is not a minor distinction. OAuth and OIDC are security protocols. The value of testing them comes from exercising the real behavior: actual redirects, actual token exchanges, actual state validation. When every external interaction is replaced with a stub, the test becomes a tautology.&lt;/p&gt;

&lt;p&gt;The problem is not OAuth itself. It is how we structure clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OIDC clients are hard to test
&lt;/h2&gt;

&lt;p&gt;Most OIDC libraries combine several concerns into a single abstraction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protocol logic&lt;/strong&gt;: PKCE code challenges, state parameters, nonce validation, token parsing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP&lt;/strong&gt;: fetch calls, interceptors, retry logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt;: localStorage, sessionStorage, cookies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework concerns&lt;/strong&gt;: React hooks, Angular services, Vue composables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates implicit behavior. A single &lt;code&gt;useAuth()&lt;/code&gt; hook might trigger discovery, check for stored tokens, initiate a background refresh, and update reactive state, all before the component finishes mounting. None of these steps are visible to the caller.&lt;/p&gt;

&lt;p&gt;It also creates tight coupling to the runtime. You cannot test the protocol logic without also dealing with fetch, the DOM, and framework-specific rendering. And so the instinct is to mock everything. Replace fetch with a spy. Stub sessionStorage. Fake the redirect.&lt;/p&gt;

&lt;p&gt;When everything is coupled, everything has to be mocked. And when everything is mocked, you are testing a simulation, not the thing itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A different approach: treat OIDC as a protocol
&lt;/h2&gt;

&lt;p&gt;OIDC does not need to be a runtime-driven client. If you look at what the protocol actually requires, most of it is pure computation: building requests, validating callbacks, parsing tokens, and checking expiration. All of these take data in and return data out. They do not need fetch. They do not need localStorage. They do not need a DOM.&lt;/p&gt;

&lt;p&gt;The protocol is pure. IO is not. The mistake most libraries make is treating these as one thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture shift: separate protocol from runtime
&lt;/h2&gt;

&lt;p&gt;The idea is straightforward: split the OIDC client into two layers.&lt;/p&gt;

&lt;p&gt;The first layer is a &lt;strong&gt;functional core&lt;/strong&gt;. It contains every piece of protocol logic, and nothing else. No fetch calls. No storage access. No global state. No framework imports. Every function takes explicit parameters and returns a result. A function like &lt;code&gt;buildTokenRequest&lt;/code&gt; takes a discovery document, a code, and a code verifier, and returns an object with a URL, headers, and body. It does not send the request. That is someone else's job.&lt;/p&gt;

&lt;p&gt;The second layer is a set of &lt;strong&gt;adapters&lt;/strong&gt;. Each adapter is framework-specific and handles the IO that the core deliberately avoids. A React adapter composes core functions with fetch and React state. An Angular adapter uses HttpClient and services. A Vue adapter uses composables. A Svelte adapter uses stores.&lt;/p&gt;

&lt;p&gt;The adapters are thin. They call core functions to build requests, execute those requests using whatever HTTP mechanism the framework provides, and pass responses back through core functions for parsing and validation.&lt;/p&gt;

&lt;p&gt;The result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Protocol logic has zero dependencies, not even on fetch. It uses only the Web Crypto API for PKCE generation.&lt;/li&gt;
&lt;li&gt;No framework concerns leak into the core. React does not exist in the token parsing code.&lt;/li&gt;
&lt;li&gt;No hidden side effects. Every IO operation is explicit and visible in the adapter layer.&lt;/li&gt;
&lt;li&gt;Testing boundaries are clear. You can test the core with pure unit tests. You can test the adapters with integration tests. Neither requires mocking the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing OAuth without mocks
&lt;/h2&gt;

&lt;p&gt;This is where the architecture pays off. Because the core is pure, you can test it exhaustively with straightforward unit tests. Pass in a discovery document. Get back an authorization URL. Verify the parameters. No HTTP server needed. No browser needed. No mocks needed.&lt;/p&gt;

&lt;p&gt;But unit testing the core is only half the story. The real value comes from what this architecture enables at the integration level: testing the full OIDC flow against a real identity provider.&lt;/p&gt;

&lt;p&gt;The test setup uses Autentico, a lightweight OIDC provider built for testing. Autentico is a single binary with no external dependencies. In CI, the full setup takes roughly 500 milliseconds: generate cryptographic secrets, create an admin user, register a client, start the server. That is fast enough to spin up a fresh identity provider instance for every individual test.&lt;/p&gt;

&lt;p&gt;The goal is not to test Autentico. It is to remove the need for mocks entirely by making the provider disposable.&lt;/p&gt;

&lt;p&gt;Each test gets its own Autentico instance with its own database, its own users, and its own registered clients. There is no shared state between tests. No leftover sessions. No token caches that bleed across test boundaries. If a test fails, it fails because of the code under test, not because a previous test left the identity provider in an unexpected state.&lt;/p&gt;

&lt;p&gt;The fixture handles everything programmatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generates random cryptographic secrets (access token, refresh token, CSRF, RSA signing key)&lt;/li&gt;
&lt;li&gt;Creates a fresh SQLite database&lt;/li&gt;
&lt;li&gt;Runs the onboarding step to set up an admin user&lt;/li&gt;
&lt;li&gt;Starts the server on an isolated port&lt;/li&gt;
&lt;li&gt;Registers an OAuth client with the correct redirect URIs&lt;/li&gt;
&lt;li&gt;Creates a test user with known credentials&lt;/li&gt;
&lt;li&gt;Waits for the health check endpoint to respond&lt;/li&gt;
&lt;li&gt;Tears everything down after the test completes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No manual configuration. No shared test environment. No Docker containers. Just a binary that starts in under a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic end-to-end tests
&lt;/h2&gt;

&lt;p&gt;With a real identity provider running per test, the end-to-end tests exercise the actual protocol flow through a real browser.&lt;/p&gt;

&lt;p&gt;Using Playwright, each test performs the full sequence: navigate to the application, click login, get redirected to the identity provider, fill in credentials, submit, get redirected back with an authorization code, exchange the code for tokens, fetch user info, and verify the UI reflects the authenticated state.&lt;/p&gt;

&lt;p&gt;Nothing is intercepted. Nothing is stubbed. The browser makes real HTTP requests. The identity provider issues real tokens signed with a real RSA key. The application parses real JWT claims and validates real nonces.&lt;/p&gt;

&lt;p&gt;The tests assert both UI state and the exact protocol sequence. A traffic tracker records every fetch request and browser navigation to the identity provider in the order they occur, filtered to OIDC-relevant paths. After each test, assertions verify not just that the login succeeded, but that the exact expected sequence happened in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET  /.well-known/openid-configuration   # app loads, fetches discovery
NAV  /oauth2/authorize                    # browser redirects to IdP
GET  /.well-known/openid-configuration   # app reloads after callback, fetches discovery again
POST /oauth2/token                        # exchanges authorization code for tokens
GET  /oauth2/userinfo                     # fetches user profile
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two discovery calls are not a bug. There is a full page navigation between them. The first happens when the app mounts. The browser then navigates to the authorization endpoint. After the user authenticates, the IdP redirects back, the app reloads from scratch, and discovery is fetched again before the token exchange. The sequence tracker makes this visible. An earlier version of the test suite tracked fetches and navigations separately, which made it look like both discoveries happened together. The combined sequence revealed the actual interleaving.&lt;/p&gt;

&lt;p&gt;Most tests assert outcomes. These tests also assert the protocol itself. A token refresh that should not have happened. A missing userinfo request. A navigation that fired before a fetch it was supposed to follow. These are the kinds of issues that mock-based tests cannot detect, because the mocks only respond to the calls you anticipated.&lt;/p&gt;

&lt;p&gt;The tests also verify security properties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tokens are never stored in localStorage or sessionStorage&lt;/li&gt;
&lt;li&gt;Callback URL parameters (code, state) are cleaned up after processing&lt;/li&gt;
&lt;li&gt;Sessions are not preserved across page reloads (in-memory only)&lt;/li&gt;
&lt;li&gt;The back button after logout does not expose authenticated content&lt;/li&gt;
&lt;li&gt;Tampered state parameters trigger the correct error&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these tests runs against the real flow. The assertion that tokens are not in storage is meaningful because real tokens were actually issued and processed. The assertion about state mismatch is meaningful because a real authorization request was initiated with a real state parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running tests across frameworks
&lt;/h2&gt;

&lt;p&gt;Because the core is framework-agnostic and each adapter is a thin wrapper, the same test suite runs against every framework. The same spec file tests React, Angular, Vue, Svelte, Lit, Solid, and Preact. Each framework gets its own dev server on an isolated port, its own Autentico instance on a separate port, and its own database.&lt;/p&gt;

&lt;p&gt;A shell script orchestrates the runs with configurable parallelism. Locally, with all eight frameworks running in parallel, the full suite completes in under a minute. In CI, they run sequentially to stay within resource limits.&lt;/p&gt;

&lt;p&gt;The test names are prefixed with the framework identifier, so failures are immediately attributable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[React] OIDC Login Flow &amp;gt; completes full login flow with tokens
[Angular] RequireAuth &amp;gt; auto-refreshes expired token when navigating to protected page
[Vue] Security &amp;gt; tokens are not stored in localStorage or sessionStorage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This setup catches framework-specific regressions. A change to the Svelte adapter that accidentally double-fires a discovery request will fail the traffic assertion even though the UI behavior looks correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this catches that mocks don't
&lt;/h2&gt;

&lt;p&gt;One concrete example: token refresh race conditions.&lt;/p&gt;

&lt;p&gt;The test for automatic token refresh works like this. First, complete a full login. Then, override &lt;code&gt;Date.now&lt;/code&gt; in the browser to simulate time passing beyond the token's expiration. Then navigate to a protected page. The RequireAuth guard should detect the expired token, attempt a refresh, and let the user through if the refresh succeeds.&lt;/p&gt;

&lt;p&gt;The tricky part is restoring the clock. Restoring &lt;code&gt;Date.now&lt;/code&gt; from Playwright's &lt;code&gt;page.evaluate&lt;/code&gt; after the refresh arrives as a macrotask, but the framework's state update from the refresh response runs in the microtask chain. The component re-renders with the new token while &lt;code&gt;Date.now&lt;/code&gt; still returns the fake expired time, triggering another refresh.&lt;/p&gt;

&lt;p&gt;The solution is to patch &lt;code&gt;window.fetch&lt;/code&gt; alongside &lt;code&gt;Date.now&lt;/code&gt;, and restore the real clock from inside the fetch promise chain, before the framework processes the response.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical edge case. It is a real bug that surfaced during development. A mock-based test would never catch it because the mock controls both the clock and the response, and there is no actual async flow to create the race condition.&lt;/p&gt;

&lt;p&gt;Another example: the test that revokes a refresh token server-side, then navigates to a protected page. The guard attempts a refresh, gets a failure from the real identity provider, and falls back to a full login redirect. With mocks, you would return a 400 from a stubbed endpoint. With a real provider, the revocation is real, the failure is real, and the redirect is real. If the client's error handling has a subtle bug in how it interprets the provider's error response, the real test catches it. The mock never will, because the mock returns exactly the error format you expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs
&lt;/h2&gt;

&lt;p&gt;This approach is not free. There are real costs.&lt;/p&gt;

&lt;p&gt;Running a real identity provider adds setup complexity. The test fixture is more involved than a simple &lt;code&gt;beforeEach&lt;/code&gt; that sets up mocks. The Autentico binary needs to be downloaded, and each test pays the cost of starting a server process.&lt;/p&gt;

&lt;p&gt;A single test provider gives you deterministic behavior, but it does not cover provider-specific quirks. Real-world OIDC providers have subtle differences in token formats, claim structures, and error responses. Testing against Autentico validates the protocol, not every provider's interpretation of it.&lt;/p&gt;

&lt;p&gt;The tests are slower than pure unit tests. A full E2E test with browser automation, server startup, and real HTTP exchanges takes seconds, not milliseconds. The per-test Autentico instance adds roughly 500 milliseconds of overhead. For a single test, that is noticeable. Across a full suite with parallelism, it is manageable.&lt;/p&gt;

&lt;p&gt;This is not the fastest way to test auth. It is the most reliable. When the suite passes, you know that the full OIDC flow works in a real browser against a real provider. When it fails, the failure points to an actual problem, not a gap between your mocks and reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;OAuth is not inherently hard to test. It becomes hard when protocol logic, IO, and framework concerns are mixed into one abstraction. When they are separated, each piece becomes testable on its own terms.&lt;/p&gt;

&lt;p&gt;The protocol layer is pure computation. Test it with inputs and outputs. The adapter layer is framework-specific IO. Test it against a real provider. The identity provider setup is fast enough to be disposable. Give each test a fresh instance and eliminate shared state entirely.&lt;/p&gt;

&lt;p&gt;When the suite passes, you are not trusting mocks. You are verifying the protocol itself.&lt;/p&gt;

&lt;p&gt;This approach is implemented in &lt;a href="https://github.com/eugenioenko/oidc-js" rel="noopener noreferrer"&gt;oidc-js&lt;/a&gt;, a zero-dependency, cross-framework OIDC client built around a functional core and thin adapters, tested end-to-end with &lt;a href="https://github.com/eugenioenko/autentico" rel="noopener noreferrer"&gt;Autentico&lt;/a&gt;, a lightweight OIDC provider built for exactly this kind of workflow.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What 200 Concurrent Users Taught Me About SQLite Performance</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Tue, 28 Apr 2026 20:00:51 +0000</pubDate>
      <link>https://dev.to/eugenioenko/what-200-concurrent-users-taught-me-about-sqlite-performance-442j</link>
      <guid>https://dev.to/eugenioenko/what-200-concurrent-users-taught-me-about-sqlite-performance-442j</guid>
      <description>&lt;p&gt;I was about to release &lt;a href="http://autentico.top/" rel="noopener noreferrer"&gt;Autentico&lt;/a&gt; 2.0. The feature work was done, tests were passing, docs were updated. Before tagging the release I figured I'd spend some time on performance. Run some stress tests, see where things stand, maybe squeeze out some easy wins. What followed was a week-long detour through profiling, architecture design, benchmarking, and a humbling lesson about assumptions.&lt;/p&gt;

&lt;p&gt;Autentico is a self-contained OAuth 2.0 / OpenID Connect identity provider built with Go and SQLite. One binary, one database file, no external dependencies. The benchmark workload is a full PKCE authorization code flow: authorize, login with password, token exchange, token introspection, and refresh. Five HTTP requests per iteration, four or five SQLite writes per iteration, and one bcrypt password verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Profiling on the Wrong Machine
&lt;/h2&gt;

&lt;p&gt;I started with k6 stress tests on my older i5 laptop. 100 virtual users, 30 seconds, the full auth flow. The results were fine but not great. So I profiled.&lt;/p&gt;

&lt;p&gt;90% of CPU time was spent in &lt;code&gt;bcrypt.CompareHashAndPassword&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the function that verifies a user's password against the stored hash. It's intentionally slow (that's the point of bcrypt), it's CPU-bound, and it was dominating everything else. SQLite writes took microseconds. JWT signing was negligible. HTTP routing was invisible. Just bcrypt, eating all available cores.&lt;/p&gt;

&lt;p&gt;The conclusion seemed obvious: bcrypt is the bottleneck, and you can't make bcrypt faster. You can only do more of it in parallel. But on a single machine running SQLite, you can't just add more instances. SQLite is single-writer, single-file. You can't horizontally scale the traditional way.&lt;/p&gt;

&lt;p&gt;Or can you?&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing Verifico
&lt;/h2&gt;

&lt;p&gt;The bottleneck wasn't the database. It was one function call. So what if you scaled just that function?&lt;/p&gt;

&lt;p&gt;I explored the options systematically:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CQRS with SQLite replication.&lt;/strong&gt; LiteFS can replicate SQLite across nodes, one primary for writes, replicas for reads. A real architecture, but it solves a general scaling problem. Mine was specific. I didn't need to distribute reads and writes. I needed to distribute bcrypt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres.&lt;/strong&gt; The standard answer for outgrowing SQLite. But Postgres doesn't solve bcrypt CPU. You'd still run &lt;code&gt;CompareHashAndPassword&lt;/code&gt; on the application server. Multiple instances behind a load balancer would spread the load, but you'd be paying for full application instances (database connections, memory, middleware) when all you need is more CPU for one function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Child processes.&lt;/strong&gt; Spawn separate processes for bcrypt work. But Go already parallelizes CPU-bound work across all cores via goroutines and the runtime scheduler. On a single machine, you can't beat Go's built-in parallelism. Separate processes just add IPC overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sticky sessions.&lt;/strong&gt; Route users to specific instances. But you need a shared lookup table, which needs a shared database, which is the problem you're trying to avoid.&lt;/p&gt;

&lt;p&gt;Then the idea clicked: keep Autentico as a single instance, owning the database and handling everything. But when it needs to verify a password, send the hash and the plaintext to a remote worker. The worker runs bcrypt and returns true or false. Workers are stateless, trivial, and can run on the cheapest hardware available.&lt;/p&gt;

&lt;p&gt;I called it Verifico ("I verify" in Italian, matching Autentico's naming). Same binary, new subcommand: &lt;code&gt;autentico verifico start&lt;/code&gt;. One HTTP endpoint, one function call, a shared secret for auth, and round-robin load balancing with automatic fallback to local bcrypt if workers are down.&lt;/p&gt;

&lt;p&gt;The security model went through its own journey. I started at mTLS (operationally heavy for a boolean endpoint), worked through AES encryption (reimplementing TLS poorly), landed on a shared secret over a private network. The password already traveled over the public internet to reach Autentico. One more hop inside a VPC is no worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  It Worked
&lt;/h2&gt;

&lt;p&gt;On the i5, Verifico delivered real improvements. With the server constrained to 2 cores and workers handling bcrypt, non-login endpoints dropped from seconds to single-digit milliseconds. The server's cores were free for HTTP handling, SQLite queries, and JWT signing. Throughput scaled linearly with worker count, up to about 6 cores. At 8 it flattened out.&lt;/p&gt;

&lt;p&gt;I was pleased. Built a clean solution, benchmarked it, it worked. Ready to ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  It Didn't Work
&lt;/h2&gt;

&lt;p&gt;Then I ran the same benchmarks on a modern Ryzen 7 desktop. 16 cores, faster single-thread performance, more cache.&lt;/p&gt;

&lt;p&gt;I constrained Autentico to 2 cores and started adding 2-core workers: 2+2, 2+2+2, all the way up to 2+7x2. On the i5, throughput had kept climbing with each worker up to 6 cores. On the Ryzen:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;iter/s&lt;/th&gt;
&lt;th&gt;Login p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2 server + 2 worker&lt;/td&gt;
&lt;td&gt;15.4/s&lt;/td&gt;
&lt;td&gt;3.61s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 server + 4 worker&lt;/td&gt;
&lt;td&gt;15.4/s&lt;/td&gt;
&lt;td&gt;3.68s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 server + 6 worker&lt;/td&gt;
&lt;td&gt;15.2/s&lt;/td&gt;
&lt;td&gt;3.58s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 server + 10 worker&lt;/td&gt;
&lt;td&gt;15.0/s&lt;/td&gt;
&lt;td&gt;3.60s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 server + 14 worker&lt;/td&gt;
&lt;td&gt;14.7/s&lt;/td&gt;
&lt;td&gt;3.76s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Flat. Five configurations, 2 to 14 worker cores, and throughput barely moved. Adding workers did nothing.&lt;/p&gt;

&lt;p&gt;The Ryzen was simply faster at bcrypt. Even at the default cost of 10, each core chewed through password hashes fast enough that bcrypt stopped being the bottleneck. The real contention was elsewhere entirely.&lt;/p&gt;

&lt;p&gt;I had spent days designing, implementing, and benchmarking a solution for a bottleneck that was hardware-specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Real Bottleneck
&lt;/h2&gt;

&lt;p&gt;I went back to profiling, this time on the Ryzen. A Go block profile under load revealed that every contention point was at &lt;code&gt;database/sql.(*DB).conn&lt;/code&gt;. Goroutines waiting for a connection from the pool. Not SQLite's file lock, not disk I/O. The Go connection pool.&lt;/p&gt;

&lt;p&gt;Reads accounted for 65% of total contention, writes 35%. The top offenders were all routine operations: looking up a client by ID, creating a session, creating a token. Fast queries, stuck waiting in line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Boring Win: WAL Mode
&lt;/h2&gt;

&lt;p&gt;SQLite's default rollback journal locks the entire database during writes, blocking all readers. WAL (Write-Ahead Logging) changes this: readers see a consistent snapshot while writes go to a separate log. The change is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;PRAGMA&lt;/span&gt; &lt;span class="n"&gt;journal_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's persistent. Set it once and every future connection inherits it. No application code changes.&lt;/p&gt;

&lt;p&gt;Results at 200 virtual users, 30 seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cores&lt;/th&gt;
&lt;th&gt;Without WAL&lt;/th&gt;
&lt;th&gt;With WAL&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;13.4 iter/s&lt;/td&gt;
&lt;td&gt;16.7 iter/s&lt;/td&gt;
&lt;td&gt;+25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;23.6 iter/s&lt;/td&gt;
&lt;td&gt;31.3 iter/s&lt;/td&gt;
&lt;td&gt;+33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;32.2 iter/s&lt;/td&gt;
&lt;td&gt;49.8 iter/s&lt;/td&gt;
&lt;td&gt;+55%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;33.0 iter/s&lt;/td&gt;
&lt;td&gt;54.3 iter/s&lt;/td&gt;
&lt;td&gt;+65%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;31.9 iter/s&lt;/td&gt;
&lt;td&gt;50.2 iter/s&lt;/td&gt;
&lt;td&gt;+57%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One pragma. No code changes. Up to 65% throughput improvement. But WAL alone hits a ceiling around 6 cores and actually regresses past that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Scaling Win: Read/Write Pool Split
&lt;/h2&gt;

&lt;p&gt;WAL allows concurrent readers alongside a single writer. The natural next step: give readers their own connection pool.&lt;/p&gt;

&lt;p&gt;I split the single &lt;code&gt;*sql.DB&lt;/code&gt; into two pools. A write pool with one connection (serializing all mutations, eliminating SQLITE_BUSY errors) and a read pool with multiple connections for concurrent SELECT queries.&lt;/p&gt;

&lt;p&gt;The key was making this invisible to callers. Instead of updating every file that touches the database, I wrote a &lt;code&gt;DB&lt;/code&gt; wrapper that routes by method: &lt;code&gt;Exec&lt;/code&gt; and &lt;code&gt;Begin&lt;/code&gt; go to the writer, &lt;code&gt;Query&lt;/code&gt; and &lt;code&gt;QueryRow&lt;/code&gt; go to the reader pool. Every package just calls &lt;code&gt;db.GetDB()&lt;/code&gt; and the routing happens automatically. Zero changes to business logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;DB&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;
    &lt;span class="n"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also required some iteration. The first attempt was slower due to a bug where pooled connections weren't getting their PRAGMA settings. Once fixed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cores&lt;/th&gt;
&lt;th&gt;WAL Only&lt;/th&gt;
&lt;th&gt;WAL + Pool Split&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;49.8 iter/s&lt;/td&gt;
&lt;td&gt;57.0 iter/s&lt;/td&gt;
&lt;td&gt;+14%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;54.3 iter/s&lt;/td&gt;
&lt;td&gt;76.1 iter/s&lt;/td&gt;
&lt;td&gt;+40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;50.2 iter/s&lt;/td&gt;
&lt;td&gt;88.3 iter/s&lt;/td&gt;
&lt;td&gt;+76%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;45.9 iter/s&lt;/td&gt;
&lt;td&gt;101.4 iter/s&lt;/td&gt;
&lt;td&gt;+121%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where WAL alone plateaus and regresses, the pool split keeps scaling. At 500 virtual users over 60 seconds, the pool split delivered 3.5x the throughput of the main branch with 59-78% latency reduction across all endpoints. Zero errors on both configurations.&lt;/p&gt;

&lt;p&gt;The read pool sweet spot was 4 connections. More than that floods the writer with contention when all those concurrent reads finish simultaneously and try to write. The auto-calculation &lt;code&gt;min(available CPUs, 4)&lt;/code&gt; with a floor of 2 covers most cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Shipped in 2.0
&lt;/h2&gt;

&lt;p&gt;Two changes made it into the release:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WAL mode&lt;/strong&gt;, enabled by default. Free performance for every deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read/write connection pool split&lt;/strong&gt;, transparent to users. The server auto-tunes the read pool size based on available CPUs.&lt;/p&gt;

&lt;p&gt;Verifico didn't ship. The benchmarks on the Ryzen showed it wasn't solving a real bottleneck, so there was no reason to add the complexity. The code is there if the need ever materializes on constrained hardware, but for now it's a solution waiting for a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Profiling tells the truth, but only about the machine you're sitting at.&lt;/strong&gt; I should have known better. In my early years I spent time writing x86 assembly with FASM, where you learn that certain instructions cost more clock cycles than others and that two CPUs at the same clock speed can have very different real-world performance thanks to pipeline optimizations, L1/L2/L3 cache differences, and branch prediction. I knew hardware isn't uniform. What I didn't expect was that the &lt;em&gt;scaling behavior&lt;/em&gt; would change. I assumed that if adding worker cores improved throughput on one machine, it would improve throughput on another, maybe at different absolute numbers but with the same shape. Instead, the Ryzen's faster per-core bcrypt performance shifted the bottleneck entirely. The curve wasn't the same shape at a different scale. It was a different curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring fix usually wins.&lt;/strong&gt; WAL mode is in the SQLite documentation. Connection pooling is a well-understood pattern. Together they more than doubled throughput. Neither required novel architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the optimization, then question it.&lt;/strong&gt; I don't regret building Verifico. The design process (working through CQRS, Postgres, gRPC, mTLS, landing on the simplest thing) was valuable, and it works for its intended use case. But I should have validated the assumption on more than one machine before committing to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't benchmark at low concurrency and call it done.&lt;/strong&gt; Some of the intermediate results at 100 virtual users looked promising for approaches that fell apart at 200. Always test at your target load.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/eugenioenko/autentico" rel="noopener noreferrer"&gt;Autentico&lt;/a&gt; is an open-source OAuth 2.0 / OpenID Connect identity provider. Version 2.0 is coming soon.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>go</category>
      <category>idp</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why your drawing app uses 2% CPU when you're not using it</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Sun, 19 Apr 2026 18:45:14 +0000</pubDate>
      <link>https://dev.to/eugenioenko/why-your-drawing-app-uses-2-cpu-when-youre-not-using-it-10e0</link>
      <guid>https://dev.to/eugenioenko/why-your-drawing-app-uses-2-cpu-when-youre-not-using-it-10e0</guid>
      <description>&lt;p&gt;&lt;em&gt;A measured comparison of Figma, tldraw, Excalidraw, and Skedoodle, and the architectural choice that makes the difference.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Open your browser. Go to any drawing or whiteboarding app: tldraw, Excalidraw, Figma, whatever you use. Put it on a blank canvas. Don't touch anything.&lt;/p&gt;

&lt;p&gt;Open your browser's task manager.&lt;/p&gt;

&lt;p&gt;That app is probably using &lt;strong&gt;1–3% CPU&lt;/strong&gt; right now. Not the browser as a whole. Not all your tabs combined. Just that one page, sitting there, doing nothing visible. Figma alone burns 3.49%. Multiply across every "modern web app" tab you keep open and you start to understand why your fan spins up when you're not using the computer.&lt;/p&gt;

&lt;p&gt;I wanted to know where that CPU was going. I built a Playwright rig, loaded tldraw, Excalidraw, and Figma on a blank canvas, and sampled CPU for 30 seconds across 5 runs. I also measured a drawing app of my own, &lt;a href="https://skedoodle.top" rel="noopener noreferrer"&gt;Skedoodle&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's the result:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F53ip50z8bjdrsot1n4mv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F53ip50z8bjdrsot1n4mv.png" alt=" " width="800" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three apps pay a tax. One sits at the measurement noise floor. This post is about where each one's idle CPU goes, and then about a more surprising finding underneath: rendering architecture isn't what determines active CPU.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methodology in one paragraph
&lt;/h2&gt;

&lt;p&gt;I built a small &lt;a href="https://github.com/eugenioenko/skedoodle/tree/chore/perf-plan/perf" rel="noopener noreferrer"&gt;Playwright-based perf framework&lt;/a&gt; that opens each app in Chromium, sits on a blank canvas for 30 seconds, and samples Chrome DevTools Protocol &lt;code&gt;Performance.metrics&lt;/code&gt; every 500ms. The reported "CPU%" is &lt;code&gt;ΔTaskDuration / wall_clock&lt;/code&gt;, attributed to the &lt;strong&gt;page&lt;/strong&gt;, not the whole browser process. Median of 5 runs; whiskers on the chart are min–max. Machine: Microsoft Surface, Intel Core i5-1035G7, 8 cores, Arch Linux. The &lt;a href="https://github.com/eugenioenko/skedoodle/blob/chore/perf-plan/perf_results.md" rel="noopener noreferrer"&gt;full methodology and raw data&lt;/a&gt; are in the repo. &lt;code&gt;pnpm --filter skedoodle-perf baseline&lt;/code&gt; reproduces every number in this post.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why tldraw ticks every frame
&lt;/h2&gt;

&lt;p&gt;tldraw ships a component called &lt;code&gt;TickManager&lt;/code&gt;. It does what the name suggests: runs forever. &lt;a href="https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/editor/managers/TickManager/TickManager.ts" rel="noopener noreferrer"&gt;Here's the relevant code&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPaused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelRaf&lt;/span&gt;&lt;span class="p"&gt;?.()&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelRaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;throttleToNextFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;bind&lt;/span&gt;
&lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isPaused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updatePointerVelocity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;frame&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;editor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tick&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancelRaf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;throttleToNextFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// re-arm for next frame&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every frame (60Hz), &lt;code&gt;tick&lt;/code&gt; runs and re-schedules itself via &lt;code&gt;requestAnimationFrame&lt;/code&gt;. No dirty-flag guard. It ticks regardless of whether anything changed.&lt;/p&gt;

&lt;p&gt;And the tick does real work. It updates pointer velocity even when the pointer hasn't moved. It drains an event queue that might be empty. It fires &lt;code&gt;'frame'&lt;/code&gt; and &lt;code&gt;'tick'&lt;/code&gt; to anyone listening. The listeners do their own work: viewport and camera animation checks, scribble handlers (no-op when idle, but still a function call), and a &lt;code&gt;PerformanceManager._onFrame&lt;/code&gt; that computes &lt;code&gt;getCulledShapes()&lt;/code&gt; on every frame.&lt;/p&gt;

&lt;p&gt;None of that is wasted effort inside tldraw's model. Pointer velocity enables gesture recognition and flick handling. Frame events drive camera tweens and smooth zoom-to-fit. Culling keeps active-draw fast at scale. If you want those features, something has to run the tick. Multiply 60 ticks by half a millisecond of work each and you get ~1.5% CPU as the price of having them.&lt;/p&gt;

&lt;p&gt;Skedoodle doesn't have most of those features. So it doesn't tick.&lt;/p&gt;




&lt;h2&gt;
  
  
  Excalidraw's React reconciler
&lt;/h2&gt;

&lt;p&gt;Excalidraw does &lt;strong&gt;not&lt;/strong&gt; run a perpetual rAF. Their &lt;code&gt;throttleRAF&lt;/code&gt; helper is pull-based; it only schedules when called:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;throttleRAF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;timerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;lastArgs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheduleFunc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;timerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;timerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastArgs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;lastArgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And their &lt;a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/renderer/animation.ts" rel="noopener noreferrer"&gt;&lt;code&gt;AnimationController&lt;/code&gt;&lt;/a&gt; explicitly stops itself when there's nothing left to animate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AnimationController&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;animations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;AnimationController&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isRunning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// loop stops here when idle&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So where does Excalidraw's 1.18% idle CPU go? Into React's reconciler. Excalidraw stores hover state, current tool, and pointer position in React component state. Each &lt;code&gt;setState&lt;/code&gt; triggers &lt;code&gt;componentDidUpdate&lt;/code&gt;, which runs a ~160-line prev/next diff, commits to its store, fires &lt;code&gt;onChange&lt;/code&gt; listeners, and toggles theme classes.&lt;/p&gt;

&lt;p&gt;This is a reasonable design choice. Keeping interaction state in React gives you the normal React ergonomics: declarative rendering, hooks, standard event handling. The cost is that any internal state change wakes the reconciler, and at idle there's still enough internal churn (hover ticks, mouse-move handlers, periodic state sync) to keep it awake on a steady cadence.&lt;/p&gt;

&lt;p&gt;It's a different shape of problem from tldraw's: not a perpetual rAF, but a steady drip of React work, landing at roughly the same cost.&lt;/p&gt;




&lt;h2&gt;
  
  
  Figma is a different kind of cost
&lt;/h2&gt;

&lt;p&gt;Figma's idle CPU is the highest of the four, and most of it isn't rendering. At idle, the page is running: a websocket keepalive to Figma's backend, CRDT bookkeeping for the file you have open, cursor-presence logic for other collaborators, autosave timers, and the usual authenticated-product chatter (telemetry, experiment assignment, analytics).&lt;/p&gt;

&lt;p&gt;The perf runs for the other three apps (tldraw OSS, Excalidraw, Skedoodle) were local and unauthenticated. None of them were paying for collab infrastructure at measurement time. Figma doesn't offer a local-only mode, so its number reflects a shipping collaborative product rather than a fair architectural peer. Keep it in the chart as a ceiling on "what a production collaborative whiteboard costs idle," not as a comparison against the other three.&lt;/p&gt;

&lt;p&gt;The rest of this post is about the other three.&lt;/p&gt;




&lt;h2&gt;
  
  
  Skedoodle's 0.09%: event-driven rendering
&lt;/h2&gt;

&lt;p&gt;Skedoodle's idle CPU is near zero because nothing polls. The canvas doesn't repaint unless a user event changed the scene. State mutations don't wake a reconciler because canvas state isn't in React. There's no equivalent of tldraw's &lt;code&gt;TickManager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two choices enforce this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The renderer's internal loop is disabled.&lt;/strong&gt; Skedoodle is built on &lt;a href="https://two.js.org" rel="noopener noreferrer"&gt;Two.js&lt;/a&gt;, a thin 2D renderer. Two.js's default is its own internal &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop, the &lt;code&gt;autostart: true&lt;/code&gt; option. Enable it, and Two.js will call &lt;code&gt;update()&lt;/code&gt; every frame for you, forever. If it were left on, Skedoodle would measure about the same as tldraw.&lt;/p&gt;

&lt;p&gt;The first line of Skedoodle's canvas setup turns it off. &lt;a href="https://github.com/eugenioenko/skedoodle/blob/main/client/src/canvas/canvas.hook.tsx" rel="noopener noreferrer"&gt;&lt;code&gt;client/src/canvas/canvas.hook.tsx&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Two&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;twoType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;appendTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;autostart: false&lt;/code&gt;, Two.js never calls &lt;code&gt;update()&lt;/code&gt; on its own. Something in the application has to call it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The application only calls &lt;code&gt;update()&lt;/code&gt; on user events.&lt;/strong&gt; Skedoodle's entire render-scheduling layer is this one method on the canvas manager:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;throttledTwoUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updateFrequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useOptionsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;updateFrequency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updateFrequency&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;two&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_throttledUpdate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_lastFrequency&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;updateFrequency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_lastFrequency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;updateFrequency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_throttledUpdate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;two&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;updateFrequency&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_throttledUpdate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice.&lt;/p&gt;

&lt;p&gt;First, the function reads &lt;code&gt;updateFrequency&lt;/code&gt; from a Zustand store on every call. That's deliberate: the user can change the throttle rate from the UI at runtime, and the next invocation picks up the new value without re-instantiation.&lt;/p&gt;

&lt;p&gt;Second, if &lt;code&gt;updateFrequency&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, the call goes through immediately with no throttling. This is "High Performance" mode in the UI. For interactions like dragging a single shape or editing a bezier handle, unthrottled gives the most responsive feel and costs nothing extra, because the call rate is already bounded by the pointer event rate.&lt;/p&gt;

&lt;p&gt;Third, for any non-zero frequency, Skedoodle builds a throttled wrapper using lodash's &lt;code&gt;throttle&lt;/code&gt; (leading + trailing edges) and caches it. The cache is keyed on the frequency value, so changing the throttle rate invalidates and rebuilds; otherwise the same wrapper is reused across calls.&lt;/p&gt;

&lt;p&gt;Who calls this? Tool handlers do, after they've mutated scene state — the brush tool on every pointer-move, the shape tool after adjusting dimensions, the pointer tool on selection changes. Zustand store mutations that affect scene state call it. Nothing else. When the user is sitting still, &lt;code&gt;throttledTwoUpdate()&lt;/code&gt; isn't called, &lt;code&gt;two.update()&lt;/code&gt; doesn't run, and the canvas doesn't repaint.&lt;/p&gt;

&lt;p&gt;That's the 0.09%. It isn't a trick. It's what's left when you remove everything that was polling.&lt;/p&gt;

&lt;p&gt;The throttle rate (10, 30, 60, or 120 FPS, or "High Performance" for unthrottled) is exposed in the Settings panel as "Update Frequency." That last detail matters: it's evidence that the event-driven model is product surface, not accidental. A thick-library architecture couldn't offer that knob, because the library owns its own tick rate.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tie that's the real story
&lt;/h2&gt;

&lt;p&gt;Here's what happens when everyone's actually drawing: a synthesized 15-second pointer trace (Archimedean spiral, 60 Hz, 902 events) replayed identically across all four apps.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F29rzvhu8taas6s2l7gcq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F29rzvhu8taas6s2l7gcq.png" alt=" " width="800" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Skedoodle and tldraw land &lt;strong&gt;0.23 percentage points apart&lt;/strong&gt; on median CPU across five runs. That's noise-floor territory, and it's the finding that most changed how I think about this.&lt;/p&gt;

&lt;p&gt;These two apps have nothing architecturally in common on the rendering side. Skedoodle uses Two.js's SVG renderer. tldraw built its own React-plus-canvas rendering stack from scratch. Totally different choices. Same cost.&lt;/p&gt;

&lt;p&gt;Which means &lt;strong&gt;active-draw CPU is not determined by which rendering library you pick&lt;/strong&gt;. It's determined by whether your app does anything &lt;em&gt;else&lt;/em&gt; while rendering. tldraw ticks every frame and drains the queue; Skedoodle runs its throttled update. Both do roughly the same amount of shape-drawing work per user event. Same number.&lt;/p&gt;

&lt;p&gt;Excalidraw is ~8 points higher, almost certainly rough.js doing stroke roughening on every pointer event. Figma saturates a CPU core: every stroke routes through a WASM renderer, a CRDT, autosave persistence, and telemetry. Different cost structure entirely.&lt;/p&gt;

&lt;p&gt;Put together: &lt;strong&gt;idle cost and active cost are different problems.&lt;/strong&gt; Idle is about what your app does when no one's asking it to do anything, which is architectural. Active is about how much work each user interaction triggers, which is workload-dependent. The first is a design choice. The second is mostly inherent.&lt;/p&gt;

&lt;p&gt;Picking Two.js over tldraw won't make drawing faster. Picking an event-driven architecture over a perpetual tick will make your app disappear when no one's using it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tax
&lt;/h2&gt;

&lt;p&gt;There's a reason most drawing apps use something thicker than Two.js. With a thin renderer you write your own interaction layer: selection, hit-testing, handles, undo/redo, snapping, the whole surface. In Skedoodle's case that's roughly 5,000 lines of application code that exists specifically because the library didn't provide it. tldraw, Fabric, and Konva give you all of that.&lt;/p&gt;

&lt;p&gt;Other costs worth being honest about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You re-discover bug classes.&lt;/strong&gt; An early version of Skedoodle had a selection/hover layering bug where selection chrome rendered beneath newly drawn shapes; a mature transformer library would have prevented it by owning the chrome layer. Similar categories (pointer capture during fast drags, z-ordering of rotation handles against content, hit-testing that treats stroked paths as fills, coordinate math that breaks at extreme zoom levels) are things a library like tldraw or Konva has already worked through. With a thin renderer, you encounter them yourself, usually after they ship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade path is closer to the metal.&lt;/strong&gt; When the library has a bug, it's more likely to be your problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flip side of "own your render loop" is "own your interaction stack." It's not free, just moved.&lt;/p&gt;




&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;p&gt;Three workloads where event-driven rendering stops helping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scenes that legitimately need every frame.&lt;/strong&gt; Tween systems, physics, particle effects, animated cursors. If the scene changes without user input, an event-driven loop has nothing to trigger it. You need a tick. tldraw's model fits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thousands of continuously moving shapes.&lt;/strong&gt; When redraws are expensive &lt;em&gt;and&lt;/em&gt; frequent, the cost isn't in whether to call &lt;code&gt;update()&lt;/code&gt;, it's in whether the renderer can batch, dirty-rect, or cull. Thin renderers without those primitives stop helping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transformer-heavy interaction surfaces.&lt;/strong&gt; If your product is defined by multi-select, rotation handles, and snapping across transformed groups, the LOC cost of building that yourself is large and front-loaded. tldraw's transformer is legitimately good. Buy it; don't rebuild it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skedoodle's workload is sparse updates driven by user input. Event-driven fits. If it were a particle simulator, I'd want every frame to fire and I'd run a &lt;code&gt;TickManager&lt;/code&gt; too.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/eugenioenko/skedoodle/tree/chore/perf-plan/perf" rel="noopener noreferrer"&gt;perf framework&lt;/a&gt; is committed alongside the Skedoodle source, with a written-down methodology and a 5-run baseline. &lt;code&gt;pnpm install &amp;amp;&amp;amp; pnpm --filter skedoodle-perf baseline&lt;/code&gt; reproduces every number in this post. Figma needs a one-time auth capture, and the &lt;a href="https://github.com/eugenioenko/skedoodle/blob/chore/perf-plan/perf/README.md" rel="noopener noreferrer"&gt;README&lt;/a&gt; walks through it.&lt;/p&gt;

&lt;p&gt;The architectural choice here is event-driven rendering: nothing polls, renders happen because something changed. &lt;code&gt;autostart: false&lt;/code&gt; enforces it at the Two.js boundary. &lt;code&gt;throttledTwoUpdate&lt;/code&gt; enforces it inside the application. Neither alone is the whole story; the combination is.&lt;/p&gt;

&lt;p&gt;Your drawing app doesn't have to use 2% CPU when you're not using it. It uses that much because of a choice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source: &lt;a href="https://github.com/eugenioenko/skedoodle" rel="noopener noreferrer"&gt;github.com/eugenioenko/skedoodle&lt;/a&gt;. The perf framework lives in the &lt;code&gt;perf/&lt;/code&gt; directory; baseline numbers in &lt;code&gt;perf_results.md&lt;/code&gt;; the research notes that became this post in &lt;code&gt;article_notes.md&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>performance</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Found 5 Security Bugs in My OAuth2 Provider on My First Try (With an MCP Security Tool)</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Thu, 09 Apr 2026 04:51:02 +0000</pubDate>
      <link>https://dev.to/eugenioenko/i-found-5-security-bugs-in-my-oauth2-provider-on-my-first-try-with-an-mcp-security-tool-3ckn</link>
      <guid>https://dev.to/eugenioenko/i-found-5-security-bugs-in-my-oauth2-provider-on-my-first-try-with-an-mcp-security-tool-3ckn</guid>
      <description>&lt;p&gt;I built &lt;a href="https://github.com/eugenioenko/autentico" rel="noopener noreferrer"&gt;Autentico&lt;/a&gt;, a self-contained OAuth 2.0 / OpenID Connect identity provider in Go. I took spec compliance seriously. Every code path is annotated with the RFC section it implements, I passed the OpenID Foundation conformance suite, and I ran OWASP ZAP scans against it. I thought I was in good shape.&lt;/p&gt;

&lt;p&gt;Then I connected &lt;a href="https://github.com/go-appsec/toolbox" rel="noopener noreferrer"&gt;go-appsec/toolbox&lt;/a&gt; to Claude Code, browsed my app for ten minutes, and found five vulnerabilities (including a HIGH severity issue) on my very first session with the tool. I had almost no prior experience with security testing.&lt;/p&gt;

&lt;p&gt;Here's how that happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The foundation: RFC annotations and conformance testing
&lt;/h2&gt;

&lt;p&gt;When I built Autentico, I wanted to do things by the book. Every return path, every validation check, every error response references the exact spec section that mandates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// RFC 7009 §2.1: "The authorization server first validates the client&lt;/span&gt;
&lt;span class="c"&gt;// credentials (in case of a confidential client)."&lt;/span&gt;
&lt;span class="n"&gt;authenticatedClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticateClientFromRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// RFC 6749 §10.4: refresh token MUST be bound to the client it was issued to;&lt;/span&gt;
&lt;span class="c"&gt;// presenting a refresh token issued to a different client MUST be rejected.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;authToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;authToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientID&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientID&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

&lt;span class="c"&gt;// RFC 7662 §2.2: REQUIRED. Whether the token is currently active.&lt;/span&gt;
&lt;span class="n"&gt;Active&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="s"&gt;`json:"active"`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reviewed 10 RFCs and specs across the OAuth2 and OIDC ecosystem, tracking every MUST, SHOULD, and MAY requirement in compliance tables. I ran the OpenID Foundation conformance suite (&lt;code&gt;oidcc-basic-certification-test-plan&lt;/code&gt;) and passed. I had unit tests, e2e tests, functional tests, and browser tests.&lt;/p&gt;

&lt;p&gt;This gave me confidence in the spec compliance of the implementation. But spec compliance and security are not the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traditional scanning: OWASP ZAP
&lt;/h2&gt;

&lt;p&gt;I ran an OWASP ZAP API scan (both authenticated and unauthenticated) against 169 URLs. The results were useful but shallow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing OWASP security headers (X-Frame-Options, CSP, Permissions-Policy, etc.)&lt;/li&gt;
&lt;li&gt;A couple of endpoints returning 500 instead of 404 for nonexistent resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I fixed everything in one PR. Final ZAP results: &lt;strong&gt;0 FAIL, 112 PASS, 4 WARN&lt;/strong&gt; (all informational). Clean bill of health from the scanner.&lt;/p&gt;

&lt;p&gt;ZAP tests what it can see from the outside: headers, status codes, common injection patterns. It doesn't understand OAuth flows, MFA logic, or token lifecycle. For that, I needed something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter go-appsec/toolbox
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/go-appsec/toolbox" rel="noopener noreferrer"&gt;go-appsec/toolbox&lt;/a&gt; is an MCP (Model Context Protocol) server designed for collaborative security testing between humans and AI agents. It's not a scanner; it's a workbench. The idea is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You&lt;/strong&gt; handle the browser: log in, navigate the app, trigger the flows you want tested&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AI agent&lt;/strong&gt; watches the traffic through a proxy, analyzes it, and suggests or executes attacks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool provides MCP tools for traffic capture (&lt;code&gt;proxy_poll&lt;/code&gt;), request replay with modifications (&lt;code&gt;replay_send&lt;/code&gt;), JWT inspection (&lt;code&gt;jwt_decode&lt;/code&gt;), cookie analysis (&lt;code&gt;cookie_jar&lt;/code&gt;), out-of-band testing (&lt;code&gt;oast_create&lt;/code&gt;), and more. You connect it to Claude Code (or any MCP-compatible client), and the AI agent uses these tools to probe your application while you drive the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;The setup took minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the toolbox MCP server with proxy on port 8080&lt;/li&gt;
&lt;li&gt;Configure the browser to proxy through it&lt;/li&gt;
&lt;li&gt;Connect the MCP server to Claude Code via &lt;code&gt;claude mcp add&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Browse the application to capture traffic&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I captured about 112 proxy flows covering OAuth authorization, token exchange, admin CRUD, account management, and MFA enrollment. Then I asked Claude to start testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found: 5 vulnerabilities on my first try
&lt;/h2&gt;

&lt;p&gt;I want to emphasize: this was my first time using the tool. I had no prior pentesting experience and very little knowledge of how to use go-appsec/toolbox effectively. I was learning the workflow as I went. Despite that, the collaboration between the tool and the AI agent produced real, actionable findings.&lt;/p&gt;

&lt;h3&gt;
  
  
  The standout: unauthenticated token introspection (HIGH)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;/oauth2/introspect&lt;/code&gt; endpoint returned full token metadata (active status, scopes, user ID, and claims) without requiring any client credentials. Anyone who had a token value could check whether it was active and extract its claims.&lt;/p&gt;

&lt;p&gt;The AI agent found this by using &lt;code&gt;request_send&lt;/code&gt; to POST to the introspect endpoint with no authorization header. The response came back &lt;code&gt;200 OK&lt;/code&gt; with &lt;code&gt;active: true&lt;/code&gt; and full claim data. This is the kind of finding that demonstrates the tool's workflow: it captured the legitimate introspect request during browsing, stripped the credentials, replayed it, and confirmed the server didn't enforce authentication. Fixed within minutes during the same session.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other four
&lt;/h3&gt;

&lt;p&gt;The remaining findings were two MEDIUM and two LOW severity issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PKCE not enforced for public clients.&lt;/strong&gt; The agent used &lt;code&gt;replay_send&lt;/code&gt; on a captured authorize flow with &lt;code&gt;code_challenge&lt;/code&gt; removed. The server accepted it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh tokens not rotated on use.&lt;/strong&gt; The agent hit the token endpoint twice with the same refresh token. Both succeeded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSRF error leaked internal config.&lt;/strong&gt; A POST without the CSRF cookie returned the environment variable name and value in the error message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored XSS in client_name&lt;/strong&gt; (no exploitable render context). A &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag was accepted in the admin API, though the output was HTML-encoded.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What passed (23 tests)
&lt;/h3&gt;

&lt;p&gt;Importantly, the tool also confirmed a lot of things were solid: redirect URI validation (6 bypass variants attempted), JWT &lt;code&gt;alg:none&lt;/code&gt; confusion, scope escalation, admin authorization enforcement, username enumeration timing, SQL injection, mass assignment, and account lockout logic. All held up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the author found: 10 more issues, deeper logic bugs
&lt;/h2&gt;

&lt;p&gt;After I shared my experience, the toolbox author ran their own session against Autentico. With deeper knowledge of both the tool and security testing methodology, they found five additional vulnerabilities. All logic-level bugs that require understanding how OAuth and MFA flows interact:&lt;/p&gt;

&lt;h3&gt;
  
  
  MFA enforcement bypass (#172)
&lt;/h3&gt;

&lt;p&gt;This one is the best example of what AI-assisted testing can find that scanners can't. MFA enforcement had four independent gaps that reinforced each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The password grant issued tokens without any MFA challenge, even when &lt;code&gt;require_mfa&lt;/code&gt; was enabled&lt;/li&gt;
&lt;li&gt;Pre-MFA sessions weren't invalidated when the policy changed&lt;/li&gt;
&lt;li&gt;An attacker with a bearer token could rotate a user's TOTP secret without presenting a valid OTP code&lt;/li&gt;
&lt;li&gt;MFA could be disabled with just the account password, no TOTP code required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No single gap is obvious in isolation. Finding them requires reasoning about the interaction between authentication flows, token grants, and policy enforcement. A scanner sees endpoints; the AI agent understood the MFA lifecycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Password grant authenticating deactivated users (#174)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;AuthenticateUser()&lt;/code&gt; function didn't check &lt;code&gt;deactivated_at&lt;/code&gt;, while every other user lookup in the codebase did. A soft-deleted user could authenticate via the password grant and receive fresh tokens indefinitely. The admin who deleted the user would have no idea. This is a one-line fix (&lt;code&gt;AND deactivated_at IS NULL&lt;/code&gt;) but finding it requires noticing the inconsistency across query patterns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Admin API audience validation bypass (#183)
&lt;/h3&gt;

&lt;p&gt;The admin API only checked that the user had the admin role. Any token belonging to an admin user was accepted regardless of which client issued it. A malicious app registered with the IdP could trick an admin into authorizing it, then replay that token against the admin API for full control. The fix enforces that tokens must also include admin audience in their audience claim, which only tokens issued through the admin client carry by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other ones:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Empty &lt;code&gt;aud&lt;/code&gt; claim in access tokens (#171).&lt;/strong&gt; Tokens had &lt;code&gt;"aud": []&lt;/code&gt;, and the admin middleware didn't validate &lt;code&gt;azp&lt;/code&gt;, so a token from any client worked on the admin API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;Cache-Control: no-store&lt;/code&gt; headers (#173).&lt;/strong&gt; Sensitive API responses (user lists, settings, sessions) could be cached by browsers and proxies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blind SSRF in federation discovery (#177).&lt;/strong&gt; The HTTP client followed redirects to internal/loopback addresses when fetching federated IdP discovery documents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;I tested my OAuth2 provider with three approaches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it found&lt;/th&gt;
&lt;th&gt;Depth&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OIDC Conformance Suite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Spec compliance gaps&lt;/td&gt;
&lt;td&gt;Protocol-level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OWASP ZAP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Missing headers, error handling&lt;/td&gt;
&lt;td&gt;Surface-level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;go-appsec/toolbox + AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 vulnerabilities including auth bypass, MFA gaps, SSRF&lt;/td&gt;
&lt;td&gt;Logic-level&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The traditional tools did their job. They confirmed my implementation followed the specs and had standard security headers in place. But the logic-level vulnerabilities (the ones that actually matter for an identity provider) only surfaced when an AI agent could reason about how the pieces fit together.&lt;/p&gt;

&lt;p&gt;What surprised me most is that I didn't need to be a security expert to get value from this. The MCP collaboration model means the agent brings security testing knowledge and methodology, while you bring the application context (which flows matter, what the admin UI does, how MFA is supposed to work). Together, you cover ground that neither could alone.&lt;/p&gt;

&lt;p&gt;Ten minutes of browsing. First time using the tool. Five findings, three fixed on the spot. That's a pretty compelling return on investment for any developer who cares about the security of what they're building.&lt;/p&gt;

&lt;p&gt;All 10 findings across both sessions have been fixed and are tracked in the &lt;a href="https://github.com/eugenioenko/autentico" rel="noopener noreferrer"&gt;Autentico Github&lt;/a&gt;. All thanks to &lt;a href="https://github.com/go-appsec/toolbox" rel="noopener noreferrer"&gt;go-appsec/toolbox Github&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>idp</category>
      <category>security</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Why I Built an Identity Provider in Go and SQLite</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Thu, 26 Mar 2026 23:44:40 +0000</pubDate>
      <link>https://dev.to/eugenioenko/zero-ceremony-identity-why-i-built-a-single-binary-oidc-provider-in-go-12d1</link>
      <guid>https://dev.to/eugenioenko/zero-ceremony-identity-why-i-built-a-single-binary-oidc-provider-in-go-12d1</guid>
      <description>&lt;p&gt;When I set out to build &lt;a href="https://autentico.top/" rel="noopener noreferrer"&gt;Auténtico&lt;/a&gt;, my primary goal was to create a fully-featured OpenID Connect Identity Provider where &lt;strong&gt;operational simplicity was the first-class design principle&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Identity infrastructure is notoriously complex. A typical self-hosted setup involves a database server, a cache tier like Redis, a worker queue, and the identity service itself. When I needed a lightweight OpenID Connect (OIDC) server to run on a small 2GB RAM VPS, I realized the existing landscape was either operationally exhausting or structurally flawed for my specific needs.&lt;/p&gt;

&lt;p&gt;This is the story of how (and why) I built &lt;strong&gt;Auténtico&lt;/strong&gt;, a self-contained, single-binary OIDC provider backed by SQLite that removes the ceremony from identity management.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Itch: Finding the Right Lightweight IdP
&lt;/h2&gt;

&lt;p&gt;My journey started because I was researching and implementing a frontend OIDC library for product needs at my company. That scratched an itch, and I evolved it into a functional backend OIDC protocol server in Go.&lt;/p&gt;

&lt;p&gt;Months later, when I needed a lightweight Identity Provider, I evaluated the popular options but quickly hit roadblocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Casdoor:&lt;/strong&gt; I didn't like how they treated private data. Their demo instances recycle accounts every 5 minutes, making it impossible to truly test account deletion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PocketId:&lt;/strong&gt; This is a fantastic tool, but it had a critical UX flaw for my needs: it is &lt;strong&gt;passkey-only&lt;/strong&gt; by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While passkeys are the future, the current ecosystem is heavily fragmented. If a user is on an older OS or a restrictive browser, a passkey-only IdP completely locks them out. &lt;/p&gt;




&lt;h2&gt;
  
  
  The Antidote: Zero-Ceremony Architecture
&lt;/h2&gt;

&lt;p&gt;I decided to convert my OIDC protocol server into a full IdP, ensuring that every architectural decision was evaluated against a single question: &lt;strong&gt;does this reduce or increase the operational burden on the person running this?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://autentico.top/" rel="noopener noreferrer"&gt;Auténtico&lt;/a&gt; removes the entire traditional identity stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single Binary:&lt;/strong&gt; The entire IdP runs as one Go binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded SQLite:&lt;/strong&gt; There is no separate database server. The entire state lives in one file. Eliminating the external database removes connection pool tuning, credential rotation, and network partitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No External Infrastructure:&lt;/strong&gt; No Redis, no Postgres, no message queues. Background cleanup goroutines automatically purge expired tokens, sessions, and auth codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded UIs:&lt;/strong&gt; Both the Admin dashboard (React/Ant Design) and the user-facing Account UI (React/Tailwind) are compiled directly into the binary using &lt;code&gt;go:embed&lt;/code&gt;. There are zero separate frontend deployments.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Flexibility Over Dogma: Solving the Passkey Trap
&lt;/h2&gt;

&lt;p&gt;To solve the hardware and OS fragmentation issues I experienced with passkeys, I ensured Auténtico wouldn't trap operators into a single authentication path.&lt;/p&gt;

&lt;p&gt;Instead, Auténtico offers &lt;strong&gt;three distinct authentication modes&lt;/strong&gt; that are switchable at runtime without restarting the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;password&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;password_and_passkey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;passkey_only&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you deploy &lt;code&gt;passkey_only&lt;/code&gt; and discover your users' specific browser combinations are failing, you can instantly flip a setting in the Admin UI to fall back to passwords. For robust security without passkeys, it includes standard fallback methods like &lt;strong&gt;TOTP&lt;/strong&gt; (with in-browser QR enrollment) and &lt;strong&gt;Email OTP&lt;/strong&gt;. For users with modern browsers, it fully supports hardware-backed &lt;strong&gt;FIDO2&lt;/strong&gt; authentication and even allows first-login registration in one seamless flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Deliberately Un-clever" Architecture &amp;amp; The AI Accelerator
&lt;/h2&gt;

&lt;p&gt;To make this work, the codebase had to be &lt;strong&gt;deliberately un-clever&lt;/strong&gt;. I designed a strict vertical-slice architecture where each package (like &lt;code&gt;pkg/login&lt;/code&gt; or &lt;code&gt;pkg/token&lt;/code&gt;) owns its exact slice of functionality with a predictable structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;model.go&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;handler.go&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;service.go&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Database CRUD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This strictness had a massive secondary benefit: it created the &lt;strong&gt;perfect environment for AI&lt;/strong&gt;. Because I spent the time establishing this blueprint, I reached a tipping point where I could hand off the boilerplate. AI agents seamlessly followed the patterns to generate the CRUD operations and rapidly write over &lt;strong&gt;700 tests&lt;/strong&gt; (hitting &lt;strong&gt;80% coverage&lt;/strong&gt;) precisely because the architectural constraints were so rigid.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scale Ceiling (And Why It Doesn't Matter)
&lt;/h2&gt;

&lt;p&gt;The immediate pushback to this architecture is always: &lt;em&gt;"SQLite doesn't scale."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I am intentionally honest about the scale ceiling: SQLite serializes writes. Auténtico is &lt;strong&gt;not&lt;/strong&gt; designed for active-active multi-region deployments or massive enterprise horizontal scaling.&lt;/p&gt;

&lt;p&gt;However, let's look at the math:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concurrency&lt;/th&gt;
&lt;th&gt;Error rate&lt;/th&gt;
&lt;th&gt;Login p95&lt;/th&gt;
&lt;th&gt;Token p95&lt;/th&gt;
&lt;th&gt;Assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20 VUs&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;86ms&lt;/td&gt;
&lt;td&gt;54ms&lt;/td&gt;
&lt;td&gt;Comfortable — imperceptible to users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 VUs&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;611ms&lt;/td&gt;
&lt;td&gt;647ms&lt;/td&gt;
&lt;td&gt;Supported — fully functional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 VUs&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;3.36s&lt;/td&gt;
&lt;td&gt;3.89s&lt;/td&gt;
&lt;td&gt;Degraded — users feel the wait&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Performance tests with k6 show the system degrades gracefully via SQLite's busy timeout—queueing requests and adding latency rather than throwing errors.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For most teams running internal tools, small-to-mid-sized apps, or self-hosted environments, &lt;strong&gt;trading infinite horizontal scaling for zero operational overhead is absolutely the right choice&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Operational simplicity does not mean protocol simplicity.&lt;/p&gt;

&lt;p&gt;Auténtico strictly enforces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OIDC Discovery&lt;/strong&gt; — publishes &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt; so relying parties auto-configure without hardcoding endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWK Set&lt;/strong&gt; — exposes public signing keys at &lt;code&gt;/.well-known/jwks.json&lt;/code&gt; for independent token verification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RS256 JWT Signing&lt;/strong&gt; — asymmetric signing; the private key never leaves the IdP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth2/OIDC protocol&lt;/strong&gt;: ImplementsOIDC protocol&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin UI&lt;/strong&gt;: For admins to manage clients, users and session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account UI&lt;/strong&gt;: For users to manage they profile&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swagger OpenAPI docs&lt;/strong&gt;: Publishes api specs docs &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are a small team, an indie developer, or just someone who wants to deploy an Identity Provider without taking on a second job as a sysadmin, sometimes &lt;strong&gt;the best architecture is the one you barely have to think about&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://autentico.top/" rel="noopener noreferrer"&gt;Auténtico&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/eugenioenko/autentico" rel="noopener noreferrer"&gt;Github&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>identity</category>
      <category>idp</category>
      <category>go</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>The Lightweight JavaScript Framework Renaissance of 2026</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Tue, 24 Mar 2026 01:43:52 +0000</pubDate>
      <link>https://dev.to/eugenioenko/the-lightweight-javascript-framework-renaissance-of-2026-4ee0</link>
      <guid>https://dev.to/eugenioenko/the-lightweight-javascript-framework-renaissance-of-2026-4ee0</guid>
      <description>&lt;h1&gt;
  
  
  Best JavaScript Frameworks in 2026: For AI and Humans
&lt;/h1&gt;

&lt;p&gt;The JavaScript framework landscape in 2026 looks different from what it did three years ago. Not because React disappeared or Vue lost relevance, but because something shifted in how code gets written. AI coding assistants now author a significant portion of frontend code. That changes the evaluation criteria in ways the existing framework rankings haven't caught up with yet.&lt;/p&gt;

&lt;p&gt;This article covers both the established giants and the growing category of lightweight libraries that are having a quiet renaissance. The goal is to help you pick the right tool given who, or what, will be writing most of your code.&lt;/p&gt;




&lt;h2&gt;
  
  
  The New Evaluation Criteria
&lt;/h2&gt;

&lt;p&gt;The classic framework checklist covered performance, ecosystem, learning curve, and job market. Those still matter. But in 2026, two new questions belong on that list:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does this framework cost an AI to get right?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every framework has footguns. The question is whether those footguns require deep framework-specific knowledge to avoid, or whether they're the kind of mistakes any developer (human or AI) would catch on a first read. Frameworks with fewer implicit rules produce more reliable AI-generated code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you run it without a build pipeline?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For quick prototypes, internal tools, and AI-generated demos, the ability to drop a script tag and go is genuinely valuable. Not every project needs a bundler, and forcing one adds friction that compounds when an AI agent is setting up the environment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Heavy Framework Tax
&lt;/h2&gt;

&lt;p&gt;React, Vue, Angular, and Svelte dominate the ecosystem. They dominate for real reasons: massive communities, mature tooling, rich ecosystems, and years of production hardening. None of what follows is an argument to abandon them.&lt;/p&gt;

&lt;p&gt;But they carry weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React&lt;/strong&gt; requires understanding hooks ordering rules, &lt;code&gt;useEffect&lt;/code&gt; dependency arrays, stale closure behavior, and the distinction between controlled and uncontrolled components. These are not obvious from the surface syntax. AI agents generating React code make predictable, repeatable mistakes in all of these areas. The community has documented them extensively, which means LLMs have seen the patterns, but also means the footguns are well-established and hard to train away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vue 3&lt;/strong&gt; is more approachable. The Composition API is clean, and &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; reduces boilerplate significantly. The reactivity model is intuitive. But the template compiler is a black box, the distinction between &lt;code&gt;ref&lt;/code&gt; and &lt;code&gt;reactive&lt;/code&gt; trips up new users (human and AI alike), and the ecosystem split between Options API and Composition API adds cognitive overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Angular&lt;/strong&gt; is the most structured of the group. Structure helps, but Angular's DI system, decorators, zone.js, and now the signals migration mean there is a lot of framework-specific knowledge required before you can write idiomatic code. It remains the right choice for large enterprise teams where that structure is the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Svelte&lt;/strong&gt; compiles away at build time, which is elegant. But the compiler is the framework. You cannot use Svelte without a build step, the template syntax is non-standard HTML, and the reactivity model (especially the &lt;code&gt;$:&lt;/code&gt; syntax in Svelte 4 and the runes in Svelte 5) requires knowing Svelte specifically. An AI agent that hasn't seen enough Svelte in training will produce subtly wrong reactive code.&lt;/p&gt;

&lt;p&gt;None of this is fatal. Millions of applications run on these frameworks and will continue to. But there is a real cost, and it is higher when an AI is holding the pen.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Light Library Renaissance
&lt;/h2&gt;

&lt;p&gt;A different category of tools has been growing steadily: small, focused libraries that add reactivity and component structure on top of the browser's native model rather than replacing it. They tend to share a few traits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No build step required for core functionality&lt;/li&gt;
&lt;li&gt;Templates that stay close to HTML, or use standard JS tagged literals&lt;/li&gt;
&lt;li&gt;Signal-based or proxy-based reactivity with simple rules&lt;/li&gt;
&lt;li&gt;Minimal framework-specific concepts to learn&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In 2026, this category is no longer a niche. It is a legitimate choice for a wide range of projects, and in many cases the better choice for AI-generated code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Arrow.js
&lt;/h2&gt;

&lt;p&gt;Arrow.js (&lt;code&gt;@arrow-js/core&lt;/code&gt;) is one of the most technically interesting entries in this space. It was built by Standard Agents and has an architecture that reads like a deliberate response to framework complexity.&lt;/p&gt;

&lt;p&gt;The core model is simple: reactive state is a plain object wrapped in &lt;code&gt;reactive()&lt;/code&gt;, and templates are JavaScript tagged literals using the &lt;code&gt;html&lt;/code&gt; tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@arrow-js/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`
  &amp;lt;div&amp;gt;
    &amp;lt;p&amp;gt;Count: &lt;/span&gt;&lt;span class="p"&gt;${()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/p&amp;gt;
    &amp;lt;button @click="&lt;/span&gt;&lt;span class="p"&gt;${()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;+&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things stand out here. Reactive expressions in templates are just arrow functions. Static values render once; functions are tracked and re-run when dependencies change. That distinction is explicit in the syntax, not hidden behind a compiler.&lt;/p&gt;

&lt;p&gt;The reactivity model is proxy-based rather than signal-based. This means you mutate properties directly (&lt;code&gt;state.count++&lt;/code&gt;), and array mutations like &lt;code&gt;.push()&lt;/code&gt; trigger updates without requiring a reassignment. For developers coming from plain JavaScript, this feels natural.&lt;/p&gt;

&lt;p&gt;Components are defined with &lt;code&gt;component()&lt;/code&gt;, a factory function that runs once per slot and returns a template. Local state, side effects, and cleanup all live inside the factory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;component&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;reactive&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;button @click="&lt;/span&gt;&lt;span class="p"&gt;${()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clicks&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;
    &lt;/span&gt;&lt;span class="p"&gt;${()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
  &amp;lt;/button&amp;gt;`&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Arrow's package ecosystem is notably complete. Beyond the core, it ships SSR with &lt;code&gt;@arrow-js/ssr&lt;/code&gt;, client-side hydration with &lt;code&gt;@arrow-js/hydrate&lt;/code&gt;, hydration boundary recovery, and a QuickJS/WASM sandbox (&lt;code&gt;@arrow-js/sandbox&lt;/code&gt;) for safely running user-authored Arrow code in the browser. That last one is unusual and speaks to an AI-native use case: letting agents generate and execute code without granting them access to the host page.&lt;/p&gt;

&lt;p&gt;The tradeoff is that templates live in JavaScript. The &lt;code&gt;html&lt;/code&gt; tagged literal approach means your markup is a JS string, not a file an HTML tool understands natively. That is a different philosophy from the HTML-first camp, and whether it is a feature or a limitation depends on the project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Kasper.js
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kasperjs.top" rel="noopener noreferrer"&gt;Kasper.js&lt;/a&gt; takes the opposite position on the templates-vs-JavaScript question. Templates are valid HTML. Directives are standard HTML attributes prefixed with &lt;code&gt;@&lt;/code&gt;. Any developer who knows HTML can read a Kasper template without knowing Kasper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;template&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Count: {{count.value}}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;on:click=&lt;/span&gt;&lt;span class="s"&gt;"increment()"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;if=&lt;/span&gt;&lt;span class="s"&gt;"count.value &amp;gt; 10"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;You clicked a lot.&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kasper-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reactivity model uses signals with explicit &lt;code&gt;.value&lt;/code&gt; reads and writes. This is more verbose than Arrow's proxy approach, but it makes reactive reads visible in both code and templates. An AI agent reading a Kasper template knows exactly which values are reactive and when they update.&lt;/p&gt;

&lt;p&gt;Components are classes. This is a deliberate choice for AI compatibility: classes have well-defined ownership, explicit methods, and a lifecycle that maps directly to familiar OOP patterns. There are no hook rules, no dependency arrays, no rules about where you can call what. &lt;code&gt;onMount&lt;/code&gt;, &lt;code&gt;onChanges&lt;/code&gt;, &lt;code&gt;onRender&lt;/code&gt;, &lt;code&gt;onDestroy&lt;/code&gt;: the lifecycle is what it says it is.&lt;/p&gt;

&lt;p&gt;Cleanup is handled through a single &lt;code&gt;AbortController&lt;/code&gt; that every component owns. &lt;code&gt;this.watch()&lt;/code&gt;, &lt;code&gt;this.effect()&lt;/code&gt;, &lt;code&gt;this.computed()&lt;/code&gt;, and all &lt;code&gt;@on:&lt;/code&gt; event listeners are released automatically when the component is destroyed. No &lt;code&gt;return () =&amp;gt; cleanup()&lt;/code&gt;. No forgetting to unsubscribe.&lt;/p&gt;

&lt;p&gt;The expression evaluator is worth noting: it is a custom recursive-descent parser, not &lt;code&gt;eval&lt;/code&gt; and not &lt;code&gt;new Function&lt;/code&gt;. This means Kasper templates work under strict Content Security Policies, which matters for enterprise and regulated environments. The parser is more capable than it might sound: it covers the full practical range of JavaScript expressions, including arrow functions, optional chaining, nullish coalescing, object and array literals with spread, &lt;code&gt;typeof&lt;/code&gt;, &lt;code&gt;instanceof&lt;/code&gt;, postfix and prefix operators, and a pipeline operator (&lt;code&gt;|&amp;gt;&lt;/code&gt;). The only meaningful gaps compared to full JavaScript are statement-level constructs like &lt;code&gt;async/await&lt;/code&gt;, &lt;code&gt;for&lt;/code&gt; loops, and &lt;code&gt;switch&lt;/code&gt;, none of which belong in a template expression anyway.&lt;/p&gt;

&lt;p&gt;The no-build-step story is genuine. One CDN import (16KB gzipped) and you have signals, a router, slots, and lazy loading. The Vite plugin adds single-file &lt;code&gt;.kasper&lt;/code&gt; components on top, but it is optional.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;App&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://cdn.jsdelivr.net/npm/kasper-js/dist/kasper.min.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;button @on:click="count.value++"&amp;gt;{{count.value}}&amp;lt;/button&amp;gt;`&lt;/span&gt;

  &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;counter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Counter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kasper also ships an &lt;code&gt;llms.txt&lt;/code&gt; at &lt;a href="https://kasperjs.top/llms.txt" rel="noopener noreferrer"&gt;kasperjs.top/llms.txt&lt;/a&gt;, a machine-readable reference file specifically for AI agents. It covers the full API surface in a compact format designed for context windows, which reflects where the framework sees the ecosystem going.&lt;/p&gt;




&lt;h2&gt;
  
  
  Others Worth Knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Alpine.js&lt;/strong&gt; is the simplest entry point in the light library category. Directives live directly on HTML elements as attributes (&lt;code&gt;x-data&lt;/code&gt;, &lt;code&gt;x-show&lt;/code&gt;, &lt;code&gt;x-on:click&lt;/code&gt;). There is no component system, no build step, and very little to learn. It is excellent for adding interactivity to server-rendered pages. It is not the right tool for building SPAs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lit&lt;/strong&gt; comes from Google and is built on Web Components. Templates use tagged literals like Arrow, and reactivity is property-based. Lit components are real custom elements, which means they work in any framework or no framework. The tradeoff is that Web Component conventions (shadow DOM, attribute reflection, property vs attribute distinctions) add complexity that pure library approaches avoid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solid.js&lt;/strong&gt; is a compiler-based framework like Svelte, but its output is fine-grained reactive updates with no virtual DOM. Performance is exceptional. The JSX surface looks like React, which helps with adoption, but the mental model is fundamentally different: components run once, and reactivity is tracked through signal reads. Solid is worth learning if performance is a primary concern and you are comfortable with a build step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Petite-Vue&lt;/strong&gt; is a distribution of Vue designed for progressive enhancement. It is small, requires no build step, and works well when you need Vue's template syntax on an existing server-rendered page. It is not a full SPA framework.&lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Build Required&lt;/th&gt;
&lt;th&gt;Reactivity&lt;/th&gt;
&lt;th&gt;Template Style&lt;/th&gt;
&lt;th&gt;AI-Friendly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Hooks&lt;/td&gt;
&lt;td&gt;JSX&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vue 3&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;Proxy/Ref&lt;/td&gt;
&lt;td&gt;HTML + compiler&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Angular&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Signals/Zone&lt;/td&gt;
&lt;td&gt;HTML + compiler&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Svelte 5&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Runes&lt;/td&gt;
&lt;td&gt;HTML + compiler&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrow.js&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Proxy&lt;/td&gt;
&lt;td&gt;JS tagged literals&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kasper.js&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Signals&lt;/td&gt;
&lt;td&gt;Valid HTML&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine.js&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Proxy&lt;/td&gt;
&lt;td&gt;Inline HTML&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lit&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Properties&lt;/td&gt;
&lt;td&gt;JS tagged literals&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Solid&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Signals&lt;/td&gt;
&lt;td&gt;JSX&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  How to Choose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use React, Vue, or Angular&lt;/strong&gt; when you are joining an existing team, building a product that needs a large ecosystem, or hiring for a team that already knows the framework. The community, tooling, and hiring pool are real advantages that lightweight alternatives cannot match yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Svelte or Solid&lt;/strong&gt; when bundle size and runtime performance are primary constraints and you are comfortable with a compiler in the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Arrow.js&lt;/strong&gt; when you want the smallest possible runtime, prefer JavaScript-centric templates, need SSR with hydration out of the box, or are building tooling where the sandbox package is relevant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Kasper.js&lt;/strong&gt; when HTML-first templates matter (for readability, CSP compliance, or AI-generated code), when you want class-based components with automatic cleanup, or when a no-build-step option has real value for your workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Alpine.js&lt;/strong&gt; when you have server-rendered HTML and want to add interactivity without touching the build pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use Lit&lt;/strong&gt; when Web Components interoperability is a requirement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The right framework in 2026 is still context-dependent. The established four are not going anywhere, and for many teams they remain the correct answer.&lt;/p&gt;

&lt;p&gt;But the light library category has matured. Arrow.js and Kasper.js in particular are not toys or experiments: they are complete, well-tested solutions with clear architectural philosophies. They are simpler by design, not by omission. And in an era where AI agents write a growing share of frontend code, simpler-by-design has compounding returns.&lt;/p&gt;

&lt;p&gt;The best framework is the one your team, and your tools, can use correctly. In 2026, that calculation includes AI as a member of the team.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building a JavaScript Framework (and Failing Twice at Reactivity)</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Mon, 23 Mar 2026 01:46:01 +0000</pubDate>
      <link>https://dev.to/eugenioenko/building-a-javascript-framework-and-failing-twice-at-reactivity-5aod</link>
      <guid>https://dev.to/eugenioenko/building-a-javascript-framework-and-failing-twice-at-reactivity-5aod</guid>
      <description>&lt;p&gt;About five years ago, I didn't set out to build a framework I'd use in production.&lt;/p&gt;

&lt;p&gt;I just wanted to understand them.&lt;/p&gt;

&lt;p&gt;I had already written parsers and interpreters before, so I knew the mechanics: tokenization, ASTs, execution. But frameworks felt different. They weren't just about parsing code; they were about state, updates, and keeping the UI in sync. Reactivity was the part I didn't understand. So I decided to build one from scratch.&lt;/p&gt;

&lt;p&gt;I started with the pieces I knew: a scanner, a parser, a JavaScript interpreter, and an HTML template parser. After a while, I had a working system: a small component model and a template engine that could render real views. It looked like a framework.&lt;/p&gt;

&lt;p&gt;But it was missing the one thing that actually makes a framework feel alive: reactivity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;template&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;{{count.value}}&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"actions"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;on:click=&lt;/span&gt;&lt;span class="s"&gt;"count -= 1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;on:click=&lt;/span&gt;&lt;span class="s"&gt;"count += 1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kasper-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Part That Failed Twice
&lt;/h2&gt;

&lt;p&gt;I tried implementing reactivity early on. It didn't work. I've tried with using Proxy, I tried just using a render() function, it was not clicking. There where other parts of the framework I struggled with as well, but reactivity left an imprint.&lt;/p&gt;

&lt;p&gt;Later, I tried again. This time I got something running; but it was fragile. It only worked for a single component. Child updates broke. State invalidation was inconsistent. It looked like reactivity, but you couldn't trust it.&lt;/p&gt;

&lt;p&gt;So I dropped it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming Back to It
&lt;/h2&gt;

&lt;p&gt;The project sat dormant for a long time, almost abandoned. Other projects took over. Life moved on. After a few years away, I returned to it. This time, I approached it differently. Instead of trying to "add reactivity," I focused on correctness first, testing everything, and simplifying assumptions.&lt;/p&gt;

&lt;p&gt;With the help of AI agents, I rebuilt the system around signals.&lt;/p&gt;

&lt;p&gt;That changed everything.&lt;/p&gt;

&lt;p&gt;Once signals were in place, the architecture became much simpler: no virtual DOM, no diffing, direct updates to real DOM nodes, fine-grained reactivity. But the real breakthrough wasn't just the model. It was the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  600 Tests Later
&lt;/h2&gt;

&lt;p&gt;I started adding tests. Then more tests. Then hundreds more. With AI assistance, I reached 600+ test cases. At that point, something unexpected happened: the AI couldn't generate any new meaningful tests. Everything obvious and most non-obvious cases were already covered.&lt;/p&gt;

&lt;p&gt;The test were meaningful. It felt complete.&lt;/p&gt;

&lt;p&gt;But it wasn't. Of course it wasn't. Just because 600+ tests pass it doesn't mean your system has no bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Test Wasn't Tests
&lt;/h2&gt;

&lt;p&gt;The codebase looked solid. The tests passed. But there was still a problem: no one had actually used the framework to build real apps.&lt;/p&gt;

&lt;p&gt;So I tried something different. Instead of writing apps manually, I asked AI agents to build them.&lt;/p&gt;

&lt;p&gt;And they failed immediately.&lt;/p&gt;

&lt;p&gt;Not because the framework was broken; but because the AI didn't know how to use it. This was a surprising moment. The system worked, but it wasn't understandable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Missing Piece: Documentation for AI
&lt;/h2&gt;

&lt;p&gt;That's when I introduced something modern: &lt;code&gt;llms.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A dedicated, structured specification designed for AI agents. Not marketing docs. Not tutorials. Just syntax, rules, constraints, and examples. Think of it as a "principal engineer version" of the API.&lt;/p&gt;

&lt;p&gt;Then I started a loop: give the AI the spec, ask it to build an app, observe where it fails, update the spec, repeat.&lt;/p&gt;

&lt;p&gt;After a few iterations, something remarkable happened. The AI started generating full apps on the first try: todo apps, CRUD interfaces, Kanban boards, tree views, infinite scroll, even Game of Life. All working.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/eugenioenko/we-had-to-write-docs-for-ai-llmstxt-changed-everything-44f5"&gt;Article about my experience with llms.txt&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Surprising Insight About AI
&lt;/h2&gt;

&lt;p&gt;At one point, the AI became very confident about a "necessary" architectural change. It proposed a redesign that would require around 100 lines of changes. We tried it. It failed repeatedly.&lt;/p&gt;

&lt;p&gt;After stepping back and analyzing the problem, the real fix was 5 lines of code.&lt;/p&gt;

&lt;p&gt;That moment stuck with me. AI can be incredibly helpful but it can also confidently overcomplicate problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Tests Stop Helping
&lt;/h2&gt;

&lt;p&gt;With 600+ tests, the system looked stable. But once the AI started generating real applications, new edge cases appeared: subtle rendering issues, lifecycle timing problems, data edge cases that no unit test would have caught in isolation.&lt;/p&gt;

&lt;p&gt;So I kept going. Built more apps (shopping cart, dashboards, editors, product listing, interractive tables with pagination), fed failures back into the system, and added more tests. Real usage found things that testing alone never would.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Made the Framework Stable
&lt;/h2&gt;

&lt;p&gt;Looking back, it wasn't one thing. It was the combination of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A simple reactive model (signals)&lt;/li&gt;
&lt;li&gt;Relentless testing (600+ cases)&lt;/li&gt;
&lt;li&gt;Real-world usage (apps, not just tests)&lt;/li&gt;
&lt;li&gt;AI as both a developer and a user&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Unexpected Win
&lt;/h2&gt;

&lt;p&gt;One design choice I made years ago turned out to be critical: the template syntax was valid HTML. Originally, this was just for better syntax highlighting. But later, it made the framework significantly more AI-friendly. No custom grammar. No ambiguity. Just HTML with extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I started today, I would design the reactive model first, write tests earlier (a lot earlier), treat AI as a first-class user from day one, and create a machine-readable spec alongside human docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Ended Up
&lt;/h2&gt;

&lt;p&gt;After years of on-and-off work, multiple failures, and hundreds of tests, the framework is stable. Not because it's perfect, but because it survived repeated redesigns, real usage, and constant pressure from both humans and machines.&lt;/p&gt;

&lt;p&gt;Building the framework wasn't the hardest part. Making it correct, usable, and understandable for both humans and AI was the real challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it out!
&lt;/h2&gt;

&lt;p&gt;Learn more about kasper.js at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kasperjs.top" rel="noopener noreferrer"&gt;kasperjs.top/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/eugenioenko/kasper-js" rel="noopener noreferrer"&gt;github.com/eugenioenko/kasper-js&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>frameworks</category>
      <category>reactive</category>
      <category>parser</category>
    </item>
    <item>
      <title>We Had to Write Docs for AI: llms.txt Changed Everything</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Mon, 23 Mar 2026 01:37:14 +0000</pubDate>
      <link>https://dev.to/eugenioenko/we-had-to-write-docs-for-ai-llmstxt-changed-everything-44f5</link>
      <guid>https://dev.to/eugenioenko/we-had-to-write-docs-for-ai-llmstxt-changed-everything-44f5</guid>
      <description>&lt;p&gt;Most developers write documentation for humans.&lt;/p&gt;

&lt;p&gt;While building my JavaScript framework, I ran into a problem I didn't expect: the framework worked but AI couldn't use it. Not "wasn't perfect." Not "made small mistakes." It completely failed to build even basic apps correctly unless it had the source code of the framework available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Moment Things Broke
&lt;/h2&gt;

&lt;p&gt;After years of work, I finally had a stable system: a custom scanner, parser, interpreter, a template engine with components, a signal-based reactivity system, and around 600 tests covering edge cases. I thought I was done.&lt;/p&gt;

&lt;p&gt;So I tried something simple: "Build a todo app using this framework."&lt;/p&gt;

&lt;p&gt;What I got back looked confident, but was completely wrong. Wrong syntax. Wrong mental model. Invented features that didn't exist.&lt;/p&gt;

&lt;p&gt;This wasn't a bug in the framework. It was a documentation failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  README Is Not Enough Anymore
&lt;/h2&gt;

&lt;p&gt;Traditional documentation is designed for humans: narrative explanations, gradual onboarding, examples mixed with storytelling.&lt;/p&gt;

&lt;p&gt;AI doesn't work like that. It doesn't "read" docs. It pattern-matches and guesses. So when the documentation is incomplete, ambiguous, or too prose-heavy, AI fills in the gaps. Confidently. Incorrectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: llms.txt
&lt;/h2&gt;

&lt;p&gt;The solution was simple in hindsight: treat AI like a strict compiler, not a reader.&lt;/p&gt;

&lt;p&gt;I created a new file: &lt;code&gt;llms.txt&lt;/code&gt;. Not marketing docs. Not tutorials. Just raw, explicit specification.&lt;/p&gt;

&lt;p&gt;The rules were strict:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No prose.&lt;/strong&gt; No storytelling, no explanations. Only syntax, rules, and constraints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No ambiguity.&lt;/strong&gt; There's a big difference between:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You can use &lt;code&gt;@if&lt;/code&gt; for conditional rendering.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;@if="condition"
&lt;span class="p"&gt;-&lt;/span&gt; condition must be a valid JS expression
&lt;span class="p"&gt;-&lt;/span&gt; evaluates to truthy/falsy
&lt;span class="p"&gt;-&lt;/span&gt; false removes node from DOM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Complete surface area.&lt;/strong&gt; All directives, template expressions, components, lifecycle hooks, signal behavior, everything explicitly defined. Nothing implied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimal but real examples:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ul&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;each=&lt;/span&gt;&lt;span class="s"&gt;"item in items"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"item.id"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ item.name }}&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reading the Docs + Source Code
&lt;/h2&gt;

&lt;p&gt;Even with &lt;code&gt;llms.txt&lt;/code&gt;, the AI couldn't just guess everything. It needed to read a lot of source code, inspect function signatures, understand how signals propagate, see how component lifecycle worked. Only then could it map the spec to the actual implementation and generate working apps.&lt;/p&gt;

&lt;p&gt;Building apps wasn't magic. It was AI + spec + code comprehension.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Iteration Loop
&lt;/h2&gt;

&lt;p&gt;I didn't just hand over the file and hope. The loop looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Give AI the current &lt;code&gt;llms.txt&lt;/code&gt; and source access&lt;/li&gt;
&lt;li&gt;Ask it to build a real app (todo, kanban, etc.)&lt;/li&gt;
&lt;li&gt;Observe failures&lt;/li&gt;
&lt;li&gt;Fix the spec&lt;/li&gt;
&lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few things became clear along the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing features aren't always obvious.&lt;/strong&gt; At one point, AI kept trying to use &lt;code&gt;@keydown.enter&lt;/code&gt;. I had never documented it but the framework already supported it. The fix was to update the spec, not the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ambiguity is worse than missing features.&lt;/strong&gt; Undocumented features lead to confident guesses. Vaguely documented features lead to confident &lt;em&gt;wrong&lt;/em&gt; guesses. Explicit rules always win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI exposes your own blind spots.&lt;/strong&gt; It suggested massive architectural rewrites; redesign scope tracking, refactor core systems. All seemed convincing. The result: 100 lines of changes, none of which worked. The real fix? Five lines of code. AI can be very persuasive about the wrong solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  When It Finally Clicked
&lt;/h2&gt;

&lt;p&gt;After a few iterations of refining &lt;code&gt;llms.txt&lt;/code&gt; and reading source code, AI could reliably generate todo apps, Kanban boards, tree views, infinite scroll, and Game of Life (first try), fully working, following spec.&lt;/p&gt;

&lt;p&gt;Real apps also exposed edge cases that 600 unit tests never would: shopping carts, form wizards, markdown editors, live dashboards. The tests covered everything within a known model. Real usage kept expanding the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Types of Documentation
&lt;/h2&gt;

&lt;p&gt;There are now two distinct audiences for docs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human docs&lt;/strong&gt;: explain concepts, tell the story, teach mental models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI docs (&lt;code&gt;llms.txt&lt;/code&gt;)&lt;/strong&gt;: define rules, eliminate ambiguity, maximize correctness.&lt;/p&gt;

&lt;p&gt;Both are necessary. They serve completely different purposes and shouldn't be conflated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Unexpected Payoff
&lt;/h2&gt;

&lt;p&gt;One design decision made early on turned out to help here too: the template syntax was valid HTML. This meant free syntax highlighting, editor support, and it turns out, AI-friendly defaults. The more your syntax looks like existing patterns, the less AI has to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;The hardest part wasn't building the framework. It wasn't reactivity or performance.&lt;/p&gt;

&lt;p&gt;It was making the system understandable to something that doesn't actually understand.&lt;/p&gt;

&lt;p&gt;We're entering a world where humans write ideas and AI writes implementations. In that world, specification becomes the product. Not just a supplement to the code, the thing that makes the code usable at all.&lt;/p&gt;

&lt;p&gt;Learn more about kasper.js at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kasperjs.top" rel="noopener noreferrer"&gt;kasperjs.top&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kasperjs.top/guides/agents/" rel="noopener noreferrer"&gt;Using kasperjs with AI Agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/eugenioenko/kasper-js" rel="noopener noreferrer"&gt;github.com/eugenioenko/kasper-js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llmstxt.org&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>documentation</category>
      <category>javascript</category>
      <category>llms</category>
    </item>
    <item>
      <title>Adding Attribute-Based Access Control to a Real-Time Collaborative App with OpenTDF</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Fri, 20 Mar 2026 07:52:05 +0000</pubDate>
      <link>https://dev.to/eugenioenko/adding-attribute-based-access-control-to-a-real-time-collaborative-app-with-opentdf-76e</link>
      <guid>https://dev.to/eugenioenko/adding-attribute-based-access-control-to-a-real-time-collaborative-app-with-opentdf-76e</guid>
      <description>&lt;p&gt;I built &lt;a href="https://github.com/eugenioenko/skedoodle" rel="noopener noreferrer"&gt;Skedoodle&lt;/a&gt;, an open-source real-time collaborative sketching app. Think a lightweight Figma for doodling: multiple users connect over WebSocket, draw on a shared infinite canvas, and see each other's cursors move in real time. It's built with React, TypeScript, Two.js for vector graphics, and Zustand for state management, with an Express backend handling persistence and real-time sync.&lt;/p&gt;

&lt;p&gt;Building the interactive parts was the fun challenge. Throttled rendering at 60fps, path simplification algorithms to keep stroke data lean, touch support, pan and zoom on an infinite canvas, undo/redo that works across multiple collaborators. Skedoodle is a proper interactive app, not a toy demo.&lt;/p&gt;

&lt;p&gt;But it had a glaring gap: &lt;strong&gt;no authorization&lt;/strong&gt;. Authentication? Sure, users logged in via OIDC. But once you were in, you could access any sketch if you knew the ID. Think YouTube: every video is technically accessible if you have the link, even "unlisted" ones. Skedoodle had the same problem. There was no way to control who could see or edit what.&lt;/p&gt;

&lt;p&gt;I needed to fix this. And rather than hand-roll role checks and a collaborators table, I wanted to use a proper policy engine — one that could handle the simple case today and scale to more complex scenarios without rewriting everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Project Started
&lt;/h2&gt;

&lt;p&gt;This whole project started because I was working with an AI agent to generate an &lt;a href="https://opentdf.io/llms.txt" rel="noopener noreferrer"&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/a&gt; for OpenTDF; a structured documentation file designed to give AI agents enough context to work with a platform. Once we had it, the obvious next step was to test it: take a real project with no authorization at all, point an agent at the &lt;code&gt;llms.txt&lt;/code&gt;, and see if it could build a correct ABAC integration from scratch.&lt;/p&gt;

&lt;p&gt;Skedoodle was the perfect candidate. A real collaborative app, with authentication but zero authorization. The experiment: could an AI agent, armed only with OpenTDF's &lt;code&gt;llms.txt&lt;/code&gt; and a description of the access model I wanted, deliver a working integration?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OpenTDF
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://opentdf.io/" rel="noopener noreferrer"&gt;OpenTDF&lt;/a&gt; is an open-source platform maintained by &lt;a href="https://www.virtru.com/" rel="noopener noreferrer"&gt;Virtru&lt;/a&gt; that provides attribute-based access control (ABAC) alongside end-to-end encryption via the &lt;a href="https://github.com/opentdf/spec" rel="noopener noreferrer"&gt;Trusted Data Format&lt;/a&gt; specification.&lt;/p&gt;

&lt;p&gt;What drew me in was how &lt;strong&gt;lightweight the authorization integration is&lt;/strong&gt;. OpenTDF is known for its encryption capabilities, but the ABAC engine stands entirely on its own. You don't need to encrypt anything to use it. You define policies, and the platform makes access decisions. That's exactly what I needed: a centralized policy engine that could answer "does this user have access to this sketch?" based on attributes rather than hardcoded role checks.&lt;/p&gt;

&lt;p&gt;The ABAC model is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You define &lt;strong&gt;namespaces&lt;/strong&gt; and &lt;strong&gt;attributes&lt;/strong&gt; (e.g., &lt;code&gt;https://skedoodle.com/attr/sketch-access&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Each attribute has &lt;strong&gt;values&lt;/strong&gt; and a &lt;strong&gt;rule&lt;/strong&gt; (AnyOf, AllOf, or Hierarchy)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subject mappings&lt;/strong&gt; connect identity provider claims to attribute entitlements&lt;/li&gt;
&lt;li&gt;When someone requests access, the platform evaluates their entitlements against the resource's required attributes and returns &lt;strong&gt;permit or deny&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No SDKs to embed, no agents to deploy. It's a JSON API you call. Your app manages the data, OpenTDF manages the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Access Model
&lt;/h2&gt;

&lt;p&gt;What I wanted was straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Owner&lt;/strong&gt; creates a sketch and always has full access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner can invite&lt;/strong&gt; other users by username&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Owner can remove&lt;/strong&gt; any collaborator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collaborators&lt;/strong&gt; can draw on the sketch and can leave voluntarily&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collaborators cannot&lt;/strong&gt; remove other collaborators or the owner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No public access&lt;/strong&gt; — every sketch requires an explicit ABAC grant. Read-only public sharing could be layered on later as a separate attribute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Simple enough for users to understand, but it needs proper enforcement at every layer: REST API, WebSocket connections, and the real-time command stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building It with an AI Agent
&lt;/h2&gt;

&lt;p&gt;I used Claude Code as my coding agent. The agent fetched OpenTDF's &lt;code&gt;llms.txt&lt;/code&gt; at runtime, which gave it the architectural overview, API reference, Connect RPC URL patterns, protobuf enum values, and curl examples it needed to understand the platform.&lt;/p&gt;

&lt;p&gt;The agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read the docs and &lt;strong&gt;correctly chose ABAC authorization over full TDF encryption&lt;/strong&gt;, understanding that per-command encryption would be impractical for real-time collaboration&lt;/li&gt;
&lt;li&gt;Designed an attribute scheme (one attribute value per sketch, AnyOf rule) that maps cleanly to the sharing model&lt;/li&gt;
&lt;li&gt;Built the entire integration: REST API, WebSocket authorization, OpenTDF service with subject mapping lifecycle, and client UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;llms.txt&lt;/code&gt; gave the agent enough context to use the right API patterns without guessing — the correct RPC URL format, the exact enum values for condition operators and boolean types, the entity identifier structure for &lt;code&gt;GetDecisions&lt;/code&gt;. I described the access model I wanted, and it delivered a working integration.&lt;/p&gt;

&lt;p&gt;The ongoing iteration — refining the architecture, debugging access issues, removing redundant layers — was also done collaboratively with the agent, with &lt;code&gt;llms.txt&lt;/code&gt; as the shared reference for how OpenTDF's APIs work. When we hit an issue where ABAC returned PERMIT but the app still denied access, the agent was able to trace the problem because it understood the full authorization flow from the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Integration Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ABAC as the Single Source of Truth
&lt;/h3&gt;

&lt;p&gt;There's no &lt;code&gt;collaborators&lt;/code&gt; table in the database. OpenTDF is the &lt;strong&gt;sole authority&lt;/strong&gt; for access control. The database stores sketches, commands, and users. Who has access to what is entirely managed through OpenTDF subject mappings.&lt;/p&gt;

&lt;p&gt;This is a deliberate design choice. Instead of maintaining a local access control table and keeping it in sync with a policy engine, the application delegates all authorization to OpenTDF. The only local concept of "role" is ownership: the &lt;code&gt;Sketch&lt;/code&gt; table has an &lt;code&gt;ownerId&lt;/code&gt; field. Everything else — who can access which sketch, whether a given user is permitted — comes from ABAC.&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy Structure
&lt;/h3&gt;

&lt;p&gt;On server startup, the service registers Skedoodle's policy structure with OpenTDF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://skedoodle.com&lt;/span&gt;
&lt;span class="na"&gt;Attribute: sketch-access (rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AnyOf)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each sketch gets its own attribute value. Subject mappings are actively managed as part of the application lifecycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sketch created&lt;/strong&gt; → register an attribute value, create a subject mapping for the owner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collaborator invited&lt;/strong&gt; → create a subject mapping linking the user's username to the sketch's attribute value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collaborator removed&lt;/strong&gt; → delete the subject mapping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access check&lt;/strong&gt; → call &lt;code&gt;GetDecisions&lt;/code&gt; to verify the user has a valid entitlement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Sharing Workflow
&lt;/h3&gt;

&lt;p&gt;Three endpoints handle collaboration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST   /api/sketches/:id/collaborators           Owner invites by username
DELETE /api/sketches/:id/collaborators/:username  Owner removes, or user leaves
GET    /api/sketches/:id/collaborators            List who has access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an owner invites a collaborator, the app creates a subject mapping in OpenTDF:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;policy.subjectmapping.SubjectMappingService&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CreateSubjectMapping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;attributeValueId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;valueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;newSubjectConditionSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;subjectSets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;conditionGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="na"&gt;booleanOperator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CONDITION_BOOLEAN_TYPE_ENUM_OR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="na"&gt;subjectExternalSelectorValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.username&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUBJECT_MAPPING_OPERATOR_ENUM_IN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;subjectExternalValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
              &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells the platform: when a user's Keycloak &lt;code&gt;.username&lt;/code&gt; matches, grant them the sketch's attribute value entitlement.&lt;/p&gt;

&lt;p&gt;Listing collaborators queries &lt;code&gt;ListSubjectMappings&lt;/code&gt; and filters for mappings that match the sketch's attribute value. Removing a collaborator deletes the mapping. There's no local state to keep in sync.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access Checks
&lt;/h3&gt;

&lt;p&gt;Every protected operation — loading a sketch, fetching commands, joining a WebSocket room, saving commands — calls &lt;code&gt;GetDecisions&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization.AuthorizationService&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GetDecisions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;decisionRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;entityChains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;resourceAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;attributeValueFqns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s2"&gt;`https://skedoodle.com/attr/sketch-access/value/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sketchId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decisionResponses&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DECISION_PERMIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the platform denies access or is unreachable, the request is rejected. This is a deliberate choice — ABAC is the single source of truth, so there's no stale local copy to fall back to. In a production deployment where availability is critical, you'd want to run OpenTDF with redundancy, or introduce a short-lived decision cache as a buffer. For Skedoodle, fail-closed is the right tradeoff: denying access temporarily is better than granting it incorrectly.&lt;/p&gt;

&lt;h3&gt;
  
  
  WebSocket Enforcement
&lt;/h3&gt;

&lt;p&gt;Real-time collaboration adds a wrinkle. You can't call a policy service on every brush stroke. The approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authorize on join&lt;/strong&gt;: call &lt;code&gt;GetDecisions&lt;/code&gt; when a user connects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce at the room level&lt;/strong&gt;: owners and collaborators can draw, the role is set once at join time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kick on revocation&lt;/strong&gt;: when access is removed via the API, immediately disconnect the user
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// When an owner removes a collaborator&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mappingId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;opentdfService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findSubjectMappingId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetUsername&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sketchId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mappingId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;opentdfService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteSubjectMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mappingId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sketchId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kickClientByUsername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetUsername&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client handles revocation gracefully with a dialog explaining what happened and options to go back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Listing Sketches from ABAC
&lt;/h3&gt;

&lt;p&gt;To show a user their sketches, the app queries both the database and OpenTDF in parallel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ownedSketches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;abacSketchIds&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sketch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;opentdfService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listSketchIdsForUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Owned sketches come from the database. Shared sketches come from OpenTDF by iterating subject mappings and extracting sketch IDs from attribute value FQNs. The two lists are merged, deduped, and returned with roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Shows About ABAC
&lt;/h2&gt;

&lt;p&gt;This integration replaced what would typically be a &lt;code&gt;collaborators&lt;/code&gt; join table, a set of role-checking queries, and manual sync logic — with a handful of API calls to a policy engine.&lt;/p&gt;

&lt;p&gt;Where ABAC gets interesting is what happens next. Today Skedoodle's access model is simple: per-sketch, per-user grants. But the same infrastructure supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mapping team membership to sketch access (subject mappings based on group claims instead of individual usernames)&lt;/li&gt;
&lt;li&gt;Classification-based access (new attributes with AllOf or Hierarchy rules)&lt;/li&gt;
&lt;li&gt;Cross-organization sharing (attribute values scoped to external identity providers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These would be &lt;strong&gt;policy changes&lt;/strong&gt; — new attributes, new subject mappings — not application code changes. The &lt;code&gt;checkAccess()&lt;/code&gt; call stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Timeline
&lt;/h2&gt;

&lt;p&gt;The entire integration took &lt;strong&gt;one afternoon&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Switch identity provider to Keycloak&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create Keycloak client + test users&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collaborator API + OpenTDF subject mapping lifecycle&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket authorization + kick-on-revoke&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client UI (share dialog, access denied, role badges)&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenTDF ABAC service integration&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging and polish&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The OpenTDF integration itself was the smallest piece. Most of the work was building the sharing UX and enforcing access at the WebSocket layer. OpenTDF slotted in cleanly because it's designed to be an authorization service you call, not a framework you restructure your app around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ABAC can be your single source of truth for access control.&lt;/strong&gt; Instead of maintaining a collaborators table and keeping it in sync with a policy engine, Skedoodle delegates all authorization to OpenTDF. The application code doesn't contain access control logic beyond "ask OpenTDF and respect the answer."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The integration surface is small.&lt;/strong&gt; Six API operations cover the entire authorization model, callable from any language with plain &lt;code&gt;fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time apps need smart enforcement points.&lt;/strong&gt; You can't call a policy service on every WebSocket message. Authorize on connect, enforce roles at the room level, and handle revocation proactively by kicking disconnected users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt; makes AI-assisted integration practical.&lt;/strong&gt; The agent built a working ABAC integration from documentation alone. Structured, machine-readable docs lower the barrier to adoption — not just for AI agents, but for any developer exploring a new platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ABAC scales where RBAC doesn't.&lt;/strong&gt; Roles are fine until you need to express "users in department X with clearance level Y can access resources tagged with classification Z." That sentence maps directly to ABAC attributes. Trying to model it with roles leads to an explosion of role combinations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://opentdf.io/" rel="noopener noreferrer"&gt;OpenTDF&lt;/a&gt; integration lives in a dedicated fork: &lt;a href="https://github.com/eugenioenko/skedoodle-opentdf" rel="noopener noreferrer"&gt;skedoodle-opentdf&lt;/a&gt;. It includes everything you need to run the full stack locally.&lt;/p&gt;

&lt;p&gt;If you're building an app that needs access control beyond basic ownership — especially if you want centralized policy management or the flexibility to evolve your authorization model over time — ABAC with &lt;a href="https://opentdf.io/" rel="noopener noreferrer"&gt;OpenTDF&lt;/a&gt; is worth a look.&lt;/p&gt;

</description>
      <category>abac</category>
      <category>opentdf</category>
      <category>authorization</category>
    </item>
    <item>
      <title>Kneel Before Zod!</title>
      <dc:creator>Eugene Yakhnenko</dc:creator>
      <pubDate>Fri, 16 Jan 2026 02:56:00 +0000</pubDate>
      <link>https://dev.to/eugenioenko/kneel-before-zod-5edm</link>
      <guid>https://dev.to/eugenioenko/kneel-before-zod-5edm</guid>
      <description>&lt;p&gt;TypeScript has changed the game for JavaScript developers by adding static type checking, but it doesn’t automatically handle data validation. Especially when dealing with external sources like APIs or user inputs.&lt;br&gt;
Lets break down the challenges of data validation in TypeScript, explores possible solutions, and takes a closer look at Zod, a powerful validation library.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Data Validation Matters in TypeScript
&lt;/h2&gt;

&lt;p&gt;Data validation is all about making sure the data you receive is in the right format and contains the right information. This is especially important when handling external data, like API responses, user input or data from local storage. When you define types in TypeScript, they help during development, but they don’t actually enforce anything at runtime. So even if you expect an API to return a certain structure, TypeScript won’t stop it from giving you something completely different.&lt;br&gt;
You've probably experienced this issue tons of times with errors like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;VM228:1 Uncaught TypeError: Cannot read properties of undefined (reading 'something')&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Compile-Time vs. Runtime-Time Gap
&lt;/h2&gt;

&lt;p&gt;One of the biggest challenges in TypeScript data validation is the difference between what TypeScript checks at compile time and what actually happens at runtime. For example, when you fetch data from an API, TypeScript assumes it matches your type definitions, but in reality, there’s no guarantee.&lt;br&gt;
Same issue when reading from localStorage. Even when &lt;code&gt;JSON.parse()&lt;/code&gt; succeeds, there's no guarantee that the data has the shape you're expecting.&lt;br&gt;
This gap means that without extra validation, your app could end up working with incorrect or unexpected data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// But nothing ensures data actually matches User interface&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retrieveUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// But nothing ensures data actually matches User interface&lt;/span&gt;
  &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API interfaces are contracts, and usually this is not an issue, specially if you are also the maintainer of the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Solutions for TypeScript Data Validation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Type Guards and Assertion Functions
&lt;/h3&gt;

&lt;p&gt;TypeScript's built-in type guards provide a simple validation mechanism:&lt;br&gt;
&lt;a href="https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards" rel="noopener noreferrer"&gt;Type Guards Docs&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;processUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// TypeScript knows data is User here&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid user data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach works but becomes unwieldy for complex objects, requiring manual implementation of validation logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zod as a Solution for TypeScript Validation
&lt;/h2&gt;

&lt;p&gt;Zod is a TypeScript-first schema validation library with static type inference. It allows defining schemas that validate data at runtime while automatically inferring TypeScript types.&lt;br&gt;
&lt;a href="https://zod.dev/" rel="noopener noreferrer"&gt;Zod Docs&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Extract the inferred type&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// { id: string; email: string }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retrieve from local storage function would look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retrieveUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validatedUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;validatedUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// User matches the type&lt;/span&gt;
  &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pros of Zod
&lt;/h2&gt;

&lt;h3&gt;
  
  
  TypeScript-First Design
&lt;/h3&gt;

&lt;p&gt;Zod was built specifically for TypeScript, resulting in excellent type inference and integration with TypeScript's type system. This enables catching type errors during development rather than at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema-to-Type Inference
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;z.infer&amp;lt;typeof schema&amp;gt;&lt;/code&gt; pattern allows extracting TypeScript types directly from validation schemas, ensuring perfect alignment between validation and types.&lt;/p&gt;

&lt;h3&gt;
  
  
  Comprehensive Schema Options
&lt;/h3&gt;

&lt;p&gt;Zod supports a wide range of validation options, from simple primitives to complex structures including objects, arrays, tuples, unions, and even functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handy Zod utility: validateSchemaOrThrow
&lt;/h2&gt;

&lt;p&gt;Here is a handy utility for validating schemas. It will attempt to validate the schema.&lt;br&gt;
When it succeeds it returns the validated data. It will re-throw the combined zod errors when data is invalid.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ZodRawShape&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateSchemaOrThrow&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;ZodRawShape&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ZodObject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ZodObject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;parse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is how it would end up being used in a framework route for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credentials&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateSchemaOrThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LoginSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loginUserOrThrow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authUser&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unexpected login error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  More info at
&lt;/h2&gt;

&lt;p&gt;&lt;a href="[https://zod.dev/]"&gt;https://zod.dev/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>zod</category>
      <category>react</category>
      <category>typescript</category>
      <category>validation</category>
    </item>
  </channel>
</rss>
