Skip to content

watany-dev/kantan-ui

Repository files navigation

kantan-ui

A Streamlit-style UI framework that depends only on Web standards and Hono.

Features

  • Simple - Declarative API like Streamlit (kt.button(), kt.slider(), etc.)
  • Real-time - Instant UI updates via WebSocket with multi-tab sync support
  • Lightweight - Depends only on Hono, supports multiple runtimes (Bun, Node.js, Deno)
  • Session Management - Automatic state management for multiple users
  • Connection Stability - Ping/Pong, auto-reconnect, sequence-based patch recovery
  • Streaming - Progressive rendering for large UIs
  • Security - Magic byte validation, polyglot detection, and XSS protection for file uploads

Quick Start

Requirements

  • Bun v1.0+ (recommended)
  • Or Node.js v18+, Deno

Installation

bun install

Start Development Server

bun run dev

Open http://localhost:3000 in your browser to see the demo app.

Usage

Basic App (Declarative API)

import { createApp, kt, createTypedSessionState } from "kantan-ui";

// Define type-safe session state
type AppState = {
  count: number;
};

const state = createTypedSessionState<AppState>({
  count: 0,  // Default value
});

const script = () => {
  kt.title("Counter App");

  // Button returns true when clicked
  if (kt.button("+ Increment")) {
    state.count++;  // Type-safe! No type assertion needed
  }

  kt.write(`Count: ${state.count}`);

  // Return undefined when using declarative API
  return undefined;
};

// All runtimes: create app with await createApp
export default await createApp(script, { port: 3000 });

Starting the Server

Bun (Recommended)

bun run src/index.ts

Bun automatically starts a server when export default returns an object with fetch/websocket/port.

Node.js

import { createApp } from "kantan-ui";
import { serve } from "kantan-ui/serve";

const app = await createApp(script);
serve(app, { port: 3000 });

kt API (Declarative API)

The kt object lets you build UI intuitively like Streamlit. Each function automatically outputs HTML and returns appropriate values.

Output API

import { kt } from "kantan-ui";

kt.title("Title");           // <h1>
kt.header("Header");         // <h2>
kt.subheader("Subheader");   // <h3>
kt.write("Text");            // Text output
kt.text("Text");             // Alias for write
kt.divider();                // Horizontal rule <hr>
kt.html("<div>Raw HTML</div>"); // Raw HTML output (caution: XSS risk)
kt.markdown("**Bold** text");   // Markdown rendering
kt.code("const x = 1;", "typescript"); // Code block with syntax highlighting
kt.json({ key: "value" });   // Collapsible JSON viewer

Streaming API

Display text progressively from streaming sources (ideal for LLM responses).

// AsyncGenerator (LLM-style streaming)
async function* generateResponse() {
  yield "Hello, ";
  await new Promise(r => setTimeout(r, 100));
  yield "World!";
}
const fullText = await kt.write_stream(generateResponse());

// Array (instant display)
await kt.write_stream(["Item 1, ", "Item 2, ", "Item 3"]);

// With Markdown rendering on completion
await kt.write_stream(["# Title\n", "\nThis is **bold** text."], {
  markdown: true,
});

// Custom CSS class
await kt.write_stream(chunks, { className: "my-stream" });

// ReadableStream (Web standard)
const stream = new ReadableStream<string>({
  start(controller) {
    controller.enqueue("Streaming...");
    controller.close();
  }
});
await kt.write_stream(stream);

// Response from fetch
const response = await fetch("/api/stream");
await kt.write_stream(response);

Supported Sources:

  • AsyncIterable<string> - async generators, LLM streams
  • Iterable<string> - arrays, iterators
  • ReadableStream<string> - Web standard streams
  • Response - fetch API responses
  • Factory functions returning any of the above

Options:

  • markdown: boolean - Render as Markdown when stream completes
  • className: string - Custom CSS class for styling

Features:

  • Blinking cursor during streaming
  • Automatic cursor removal on completion
  • XSS-safe text rendering
  • Multiple concurrent streams supported

Alert API

kt.success("Operation completed!");
kt.error("Something went wrong");
kt.warning("Please check your input");
kt.info("Here's some information");

Feedback API

// Progress bar (0-1 or 0-100 auto-detected)
kt.progress(0.75);
kt.progress(75, { label: "Downloading..." });

// Loading spinner
kt.spinner("Processing...");

// Toast notification
kt.toast("Saved successfully!");
kt.toast("Error occurred", { type: "error" });

Data Display API

// Table - supports various data formats
kt.table([
  { name: "Alice", age: 30 },
  { name: "Bob", age: 25 },
]);

