camelon

Reactivity

Client interactivity comes from Datastar. camelon compiles a typed set of JSX props — on:, bind:, signals, text — to Datastar data-* attributes at build time. Set jsxImportSource: "camelon" in your tsconfig to turn it on.

export default function Counter() {
  return (
    <div class="d-row" signals='{"count": 0}'>
      <button class="d-btn" on:click="@post('/counter/decrement')"></button>
      <output class="d-num" text="$count">0</output>
      <button class="d-btn" on:click="@post('/counter/increment')">+</button>
    </div>
  );
}
import type { RouteArgs } from 'camelon';

export async function action({ stream, signals }: RouteArgs) {
  const { count = 0 } = signals as { count?: number };
  stream.patchSignals({ count: count + 1 });
}
import type { RouteArgs } from 'camelon';

export async function action({ stream, signals }: RouteArgs) {
  const { count = 0 } = signals as { count?: number };
  stream.patchSignals({ count: count - 1 });
}
service worker

The buttons @post to two resource routes. Each reads the current count off the posted signals and patches the new value back over SSE. The increment.ts and decrement.ts tabs are the whole server side.

Props

You write Compiles to
signals='{"count": 0}' data-signals='{"count": 0}'
text="$count" data-text="$count"
bind:name data-bind:name (two-way bind input ↔ $name)
on:click="@post('/x')" data-on:click="@post('/x')"
on:input__debounce-300ms data-on:input__debounce.300ms

Write dotted modifiers with -. Raw data-* props pass through untouched.

bind: keeps an input and a signal in sync:

export default function Greet() {
  return (
    <div class="d-card" signals='{"name": ""}'>
      <input class="d-input" bind:name="" placeholder="your name" />
      <p>Hello <span text="$name">friend</span>!</p>
    </div>
  );
}
service worker

Server patches

A resource route answers @post/@get over the stream arg. Patch signals or whole elements. Every patched fragment needs a stable id.

export async function action({ stream, signals }) {
  const { count = 0 } = signals;
  stream.patchSignals({ count: count + 1 });
}

No stream.close() — the stream closes on its own when the action returns.

signals holds the current client state. A Datastar @post also sends it as the JSON body, so one ActionInput type validates a form submit and a Datastar submit.

Datastar 1.0.2: data-on-load is inert. Use data-effect for run-on-load and long-lived SSE connections.