AppRun Skills
Overview
- •Build AppRun apps with MVU (Model-View-Update) in TypeScript.
- •Prefer pure update functions for testability.
- •Use
mounted()for components embedded in JSX. - •Use
state = asynconly for top-level routed pages that must load async data.
Project Setup
Recommended Project Structure
web/ # Frontend application root ├── index.html # Entry HTML file ├── package.json # Dependencies and scripts ├── vite.config.js # Vite configuration ├── src/ │ ├── main.tsx # Application entry point (routes registration) │ ├── api.ts # REST API client (optional) │ ├── styles.css # Application styles │ ├── tsconfig.json # TypeScript configuration │ ├── components/ # Reusable UI components │ │ ├── Layout.tsx # Root layout container │ │ └── ... # Other reusable components │ ├── domain/ # Business logic modules (optional) │ │ └── ... # Pure functions and business logic │ ├── pages/ # Top-level page components │ │ ├── Home.tsx # Example: Home page │ │ └── ... # Other route pages │ ├── types/ # TypeScript type definitions │ │ ├── index.ts # Shared types │ │ └── jsx.d.ts # JSX type declarations │ └── utils/ # Utility functions └── public/ # Static assets (optional)
Vite Configuration
import { defineConfig } from 'vite'
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 8080,
open: true,
historyApiFallback: true, // SPA mode
proxy: {
// Proxy API requests to backend
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
secure: false
}
}
}
})
Package.json
{
"name": "my-apprun-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "tsc --noEmit"
},
"devDependencies": {
"apprun": "^3.38.0",
"typescript": "^5.0.0",
"vite": "^5.0.0"
}
}
TypeScript Configuration
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react",
"jsxFactory": "app.createElement",
"jsxFragmentFactory": "app.Fragment",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Critical Settings for AppRun:
- •
jsx: "react"- Enables JSX syntax - •
jsxFactory: "app.createElement"- Uses AppRun's JSX factory - •
jsxFragmentFactory: "app.Fragment"- Uses AppRun's Fragment support - •
moduleResolution: "bundler"- Optimized for Vite
Entry Points
HTML Entry (index.html):
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>My AppRun App</title> </head> <body> <div id="root"></div> <script type="module" src="src/main.tsx"></script> </body> </html>
Application Entry (src/main.tsx):
import app from 'apprun';
import Layout from './components/Layout';
import Home from './pages/Home';
import About from './pages/About';
import './styles.css';
app.render('#root', <Layout />);
app.addComponents('#pages', {
'/': Home,
'/about': About,
});
Layout Component (src/components/Layout.tsx):
import app from 'apprun';
export default () => (
<div id="app">
<div id="pages"></div>
</div>
);
Styling Options
Option 1: Vanilla CSS
/* src/styles.css */
:root {
--color-primary: #007bff;
--color-text: #333;
--spacing-unit: 8px;
}
body {
font-family: system-ui, -apple-system, sans-serif;
color: var(--color-text);
margin: 0;
padding: 0;
}
Option 2: Tailwind CSS v4
Install Tailwind v4:
npm install -D tailwindcss@next @tailwindcss/vite@next
Update vite.config.js:
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss()],
// ... other config
})
Import in src/styles.css:
@import "tailwindcss";
Use in components:
<div className="flex items-center gap-4 p-4 bg-white rounded-lg shadow"> <h1 className="text-2xl font-bold">Hello World</h1> </div>
Option 3: CSS Modules
import styles from './MyComponent.module.css';
export default () => (
<div className={styles.container}>
<h1 className={styles.title}>Hello</h1>
</div>
);
API Client Pattern
// src/api.ts
const API_BASE_URL = '/api';
interface RequestOptions extends RequestInit {
params?: Record<string, string>;
}
async function request<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { params, ...fetchOptions } = options;
let url = `${API_BASE_URL}${endpoint}`;
if (params) {
const query = new URLSearchParams(params).toString();
url += `?${query}`;
}
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
...fetchOptions,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
export const api = {
get: <T>(endpoint: string, params?: Record<string, string>) =>
request<T>(endpoint, { method: 'GET', params }),
post: <T>(endpoint: string, data?: unknown) =>
request<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
}),
put: <T>(endpoint: string, data?: unknown) =>
request<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
};
export default api;
Quick Start
# 1. Create project npm create vite@latest my-apprun-app -- --template vanilla-ts cd my-apprun-app # 2. Install AppRun npm install npm install -D apprun # 3. Configure TypeScript (update tsconfig.json with settings above) # 4. Rename entry file mv src/main.ts src/main.tsx # 5. Create basic app structure # (Add Layout, pages, components as shown above) # 6. Run development server npm run dev # 7. Build for production npm run build npm run preview
Why Vite + AppRun?
Why Vite:
- •Fast development with instant HMR
- •Optimized builds with Rollup
- •First-class TypeScript support
- •Minimal configuration
Why AppRun:
- •Lightweight (~7KB gzipped)
- •Simple MVU pattern
- •Direct DOM updates (no virtual DOM)
- •Full TypeScript support
- •Built-in routing
Component Patterns - Decision Tree
- •Manages state + user interactions? → Stateful Class Component
- •Popup/modal/overlay? → Modal Component (use
mounted()) - •Display-only from props? → Functional Component
- •10+ events needing type safety? → Typed Events Pattern
Stateful Class Component
Structure Order: Imports → Interfaces → Helpers → Actions → Component
import { app, Component } from 'apprun';
interface Props { data?: any; }
export interface State {
loading: boolean;
error: string | null;
successMessage?: string;
// ... specific fields
}
const getStateFromProps = (props: Props): State => ({ /* ... */ });
export const saveData = async function* (state: State): AsyncGenerator<State> {
// Validation
if (!state.data.name.trim()) {
yield { ...state, error: 'Name required' };
return;
}
// Loading
yield { ...state, loading: true, error: null };
// API call
try {
await api.save(state.data);
yield { ...state, loading: false, successMessage: 'Saved!' };
app.run('data-saved');
} catch (error: any) {
yield { ...state, loading: false, error: error.message };
}
};
export default class MyComponent extends Component<State> {
declare props: Readonly<Props>;
mounted = (props: Props): State => getStateFromProps(props);
view = (state: State) => {
if (state.loading) return <div>Loading...</div>;
if (state.error) return <div className="error">{state.error}</div>;
return (
<form>
<input $bind="data.name" />
<button $onclick={[saveData]} disabled={state.loading}>Save</button>
</form>
);
};
}
View Pattern: Guard clauses → Early returns → Main content
Modal Component
CRITICAL: Must use mounted() (embedded in JSX), not state = async
export default class Modal extends Component<State> {
declare props: Readonly<Props>;
mounted = (props: Props): State => getStateFromProps(props);
view = (state: State) => (
<div className="modal-backdrop" onclick={closeModal}>
<div className="modal-content" onclick={(e) => e.stopPropagation()}>
<button onclick={closeModal}>×</button>
{/* content */}
</div>
</div>
);
}
Requirements: Close button + backdrop click + stopPropagation
JSX Children Renderer Pattern
Use this when a JSX-embedded class component should accept content as children, for example:
<Editor>{pages}</Editor> where pages is a render function.
type EditorProps = {
pages?: () => any; // optional backward-compatible prop API
children?: any; // from JSX children
};
type EditorState = EditorProps & {
source?: HTMLElement | null;
};
export default class Editor extends Component<EditorState> {
// Required for AppRun JSX attribute typing to avoid TS2607
declare props: Readonly<EditorProps>;
mounted = (props: EditorProps = {}, children: any[] = []): EditorState => {
const safeProps = props || {};
const normalizedChildren = children.length === 1 ? children[0] : children;
const pages = safeProps.pages ?? (typeof normalizedChildren === 'function' ? normalizedChildren : undefined);
const content = pages ? undefined : normalizedChildren;
return { ...safeProps, pages, children: content };
};
view = ({ pages, children }: EditorState) => {
const content = pages ? pages() : children;
return <div class="editor">{content}</div>;
};
}
Notes:
- •Keep backward compatibility by supporting both
<Editor pages={pages} />and<Editor>{pages}</Editor>. - •In AppRun class components rendered via JSX, include
declare propsso attributes are type-safe.
Functional Component
export interface Props {
data: DataType[];
onItemClick?: (item: DataType) => void;
}
export default function DisplayComponent({ data, onItemClick }: Props) {
if (!data?.length) return <div>No items</div>;
return (
<ul>
{data.map(item => (
<li onclick={() => onItemClick?.(item)}>{item.name}</li>
))}
</ul>
);
}
Pattern: Destructure → Guard clauses → Main render
Typed Events Pattern
Payload Rules:
- •Single value →
payload: string| Call:$onclick={['delete', id]} - •Multiple values →
payload: { id: string; name: string }| Call:$onclick={['edit', { id, name }]} - •No payload →
payload: void| Call:$onclick="save" - •Input events →
payload: { target: { value: string } }
// types/events.ts
export type MyEvents =
| { name: 'save'; payload: void }
| { name: 'delete'; payload: string }
| { name: 'edit'; payload: { id: string; name: string } };
export type MyEventName = MyEvents['name'];
// Component
class MyComponent extends Component<State, MyEventName> {
override update = myHandlers;
}
// Handlers (OBJECT format, not array)
export const myHandlers: Update<State, MyEventName> = {
save: (state): State => ({ ...state, saved: true }),
delete: (state, id: string): State => ({
...state,
items: state.items.filter(i => i.id !== id)
}),
edit: (state, { id, name }: { id: string; name: string }): State => ({
...state,
editing: { id, name }
})
};
stopPropagation: Add event as last parameter
'click-item': (state, id: string, e?: Event): State => {
e?.stopPropagation();
return { ...state, selected: id };
}
Event Directives
AppRun Directives (Trigger Update Handlers)
| Directive | Use Case | Example |
|---|---|---|
$bind="field" | Two-way binding (PREFERRED for forms) | <input $bind="name" /> |
$bind="nested.field" | Nested property | <input $bind="user.profile.name" /> |
$onclick="action" | String action | <button $onclick="save" /> |
$onclick={['action', data]} | Action with params | <button $onclick={['delete', id]} /> |
$onclick={[func]} | Direct function | <button $onclick={[saveData]} /> |
$oninput="handler" | Custom input handling | <input $oninput="validate" /> |
Other directives: $onchange, $onsubmit, $onfocus, $onblur, $onkeydown
Standard HTML Events (DOM Manipulation)
Use onclick, oninput, etc. for direct DOM manipulation only:
<div onclick={(e) => e.stopPropagation()}>Content</div>
When to Use What
- •✅
$bind- Simple form fields (no handler needed) - •✅
$oninput- Validation, transformation, debouncing - •✅
$onclick- Trigger update handlers - •❌ Never -
$onclick={() => app.run('action')}
Validation Example:
$oninput="validate-email"
'validate-email': (state, e: Event) => {
const email = (e.target as HTMLInputElement).value;
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return { ...state, email, emailError: valid ? null : 'Invalid' };
}
Update Handlers
Sync: Return new state
'increment': (state) => ({ ...state, count: state.count + 1 })
Async: Use async
'load': async (state) => {
this.setState({ ...state, loading: true });
const data = await api.fetch();
return { ...state, data, loading: false };
}
Generator: Multi-step with intermediate renders (PREFERRED for complex flows)
'save': async function* (state) {
yield { ...state, loading: true };
await api.save(state.data);
yield { ...state, loading: false, success: true };
}
Side Effects: No return = no re-render
'navigate': (state) => {
window.location.href = '/path';
// No return - no re-render
}
Component Communication
| Pattern | Use Case | Implementation |
|---|---|---|
| Props | Parent → Child | Pass data via props |
| Callbacks | Child → Parent | Pass function via props |
| Global Events | Any → Any | is_global_event = () => true |
Global Events:
// Modal component
class Modal extends Component {
is_global_event = () => true;
update = {
'open-modal': (state, data) => ({ ...state, visible: true, data }),
'close-modal': (state) => ({ ...state, visible: false })
};
}
// Any component can trigger
<button onclick={() => app.run('open-modal', data)}>Open</button>
Critical Rules
State Initialization
| Component Type | Use | Example |
|---|---|---|
| JSX Embedded | mounted() | mounted = (props) => getStateFromProps(props) |
| Top-Level Routed | state = async | state = async () => { const data = await api.fetch(); return { data }; } |
❌ NEVER mix both mounted() and state = async
TypeScript rule: for class components used as JSX tags, add declare props: Readonly<Props>; to avoid TS2607.
State Updates
Returning state triggers re-render:
- •Immutable (recommended):
return { ...state, field: value } - •Mutable (allowed):
state.field = value; return state - •Side effects only: Don't return (no re-render)
Required State Properties
interface State {
loading: boolean; // For async operations
error: string | null; // For error messages
successMessage?: string; // For success feedback
// ... specific fields
}
Deep Cloning
// Nested object update
return {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
name
}
}
};
Anti-Patterns
❌ DON'T:
// Don't use $onclick with arrow functions calling app.run
$onclick={() => app.run('action')}
// Don't forget error handling in async
async function save() { await api.save(); } // No try-catch!
// Don't use manual input when $bind available
$oninput={(e) => setState({ ...state, field: e.target.value })}
// Don't use state = async for JSX embedded components
class Modal extends Component {
state = async () => { /* WRONG */ };
}
// Don't forget defensive programming
messages.map() // messages might be undefined - use messages?.map()
// Don't use array format for update handlers
update = [['event', handler]] // WRONG - use object format
// Don't mutate state directly
state.count++; // WRONG
Routing, Linking, and Component Registration
This section explains how AppRun applications handle routing, page navigation, and component registration.
Overview
The app uses AppRun's built-in routing system without any external router libraries. Routes are defined declaratively, and navigation uses standard HTML anchor tags or programmatic methods.
1. Component Registration
Routes are registered centrally in main.tsx:
import app from 'apprun';
import Layout from './components/Layout';
import Home from './pages/Home';
import World from './pages/World';
app.render('#root', <Layout />);
app.addComponents('#pages', {
'/': Home,
'/World': World,
// '/Agent': Agent, // commented out
// '/Settings': Settings, // commented out
});
How It Works:
- •
app.render('#root', <Layout />): Renders the top-level Layout component into the#rootDOM element - •
app.addComponents('#pages', {...}): Registers route-to-component mappings- •Key: Route path (e.g.,
'/','/World') - •Value: Component class (e.g.,
Home,World) - •Components are rendered into the
#pagescontainer defined in Layout
- •Key: Route path (e.g.,
2. Layout Container
The Layout component provides the rendering container for routed pages:
// web/src/components/Layout.tsx export default () => <div id="main" className="w-full min-h-screen"> <div id="pages"></div> </div>
- •Minimal wrapper with full-width, full-height container
- •The
#pagesdiv is where route components are dynamically rendered - •AppRun automatically swaps components based on the current route
3. Page Linking (Declarative Navigation)
The app uses standard HTML anchor tags for navigation:
Example from Home Component:
// Navigate to a specific world
<a href={'/World/' + worldName}>
<button className="btn btn-primary">
Enter {worldName}
</button>
</a>
Example from World Component:
// Navigate back to home
<a href="/">
<button className="back-button" title="Back to Worlds">
<span className="world-back-icon">←</span>
</button>
</a>
How It Works:
- •Standard
<a href="">links trigger AppRun's routing - •AppRun intercepts link clicks and updates the route without full page reload
- •Route parameters (like world name) are included in the URL path
- •No special Link component required—just plain HTML
4. Programmatic Navigation
Components can navigate programmatically in code handlers using route events or window.location.href:
Example from Home Component Update Handler:
update = {
'enter-world': (state: HomeState, world: World): void => {
// Navigate to /World/:worldName
window.location.href = '/World/' + world.name;
}
}
When to Use:
- •Inside event handlers that need to navigate after logic
- •When navigation is a side effect (return
voidinstead of new state) - •For conditional navigation based on user actions
5. Route Parameters
Routes can include dynamic parameters in the path:
URL Pattern:
/World/:worldName
Parsing Parameters:
Components can access route parameters from the URL:
// Example: /World/MyWorld
const worldName = window.location.pathname.split('/')[2]; // "MyWorld"
Route Handler Pattern:
update = {
'/World': async (state, worldName: string) => {
// worldName is parsed from the URL
return {
...state,
worldName,
// ... load world data
};
}
}
6. Component Architecture (MVU Pattern)
Page components follow AppRun's Model-View-Update pattern:
export default class PageComponent extends Component<StateType> {
// 1. STATE: Initial data and loading states
state = {
loading: true,
data: null,
// ...
};
// 2. VIEW: Render function that returns JSX
view = (state: StateType) => {
return <div>
{/* JSX markup */}
</div>;
};
// 3. UPDATE: Event handlers
update = {
'event-name': (state, payload) => {
// Return new state to trigger re-render
return { ...state, newData: payload };
},
'navigation-event': (state) => {
// Return void for side effects (no re-render)
window.location.href = '/path';
}
};
}
Key Principles:
- •State: Plain object with component data
- •View: Pure function that converts state to JSX
- •Update: Event handlers that return new state or void
- •Immutability: Always return new state objects, never mutate
7. Event System
Local vs Global Events:
Components can be configured to listen to global events:
export default class WorldComponent extends Component {
// Make all events global (visible across components)
override is_global_event = () => true;
}
Event Propagation:
- •Local events: Only visible within the component
- •Global events: Can be triggered from child components or other parts of the app
- •Use
app.run('event-name', payload)to trigger events programmatically
Event Handler Types:
update = {
// Returns new state → triggers re-render
'update-data': (state, newData) => ({
...state,
data: newData
}),
// Returns void → no re-render (side effect only)
'navigate': (state) => {
window.location.href = '/path';
}
}
8. Best Practices
Navigation:
- •✅ Use
<a href="">for simple links - •✅ Use route events like
app.run('/Route', param)orwindow.location.hrefin code handlers - •✅ Include route parameters in the path:
/World/${name} - •❌ Don't use client-side routing for external URLs
- •❌ Don't use inline
onclick="location.href='...'"for internal app routes
Component Registration:
- •✅ Register all routes in a single place (
main.tsx) - •✅ Use clear, semantic route paths
- •✅ Keep the route structure flat and simple
- •❌ Don't nest routes deeply
Event Handling:
- •✅ Return new state to trigger re-render
- •✅ Return void for navigation or side effects
- •✅ Use descriptive event names:
'load-world','delete-chat' - •❌ Don't mutate state directly
URL Structure:
/ → Home page (world selection) /World/:name → World page (chat interface) /Agent/:id → Agent page (currently disabled) /Settings → Settings page (currently disabled)
9. Example Flow: Entering a World
Step 1: User clicks "Enter World" button on Home page
// Home.tsx
<a href={'/World/' + world.name}>
<button className="btn btn-primary">
Enter {world.name}
</button>
</a>
Step 2: AppRun intercepts the link and updates route
- •URL changes to
/World/MyWorld - •AppRun's router detects the route change
- •Router looks up the registered component for
/World
Step 3: World component is mounted and initialized
// World.tsx
update = {
'/World': async (state, worldName: string) => {
// Load world data from API
const world = await api.getWorld(worldName);
const messages = await api.getMessages(worldName);
return {
...state,
worldName,
world,
messages,
loading: false
};
}
}
Step 4: World component renders with loaded data
- •View function receives the updated state
- •Chat interface displays with agents and messages
- •Component is now interactive and listening for events
10. Debugging Tips
Check Current Route:
console.log(window.location.pathname); // "/World/MyWorld"
Monitor Route Changes:
app.on('//', (route) => {
console.log('Route changed to:', route);
});
Verify Component Registration:
// Check if component is registered for a route
// Look for the component rendering in #pages container
console.log(document.querySelector('#pages').innerHTML);
Summary
- •Registration:
app.addComponents('#pages', { path: Component }) - •Navigation: Use
<a href="">,app.run('/route', ...), orwindow.location.hrefin code handlers - •Route Params: Parsed from URL path in route handlers
- •Component Pattern: MVU (Model-View-Update)
- •Events: Local by default, can be made global with
is_global_event() - •No Router Library: AppRun's built-in routing handles everything
Testing (Vitest)
- •Unit test pure update functions.
- •Iterate async generators to capture each yield.
- •Mock APIs with
vi.mock.
import { describe, it, expect, vi } from 'vitest';
import { save } from './Form';
import api from '../api';
vi.mock('../api');
describe('save', () => {
it('yields validation then stops', async () => {
const state = { loading: false, error: null, form: { name: '' } } as State;
const gen = save(state);
const first = await gen.next();
expect(first.value?.error).toBe('Name is required');
});
});
Development Checklist
Component Structure
- • Imports at top
- • Props interface with
?for optional - • State interface (exported)
- • Helper functions
- • Action functions (exported for
$onclickand testing) - • Component class with
mountedorstate = async - • JSX class components declare
propsfor TS compatibility
TypeScript Types
- • Props interface
- • State interface exported
- • Event types for 10+ events (discriminated union)
- • Generic types:
Component<State, EventName> - • Async generators:
AsyncGenerator<State>
View Method
- • Guard clauses first (loading, error, success)
- • Early returns for special states
- • Main content last
- • Defensive programming (
data?.map(), defaults)
State Management
- • Include
loading,error,successMessage? - • Return new state to re-render
- • Use
mounted()for JSX embedded - • If using JSX children content, merge children in
mounted(props, children) - • Use
state = asynconly for routed pages - • Never mix both
Event Handling
- • Use
$bindfor simple forms - • Use
$onclick(notonclick={() => app.run()}) - • Export action functions for reusability
- • Use async generators for multi-step
- • Add try-catch in async functions
Error Handling
- • Try-catch in async operations
- • Error state in interface
- • Error display in view
- • Loading states during async
- • Success messages
Best Practices
- • Keep update logic pure when possible
- • Use global events for cross-component
- • Add catch-all route for 404
- • Test update logic and error paths
- • Use descriptive event names
Quick Reference
Component Selection:
- •State + interactions → Stateful Class
- •Modal/popup → Modal Component (
mounted()) - •Display only → Functional
- •10+ events → Typed Events
State Init:
- •JSX embedded →
mounted() - •Routed page →
state = async
Events:
- •
$bindfor forms (preferred) - •
$onclickfor actions - •Typed for large components
Updates:
- •Return state → re-render
- •No return → side effect
- •Generators → multi-step
Communication:
- •Props: parent → child
- •Callbacks: child → parent
- •Global: any → any