// 2D array format
kt.table([
  ["Name", "Age"],
  ["Alice", 30],
  ["Bob", 25],
]);

// Explicit header specification
kt.table({
  columns: ["Name", "Age"],
  data: [["Alice", 30], ["Bob", 25]],
});

// Metric - KPI display with optional delta
kt.metric("Revenue", "$1,234");
kt.metric("Revenue", "$1,234", { delta: "+12%" });
kt.metric("Response Time", "120ms", { delta: "+15ms", delta_color: "inverse" });

Page Config API

// Page settings (title, layout, etc.)
kt.set_page_config({
  title: "My App",
  layout: "wide",  // "centered" | "wide"
  icon: "🚀",
});

// Force script re-execution
kt.rerun();

Widget API

// Button - returns true when clicked
if (kt.button("Click me", { key: "my_button" })) {
  // Handle button click
}

// Slider - returns current value
const volume = kt.slider("Volume", 0, 100, 50, { key: "volume" });

// Text input - returns current input value
const name = kt.text_input("Your name", "Default", { key: "name" });

// Selectbox - returns selected value
const color = kt.selectbox("Color", ["Red", "Green", "Blue"], "Blue", { key: "color" });

// Download button - triggers file download
kt.download_button("Download CSV", "name,age\nAlice,30", "data.csv", {
  mime: "text/csv",
});

// Checkbox - returns boolean
const agreed = kt.checkbox("I agree", false, { key: "agree" });

// Toggle - returns boolean (switch style)
const darkMode = kt.toggle("Dark mode", false, { key: "dark_mode" });

// Radio buttons - returns selected value
const size = kt.radio("Size", ["S", "M", "L"], "M", { key: "size" });

// Number input - returns number
const age = kt.number_input("Age", 0, 120, 25, { key: "age", step: 1 });

// Text area - returns multiline text
const bio = kt.text_area("Bio", "Tell us about yourself...", { key: "bio" });

// Multiselect - returns array of selected values
const tags = kt.multiselect("Tags", ["Tech", "Design", "Business"], [], { key: "tags" });

// Date input - returns "YYYY-MM-DD" string
const birthday = kt.date_input("Birthday", "2000-01-15", {
  min: "1900-01-01",
  max: "2024-12-31",
});

// Time input - returns "HH:MM" string
const alarm = kt.time_input("Alarm", "08:30", { step: 60 });

// File uploader - returns UploadedFile object
const file = kt.file_uploader("Upload file", { accept: "image/*", maxSize: 5 * 1024 * 1024 });
if (file) {
  kt.write(`Uploaded: ${file.name} (${file.size} bytes)`);
  const content = file.text();  // or file.arrayBuffer()
}

// Multiple files
const files = kt.file_uploader("Upload files", { multiple: true });
for (const f of files) {
  kt.write(`${f.name}: ${f.type}`);
}

Layout API

// Tabs - organize content in multiple tabs
const [tab1, tab2, tab3] = kt.tabs(["Overview", "Data", "Settings"]);

tab1(() => {
  kt.header("Overview");
  kt.write("This is the overview tab.");
});

tab2(() => {
  kt.header("Data");
  kt.table(data);
});

tab3(() => {
  kt.header("Settings");
  kt.write("Configure your preferences here.");
});

// Columns - create multi-column layout
kt.columns([
  () => kt.write("Left"),
  () => kt.write("Right"),
]);

// With ratios (1:2:1 = 25%:50%:25%)
kt.columns(
  [
    () => kt.write("Sidebar"),
    () => kt.write("Main content"),
    () => kt.write("Sidebar"),
  ],
  { ratios: [1, 2, 1] }
);

// Container - group content
kt.container(() => {
  kt.write("Grouped content");
  kt.button("Action");
}, { border: true });

// Expander - collapsible section
kt.expander("See details", () => {
  kt.write("Hidden content");
});

// Expanded by default
kt.expander("Important notice", () => {
  kt.write("Please read this!");
}, { expanded: true });

Sidebar API

// Callback notation
kt.sidebar(() => {
  kt.title("Settings");
  kt.button("Reset");
});

// Object notation
kt.sidebar.title("Settings");
kt.sidebar.button("Reset");

// Custom width
kt.sidebar(() => {
  kt.title("Wide Sidebar");
}, { width: "350px" });

Form API

// Form with submit button
kt.form("login_form", () => {
  const username = kt.text_input("Username");
  const password = kt.text_input("Password", "", { type: "password" });
  if (kt.form_submit_button("Login")) {
    // Handle form submission
  }
});

// Validation errors
kt.form("contact", () => {
  const email = kt.text_input("Email");
  if (kt.form_submit_button("Send")) {
    if (!email.includes("@")) {
      kt.validation_error("Please enter a valid email address");
      return;
    }
    // Process valid form data
  }
});

// Multiple validation errors
kt.form("signup", () => {
  const name = kt.text_input("Name");
  const email = kt.text_input("Email");
  if (kt.form_submit_button("Sign Up")) {
    const errors = [];
    if (!name) errors.push("Name is required");
    if (!email) errors.push("Email is required");
    if (errors.length > 0) {
      kt.validation_errors(errors);
      return;
    }
  }
});

Chat API

import { kt, createTypedSessionState } from "kantan-ui";

// Message history type definition
type ChatState = {
  messages: Array<{ role: "user" | "assistant"; content: string }>;
};

const state = createTypedSessionState<ChatState>({
  messages: [],
});

// Chat container (with auto-scroll)
kt.chat_container(() => {
  for (const msg of state.messages) {
    kt.chat_message(msg.role, msg.content);
  }
}, { height: "400px" });

// Individual chat messages
kt.chat_message("user", "Hello!");
kt.chat_message("assistant", "Hi! How can I help you?");

// Custom avatar and name
kt.chat_message("user", "What is **TypeScript**?", {
  name: "Alice",
  avatar: "🧑‍💻",
});

// System message
kt.chat_message("system", "Session started at 10:00 AM");

Features:

  • Role-based styling (user / assistant / system)
  • Markdown content support
  • Customizable avatar and display name
  • Auto-scroll (pauses when user scrolls up)

Empty Placeholder API

Create dynamically updatable placeholders. Similar to Streamlit's st.empty().

// Create placeholder
const status = kt.empty({ key: "status" });

// Dynamically change content on button click
if (kt.button("Start Process")) {
  status.spinner("Processing...");
}

if (kt.button("Complete")) {
  status.success("Done!");
}

if (kt.button("Show Error")) {
  status.error("Something went wrong!");
}

if (kt.button("Clear")) {
  status.empty();  // Clear content
}

// Show progress bar
const progress = kt.empty({ key: "progress" });
if (kt.button("Show Progress")) {
  progress.progress(0.75, { text: "75% complete" });
}

Placeholder Object Methods:

  • write(content) - Display text/number/boolean
  • text(content) - Display plain text
  • markdown(content) - Display Markdown
  • html(content) - Display raw HTML
  • json(data) - Display formatted JSON
  • code(content, language?) - Display code block
  • success(message) - Success alert
  • error(message) - Error alert
  • warning(message) - Warning alert
  • info(message) - Info alert
  • progress(value, config?) - Progress bar (0.0-1.0)
  • spinner(text?) - Loading spinner
  • empty() - Clear content

Session State Management

Manage per-user session state. Similar to Streamlit's st.session_state.

createTypedSessionState (Recommended)

Create type-safe session state. Access safely without type assertions, with IDE completion support.

import { createTypedSessionState } from "kantan-ui";

// Define type and specify defaults
type AppState = {
  counter: number;
  name: string;
  items: string[];
};

const state = createTypedSessionState<AppState>({
  counter: 0,
  name: "World",
  items: [],
});

// Type-safe access
state.counter++;           // OK - number type
state.name = "Hello";      // OK - string type
state.items.push("item");  // OK - string[] type
// state.unknown = 1;      // Compile error!

session_state (Legacy)

For dynamic keys, the traditional session_state is also available.

import { session_state } from "kantan-ui";

// Initialize
if (session_state.myValue === undefined) {
  session_state.myValue = "initial";
}

// Read (type assertion required)
const value = session_state.myValue as string;

// Write
session_state.myValue = "new value";

Cache API

Cache expensive function results to improve performance. Similar to Streamlit's @st.cache_data and @st.cache_resource.

cache_data

For serializable data (API results, computed values):

import { kt } from "kantan-ui";

// Basic usage
const fetchUsers = kt.cache_data(async (limit: number) => {
  const res = await fetch(`/api/users?limit=${limit}`);
  return res.json();
});

const users = await fetchUsers(10);  // Cached on subsequent calls

// With TTL (expires in 1 hour)
const fetchWeather = kt.cache_data(async (city: string) => {
  return await weatherApi.get(city);
}, { ttl: 3600 });

// With max entries (LRU eviction)
const searchProducts = kt.cache_data(async (query: string) => {
  return await productApi.search(query);
}, { max_entries: 50 });

// Clear cache
fetchUsers.clear();

cache_resource

For non-serializable resources (database connections, ML models):

// Returns the same instance on every call
const getDb = kt.cache_resource(() => {
  return new DatabaseConnection(process.env.DB_URL);
});

const db1 = getDb();
const db2 = getDb();
console.log(db1 === db2); // true

Global cache clear

kt.cache_data.clear();      // Clear all cache_data caches
kt.cache_resource.clear();  // Clear all cache_resource caches
kt.clear_all_caches();      // Clear all caches

Imperative API (Low-level API)

For finer control, imperative APIs are also available.

import { button, renderButton, slider, renderSlider } from "kantan-ui";

// Functional API (returns true when pressed)
const pressed = button("Click me", { key: "my_button" });

// For rendering (returns HTML)
const html = renderButton("Click me", { key: "my_button" });

Project Structure

src/
├── index.ts          # Entry point (exports)
├── app.ts            # createApp function
├── server.ts         # Demo server
├── client/           # Client script generation
│   ├── script.ts     # WebSocket/event handling script
│   ├── types.ts      # Client config types
│   └── index.ts
├── config/           # Configuration management
│   ├── defaults.ts   # Default settings
│   ├── types.ts      # Config types
│   └── index.ts
├── kt/               # Declarative API (Streamlit-style)
│   ├── context.ts    # Render context
│   ├── config.ts     # Page config (set_page_config)
│   ├── control.ts    # Control API (rerun)
│   ├── chat.ts       # Chat API (chat_message, chat_container)
│   ├── data.ts       # Data display (table)
│   ├── empty.ts      # Empty placeholder
│   ├── feedback.ts   # Feedback API (progress, spinner, toast)
│   ├── form.ts       # Form API
│   ├── layout.ts     # Layout (tabs, columns, container, expander)
│   ├── sidebar.ts    # Sidebar API
│   ├── output.ts     # Output API (title, write, header, etc.)
│   ├── stream.ts     # Streaming API (write_stream)
│   ├── stream-utils.ts   # Stream normalization utilities
│   ├── stream-registry.ts # Pending stream management
│   ├── widgets.ts    # Widget API (button, slider, etc.)
│   └── index.ts
├── runtime/          # Runtime context management
│   ├── context.ts    # getContext/setContext
│   ├── rerun.ts      # Script re-execution logic
│   ├── stream-processor.ts # Stream processing engine
│   └── index.ts
├── session/          # Session management
│   ├── manager.ts    # SessionManager (multi-tab support)
│   ├── state.ts      # session_state (Proxy implementation)
│   ├── types.ts      # Type definitions
│   └── index.ts
├── utils/            # Utilities
│   ├── html.ts       # HTML escaping, etc.
│   ├── sanitize.ts   # Filename sanitization
│   ├── magic-bytes.ts # Magic byte validation
│   ├── polyglot-detection.ts # Polyglot detection
│   ├── file-validation.ts # File validation integration
│   └── type-guards.ts
├── websocket/        # WebSocket handling
│   ├── handler.ts    # WebSocket handler
│   ├── types.ts      # Message type definitions
│   └── index.ts
└── widgets/          # UI widgets (imperative API)
    ├── button.ts
    ├── slider.ts
    ├── text-input.ts
    ├── text-area.ts
    ├── selectbox.ts
    ├── download-button.ts
    ├── checkbox.ts
    ├── toggle.ts
    ├── radio.ts
    ├── number-input.ts
    ├── multiselect.ts
    ├── date-input.ts
    ├── time-input.ts
    ├── file-uploader.ts
    ├── uploaded-file.ts
    ├── image.ts
    ├── placeholder.ts
    ├── core.ts
    ├── registry.ts
    ├── types.ts
    └── index.ts

NPM Scripts

Command Description
bun run dev Start development server (hot reload)
bun run build Production build
bun run test Run unit tests (Vitest)
bun run test:watch Unit tests (watch mode)
bun run test:coverage Tests with coverage
bun run test:e2e E2E tests (Playwright)
bun run lint Lint check with Biome
bun run lint:fix Auto-fix lint issues
bun run ci CI pipeline (lint + build + test:coverage)

How It Works

  1. Client loads the page and establishes WebSocket connection
  2. Server creates a session and sends initial HTML
  3. When user interacts with widgets, sendEvent notifies the server (with debounce)
  4. Server updates session_state and re-executes the script (rerun)
  5. New HTML is sent to client via WebSocket (streaming supported)
  6. Client updates DOM (preserving focus state)

Connection Management

  • Ping/Pong: Server periodically sends ping to monitor connection state
  • Auto-reconnect: Exponential backoff retry on disconnect
  • Sequence Numbers: Recover missed patches on reconnect
  • Multi-tab: Broadcast state changes to all tabs of the same session

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •